aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/services
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
committercrupest <crupest@outlook.com>2020-10-27 19:21:35 +0800
commit05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33 (patch)
tree929e514de85eb82a5acb96ecffc6e6d2d95f878f /FrontEnd/src/app/services
parent986c6f2e3b858d6332eba0b42acc6861cd4d0227 (diff)
downloadtimeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.gz
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.tar.bz2
timeline-05ccb4d8f1bbe3fb64e117136b4a89bcfb0b0b33.zip
Split front and back end.
Diffstat (limited to 'FrontEnd/src/app/services')
-rw-r--r--FrontEnd/src/app/services/DataHub.ts225
-rw-r--r--FrontEnd/src/app/services/alert.ts61
-rw-r--r--FrontEnd/src/app/services/common.ts23
-rw-r--r--FrontEnd/src/app/services/timeline.ts702
-rw-r--r--FrontEnd/src/app/services/user.ts393
5 files changed, 1404 insertions, 0 deletions
diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts
new file mode 100644
index 00000000..93a9b41f
--- /dev/null
+++ b/FrontEnd/src/app/services/DataHub.ts
@@ -0,0 +1,225 @@
+import { pull } from "lodash";
+import { Observable, BehaviorSubject, combineLatest } from "rxjs";
+import { map } from "rxjs/operators";
+
+export type Subscriber<TData> = (data: TData) => void;
+
+export type WithSyncStatus<T> = T & { syncing: boolean };
+
+export class DataLine<TData> {
+ private _current: TData | undefined = undefined;
+
+ private _syncPromise: Promise<void> | null = null;
+ private _syncingSubject = new BehaviorSubject<boolean>(false);
+
+ private _observers: Subscriber<TData>[] = [];
+
+ constructor(
+ private config: {
+ sync: () => Promise<void>;
+ destroyable?: (value: TData | undefined) => boolean;
+ disableInitSync?: boolean;
+ }
+ ) {
+ if (config.disableInitSync !== true) {
+ setImmediate(() => void this.sync());
+ }
+ }
+
+ private subscribe(subscriber: Subscriber<TData>): void {
+ this._observers.push(subscriber);
+ if (this._current !== undefined) {
+ subscriber(this._current);
+ }
+ }
+
+ private unsubscribe(subscriber: Subscriber<TData>): void {
+ if (!this._observers.includes(subscriber)) return;
+ pull(this._observers, subscriber);
+ }
+
+ getObservable(): Observable<TData> {
+ return new Observable<TData>((observer) => {
+ const f = (data: TData): void => {
+ observer.next(data);
+ };
+ this.subscribe(f);
+
+ return () => {
+ this.unsubscribe(f);
+ };
+ });
+ }
+
+ getSyncStatusObservable(): Observable<boolean> {
+ return this._syncingSubject.asObservable();
+ }
+
+ getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> {
+ return combineLatest([
+ this.getObservable(),
+ this.getSyncStatusObservable(),
+ ]).pipe(
+ map(([data, syncing]) => ({
+ ...data,
+ syncing,
+ }))
+ );
+ }
+
+ get value(): TData | undefined {
+ return this._current;
+ }
+
+ next(value: TData): void {
+ this._current = value;
+ this._observers.forEach((observer) => observer(value));
+ }
+
+ get isSyncing(): boolean {
+ return this._syncPromise != null;
+ }
+
+ sync(): Promise<void> {
+ if (this._syncPromise == null) {
+ this._syncingSubject.next(true);
+ this._syncPromise = this.config.sync().then(() => {
+ this._syncingSubject.next(false);
+ this._syncPromise = null;
+ });
+ }
+
+ return this._syncPromise;
+ }
+
+ syncWithAction(
+ syncAction: (line: DataLine<TData>) => Promise<void>
+ ): Promise<void> {
+ if (this._syncPromise == null) {
+ this._syncingSubject.next(true);
+ this._syncPromise = syncAction(this).then(() => {
+ this._syncingSubject.next(false);
+ this._syncPromise = null;
+ });
+ }
+
+ return this._syncPromise;
+ }
+
+ get destroyable(): boolean {
+ const customDestroyable = this.config?.destroyable;
+
+ return (
+ this._observers.length === 0 &&
+ !this.isSyncing &&
+ (customDestroyable != null ? customDestroyable(this._current) : true)
+ );
+ }
+}
+
+export class DataHub<TKey, TData> {
+ private sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
+ private keyToString: (key: TKey) => string;
+ private destroyable?: (key: TKey, value: TData | undefined) => boolean;
+
+ private readonly subscriptionLineMap = new Map<string, DataLine<TData>>();
+
+ private cleanTimerId = 0;
+
+ // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called.
+ constructor(config: {
+ sync: (key: TKey, line: DataLine<TData>) => Promise<void>;
+ keyToString?: (key: TKey) => string;
+ destroyable?: (key: TKey, value: TData | undefined) => boolean;
+ }) {
+ this.sync = config.sync;
+ this.keyToString =
+ config.keyToString ??
+ ((value): string => {
+ if (typeof value === "string") return value;
+ else
+ throw new Error(
+ "Default keyToString function only pass string value."
+ );
+ });
+
+ this.destroyable = config.destroyable;
+ }
+
+ private cleanLines(): void {
+ const toDelete: string[] = [];
+ for (const [key, line] of this.subscriptionLineMap.entries()) {
+ if (line.destroyable) {
+ toDelete.push(key);
+ }
+ }
+
+ if (toDelete.length === 0) return;
+
+ for (const key of toDelete) {
+ this.subscriptionLineMap.delete(key);
+ }
+
+ if (this.subscriptionLineMap.size === 0) {
+ window.clearInterval(this.cleanTimerId);
+ this.cleanTimerId = 0;
+ }
+ }
+
+ private createLine(key: TKey, disableInitSync = false): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ const { destroyable } = this;
+ const newLine: DataLine<TData> = new DataLine<TData>({
+ sync: () => this.sync(key, newLine),
+ destroyable:
+ destroyable != null ? (value) => destroyable(key, value) : undefined,
+ disableInitSync: disableInitSync,
+ });
+ this.subscriptionLineMap.set(keyString, newLine);
+ if (this.subscriptionLineMap.size === 1) {
+ this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000);
+ }
+ return newLine;
+ }
+
+ getObservable(key: TKey): Observable<TData> {
+ return this.getLineOrCreate(key).getObservable();
+ }
+
+ getSyncStatusObservable(key: TKey): Observable<boolean> {
+ return this.getLineOrCreate(key).getSyncStatusObservable();
+ }
+
+ getDataWithSyncStatusObservable(
+ key: TKey
+ ): Observable<WithSyncStatus<TData>> {
+ return this.getLineOrCreate(key).getDataWithSyncStatusObservable();
+ }
+
+ getLine(key: TKey): DataLine<TData> | null {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? null;
+ }
+
+ getLineOrCreate(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
+ }
+
+ getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> {
+ const keyString = this.keyToString(key);
+ return (
+ this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true)
+ );
+ }
+
+ optionalInitLineWithSyncAction(
+ key: TKey,
+ syncAction: (line: DataLine<TData>) => Promise<void>
+ ): Promise<void> {
+ const optionalLine = this.getLine(key);
+ if (optionalLine != null) return Promise.resolve();
+ const line = this.createLine(key, true);
+ return line.syncWithAction(syncAction);
+ }
+}
diff --git a/FrontEnd/src/app/services/alert.ts b/FrontEnd/src/app/services/alert.ts
new file mode 100644
index 00000000..e4c0e653
--- /dev/null
+++ b/FrontEnd/src/app/services/alert.ts
@@ -0,0 +1,61 @@
+import React from "react";
+import pull from "lodash/pull";
+
+export interface AlertInfo {
+ type?: "primary" | "secondary" | "success" | "danger" | "warning" | "info";
+ message: string | React.FC<unknown> | { type: "i18n"; key: string };
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoEx extends AlertInfo {
+ id: number;
+}
+
+export type AlertConsumer = (alerts: AlertInfoEx) => void;
+
+export class AlertService {
+ private consumers: AlertConsumer[] = [];
+ private savedAlerts: AlertInfoEx[] = [];
+ private currentId = 1;
+
+ private produce(alert: AlertInfoEx): void {
+ for (const consumer of this.consumers) {
+ consumer(alert);
+ }
+ }
+
+ registerConsumer(consumer: AlertConsumer): void {
+ this.consumers.push(consumer);
+ if (this.savedAlerts.length !== 0) {
+ for (const alert of this.savedAlerts) {
+ this.produce(alert);
+ }
+ this.savedAlerts = [];
+ }
+ }
+
+ unregisterConsumer(consumer: AlertConsumer): void {
+ pull(this.consumers, consumer);
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ };
+ if (this.consumers.length === 0) {
+ this.savedAlerts.push(newAlert);
+ } else {
+ this.produce(newAlert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
+
+export const kAlertHostId = "alert-host";
+
+export function getAlertHost(): HTMLElement | null {
+ return document.getElementById(kAlertHostId);
+}
diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts
new file mode 100644
index 00000000..3bb6b9d7
--- /dev/null
+++ b/FrontEnd/src/app/services/common.ts
@@ -0,0 +1,23 @@
+import localforage from "localforage";
+
+import { HttpNetworkError } from "@/http/common";
+
+export const dataStorage = localforage.createInstance({
+ name: "data",
+ description: "Database for offline data.",
+ driver: localforage.INDEXEDDB,
+});
+
+export class ForbiddenError extends Error {
+ constructor(message?: string) {
+ super(message);
+ }
+}
+
+export function throwIfNotNetworkError(e: unknown): void {
+ if (!(e instanceof HttpNetworkError)) {
+ throw e;
+ }
+}
+
+export type BlobOrStatus = Blob | "loading" | "error";
diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
new file mode 100644
index 00000000..9db76281
--- /dev/null
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -0,0 +1,702 @@
+import React from "react";
+import XRegExp from "xregexp";
+import { Observable, from, combineLatest, of } from "rxjs";
+import { map, switchMap, startWith } from "rxjs/operators";
+import { uniqBy } from "lodash";
+
+import { convertError } from "@/utilities/rxjs";
+import {
+ TimelineVisibility,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ HttpTimelinePostPostRequest,
+ HttpTimelinePostPostRequestContent,
+ HttpTimelinePostPostRequestTextContent,
+ HttpTimelinePostPostRequestImageContent,
+ HttpTimelinePostInfo,
+ HttpTimelinePostTextContent,
+ getHttpTimelineClient,
+ HttpTimelineNotExistError,
+ HttpTimelineNameConflictError,
+} from "@/http/timeline";
+import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common";
+import { HttpUser } from "@/http/user";
+
+export { kTimelineVisibilities } from "@/http/timeline";
+
+export type { TimelineVisibility } from "@/http/timeline";
+
+import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
+import { DataHub, WithSyncStatus } from "./DataHub";
+import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user";
+
+export type TimelineInfo = HttpTimelineInfo;
+export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
+export type TimelineCreatePostRequest = HttpTimelinePostPostRequest;
+export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent;
+export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent;
+export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent;
+
+export type TimelinePostTextContent = HttpTimelinePostTextContent;
+
+export interface TimelinePostImageContent {
+ type: "image";
+ data: BlobOrStatus;
+}
+
+export type TimelinePostContent =
+ | TimelinePostTextContent
+ | TimelinePostImageContent;
+
+export interface TimelinePostInfo {
+ id: number;
+ content: TimelinePostContent;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+}
+
+export const timelineVisibilityTooltipTranslationMap: Record<
+ TimelineVisibility,
+ string
+> = {
+ Public: "timeline.visibilityTooltip.public",
+ Register: "timeline.visibilityTooltip.register",
+ Private: "timeline.visibilityTooltip.private",
+};
+
+export class TimelineNotExistError extends Error {}
+export class TimelineNameConflictError extends Error {}
+
+export type TimelineWithSyncStatus = WithSyncStatus<
+ | {
+ type: "cache";
+ timeline: TimelineInfo;
+ }
+ | {
+ type: "offline" | "synced";
+ timeline: TimelineInfo | null;
+ }
+>;
+
+export type TimelinePostsWithSyncState = WithSyncStatus<{
+ type:
+ | "cache"
+ | "offline" // Sync failed and use cache.
+ | "synced" // Sync succeeded.
+ | "forbid" // The list is forbidden to see.
+ | "notexist"; // The timeline does not exist.
+ posts: TimelinePostInfo[];
+}>;
+
+type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & {
+ owner: string;
+ members: string[];
+};
+
+type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & {
+ author: string;
+};
+
+export class TimelineService {
+ private getCachedTimeline(
+ timelineName: string
+ ): Promise<TimelineData | null> {
+ return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`);
+ }
+
+ private saveTimeline(
+ timelineName: string,
+ data: TimelineData
+ ): Promise<void> {
+ return dataStorage
+ .setItem<TimelineData>(`timeline.${timelineName}`, data)
+ .then();
+ }
+
+ private async clearTimelineData(timelineName: string): Promise<void> {
+ const keys = (await dataStorage.keys()).filter((k) =>
+ k.startsWith(`timeline.${timelineName}`)
+ );
+ await Promise.all(keys.map((k) => dataStorage.removeItem(k)));
+ }
+
+ private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
+ return {
+ ...timeline,
+ owner: timeline.owner.username,
+ members: timeline.members.map((m) => m.username),
+ };
+ }
+
+ private _timelineHub = new DataHub<
+ string,
+ | {
+ type: "cache";
+ timeline: TimelineData;
+ }
+ | {
+ type: "offline" | "synced";
+ timeline: TimelineData | null;
+ }
+ >({
+ sync: async (key, line) => {
+ const cache = await this.getCachedTimeline(key);
+
+ if (line.value == undefined) {
+ if (cache != null) {
+ line.next({ type: "cache", timeline: cache });
+ }
+ }
+
+ try {
+ const httpTimeline = await getHttpTimelineClient().getTimeline(key);
+
+ userInfoService.saveUsers([
+ httpTimeline.owner,
+ ...httpTimeline.members,
+ ]);
+
+ const timeline = this.convertHttpTimelineToData(httpTimeline);
+
+ if (cache != null && timeline.uniqueId !== cache.uniqueId) {
+ console.log(
+ `Timeline with name ${key} has changed to a new one. Clear old data.`
+ );
+ await this.clearTimelineData(key); // If timeline has changed, clear all old data.
+ }
+
+ await this.saveTimeline(key, timeline);
+
+ line.next({ type: "synced", timeline });
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.next({ type: "synced", timeline: null });
+ } else {
+ if (cache == null) {
+ line.next({ type: "offline", timeline: null });
+ } else {
+ line.next({ type: "offline", timeline: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ syncTimeline(timelineName: string): Promise<void> {
+ return this._timelineHub.getLineOrCreate(timelineName).sync();
+ }
+
+ getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> {
+ return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe(
+ switchMap((state) => {
+ const { timeline } = state;
+ if (timeline != null) {
+ return combineLatest(
+ [timeline.owner, ...timeline.members].map((u) =>
+ userInfoService.getUser$(u)
+ )
+ ).pipe(
+ map((users) => {
+ return {
+ ...state,
+ timeline: {
+ ...timeline,
+ owner: users[0],
+ members: users.slice(1),
+ },
+ };
+ })
+ );
+ } else {
+ return of(state as TimelineWithSyncStatus);
+ }
+ })
+ );
+ }
+
+ createTimeline(timelineName: string): Observable<TimelineInfo> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().postTimeline(
+ {
+ name: timelineName,
+ },
+ user.token
+ )
+ ).pipe(
+ convertError(HttpTimelineNameConflictError, TimelineNameConflictError)
+ );
+ }
+
+ changeTimelineProperty(
+ timelineName: string,
+ req: TimelineChangePropertyRequest
+ ): Observable<TimelineInfo> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .patchTimeline(timelineName, req, user.token)
+ .then((timeline) => {
+ void this.syncTimeline(timelineName);
+ return timeline;
+ })
+ );
+ }
+
+ deleteTimeline(timelineName: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().deleteTimeline(timelineName, user.token)
+ );
+ }
+
+ addMember(timelineName: string, username: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .memberPut(timelineName, username, user.token)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ })
+ );
+ }
+
+ removeMember(timelineName: string, username: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .memberDelete(timelineName, username, user.token)
+ .then(() => {
+ void this.syncTimeline(timelineName);
+ })
+ );
+ }
+
+ private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData {
+ return {
+ ...post,
+ author: post.author.username,
+ };
+ }
+
+ private convertHttpPostToDataList(
+ posts: HttpTimelinePostInfo[]
+ ): TimelinePostData[] {
+ return posts.map((post) => this.convertHttpPostToData(post));
+ }
+
+ private getCachedPosts(
+ timelineName: string
+ ): Promise<TimelinePostData[] | null> {
+ return dataStorage.getItem<TimelinePostData[] | null>(
+ `timeline.${timelineName}.posts`
+ );
+ }
+
+ private savePosts(
+ timelineName: string,
+ data: TimelinePostData[]
+ ): Promise<void> {
+ return dataStorage
+ .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
+ .then();
+ }
+
+ private syncPosts(timelineName: string): Promise<void> {
+ return this._postsHub.getLineOrCreate(timelineName).sync();
+ }
+
+ private _postsHub = new DataHub<
+ string,
+ {
+ type: "cache" | "offline" | "synced" | "forbid" | "notexist";
+ posts: TimelinePostData[];
+ }
+ >({
+ sync: async (key, line) => {
+ // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned.
+ await this.syncTimeline(key);
+
+ if (line.value == null) {
+ const cache = await this.getCachedPosts(key);
+ if (cache != null) {
+ line.next({ type: "cache", posts: cache });
+ }
+ }
+
+ const now = new Date();
+
+ const lastUpdatedTime = await dataStorage.getItem<Date | null>(
+ `timeline.${key}.lastUpdated`
+ );
+
+ try {
+ if (lastUpdatedTime == null) {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ key,
+ userService.currentUser?.token
+ );
+
+ userInfoService.saveUsers(
+ uniqBy(
+ httpPosts.map((post) => post.author),
+ "username"
+ )
+ );
+
+ const posts = this.convertHttpPostToDataList(httpPosts);
+ await this.savePosts(key, posts);
+ await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
+
+ line.next({ type: "synced", posts });
+ } else {
+ const httpPosts = await getHttpTimelineClient().listPost(
+ key,
+ userService.currentUser?.token,
+ {
+ modifiedSince: lastUpdatedTime,
+ includeDeleted: true,
+ }
+ );
+
+ const deletedIds = httpPosts
+ .filter((p) => p.deleted)
+ .map((p) => p.id);
+ const changed = httpPosts.filter(
+ (p): p is HttpTimelinePostInfo => !p.deleted
+ );
+
+ userInfoService.saveUsers(
+ uniqBy(
+ httpPosts
+ .map((post) => post.author)
+ .filter((u): u is HttpUser => u != null),
+ "username"
+ )
+ );
+
+ const cache = (await this.getCachedPosts(key)) ?? [];
+
+ const posts = cache.filter((p) => !deletedIds.includes(p.id));
+
+ for (const changedPost of changed) {
+ const savedChangedPostIndex = posts.findIndex(
+ (p) => p.id === changedPost.id
+ );
+ if (savedChangedPostIndex === -1) {
+ posts.push(this.convertHttpPostToData(changedPost));
+ } else {
+ posts[savedChangedPostIndex] = this.convertHttpPostToData(
+ changedPost
+ );
+ }
+ }
+
+ await this.savePosts(key, posts);
+ await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
+ line.next({ type: "synced", posts });
+ }
+ } catch (e) {
+ if (e instanceof HttpTimelineNotExistError) {
+ line.next({ type: "notexist", posts: [] });
+ } else if (e instanceof HttpForbiddenError) {
+ line.next({ type: "forbid", posts: [] });
+ } else {
+ const cache = await this.getCachedPosts(key);
+ if (cache == null) {
+ line.next({ type: "offline", posts: [] });
+ } else {
+ line.next({ type: "offline", posts: cache });
+ }
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getPosts$(timelineName: string): Observable<TimelinePostsWithSyncState> {
+ return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe(
+ switchMap((state) => {
+ if (state.posts.length === 0) {
+ return of({
+ ...state,
+ posts: [],
+ });
+ }
+
+ return combineLatest([
+ combineLatest(
+ state.posts.map((post) => userInfoService.getUser$(post.author))
+ ),
+ combineLatest(
+ state.posts.map((post) => {
+ if (post.content.type === "image") {
+ return this.getPostData$(timelineName, post.id);
+ } else {
+ return of(null);
+ }
+ })
+ ),
+ ]).pipe(
+ map(([authors, datas]) => {
+ return {
+ ...state,
+ posts: state.posts.map((post, i) => {
+ const { content } = post;
+
+ return {
+ ...post,
+ author: authors[i],
+ content: (() => {
+ if (content.type === "text") return content;
+ else
+ return {
+ type: "image",
+ data: datas[i],
+ } as TimelinePostImageContent;
+ })(),
+ };
+ }),
+ };
+ })
+ );
+ })
+ );
+ }
+
+ private getCachedPostData(key: {
+ timelineName: string;
+ postId: number;
+ }): Promise<BlobWithEtag | null> {
+ return dataStorage.getItem<BlobWithEtag | null>(
+ `timeline.${key.timelineName}.post.${key.postId}.data`
+ );
+ }
+
+ private savePostData(
+ key: {
+ timelineName: string;
+ postId: number;
+ },
+ data: BlobWithEtag
+ ): Promise<void> {
+ return dataStorage
+ .setItem<BlobWithEtag>(
+ `timeline.${key.timelineName}.post.${key.postId}.data`,
+ data
+ )
+ .then();
+ }
+
+ private syncPostData(key: {
+ timelineName: string;
+ postId: number;
+ }): Promise<void> {
+ return this._postDataHub.getLineOrCreate(key).sync();
+ }
+
+ private _postDataHub = new DataHub<
+ { timelineName: string; postId: number },
+ | { data: Blob; type: "cache" | "synced" | "offline" }
+ | { data?: undefined; type: "notexist" | "offline" }
+ >({
+ keyToString: (key) => `${key.timelineName}.${key.postId}`,
+ sync: async (key, line) => {
+ const cache = await this.getCachedPostData(key);
+ if (line.value == null) {
+ if (cache != null) {
+ line.next({ type: "cache", data: cache.data });
+ }
+ }
+
+ if (cache == null) {
+ try {
+ const res = await getHttpTimelineClient().getPostData(
+ key.timelineName,
+ key.postId
+ );
+ await this.savePostData(key, res);
+ line.next({ data: res.data, type: "synced" });
+ } catch (e) {
+ line.next({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpTimelineClient().getPostData(
+ key.timelineName,
+ key.postId,
+ cache.etag
+ );
+ if (res instanceof NotModified) {
+ line.next({ data: cache.data, type: "synced" });
+ } else {
+ await this.savePostData(key, res);
+ line.next({ data: res.data, type: "synced" });
+ }
+ } catch (e) {
+ line.next({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> {
+ return this._postDataHub.getObservable({ timelineName, postId }).pipe(
+ map((state): BlobOrStatus => state.data ?? "error"),
+ startWith("loading")
+ );
+ }
+
+ createPost(
+ timelineName: string,
+ request: TimelineCreatePostRequest
+ ): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .postPost(timelineName, request, user.token)
+ .then(() => {
+ void this.syncPosts(timelineName);
+ })
+ );
+ }
+
+ deletePost(timelineName: string, postId: number): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient()
+ .deletePost(timelineName, postId, user.token)
+ .then(() => {
+ void this.syncPosts(timelineName);
+ })
+ );
+ }
+
+ isMemberOf(username: string, timeline: TimelineInfo): boolean {
+ return timeline.members.findIndex((m) => m.username == username) >= 0;
+ }
+
+ hasReadPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ const { visibility } = timeline;
+ if (visibility === "Public") {
+ return true;
+ } else if (visibility === "Register") {
+ if (user != null) return true;
+ } else if (visibility === "Private") {
+ if (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ this.isMemberOf(user.username, timeline))
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ hasPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (timeline.owner.username === user.username ||
+ this.isMemberOf(user.username, timeline))
+ );
+ }
+
+ hasManagePermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return user != null && user.username == timeline.owner.username;
+ }
+
+ hasModifyPostPermission(
+ user: UserAuthInfo | null | undefined,
+ timeline: TimelineInfo,
+ post: TimelinePostInfo
+ ): boolean {
+ if (user != null && user.administrator) return true;
+
+ return (
+ user != null &&
+ (user.username === timeline.owner.username ||
+ user.username === post.author.username)
+ );
+ }
+}
+
+export const timelineService = new TimelineService();
+
+const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
+
+export function validateTimelineName(name: string): boolean {
+ return timelineNameReg.test(name);
+}
+
+export function useTimelineInfo(
+ timelineName: string
+): TimelineWithSyncStatus | undefined {
+ const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>(
+ undefined
+ );
+ React.useEffect(() => {
+ const subscription = timelineService
+ .getTimeline$(timelineName)
+ .subscribe((data) => {
+ setState(data);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineName]);
+ return state;
+}
+
+export function usePostList(
+ timelineName: string | null | undefined
+): TimelinePostsWithSyncState | undefined {
+ const [state, setState] = React.useState<
+ TimelinePostsWithSyncState | undefined
+ >(undefined);
+ React.useEffect(() => {
+ if (timelineName == null) {
+ setState(undefined);
+ return;
+ }
+
+ const subscription = timelineService
+ .getPosts$(timelineName)
+ .subscribe((data) => {
+ setState(data);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineName]);
+ return state;
+}
+
+export async function getAllCachedTimelineNames(): Promise<string[]> {
+ const keys = await dataStorage.keys();
+ return keys
+ .filter(
+ (key) =>
+ key.startsWith("timeline.") && (key.match(/\./g) ?? []).length === 1
+ )
+ .map((key) => key.substr("timeline.".length));
+}
diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts
new file mode 100644
index 00000000..f253fc19
--- /dev/null
+++ b/FrontEnd/src/app/services/user.ts
@@ -0,0 +1,393 @@
+import React, { useState, useEffect } from "react";
+import { BehaviorSubject, Observable, from } from "rxjs";
+import { map, filter } from "rxjs/operators";
+
+import { UiLogicError } from "@/common";
+import { convertError } from "@/utilities/rxjs";
+
+import { HttpNetworkError, BlobWithEtag, NotModified } from "@/http/common";
+import {
+ getHttpTokenClient,
+ HttpCreateTokenBadCredentialError,
+} from "@/http/token";
+import {
+ getHttpUserClient,
+ HttpUserNotExistError,
+ HttpUser,
+} from "@/http/user";
+
+import { dataStorage, throwIfNotNetworkError } from "./common";
+import { DataHub } from "./DataHub";
+import { pushAlert } from "./alert";
+
+export type User = HttpUser;
+
+export interface UserAuthInfo {
+ username: string;
+ administrator: boolean;
+}
+
+export interface UserWithToken extends User {
+ token: string;
+}
+
+export interface LoginCredentials {
+ username: string;
+ password: string;
+}
+
+export class BadCredentialError {
+ message = "login.badCredential";
+}
+
+const USER_STORAGE_KEY = "currentuser";
+
+export class UserService {
+ private userSubject = new BehaviorSubject<UserWithToken | null | undefined>(
+ undefined
+ );
+
+ get user$(): Observable<UserWithToken | null | undefined> {
+ return this.userSubject;
+ }
+
+ get currentUser(): UserWithToken | null | undefined {
+ return this.userSubject.value;
+ }
+
+ async checkLoginState(): Promise<UserWithToken | null> {
+ if (this.currentUser !== undefined) {
+ console.warn("Already checked user. Can't check twice.");
+ }
+
+ const savedUser = await dataStorage.getItem<UserWithToken | null>(
+ USER_STORAGE_KEY
+ );
+
+ if (savedUser == null) {
+ this.userSubject.next(null);
+ return null;
+ }
+
+ this.userSubject.next(savedUser);
+
+ const savedToken = savedUser.token;
+ try {
+ const res = await getHttpTokenClient().verify({ token: savedToken });
+ const user: UserWithToken = { ...res.user, token: savedToken };
+ await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user);
+ this.userSubject.next(user);
+ pushAlert({
+ type: "success",
+ message: {
+ type: "i18n",
+ key: "user.welcomeBack",
+ },
+ });
+ return user;
+ } catch (error) {
+ if (error instanceof HttpNetworkError) {
+ pushAlert({
+ type: "danger",
+ message: { type: "i18n", key: "user.verifyTokenFailedNetwork" },
+ });
+ return savedUser;
+ } else {
+ await dataStorage.removeItem(USER_STORAGE_KEY);
+ this.userSubject.next(null);
+ pushAlert({
+ type: "danger",
+ message: { type: "i18n", key: "user.verifyTokenFailed" },
+ });
+ return null;
+ }
+ }
+ }
+
+ async login(
+ credentials: LoginCredentials,
+ rememberMe: boolean
+ ): Promise<void> {
+ if (this.currentUser) {
+ throw new UiLogicError("Already login.");
+ }
+ try {
+ const res = await getHttpTokenClient().create({
+ ...credentials,
+ expire: 30,
+ });
+ const user: UserWithToken = {
+ ...res.user,
+ token: res.token,
+ };
+ if (rememberMe) {
+ await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user);
+ }
+ this.userSubject.next(user);
+ } catch (e) {
+ if (e instanceof HttpCreateTokenBadCredentialError) {
+ throw new BadCredentialError();
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ async logout(): Promise<void> {
+ if (this.currentUser === undefined) {
+ throw new UiLogicError("Please check user first.");
+ }
+ if (this.currentUser === null) {
+ throw new UiLogicError("No login.");
+ }
+ await dataStorage.removeItem(USER_STORAGE_KEY);
+ this.userSubject.next(null);
+ }
+
+ changePassword(
+ oldPassword: string,
+ newPassword: string
+ ): Observable<unknown> {
+ if (this.currentUser == undefined) {
+ throw new UiLogicError("Not login or checked now, can't log out.");
+ }
+ const $ = from(
+ getHttpUserClient().changePassword(
+ {
+ oldPassword,
+ newPassword,
+ },
+ this.currentUser.token
+ )
+ );
+ $.subscribe(() => {
+ void this.logout();
+ });
+ return $;
+ }
+}
+
+export const userService = new UserService();
+
+export function useRawUser(): UserWithToken | null | undefined {
+ const [user, setUser] = useState<UserWithToken | null | undefined>(
+ userService.currentUser
+ );
+ useEffect(() => {
+ const subscription = userService.user$.subscribe((u) => setUser(u));
+ return () => {
+ subscription.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUser(): UserWithToken | null {
+ const [user, setUser] = useState<UserWithToken | null>(() => {
+ const initUser = userService.currentUser;
+ if (initUser === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. Current user can't be undefined in useUser."
+ );
+ }
+ return initUser;
+ });
+ useEffect(() => {
+ const sub = userService.user$.subscribe((u) => {
+ if (u === undefined) {
+ throw new UiLogicError(
+ "This is a logic error in user module. User emitted can't be undefined later."
+ );
+ }
+ setUser(u);
+ });
+ return () => {
+ sub.unsubscribe();
+ };
+ });
+ return user;
+}
+
+export function useUserLoggedIn(): UserWithToken {
+ const user = useUser();
+ if (user == null) {
+ throw new UiLogicError("You assert user has logged in but actually not.");
+ }
+ return user;
+}
+
+export function checkLogin(): UserWithToken {
+ const user = userService.currentUser;
+ if (user == null) {
+ throw new UiLogicError("You must login to perform the operation.");
+ }
+ return user;
+}
+
+export class UserNotExistError extends Error {}
+
+export class UserInfoService {
+ saveUser(user: HttpUser): void {
+ const key = user.username;
+ void this._userHub.optionalInitLineWithSyncAction(key, async (line) => {
+ await this.doSaveUser(user);
+ line.next({ user, type: "synced" });
+ });
+ }
+
+ saveUsers(users: HttpUser[]): void {
+ return users.forEach((user) => this.saveUser(user));
+ }
+
+ private getCachedUser(username: string): Promise<User | null> {
+ return dataStorage.getItem<HttpUser | null>(`user.${username}`);
+ }
+
+ private doSaveUser(user: HttpUser): Promise<void> {
+ return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then();
+ }
+
+ syncUser(username: string): Promise<void> {
+ return this._userHub.getLineOrCreate(username).sync();
+ }
+
+ private _userHub = new DataHub<
+ string,
+ | { user: User; type: "cache" | "synced" | "offline" }
+ | { user?: undefined; type: "notexist" | "offline" }
+ >({
+ sync: async (key, line) => {
+ if (line.value == undefined) {
+ const cache = await this.getCachedUser(key);
+ if (cache != null) {
+ line.next({ user: cache, type: "cache" });
+ }
+ }
+
+ try {
+ const res = await getHttpUserClient().get(key);
+ await this.doSaveUser(res);
+ line.next({ user: res, type: "synced" });
+ } catch (e) {
+ if (e instanceof HttpUserNotExistError) {
+ line.next({ type: "notexist" });
+ } else {
+ const cache = await this.getCachedUser(key);
+ line.next({ user: cache ?? undefined, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getUser$(username: string): Observable<User> {
+ return this._userHub.getObservable(username).pipe(
+ map((state) => state?.user),
+ filter((user): user is User => user != null)
+ );
+ }
+
+ private getCachedAvatar(username: string): Promise<BlobWithEtag | null> {
+ return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`);
+ }
+
+ private saveAvatar(username: string, data: BlobWithEtag): Promise<void> {
+ return dataStorage
+ .setItem<BlobWithEtag>(`user.${username}.avatar`, data)
+ .then();
+ }
+
+ syncAvatar(username: string): Promise<void> {
+ return this._avatarHub.getLineOrCreate(username).sync();
+ }
+
+ private _avatarHub = new DataHub<
+ string,
+ | { data: Blob; type: "cache" | "synced" | "offline" }
+ | { data?: undefined; type: "notexist" | "offline" }
+ >({
+ sync: async (key, line) => {
+ const cache = await this.getCachedAvatar(key);
+ if (line.value == null) {
+ if (cache != null) {
+ line.next({ data: cache.data, type: "cache" });
+ }
+ }
+
+ if (cache == null) {
+ try {
+ const avatar = await getHttpUserClient().getAvatar(key);
+ await this.saveAvatar(key, avatar);
+ line.next({ data: avatar.data, type: "synced" });
+ } catch (e) {
+ line.next({ type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ } else {
+ try {
+ const res = await getHttpUserClient().getAvatar(key, cache.etag);
+ if (res instanceof NotModified) {
+ line.next({ data: cache.data, type: "synced" });
+ } else {
+ const avatar = res;
+ await this.saveAvatar(key, avatar);
+ line.next({ data: avatar.data, type: "synced" });
+ }
+ } catch (e) {
+ line.next({ data: cache.data, type: "offline" });
+ throwIfNotNetworkError(e);
+ }
+ }
+ },
+ });
+
+ getAvatar$(username: string): Observable<Blob> {
+ return this._avatarHub.getObservable(username).pipe(
+ map((state) => state.data),
+ filter((blob): blob is Blob => blob != null)
+ );
+ }
+
+ getUserInfo(username: string): Observable<User> {
+ return from(getHttpUserClient().get(username)).pipe(
+ convertError(HttpUserNotExistError, UserNotExistError)
+ );
+ }
+
+ async setAvatar(username: string, blob: Blob): Promise<void> {
+ const user = checkLogin();
+ await getHttpUserClient().putAvatar(username, blob, user.token);
+ this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" });
+ }
+
+ async setNickname(username: string, nickname: string): Promise<void> {
+ const user = checkLogin();
+ return getHttpUserClient()
+ .patch(username, { nickname }, user.token)
+ .then((user) => {
+ this.saveUser(user);
+ });
+ }
+}
+
+export const userInfoService = new UserInfoService();
+
+export function useAvatar(username?: string): Blob | undefined {
+ const [state, setState] = React.useState<Blob | undefined>(undefined);
+ React.useEffect(() => {
+ if (username == null) {
+ setState(undefined);
+ return;
+ }
+
+ const subscription = userInfoService
+ .getAvatar$(username)
+ .subscribe((blob) => {
+ setState(blob);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [username]);
+ return state;
+}