aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-01-11 21:34:57 +0800
committercrupest <crupest@outlook.com>2021-01-11 21:34:57 +0800
commit873bb613bc2deb86a4266bac160d14be265f9609 (patch)
tree00aeea22bfef21629a1004c174a88c931a47a5ca /FrontEnd/src
parent1488919a75a67ad3992e9c66031c9079c50053f2 (diff)
downloadtimeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.gz
timeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.bz2
timeline-873bb613bc2deb86a4266bac160d14be265f9609.zip
...
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/http/common.ts4
-rw-r--r--FrontEnd/src/app/http/user.ts8
-rw-r--r--FrontEnd/src/app/services/DataHub.ts225
-rw-r--r--FrontEnd/src/app/services/DataHub2.ts10
-rw-r--r--FrontEnd/src/app/services/common.ts19
-rw-r--r--FrontEnd/src/app/services/timeline.ts573
-rw-r--r--FrontEnd/src/app/services/user.ts140
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx281
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx26
9 files changed, 423 insertions, 863 deletions
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" />