From 8edf6d566cecd94d251a4e29ae8c35b77f88d6db Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 9 Jan 2021 01:07:35 +0800
Subject: ...

---
 .../views/timeline-common/TimelinePageTemplate.tsx | 62 ++++++++++++++++++----
 1 file changed, 51 insertions(+), 11 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 7f5c8206..caced3b7 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -45,7 +45,7 @@ export default function TimelinePageTemplate<TManageItem>(
     null
   );
 
-  const timelineState = useTimelineInfo(name);
+  const [timelineState, setTimelineState] = useTimelineInfo(name);
   const postListState = usePostList(name);
 
   const onPost: TimelinePostSendCallback = React.useCallback(
@@ -121,24 +121,64 @@ export default function TimelinePageTemplate<TManageItem>(
           onBookmark:
             user != null
               ? () => {
-                  void getHttpBookmarkClient()
-                    .put(name, user.token)
-                    .then(() => {
-                      pushAlert({
-                        message: {
-                          type: "i18n",
-                          key: "timeline.addBookmarkSuccess",
+                  if (timeline.isBookmark) {
+                    setTimelineState({
+                      ...timelineState,
+                      timeline: {
+                        ...timeline,
+                        isBookmark: false,
+                      },
+                    });
+                    void getHttpBookmarkClient()
+                      .delete(name)
+                      .then(
+                        () => {
+                          void timelineService.syncTimeline(name);
                         },
-                        type: "success",
-                      });
+                        () => {
+                          pushAlert({
+                            message: {
+                              type: "i18n",
+                              key: "timeline.removeBookmarkFail",
+                            },
+                            type: "danger",
+                          });
+                          setTimelineState(timelineState);
+                        }
+                      );
+                  } else {
+                    setTimelineState({
+                      ...timelineState,
+                      timeline: {
+                        ...timeline,
+                        isBookmark: true,
+                      },
                     });
+                    void getHttpBookmarkClient()
+                      .put(name)
+                      .then(
+                        () => {
+                          void timelineService.syncTimeline(name);
+                        },
+                        () => {
+                          pushAlert({
+                            message: {
+                              type: "i18n",
+                              key: "timeline.addBookmarkFail",
+                            },
+                            type: "danger",
+                          });
+                          setTimelineState(timelineState);
+                        }
+                      );
+                  }
                 }
               : undefined,
           onHighlight:
             user != null && user.hasHighlightTimelineAdministrationPermission
               ? () => {
                   void getHttpHighlightClient()
-                    .put(name, user.token)
+                    .put(name)
                     .then(() => {
                       pushAlert({
                         message: {
-- 
cgit v1.2.3


From 109deb21f406ac0a9bd49ca7fd87a1ad844050ba Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 9 Jan 2021 01:13:29 +0800
Subject: ...

---
 .../views/timeline-common/TimelinePageTemplate.tsx | 78 ++++++++--------------
 1 file changed, 28 insertions(+), 50 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index caced3b7..e0ad002a 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -121,57 +121,35 @@ export default function TimelinePageTemplate<TManageItem>(
           onBookmark:
             user != null
               ? () => {
-                  if (timeline.isBookmark) {
-                    setTimelineState({
-                      ...timelineState,
-                      timeline: {
-                        ...timeline,
-                        isBookmark: false,
-                      },
-                    });
-                    void getHttpBookmarkClient()
-                      .delete(name)
-                      .then(
-                        () => {
-                          void timelineService.syncTimeline(name);
-                        },
-                        () => {
-                          pushAlert({
-                            message: {
-                              type: "i18n",
-                              key: "timeline.removeBookmarkFail",
-                            },
-                            type: "danger",
-                          });
-                          setTimelineState(timelineState);
-                        }
-                      );
-                  } else {
-                    setTimelineState({
-                      ...timelineState,
-                      timeline: {
-                        ...timeline,
-                        isBookmark: true,
-                      },
-                    });
-                    void getHttpBookmarkClient()
-                      .put(name)
-                      .then(
-                        () => {
-                          void timelineService.syncTimeline(name);
+                  const { isBookmark } = timeline;
+                  setTimelineState({
+                    ...timelineState,
+                    timeline: {
+                      ...timeline,
+                      isBookmark: !isBookmark,
+                    },
+                  });
+                  const client = getHttpBookmarkClient();
+                  const promise = isBookmark
+                    ? client.delete(name)
+                    : client.put(name);
+                  promise.then(
+                    () => {
+                      void timelineService.syncTimeline(name);
+                    },
+                    () => {
+                      pushAlert({
+                        message: {
+                          type: "i18n",
+                          key: isBookmark
+                            ? "timeline.removeBookmarkFail"
+                            : "timeline.addBookmarkFail", // TODO: Add this translation.
                         },
-                        () => {
-                          pushAlert({
-                            message: {
-                              type: "i18n",
-                              key: "timeline.addBookmarkFail",
-                            },
-                            type: "danger",
-                          });
-                          setTimelineState(timelineState);
-                        }
-                      );
-                  }
+                        type: "danger",
+                      });
+                      setTimelineState(timelineState);
+                    }
+                  );
                 }
               : undefined,
           onHighlight:
-- 
cgit v1.2.3


From 9d1d88bacf121699ddf085b799ce191c0d7d3a50 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 9 Jan 2021 23:30:39 +0800
Subject: ...

---
 FrontEnd/src/app/locales/en/translation.json       |  6 ++--
 FrontEnd/src/app/locales/zh/translation.json       |  6 ++--
 .../views/timeline-common/TimelinePageTemplate.tsx | 32 +++++++++++++++++-----
 3 files changed, 33 insertions(+), 11 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json
index 596b5217..ebf9552a 100644
--- a/FrontEnd/src/app/locales/en/translation.json
+++ b/FrontEnd/src/app/locales/en/translation.json
@@ -107,8 +107,10 @@
         "prompt": "Are you sure to delete the post? This operation is not recoverable."
       }
     },
-    "addHighlightSuccess": "Succeeded to add highlight.",
-    "addBookmarkSuccess": "Succeeded to add bookmark."
+    "addHighlightFail": "Failed to add highlight.",
+    "removeHighlightFail": "Failed to remove highlight.",
+    "addBookmarkFail": "Failed to add bookmark.",
+    "removeBookmarkFail": "Failed to remove bookmark."
   },
   "user": {
     "username": "username",
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json
index e15e177e..0d063b4e 100644
--- a/FrontEnd/src/app/locales/zh/translation.json
+++ b/FrontEnd/src/app/locales/zh/translation.json
@@ -107,8 +107,10 @@
         "prompt": "确定删除这个消息？这个操作不可撤销。"
       }
     },
-    "addHighlightSuccess": "成功添加高光。",
-    "addBookmarkSuccess": "成功添加书签。"
+    "addHighlightFail": "添加高光失败。",
+    "removeHighlightFail": "删除高光失败。",
+    "addBookmarkFail": "添加书签失败。",
+    "removeBookmarkFail": "删除书签失败。"
   },
   "user": {
     "username": "用户名",
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index e0ad002a..35b31ec2 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -143,7 +143,7 @@ export default function TimelinePageTemplate<TManageItem>(
                           type: "i18n",
                           key: isBookmark
                             ? "timeline.removeBookmarkFail"
-                            : "timeline.addBookmarkFail", // TODO: Add this translation.
+                            : "timeline.addBookmarkFail",
                         },
                         type: "danger",
                       });
@@ -155,17 +155,35 @@ export default function TimelinePageTemplate<TManageItem>(
           onHighlight:
             user != null && user.hasHighlightTimelineAdministrationPermission
               ? () => {
-                  void getHttpHighlightClient()
-                    .put(name)
-                    .then(() => {
+                  const { isHighlight } = timeline;
+                  setTimelineState({
+                    ...timelineState,
+                    timeline: {
+                      ...timeline,
+                      isHighlight: !isHighlight,
+                    },
+                  });
+                  const client = getHttpHighlightClient();
+                  const promise = isHighlight
+                    ? client.delete(name)
+                    : client.put(name);
+                  promise.then(
+                    () => {
+                      void timelineService.syncTimeline(name);
+                    },
+                    () => {
                       pushAlert({
                         message: {
                           type: "i18n",
-                          key: "timeline.addHighlightSuccess",
+                          key: isHighlight
+                            ? "timeline.removeHighlightFail"
+                            : "timeline.addHighlightFail",
                         },
-                        type: "success",
+                        type: "danger",
                       });
-                    });
+                      setTimelineState(timelineState);
+                    }
+                  );
                 }
               : undefined,
         };
-- 
cgit v1.2.3


From 5cb8f773183a46b7be6f0af14110a499432abba7 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 9 Jan 2021 23:44:45 +0800
Subject: ...

---
 .../views/timeline-common/TimelineCardTemplate.tsx | 10 +++++--
 .../views/timeline-common/TimelinePageTemplate.tsx | 14 ++++-----
 .../timeline-common/TimelinePageTemplateUI.tsx     | 35 +++++++++++-----------
 .../src/app/views/timeline/TimelineInfoCard.tsx    |  6 ++--
 FrontEnd/src/app/views/user/UserInfoCard.tsx       |  6 ++--
 5 files changed, 38 insertions(+), 33 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
index b2b349bc..e62f76fa 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
@@ -10,7 +10,11 @@ import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
 import CollapseButton from "../timeline-common/CollapseButton";
 
 export interface TimelineCardTemplateProps
-  extends Omit<TimelineCardComponentProps<"">, "onManage" | "onMember"> {
+  extends Omit<TimelineCardComponentProps<"">, "operations"> {
+  operations: Pick<
+    TimelineCardComponentProps<"">["operations"],
+    "onHighlight" | "onBookmark"
+  >;
   infoArea: React.ReactElement;
   manageArea:
     | { type: "member"; onMember: () => void }
@@ -33,13 +37,13 @@ function TimelineCardTemplate({
   collapse,
   infoArea,
   manageArea,
-  onBookmark,
-  onHighlight,
+  operations,
   toggleCollapse,
   syncStatus,
   className,
 }: TimelineCardTemplateProps): React.ReactElement | null {
   const { t } = useTranslation();
+  const { onBookmark, onHighlight } = operations;
 
   return (
     <div className={clsx("cru-card p-2 clearfix", className)}>
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 35b31ec2..f66d14e0 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -110,7 +110,7 @@ export default function TimelinePageTemplate<TManageItem>(
             : undefined,
         }));
 
-        const others = {
+        const operations = {
           onPost: service.hasPostPermission(user, timeline)
             ? onPost
             : undefined,
@@ -189,20 +189,20 @@ export default function TimelinePageTemplate<TManageItem>(
         };
 
         if (type === "cache") {
-          return [{ timeline, posts, ...others }, "syncing"];
+          return [{ timeline, posts, operations }, "syncing"];
         } else if (type === "offline") {
-          return [{ timeline, posts, ...others }, "offline"];
+          return [{ timeline, posts, operations }, "offline"];
         } else {
           if (postListState == null) {
-            return [{ timeline, posts, ...others }, "syncing"];
+            return [{ timeline, posts, operations }, "syncing"];
           } else {
             const { type: postListType } = postListState;
             if (postListType === "synced") {
-              return [{ timeline, posts, ...others }, "synced"];
+              return [{ timeline, posts, operations }, "synced"];
             } else if (postListType === "cache") {
-              return [{ timeline, posts, ...others }, "syncing"];
+              return [{ timeline, posts, operations }, "syncing"];
             } else if (postListType === "offline") {
-              return [{ timeline, posts, ...others }, "offline"];
+              return [{ timeline, posts, operations }, "offline"];
             }
           }
         }
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index 20ec6e43..b2824c84 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -13,14 +13,16 @@ import { TimelineSyncStatus } from "./SyncStatusBadge";
 
 export interface TimelineCardComponentProps<TManageItems> {
   timeline: TimelineInfo;
-  onManage?: (item: TManageItems | "property") => void;
-  onMember: () => void;
-  onBookmark?: () => void;
-  onHighlight?: () => void;
-  className?: string;
-  collapse: boolean;
   syncStatus: TimelineSyncStatus;
+  operations: {
+    onManage?: (item: TManageItems | "property") => void;
+    onMember: () => void;
+    onBookmark?: () => void;
+    onHighlight?: () => void;
+  };
+  collapse: boolean;
   toggleCollapse: () => void;
+  className?: string;
 }
 
 export interface TimelinePageTemplateUIProps<TManageItems> {
@@ -28,11 +30,13 @@ export interface TimelinePageTemplateUIProps<TManageItems> {
     | {
         timeline: TimelineInfo;
         posts?: TimelinePostInfoEx[];
-        onManage?: (item: TManageItems | "property") => void;
-        onMember: () => void;
-        onBookmark?: () => void;
-        onHighlight?: () => void;
-        onPost?: TimelinePostSendCallback;
+        operations: {
+          onManage?: (item: TManageItems | "property") => void;
+          onMember: () => void;
+          onBookmark?: () => void;
+          onHighlight?: () => void;
+          onPost?: TimelinePostSendCallback;
+        };
       }
     | I18nText;
   syncStatus: TimelineSyncStatus;
@@ -155,10 +159,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
           <CardComponent
             className="timeline-template-card"
             timeline={data.timeline}
-            onManage={data.onManage}
-            onMember={data.onMember}
-            onBookmark={data.onBookmark}
-            onHighlight={data.onHighlight}
+            operations={data.operations}
             syncStatus={syncStatus}
             collapse={cardCollapse}
             toggleCollapse={toggleCardCollapse}
@@ -180,7 +181,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
             <Spinner variant="primary" animation="grow" />
           </div>
         )}
-        {data != null && data.onPost != null ? (
+        {data != null && data.operations.onPost != null ? (
           <>
             <div
               style={{ height: bottomSpaceHeight }}
@@ -188,7 +189,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
             />
             <TimelinePostEdit
               className="fixed-bottom"
-              onPost={data.onPost}
+              onPost={data.operations.onPost}
               onHeightChange={onPostEditHeightChange}
               timelineUniqueId={data.timeline.uniqueId}
             />
diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
index f4dbb67d..920f504d 100644
--- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
+++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx
@@ -13,8 +13,8 @@ export type OrdinaryTimelineManageItem = "delete";
 export type TimelineInfoCardProps = TimelineCardComponentProps<OrdinaryTimelineManageItem>;
 
 const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
-  const { onMember, onManage, ...otherProps } = props;
-  const { timeline } = props;
+  const { timeline, operations } = props;
+  const { onManage, onMember } = operations;
 
   const avatar = useAvatar(timeline?.owner?.username);
 
@@ -66,7 +66,7 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
           };
         }
       })()}
-      {...otherProps}
+      {...props}
     />
   );
 };
diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx
index f31a939f..01d2c096 100644
--- a/FrontEnd/src/app/views/user/UserInfoCard.tsx
+++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx
@@ -13,8 +13,8 @@ export type PersonalTimelineManageItem = "avatar" | "nickname";
 export type UserInfoCardProps = TimelineCardComponentProps<PersonalTimelineManageItem>;
 
 const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
-  const { onMember, onManage, ...otherProps } = props;
-  const { timeline } = props;
+  const { timeline, operations } = props;
+  const { onManage, onMember } = operations;
 
   const avatar = useAvatar(timeline?.owner?.username);
 
@@ -66,7 +66,7 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
           };
         }
       })()}
-      {...otherProps}
+      {...props}
     />
   );
 };
-- 
cgit v1.2.3


From 873bb613bc2deb86a4266bac160d14be265f9609 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 11 Jan 2021 21:34:57 +0800
Subject: ...

---
 FrontEnd/src/app/http/common.ts                    |   4 +
 FrontEnd/src/app/http/user.ts                      |   8 +-
 FrontEnd/src/app/services/DataHub.ts               | 225 --------
 FrontEnd/src/app/services/DataHub2.ts              |  10 +
 FrontEnd/src/app/services/common.ts                |  19 +-
 FrontEnd/src/app/services/timeline.ts              | 573 +++++++--------------
 FrontEnd/src/app/services/user.ts                  | 140 +++--
 .../views/timeline-common/TimelinePageTemplate.tsx | 281 +++++-----
 .../timeline-common/TimelinePageTemplateUI.tsx     |  26 +-
 9 files changed, 423 insertions(+), 863 deletions(-)
 delete mode 100644 FrontEnd/src/app/services/DataHub.ts

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/http/common.ts b/FrontEnd/src/app/http/common.ts
index 95d29fb6..0f46280c 100644
--- a/FrontEnd/src/app/http/common.ts
+++ b/FrontEnd/src/app/http/common.ts
@@ -179,3 +179,7 @@ export function convertToBlobWithEtag(res: AxiosResponse<Blob>): BlobWithEtag {
     etag: (res.headers as Record<"etag", string>)["etag"],
   };
 }
+
+export function extractEtag(res: AxiosResponse): string {
+  return (res.headers as Record<"etag", string>)["etag"];
+}
diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts
index 8345880e..19accc42 100644
--- a/FrontEnd/src/app/http/user.ts
+++ b/FrontEnd/src/app/http/user.ts
@@ -11,6 +11,7 @@ import {
   BlobWithEtag,
   convertToBlobWithEtag,
   convertToNotModified,
+  extractEtag,
 } from "./common";
 
 export const kUserManagement = "UserManagement";
@@ -70,7 +71,8 @@ export interface IHttpUserClient {
     username: string,
     etag: string
   ): Promise<BlobWithEtag | NotModified>;
-  putAvatar(username: string, data: Blob): Promise<void>;
+  // return etag
+  putAvatar(username: string, data: Blob): Promise<string>;
   changePassword(req: HttpChangePasswordRequest): Promise<void>;
   putUserPermission(
     username: string,
@@ -137,7 +139,7 @@ export class HttpUserClient implements IHttpUserClient {
       .catch(convertToNetworkError);
   }
 
-  putAvatar(username: string, data: Blob): Promise<void> {
+  putAvatar(username: string, data: Blob): Promise<string> {
     return axios
       .put(`${apiBaseUrl}/users/${username}/avatar`, data, {
         headers: {
@@ -145,7 +147,7 @@ export class HttpUserClient implements IHttpUserClient {
         },
       })
       .catch(convertToNetworkError)
-      .then();
+      .then(extractEtag);
   }
 
   changePassword(req: HttpChangePasswordRequest): Promise<void> {
diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts
deleted file mode 100644
index 4d618db6..00000000
--- a/FrontEnd/src/app/services/DataHub.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-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) {
-      setTimeout(() => 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/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts
index 88849da3..50ae919b 100644
--- a/FrontEnd/src/app/services/DataHub2.ts
+++ b/FrontEnd/src/app/services/DataHub2.ts
@@ -2,6 +2,16 @@ import { Observable } from "rxjs";
 
 export type DataStatus = "syncing" | "synced" | "offline";
 
+export function mergeDataStatus(statusList: DataStatus[]): DataStatus {
+  if (statusList.includes("offline")) {
+    return "offline";
+  } else if (statusList.includes("syncing")) {
+    return "syncing";
+  } else {
+    return "synced";
+  }
+}
+
 export type Subscriber<TData> = (data: TData) => void;
 
 export interface DataAndStatus<TData> {
diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts
index 3bb6b9d7..9208737b 100644
--- a/FrontEnd/src/app/services/common.ts
+++ b/FrontEnd/src/app/services/common.ts
@@ -1,6 +1,6 @@
 import localforage from "localforage";
 
-import { HttpNetworkError } from "@/http/common";
+const dataVersion = 1;
 
 export const dataStorage = localforage.createInstance({
   name: "data",
@@ -8,16 +8,17 @@ export const dataStorage = localforage.createInstance({
   driver: localforage.INDEXEDDB,
 });
 
+void (async () => {
+  const currentVersion = await dataStorage.getItem<number | null>("version");
+  if (currentVersion !== dataVersion) {
+    console.log("Data storage version has changed. Clear all data.");
+    await dataStorage.clear();
+    await dataStorage.setItem("version", dataVersion);
+  }
+})();
+
 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
index 3b9a9072..ed24c005 100644
--- a/FrontEnd/src/app/services/timeline.ts
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -1,8 +1,6 @@
 import React from "react";
 import XRegExp from "xregexp";
-import { Observable, from, combineLatest, of } from "rxjs";
-import { map, switchMap, startWith, filter } from "rxjs/operators";
-import { uniqBy } from "lodash";
+import { Observable, from } from "rxjs";
 
 import { convertError } from "@/utilities/rxjs";
 import {
@@ -19,16 +17,15 @@ import {
   HttpTimelineNotExistError,
   HttpTimelineNameConflictError,
 } from "@/http/timeline";
-import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common";
-import { HttpUser } from "@/http/user";
+import { HttpForbiddenError, HttpNetworkError } from "@/http/common";
 
 export { kTimelineVisibilities } from "@/http/timeline";
 
 export type { TimelineVisibility } from "@/http/timeline";
 
-import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common";
-import { DataHub, WithSyncStatus } from "./DataHub";
-import { userInfoService, User, AuthUser } from "./user";
+import { dataStorage } from "./common";
+import { userInfoService, AuthUser } from "./user";
+import { DataAndStatus, DataHub2 } from "./DataHub2";
 
 export type TimelineInfo = HttpTimelineInfo;
 export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
@@ -41,19 +38,21 @@ export type TimelinePostTextContent = HttpTimelinePostTextContent;
 
 export interface TimelinePostImageContent {
   type: "image";
-  data: BlobOrStatus;
+  data: Blob;
+  etag: string;
 }
 
 export type TimelinePostContent =
   | TimelinePostTextContent
   | TimelinePostImageContent;
 
-export interface TimelinePostInfo {
-  id: number;
+export type TimelinePostInfo = Omit<HttpTimelinePostInfo, "content"> & {
   content: TimelinePostContent;
-  time: Date;
+};
+
+export interface TimelinePostsInfo {
   lastUpdated: Date;
-  author: HttpUser;
+  posts: TimelinePostInfo[];
 }
 
 export const timelineVisibilityTooltipTranslationMap: Record<
@@ -65,55 +64,23 @@ export const timelineVisibilityTooltipTranslationMap: Record<
   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"> & {
+type TimelinePostData = Omit<TimelinePostInfo, "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();
-  }
+interface TimelinePostsData {
+  lastUpdated: Date;
+  posts: TimelinePostData[];
+}
 
+export class TimelineService {
   private async clearTimelineData(timelineName: string): Promise<void> {
     const keys = (await dataStorage.keys()).filter((k) =>
       k.startsWith(`timeline.${timelineName}`)
@@ -121,6 +88,10 @@ export class TimelineService {
     await Promise.all(keys.map((k) => dataStorage.removeItem(k)));
   }
 
+  private generateTimelineDataStorageKey(timelineName: string): string {
+    return `timeline.${timelineName}`;
+  }
+
   private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
     return {
       ...timeline,
@@ -129,95 +100,65 @@ export class TimelineService {
     };
   }
 
-  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);
+  readonly timelineHub = new DataHub2<string, HttpTimelineInfo | "notexist">({
+    saveData: async (timelineName, data) => {
+      if (data === "notexist") return;
 
-      if (line.value == undefined) {
-        if (cache != null) {
-          line.next({ type: "cache", timeline: cache });
-        }
-      }
+      userInfoService.saveUser(data.owner);
+      userInfoService.saveUsers(data.members);
 
-      try {
-        const httpTimeline = await getHttpTimelineClient().getTimeline(key);
+      await dataStorage.setItem<TimelineData>(
+        this.generateTimelineDataStorageKey(timelineName),
+        this.convertHttpTimelineToData(data)
+      );
+    },
+    getSavedData: async (timelineName) => {
+      const savedData = await dataStorage.getItem<TimelineData | null>(
+        this.generateTimelineDataStorageKey(timelineName)
+      );
 
-        userInfoService.saveUsers([
-          httpTimeline.owner,
-          ...httpTimeline.members,
-        ]);
+      if (savedData == null) return null;
 
-        const timeline = this.convertHttpTimelineToData(httpTimeline);
+      const owner = await userInfoService.getCachedUser(savedData.owner);
+      if (owner == null) return null;
+      const members = await userInfoService.getCachedUsers(savedData.members);
+      if (members == null) return null;
 
-        if (cache != null && timeline.uniqueId !== cache.uniqueId) {
+      return { ...savedData, owner, members };
+    },
+    fetchData: async (timelineName, savedData) => {
+      try {
+        const timeline = await getHttpTimelineClient().getTimeline(
+          timelineName
+        );
+
+        if (
+          savedData != null &&
+          savedData !== "notexist" &&
+          savedData.uniqueId !== timeline.uniqueId
+        ) {
           console.log(
-            `Timeline with name ${key} has changed to a new one. Clear old data.`
+            `Timeline with name ${timelineName} 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);
+          void this.clearTimelineData(timelineName); // If timeline has changed, clear all old data.
+        }
 
-        line.next({ type: "synced", timeline });
+        return timeline;
       } catch (e) {
         if (e instanceof HttpTimelineNotExistError) {
-          line.next({ type: "synced", timeline: null });
+          return "notexist";
+        } else if (e instanceof HttpNetworkError) {
+          return null;
         } else {
-          if (cache == null) {
-            line.next({ type: "offline", timeline: null });
-          } else {
-            line.next({ type: "offline", timeline: cache });
-          }
-          throwIfNotNetworkError(e);
+          throw 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) =>
-              state.type === "cache"
-                ? from(userInfoService.getCachedUser(u)).pipe(
-                    filter((u): u is User => u != null)
-                  )
-                : userInfoService.getUser$(u)
-            )
-          ).pipe(
-            map((users) => {
-              return {
-                ...state,
-                timeline: {
-                  ...timeline,
-                  owner: users[0],
-                  members: users.slice(1),
-                },
-              };
-            })
-          );
-        } else {
-          return of(state as TimelineWithSyncStatus);
-        }
-      })
-    );
+  syncTimeline(timelineName: string): void {
+    this.timelineHub.getLine(timelineName).sync();
   }
 
   createTimeline(timelineName: string): Observable<TimelineInfo> {
@@ -268,291 +209,145 @@ export class TimelineService {
     );
   }
 
-  private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData {
-    return {
-      ...post,
-      author: post.author.username,
-    };
+  private generatePostsDataStorageKey(timelineName: string): string {
+    return `timeline.${timelineName}.posts`;
   }
 
-  private convertHttpPostToDataList(
-    posts: HttpTimelinePostInfo[]
-  ): TimelinePostData[] {
-    return posts.map((post) => this.convertHttpPostToData(post));
-  }
+  readonly postsHub = new DataHub2<
+    string,
+    TimelinePostsInfo | "notexist" | "forbid"
+  >({
+    saveData: async (timelineName, data) => {
+      if (data === "notexist" || data === "forbid") return;
 
-  private getCachedPosts(
-    timelineName: string
-  ): Promise<TimelinePostData[] | null> {
-    return dataStorage.getItem<TimelinePostData[] | null>(
-      `timeline.${timelineName}.posts`
-    );
-  }
+      const savedData: TimelinePostsData = {
+        ...data,
+        posts: data.posts.map((p) => ({ ...p, author: p.author.username })),
+      };
 
-  private savePosts(
-    timelineName: string,
-    data: TimelinePostData[]
-  ): Promise<void> {
-    return dataStorage
-      .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
-      .then();
-  }
+      data.posts.forEach((p) => {
+        userInfoService.saveUser(p.author);
+      });
 
-  private syncPosts(timelineName: string): Promise<void> {
-    return this._postsHub.getLineOrCreate(timelineName).sync();
-  }
+      await dataStorage.setItem<TimelinePostsData>(
+        this.generatePostsDataStorageKey(timelineName),
+        savedData
+      );
+    },
+    getSavedData: async (timelineName) => {
+      const savedData = await dataStorage.getItem<TimelinePostsData | null>(
+        this.generatePostsDataStorageKey(timelineName)
+      );
+      if (savedData == null) return null;
 
-  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 authors = await userInfoService.getCachedUsers(
+        savedData.posts.map((p) => p.author)
+      );
+
+      if (authors == null) return null;
+
+      return {
+        ...savedData,
+        posts: savedData.posts.map((p, index) => ({
+          ...p,
+          author: authors[index],
+        })),
+      };
+    },
+    fetchData: async (timelineName, savedData) => {
+      const convert = async (
+        post: HttpTimelinePostInfo
+      ): Promise<TimelinePostInfo> => {
+        const { content } = post;
+        if (content.type === "text") {
+          return { ...post, content };
+        } else {
+          const data = await getHttpTimelineClient().getPostData(
+            timelineName,
+            post.id
+          );
+          return {
+            ...post,
+            content: {
+              type: "image",
+              data: data.data,
+              etag: data.etag,
+            },
+          };
         }
-      }
+      };
 
-      const now = new Date();
+      const convertList = (
+        posts: HttpTimelinePostInfo[]
+      ): Promise<TimelinePostInfo[]> =>
+        Promise.all(posts.map((p) => convert(p)));
 
-      const lastUpdatedTime = await dataStorage.getItem<Date | null>(
-        `timeline.${key}.lastUpdated`
-      );
+      const now = new Date();
 
       try {
-        if (lastUpdatedTime == null) {
-          const httpPosts = await getHttpTimelineClient().listPost(key);
-
-          userInfoService.saveUsers(
-            uniqBy(
-              httpPosts.map((post) => post.author),
-              "username"
-            )
+        if (
+          savedData == null ||
+          savedData === "forbid" ||
+          savedData === "notexist"
+        ) {
+          const httpPosts = await getHttpTimelineClient().listPost(
+            timelineName
           );
 
-          const posts = this.convertHttpPostToDataList(httpPosts);
-          await this.savePosts(key, posts);
-          await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
-
-          line.next({ type: "synced", posts });
+          return {
+            lastUpdated: now,
+            posts: await convertList(httpPosts),
+          };
         } else {
-          const httpPosts = await getHttpTimelineClient().listPost(key, {
-            modifiedSince: lastUpdatedTime,
-            includeDeleted: true,
-          });
+          const httpPosts = await getHttpTimelineClient().listPost(
+            timelineName,
+            {
+              modifiedSince: savedData.lastUpdated,
+              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 changed = await convertList(
+            httpPosts.filter((p): p is HttpTimelinePostInfo => !p.deleted)
           );
 
-          const cache = (await this.getCachedPosts(key)) ?? [];
-
-          const posts = cache.filter((p) => !deletedIds.includes(p.id));
+          const posts = savedData.posts.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));
+              posts.push(await convert(changedPost));
             } else {
-              posts[savedChangedPostIndex] = this.convertHttpPostToData(
-                changedPost
-              );
+              posts[savedChangedPostIndex] = await convert(changedPost);
             }
           }
 
-          await this.savePosts(key, posts);
-          await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now);
-          line.next({ type: "synced", posts });
+          return { lastUpdated: now, posts };
         }
       } catch (e) {
         if (e instanceof HttpTimelineNotExistError) {
-          line.next({ type: "notexist", posts: [] });
+          return "notexist";
         } else if (e instanceof HttpForbiddenError) {
-          line.next({ type: "forbid", posts: [] });
+          return "forbid";
+        } else if (e instanceof HttpNetworkError) {
+          return null;
         } else {
-          const cache = await this.getCachedPosts(key);
-          if (cache == null) {
-            line.next({ type: "offline", posts: [] });
-          } else {
-            line.next({ type: "offline", posts: cache });
-          }
-          throwIfNotNetworkError(e);
+          throw 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) =>
-              state.type === "cache"
-                ? from(userInfoService.getCachedUser(post.author)).pipe(
-                    filter((u): u is User => u != null)
-                  )
-                : userInfoService.getUser$(post.author)
-            )
-          ),
-          combineLatest(
-            state.posts.map((post) => {
-              if (post.content.type === "image") {
-                return state.type === "cache"
-                  ? from(this.getCachedPostData(timelineName, post.id))
-                  : 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);
-        }
-      }
-    },
-  });
-
-  getCachedPostData(
-    timelineName: string,
-    postId: number
-  ): Promise<Blob | null> {
-    return this._getCachedPostData({ timelineName, postId }).then(
-      (d) => d?.data ?? null
-    );
-  }
-
-  getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> {
-    return this._postDataHub.getObservable({ timelineName, postId }).pipe(
-      map((state): BlobOrStatus => state.data ?? "error"),
-      startWith("loading")
-    );
+  syncPosts(timelineName: string): void {
+    this.postsHub.getLine(timelineName).sync();
   }
 
   createPost(
@@ -563,7 +358,7 @@ export class TimelineService {
       getHttpTimelineClient()
         .postPost(timelineName, request)
         .then(() => {
-          void this.syncPosts(timelineName);
+          this.syncPosts(timelineName);
         })
     );
   }
@@ -573,7 +368,7 @@ export class TimelineService {
       getHttpTimelineClient()
         .deletePost(timelineName, postId)
         .then(() => {
-          void this.syncPosts(timelineName);
+          this.syncPosts(timelineName);
         })
     );
   }
@@ -654,18 +449,22 @@ export function validateTimelineName(name: string): boolean {
   return timelineNameReg.test(name);
 }
 
-export function useTimelineInfo(
+export function useTimeline(
   timelineName: string
 ): [
-  TimelineWithSyncStatus | undefined,
-  React.Dispatch<React.SetStateAction<TimelineWithSyncStatus | undefined>>
+  DataAndStatus<TimelineInfo | "notexist">,
+  React.Dispatch<React.SetStateAction<DataAndStatus<TimelineInfo | "notexist">>>
 ] {
-  const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>(
-    undefined
-  );
+  const [state, setState] = React.useState<
+    DataAndStatus<TimelineInfo | "notexist">
+  >({
+    status: "syncing",
+    data: null,
+  });
   React.useEffect(() => {
-    const subscription = timelineService
-      .getTimeline$(timelineName)
+    const subscription = timelineService.timelineHub
+      .getLine(timelineName)
+      .getObservalble()
       .subscribe((data) => {
         setState(data);
       });
@@ -676,20 +475,16 @@ export function useTimelineInfo(
   return [state, setState];
 }
 
-export function usePostList(
-  timelineName: string | null | undefined
-): TimelinePostsWithSyncState | undefined {
+export function usePosts(
+  timelineName: string
+): DataAndStatus<TimelinePostsInfo | "notexist" | "forbid"> {
   const [state, setState] = React.useState<
-    TimelinePostsWithSyncState | undefined
-  >(undefined);
+    DataAndStatus<TimelinePostsInfo | "notexist" | "forbid">
+  >({ status: "syncing", data: null });
   React.useEffect(() => {
-    if (timelineName == null) {
-      setState(undefined);
-      return;
-    }
-
-    const subscription = timelineService
-      .getPosts$(timelineName)
+    const subscription = timelineService.postsHub
+      .getLine(timelineName)
+      .getObservalble()
       .subscribe((data) => {
         setState(data);
       });
diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts
index 3407ad02..5c4e3ae0 100644
--- a/FrontEnd/src/app/services/user.ts
+++ b/FrontEnd/src/app/services/user.ts
@@ -1,9 +1,7 @@
 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,
@@ -22,10 +20,9 @@ import {
   UserPermission,
 } from "@/http/user";
 
-import { dataStorage, throwIfNotNetworkError } from "./common";
-import { DataHub } from "./DataHub";
-import { pushAlert } from "./alert";
 import { DataHub2 } from "./DataHub2";
+import { dataStorage } from "./common";
+import { pushAlert } from "./alert";
 
 export type User = HttpUser;
 
@@ -259,6 +256,26 @@ export class UserInfoService {
     return users.forEach((user) => this.saveUser(user));
   }
 
+  async getCachedUser(username: string): Promise<HttpUser | null> {
+    const user = await this.userHub.getLine(username).getSavedData();
+    if (user == null || user === "notexist") return null;
+    return user;
+  }
+
+  async getCachedUsers(usernames: string[]): Promise<HttpUser[] | null> {
+    const users = await Promise.all(
+      usernames.map((username) => this.userHub.getLine(username).getSavedData())
+    );
+
+    for (const u of users) {
+      if (u == null || u === "notexist") {
+        return null;
+      }
+    }
+
+    return users as HttpUser[];
+  }
+
   private generateUserDataStorageKey(username: string): string {
     return `user.${username}`;
   }
@@ -289,80 +306,52 @@ export class UserInfoService {
     },
   });
 
-  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();
-  }
-
-  getCachedAvatar(username: string): Promise<Blob | null> {
-    return this._getCachedAvatar(username).then((d) => d?.data ?? null);
+  private generateAvatarDataStorageKey(username: string): string {
+    return `user.${username}.avatar`;
   }
 
-  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);
+  readonly avatarHub = new DataHub2<string, BlobWithEtag | "notexist">({
+    saveData: async (username, data) => {
+      if (typeof data === "string") return;
+      await dataStorage.setItem<BlobWithEtag>(
+        this.generateAvatarDataStorageKey(username),
+        data
+      );
+    },
+    getSavedData: (username) =>
+      dataStorage.getItem<BlobWithEtag | null>(
+        this.generateAvatarDataStorageKey(username)
+      ),
+    fetchData: async (username, savedData) => {
+      try {
+        if (savedData == null || savedData === "notexist") {
+          return await getHttpUserClient().getAvatar(username);
+        } else {
+          const res = await getHttpUserClient().getAvatar(
+            username,
+            savedData.etag
+          );
           if (res instanceof NotModified) {
-            line.next({ data: cache.data, type: "synced" });
+            return savedData;
           } else {
-            const avatar = res;
-            await this.saveAvatar(key, avatar);
-            line.next({ data: avatar.data, type: "synced" });
+            return res;
           }
-        } catch (e) {
-          line.next({ data: cache.data, type: "offline" });
-          throwIfNotNetworkError(e);
+        }
+      } catch (e) {
+        if (e instanceof HttpUserNotExistError) {
+          return "notexist";
+        } else if (e instanceof HttpNetworkError) {
+          return null;
+        } else {
+          throw 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> {
-    await getHttpUserClient().putAvatar(username, blob);
-    this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" });
+    const etag = await getHttpUserClient().putAvatar(username, blob);
+    this.avatarHub.getLine(username).save({ data: blob, etag });
   }
 
   async setNickname(username: string, nickname: string): Promise<void> {
@@ -384,14 +373,21 @@ export function useAvatar(username?: string): Blob | undefined {
       return;
     }
 
-    const subscription = userInfoService
-      .getAvatar$(username)
-      .subscribe((blob) => {
-        setState(blob);
+    const subscription = userInfoService.avatarHub
+      .getLine(username)
+      .getObservalble()
+      .subscribe((data) => {
+        if (data.data != null && data.data !== "notexist") {
+          setState(data.data.data);
+        } else {
+          setState(undefined);
+        }
       });
+
     return () => {
       subscription.unsubscribe();
     };
   }, [username]);
+
   return state;
 }
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index f66d14e0..b4058fbe 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -1,25 +1,20 @@
 import React from "react";
 import { useTranslation } from "react-i18next";
-import { of } from "rxjs";
-import { catchError } from "rxjs/operators";
 
 import { UiLogicError } from "@/common";
 import { pushAlert } from "@/services/alert";
-import { useUser, userInfoService, UserNotExistError } from "@/services/user";
-import {
-  timelineService,
-  usePostList,
-  useTimelineInfo,
-} from "@/services/timeline";
+import { useUser } from "@/services/user";
+import { timelineService, usePosts, useTimeline } from "@/services/timeline";
 import { getHttpBookmarkClient } from "@/http/bookmark";
 import { getHttpHighlightClient } from "@/http/highlight";
+import { getHttpUserClient, HttpUserNotExistError } from "@/http/user";
 
 import { TimelineMemberDialog } from "./TimelineMember";
 import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
 import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
 import { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
 import { TimelinePostInfoEx } from "./Timeline";
+import { mergeDataStatus } from "@/services/DataHub2";
 
 export interface TimelinePageTemplateProps<TManageItem> {
   name: string;
@@ -45,8 +40,8 @@ export default function TimelinePageTemplate<TManageItem>(
     null
   );
 
-  const [timelineState, setTimelineState] = useTimelineInfo(name);
-  const postListState = usePostList(name);
+  const [timelineAndStatus, setTimelineAndStatus] = useTimeline(name);
+  const postsAndState = usePosts(name);
 
   const onPost: TimelinePostSendCallback = React.useCallback(
     (req) => {
@@ -68,147 +63,122 @@ export default function TimelinePageTemplate<TManageItem>(
     [onManageProp]
   );
 
-  const childProps = ((): [
-    data: TimelinePageTemplateUIProps<TManageItem>["data"],
-    syncStatus: TimelineSyncStatus
-  ] => {
-    if (timelineState == null) {
-      return [undefined, "syncing"];
+  const data = ((): TimelinePageTemplateUIProps<TManageItem>["data"] => {
+    const { status, data: timeline } = timelineAndStatus;
+    if (timeline == null) {
+      if (status === "offline") {
+        return { type: "custom", value: "Network Error" };
+      } else {
+        return undefined;
+      }
+    } else if (timeline === "notexist") {
+      return props.notFoundI18nKey;
     } else {
-      const { type, timeline } = timelineState;
-      if (timeline == null) {
-        if (type === "offline") {
-          return [{ type: "custom", value: "Network Error" }, "offline"];
-        } else if (type === "synced") {
-          return [props.notFoundI18nKey, "synced"];
+      const posts = ((): TimelinePostInfoEx[] | "forbid" | undefined => {
+        const { data: postsInfo } = postsAndState;
+        if (postsInfo === "forbid") {
+          return "forbid";
+        } else if (postsInfo === "notexist") {
+          return undefined;
+        } else if (postsInfo == null) {
+          return undefined;
         } else {
-          return [undefined, "syncing"];
-        }
-      } else {
-        if (postListState != null && postListState.type === "notexist") {
-          return [props.notFoundI18nKey, "synced"];
-        }
-        if (postListState != null && postListState.type === "forbid") {
-          return ["timeline.messageCantSee", "synced"];
+          return postsInfo.posts.map((post) => ({
+            ...post,
+            onDelete: service.hasModifyPostPermission(user, timeline, post)
+              ? () => {
+                  service.deletePost(name, post.id).subscribe({
+                    error: () => {
+                      pushAlert({
+                        type: "danger",
+                        message: t("timeline.deletePostFailed"),
+                      });
+                    },
+                  });
+                }
+              : undefined,
+          }));
         }
+      })();
 
-        const posts:
-          | TimelinePostInfoEx[]
-          | undefined = postListState?.posts?.map((post) => ({
-          ...post,
-          onDelete: service.hasModifyPostPermission(user, timeline, post)
+      const operations = {
+        onPost: service.hasPostPermission(user, timeline) ? onPost : undefined,
+        onManage: service.hasManagePermission(user, timeline)
+          ? onManage
+          : undefined,
+        onMember: () => setDialog("member"),
+        onBookmark:
+          user != null
             ? () => {
-                service.deletePost(name, post.id).subscribe({
-                  error: () => {
+                const { isBookmark } = timeline;
+                setTimelineAndStatus({
+                  ...timelineAndStatus,
+                  data: {
+                    ...timeline,
+                    isBookmark: !isBookmark,
+                  },
+                });
+                const client = getHttpBookmarkClient();
+                const promise = isBookmark
+                  ? client.delete(name)
+                  : client.put(name);
+                promise.then(
+                  () => {
+                    void timelineService.syncTimeline(name);
+                  },
+                  () => {
                     pushAlert({
+                      message: {
+                        type: "i18n",
+                        key: isBookmark
+                          ? "timeline.removeBookmarkFail"
+                          : "timeline.addBookmarkFail",
+                      },
                       type: "danger",
-                      message: t("timeline.deletePostFailed"),
                     });
+                    setTimelineAndStatus(timelineAndStatus);
+                  }
+                );
+              }
+            : undefined,
+        onHighlight:
+          user != null && user.hasHighlightTimelineAdministrationPermission
+            ? () => {
+                const { isHighlight } = timeline;
+                setTimelineAndStatus({
+                  ...timelineAndStatus,
+                  data: {
+                    ...timeline,
+                    isHighlight: !isHighlight,
                   },
                 });
+                const client = getHttpHighlightClient();
+                const promise = isHighlight
+                  ? client.delete(name)
+                  : client.put(name);
+                promise.then(
+                  () => {
+                    void timelineService.syncTimeline(name);
+                  },
+                  () => {
+                    pushAlert({
+                      message: {
+                        type: "i18n",
+                        key: isHighlight
+                          ? "timeline.removeHighlightFail"
+                          : "timeline.addHighlightFail",
+                      },
+                      type: "danger",
+                    });
+                    setTimelineAndStatus(timelineAndStatus);
+                  }
+                );
               }
             : undefined,
-        }));
-
-        const operations = {
-          onPost: service.hasPostPermission(user, timeline)
-            ? onPost
-            : undefined,
-          onManage: service.hasManagePermission(user, timeline)
-            ? onManage
-            : undefined,
-          onMember: () => setDialog("member"),
-          onBookmark:
-            user != null
-              ? () => {
-                  const { isBookmark } = timeline;
-                  setTimelineState({
-                    ...timelineState,
-                    timeline: {
-                      ...timeline,
-                      isBookmark: !isBookmark,
-                    },
-                  });
-                  const client = getHttpBookmarkClient();
-                  const promise = isBookmark
-                    ? client.delete(name)
-                    : client.put(name);
-                  promise.then(
-                    () => {
-                      void timelineService.syncTimeline(name);
-                    },
-                    () => {
-                      pushAlert({
-                        message: {
-                          type: "i18n",
-                          key: isBookmark
-                            ? "timeline.removeBookmarkFail"
-                            : "timeline.addBookmarkFail",
-                        },
-                        type: "danger",
-                      });
-                      setTimelineState(timelineState);
-                    }
-                  );
-                }
-              : undefined,
-          onHighlight:
-            user != null && user.hasHighlightTimelineAdministrationPermission
-              ? () => {
-                  const { isHighlight } = timeline;
-                  setTimelineState({
-                    ...timelineState,
-                    timeline: {
-                      ...timeline,
-                      isHighlight: !isHighlight,
-                    },
-                  });
-                  const client = getHttpHighlightClient();
-                  const promise = isHighlight
-                    ? client.delete(name)
-                    : client.put(name);
-                  promise.then(
-                    () => {
-                      void timelineService.syncTimeline(name);
-                    },
-                    () => {
-                      pushAlert({
-                        message: {
-                          type: "i18n",
-                          key: isHighlight
-                            ? "timeline.removeHighlightFail"
-                            : "timeline.addHighlightFail",
-                        },
-                        type: "danger",
-                      });
-                      setTimelineState(timelineState);
-                    }
-                  );
-                }
-              : undefined,
-        };
+      };
 
-        if (type === "cache") {
-          return [{ timeline, posts, operations }, "syncing"];
-        } else if (type === "offline") {
-          return [{ timeline, posts, operations }, "offline"];
-        } else {
-          if (postListState == null) {
-            return [{ timeline, posts, operations }, "syncing"];
-          } else {
-            const { type: postListType } = postListState;
-            if (postListType === "synced") {
-              return [{ timeline, posts, operations }, "synced"];
-            } else if (postListType === "cache") {
-              return [{ timeline, posts, operations }, "syncing"];
-            } else if (postListType === "offline") {
-              return [{ timeline, posts, operations }, "offline"];
-            }
-          }
-        }
-      }
+      return { timeline, posts, operations };
     }
-    throw new UiLogicError("Failed to calculate TimelinePageUITemplate props.");
   })();
 
   const closeDialog = React.useCallback((): void => {
@@ -217,10 +187,10 @@ export default function TimelinePageTemplate<TManageItem>(
 
   let dialogElement: React.ReactElement | undefined;
 
-  const timeline = timelineState?.timeline;
+  const timeline = timelineAndStatus?.data;
 
   if (dialog === "property") {
-    if (timeline == null) {
+    if (timeline == null || timeline === "notexist") {
       throw new UiLogicError(
         "Timeline is null but attempt to open change property dialog."
       );
@@ -241,7 +211,7 @@ export default function TimelinePageTemplate<TManageItem>(
       />
     );
   } else if (dialog === "member") {
-    if (timeline == null) {
+    if (timeline == null || timeline === "notexist") {
       throw new UiLogicError(
         "Timeline is null but attempt to open change property dialog."
       );
@@ -256,18 +226,15 @@ export default function TimelinePageTemplate<TManageItem>(
           service.hasManagePermission(user, timeline)
             ? {
                 onCheckUser: (u) => {
-                  return userInfoService
-                    .getUserInfo(u)
-                    .pipe(
-                      catchError((e) => {
-                        if (e instanceof UserNotExistError) {
-                          return of(null);
-                        } else {
-                          throw e;
-                        }
-                      })
-                    )
-                    .toPromise();
+                  return getHttpUserClient()
+                    .get(u)
+                    .catch((e) => {
+                      if (e instanceof HttpUserNotExistError) {
+                        return null;
+                      } else {
+                        throw e;
+                      }
+                    });
                 },
                 onAddUser: (u) => {
                   return service.addMember(name, u.username).toPromise().then();
@@ -286,7 +253,13 @@ export default function TimelinePageTemplate<TManageItem>(
 
   return (
     <>
-      <UiComponent data={childProps[0]} syncStatus={childProps[1]} />
+      <UiComponent
+        data={data}
+        syncStatus={mergeDataStatus([
+          timelineAndStatus.status,
+          postsAndState.status,
+        ])}
+      />
       {dialogElement}
     </>
   );
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index b2824c84..41246175 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -29,7 +29,7 @@ export interface TimelinePageTemplateUIProps<TManageItems> {
   data?:
     | {
         timeline: TimelineInfo;
-        posts?: TimelinePostInfoEx[];
+        posts?: TimelinePostInfoEx[] | "forbid";
         operations: {
           onManage?: (item: TManageItems | "property") => void;
           onMember: () => void;
@@ -166,16 +166,20 @@ export default function TimelinePageTemplateUI<TManageItems>(
           />
         ) : null}
         {posts != null ? (
-          <div
-            className="timeline-container"
-            style={{ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)` }}
-          >
-            <Timeline
-              containerRef={timelineRef}
-              posts={posts}
-              onResize={triggerResizeEvent}
-            />
-          </div>
+          posts === "forbid" ? (
+            <div>{t("timeline.messageCantSee")}</div>
+          ) : (
+            <div
+              className="timeline-container"
+              style={{ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)` }}
+            >
+              <Timeline
+                containerRef={timelineRef}
+                posts={posts}
+                onResize={triggerResizeEvent}
+              />
+            </div>
+          )
         ) : (
           <div className="full-viewport-center-child">
             <Spinner variant="primary" animation="grow" />
-- 
cgit v1.2.3


From 555fe9fb1ae5227a535e6beff05e7237cbbccfce Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 11 Jan 2021 21:43:11 +0800
Subject: ...

---
 FrontEnd/src/app/services/timeline.ts                  |  7 ++-----
 .../app/views/timeline-common/TimelinePageTemplate.tsx | 18 +-----------------
 2 files changed, 3 insertions(+), 22 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
index ed24c005..46feb4d9 100644
--- a/FrontEnd/src/app/services/timeline.ts
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -451,10 +451,7 @@ export function validateTimelineName(name: string): boolean {
 
 export function useTimeline(
   timelineName: string
-): [
-  DataAndStatus<TimelineInfo | "notexist">,
-  React.Dispatch<React.SetStateAction<DataAndStatus<TimelineInfo | "notexist">>>
-] {
+): DataAndStatus<TimelineInfo | "notexist"> {
   const [state, setState] = React.useState<
     DataAndStatus<TimelineInfo | "notexist">
   >({
@@ -472,7 +469,7 @@ export function useTimeline(
       subscription.unsubscribe();
     };
   }, [timelineName]);
-  return [state, setState];
+  return state;
 }
 
 export function usePosts(
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index b4058fbe..d7e4d696 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -40,7 +40,7 @@ export default function TimelinePageTemplate<TManageItem>(
     null
   );
 
-  const [timelineAndStatus, setTimelineAndStatus] = useTimeline(name);
+  const timelineAndStatus = useTimeline(name);
   const postsAndState = usePosts(name);
 
   const onPost: TimelinePostSendCallback = React.useCallback(
@@ -111,13 +111,6 @@ export default function TimelinePageTemplate<TManageItem>(
           user != null
             ? () => {
                 const { isBookmark } = timeline;
-                setTimelineAndStatus({
-                  ...timelineAndStatus,
-                  data: {
-                    ...timeline,
-                    isBookmark: !isBookmark,
-                  },
-                });
                 const client = getHttpBookmarkClient();
                 const promise = isBookmark
                   ? client.delete(name)
@@ -136,7 +129,6 @@ export default function TimelinePageTemplate<TManageItem>(
                       },
                       type: "danger",
                     });
-                    setTimelineAndStatus(timelineAndStatus);
                   }
                 );
               }
@@ -145,13 +137,6 @@ export default function TimelinePageTemplate<TManageItem>(
           user != null && user.hasHighlightTimelineAdministrationPermission
             ? () => {
                 const { isHighlight } = timeline;
-                setTimelineAndStatus({
-                  ...timelineAndStatus,
-                  data: {
-                    ...timeline,
-                    isHighlight: !isHighlight,
-                  },
-                });
                 const client = getHttpHighlightClient();
                 const promise = isHighlight
                   ? client.delete(name)
@@ -170,7 +155,6 @@ export default function TimelinePageTemplate<TManageItem>(
                       },
                       type: "danger",
                     });
-                    setTimelineAndStatus(timelineAndStatus);
                   }
                 );
               }
-- 
cgit v1.2.3


From ce08fc3865a117ee2b7f56d25fc96e80efc13587 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 11 Jan 2021 21:44:44 +0800
Subject: ...

---
 FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index d7e4d696..f5dec67c 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -78,9 +78,7 @@ export default function TimelinePageTemplate<TManageItem>(
         const { data: postsInfo } = postsAndState;
         if (postsInfo === "forbid") {
           return "forbid";
-        } else if (postsInfo === "notexist") {
-          return undefined;
-        } else if (postsInfo == null) {
+        } else if (postsInfo == null || postsInfo === "notexist") {
           return undefined;
         } else {
           return postsInfo.posts.map((post) => ({
-- 
cgit v1.2.3


From 26f02d90c2571251b32c3b03b970dd290e3892e6 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 11 Jan 2021 21:54:33 +0800
Subject: ...

---
 FrontEnd/src/app/services/timeline.ts              | 28 ++++----
 .../app/views/timeline-common/TimelineMember.tsx   | 78 +++++++++++++---------
 .../views/timeline-common/TimelinePageTemplate.tsx | 27 +-------
 3 files changed, 61 insertions(+), 72 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx')

diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts
index 46feb4d9..8bc1d40b 100644
--- a/FrontEnd/src/app/services/timeline.ts
+++ b/FrontEnd/src/app/services/timeline.ts
@@ -189,24 +189,20 @@ export class TimelineService {
     return from(getHttpTimelineClient().deleteTimeline(timelineName));
   }
 
-  addMember(timelineName: string, username: string): Observable<unknown> {
-    return from(
-      getHttpTimelineClient()
-        .memberPut(timelineName, username)
-        .then(() => {
-          void this.syncTimeline(timelineName);
-        })
-    );
+  addMember(timelineName: string, username: string): Promise<void> {
+    return getHttpTimelineClient()
+      .memberPut(timelineName, username)
+      .then(() => {
+        void this.syncTimeline(timelineName);
+      });
   }
 
-  removeMember(timelineName: string, username: string): Observable<unknown> {
-    return from(
-      getHttpTimelineClient()
-        .memberDelete(timelineName, username)
-        .then(() => {
-          void this.syncTimeline(timelineName);
-        })
-    );
+  removeMember(timelineName: string, username: string): Promise<void> {
+    return getHttpTimelineClient()
+      .memberDelete(timelineName, username)
+      .then(() => {
+        void this.syncTimeline(timelineName);
+      });
   }
 
   private generatePostsDataStorageKey(timelineName: string): string {
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
index 089d11a0..efa7e971 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
 import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
 
 import { User, useAvatar } from "@/services/user";
+import { TimelineInfo, timelineService } from "@/services/timeline";
+import { getHttpUserClient, HttpUserNotExistError } from "@/http/user";
 
 import SearchInput from "../common/SearchInput";
 import BlobImage from "../common/BlobImage";
@@ -52,15 +54,9 @@ const TimelineMemberItem: React.FC<{
   );
 };
 
-export interface TimelineMemberCallbacks {
-  onCheckUser: (username: string) => Promise<User | null>;
-  onAddUser: (user: User) => Promise<void>;
-  onRemoveUser: (username: string) => void;
-}
-
 export interface TimelineMemberProps {
-  members: User[];
-  edit: TimelineMemberCallbacks | null | undefined;
+  timeline: TimelineInfo;
+  editable: boolean;
 }
 
 const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
@@ -81,7 +77,9 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
     userSearchState.type === "user" ? userSearchState.data.username : undefined
   );
 
-  const members = props.members;
+  const { timeline } = props;
+
+  const members = [timeline.owner, ...timeline.members];
 
   return (
     <Container className="px-4 py-3">
@@ -91,13 +89,21 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
             key={member.username}
             user={member}
             owner={index === 0}
-            onRemove={props.edit?.onRemoveUser}
+            onRemove={
+              props.editable
+                ? () => {
+                    void timelineService.removeMember(
+                      timeline.name,
+                      member.username
+                    );
+                  }
+                : undefined
+            }
           />
         ))}
       </ListGroup>
       {(() => {
-        const edit = props.edit;
-        if (edit != null) {
+        if (props.editable) {
           return (
             <>
               <SearchInput
@@ -115,26 +121,34 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
                     });
                     return;
                   }
-
                   setUserSearchState({ type: "loading" });
-                  edit.onCheckUser(userSearchText).then(
-                    (u) => {
-                      if (u == null) {
+                  getHttpUserClient()
+                    .get(userSearchText)
+                    .catch((e) => {
+                      if (e instanceof HttpUserNotExistError) {
+                        return null;
+                      } else {
+                        throw e;
+                      }
+                    })
+                    .then(
+                      (u) => {
+                        if (u == null) {
+                          setUserSearchState({
+                            type: "error",
+                            data: "timeline.userNotExist",
+                          });
+                        } else {
+                          setUserSearchState({ type: "user", data: u });
+                        }
+                      },
+                      (e) => {
                         setUserSearchState({
                           type: "error",
-                          data: "timeline.userNotExist",
+                          data: `${e as string}`,
                         });
-                      } else {
-                        setUserSearchState({ type: "user", data: u });
                       }
-                    },
-                    (e) => {
-                      setUserSearchState({
-                        type: "error",
-                        data: `${e as string}`,
-                      });
-                    }
-                  );
+                    );
                 }}
               />
               {(() => {
@@ -166,10 +180,12 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
                             className="align-self-center"
                             disabled={!addable}
                             onClick={() => {
-                              void edit.onAddUser(u).then((_) => {
-                                setUserSearchText("");
-                                setUserSearchState({ type: "init" });
-                              });
+                              void timelineService
+                                .addMember(timeline.name, u.username)
+                                .then(() => {
+                                  setUserSearchText("");
+                                  setUserSearchState({ type: "init" });
+                                });
                             }}
                           >
                             {t("timeline.member.add")}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index f5dec67c..f8b2b38b 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -7,7 +7,6 @@ import { useUser } from "@/services/user";
 import { timelineService, usePosts, useTimeline } from "@/services/timeline";
 import { getHttpBookmarkClient } from "@/http/bookmark";
 import { getHttpHighlightClient } from "@/http/highlight";
-import { getHttpUserClient, HttpUserNotExistError } from "@/http/user";
 
 import { TimelineMemberDialog } from "./TimelineMember";
 import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
@@ -203,30 +202,8 @@ export default function TimelinePageTemplate<TManageItem>(
       <TimelineMemberDialog
         open
         onClose={closeDialog}
-        members={[timeline.owner, ...timeline.members]}
-        edit={
-          service.hasManagePermission(user, timeline)
-            ? {
-                onCheckUser: (u) => {
-                  return getHttpUserClient()
-                    .get(u)
-                    .catch((e) => {
-                      if (e instanceof HttpUserNotExistError) {
-                        return null;
-                      } else {
-                        throw e;
-                      }
-                    });
-                },
-                onAddUser: (u) => {
-                  return service.addMember(name, u.username).toPromise().then();
-                },
-                onRemoveUser: (u) => {
-                  service.removeMember(name, u);
-                },
-              }
-            : null
-        }
+        timeline={timeline}
+        editable={service.hasManagePermission(user, timeline)}
       />
     );
   }
-- 
cgit v1.2.3

