aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp')
-rw-r--r--Timeline/ClientApp/src/app/data/SubscriptionHub.ts67
-rw-r--r--Timeline/ClientApp/src/app/data/common.ts14
-rw-r--r--Timeline/ClientApp/src/app/data/timeline.ts383
-rw-r--r--Timeline/ClientApp/src/app/data/user.ts14
-rw-r--r--Timeline/ClientApp/src/app/http/mock/common.ts28
-rw-r--r--Timeline/ClientApp/src/app/locales/en/translation.ts5
-rw-r--r--Timeline/ClientApp/src/app/locales/scheme.ts5
-rw-r--r--Timeline/ClientApp/src/app/locales/zh/translation.ts5
-rw-r--r--Timeline/ClientApp/src/app/timeline/Timeline.tsx5
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx105
-rw-r--r--Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx104
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline-ui.sass17
-rw-r--r--Timeline/ClientApp/src/app/timeline/timeline.sass6
13 files changed, 607 insertions, 151 deletions
diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
index 2bc6de56..406d293f 100644
--- a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
+++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts
@@ -3,10 +3,11 @@
// 2. We need a way to finalize the last object. For example, if it has an object url, we need to revoke it.
// 3. Make api easier to use and write less boilerplate codes.
//
+// Currently updator will wait for last update or creation to finish. So the old data passed to it will always be right. We may add feature for just cancel last one but not wait for it.
+//
// There might be some bugs, especially memory leaks and in asynchronization codes.
import * as rxjs from 'rxjs';
-import { filter } from 'rxjs/operators';
export type Subscriber<TData> = (data: TData) => void;
@@ -23,25 +24,26 @@ class SubscriptionToken {
}
class SubscriptionLine<TData> {
- private _lastDataPromise: Promise<void>;
- private _dataSubject = new rxjs.BehaviorSubject<TData | undefined>(undefined);
- private _data$: rxjs.Observable<TData> = this._dataSubject.pipe(
- filter((d) => d !== undefined)
- ) as rxjs.Observable<TData>;
+ private _lastDataPromise: Promise<TData>;
+ private _dataSubject: rxjs.BehaviorSubject<TData>;
private _refCount = 0;
constructor(
- _creator: () => Promise<TData>,
- private _destroyer: (data: TData) => void,
+ defaultValueProvider: () => TData,
+ setup: ((old: TData) => Promise<TData>) | undefined,
+ private _destroyer: ((data: TData) => void) | undefined,
private _onZeroRef: (self: SubscriptionLine<TData>) => void
) {
- this._lastDataPromise = _creator().then((data) => {
- this._dataSubject.next(data);
- });
+ const initValue = defaultValueProvider();
+ this._lastDataPromise = Promise.resolve(initValue);
+ this._dataSubject = new rxjs.BehaviorSubject<TData>(initValue);
+ if (setup != null) {
+ this.next(setup);
+ }
}
subscribe(subscriber: Subscriber<TData>): SubscriptionToken {
- const subscription = this._data$.subscribe(subscriber);
+ const subscription = this._dataSubject.subscribe(subscriber);
this._refCount += 1;
return new SubscriptionToken(subscription);
}
@@ -50,25 +52,26 @@ class SubscriptionLine<TData> {
token._subscription.unsubscribe();
this._refCount -= 1;
if (this._refCount === 0) {
- void this._lastDataPromise.then(() => {
- const last = this._dataSubject.value;
- if (last !== undefined) {
- this._destroyer(last);
- }
- });
+ const { _destroyer: destroyer } = this;
+ if (destroyer != null) {
+ void this._lastDataPromise.then((data) => {
+ destroyer(data);
+ });
+ }
this._onZeroRef(this);
}
}
- next(updator: () => Promise<TData>): void {
+ next(updator: (old: TData) => Promise<TData>): void {
this._lastDataPromise = this._lastDataPromise
- .then(() => updator())
+ .then((old) => updator(old))
.then((data) => {
const last = this._dataSubject.value;
- if (last !== undefined) {
+ if (this._destroyer != null) {
this._destroyer(last);
}
this._dataSubject.next(data);
+ return data;
});
}
}
@@ -79,10 +82,12 @@ export interface ISubscriptionHub<TKey, TData> {
export class SubscriptionHub<TKey, TData>
implements ISubscriptionHub<TKey, TData> {
+ // If setup is set, update is called with setup immediately after setting default value.
constructor(
public keyToString: (key: TKey) => string,
- public creator: (key: TKey) => Promise<TData>,
- public destroyer: (key: TKey, data: TData) => void
+ public defaultValueProvider: (key: TKey) => TData,
+ public setup?: (key: TKey) => Promise<TData>,
+ public destroyer?: (key: TKey, data: TData) => void
) {}
private subscriptionLineMap = new Map<string, SubscriptionLine<TData>>();
@@ -92,11 +97,15 @@ export class SubscriptionHub<TKey, TData>
const line = (() => {
const savedLine = this.subscriptionLineMap.get(keyString);
if (savedLine == null) {
+ const { setup, destroyer } = this;
const newLine = new SubscriptionLine<TData>(
- () => this.creator(key),
- (data) => {
- this.destroyer(key, data);
- },
+ () => this.defaultValueProvider(key),
+ setup != null ? () => setup(key) : undefined,
+ destroyer != null
+ ? (data) => {
+ destroyer(key, data);
+ }
+ : undefined,
() => {
this.subscriptionLineMap.delete(keyString);
}
@@ -115,11 +124,11 @@ export class SubscriptionHub<TKey, TData>
// Old data is destroyed automatically.
// updator is called only if there is subscription.
- update(key: TKey, updator: (key: TKey) => Promise<TData>): void {
+ update(key: TKey, updator: (key: TKey, old: TData) => Promise<TData>): void {
const keyString = this.keyToString(key);
const line = this.subscriptionLineMap.get(keyString);
if (line != null) {
- line.next(() => updator(key));
+ line.next((old) => updator(key, old));
}
}
}
diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts
index 7f3f4e93..9f985ce6 100644
--- a/Timeline/ClientApp/src/app/data/common.ts
+++ b/Timeline/ClientApp/src/app/data/common.ts
@@ -1,4 +1,18 @@
+import localforage from 'localforage';
+
+export const dataStorage = localforage.createInstance({
+ name: 'data',
+ description: 'Database for offline data.',
+ driver: localforage.INDEXEDDB,
+});
+
export interface BlobWithUrl {
blob: Blob;
url: string;
}
+
+export class ForbiddenError extends Error {
+ constructor(message?: string) {
+ super(message);
+ }
+}
diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts
index dde204be..84eb3764 100644
--- a/Timeline/ClientApp/src/app/data/timeline.ts
+++ b/Timeline/ClientApp/src/app/data/timeline.ts
@@ -2,12 +2,15 @@ import React from 'react';
import XRegExp from 'xregexp';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';
+import { pull } from 'lodash';
-import { UserAuthInfo, checkLogin, userService } from './user';
+import { convertError } from '../utilities/rxjs';
-import { BlobWithUrl } from './common';
+import { BlobWithUrl, dataStorage, ForbiddenError } from './common';
import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub';
+import { UserAuthInfo, checkLogin, userService } from './user';
+
export { kTimelineVisibilities } from '../http/timeline';
export type { TimelineVisibility } from '../http/timeline';
@@ -27,8 +30,9 @@ import {
getHttpTimelineClient,
HttpTimelineNotExistError,
HttpTimelineNameConflictError,
+ HttpTimelineGenericPostInfo,
} from '../http/timeline';
-import { convertError } from '../utilities/rxjs';
+import { BlobWithEtag, NotModified } from '../http/common';
export type TimelineInfo = HttpTimelineInfo;
export type TimelineChangePropertyRequest = HttpTimelinePatchRequest;
@@ -62,9 +66,36 @@ export interface PostKey {
postId: number;
}
+export interface TimelinePostListState {
+ state:
+ | 'loading' // Loading posts from cache. `posts` is empty array.
+ | 'forbid' // The list is forbidden to see.
+ | 'syncing' // Cache loaded and syncing now.
+ | 'synced' // Sync succeeded.
+ | 'offline'; // Sync failed and use cache.
+ posts: TimelinePostInfo[];
+}
+
+interface PostListInfo {
+ idList: number[];
+ lastUpdated: string;
+}
+
export class TimelineService {
+ // TODO: Remove this! This is currently only used to avoid multiple fetch of timeline. Because post list need to use the timeline id and call this method. But after timeline is also saved locally, this should be removed.
+ private timelineCache = new Map<string, Promise<TimelineInfo>>();
+
getTimeline(timelineName: string): Observable<TimelineInfo> {
- return from(getHttpTimelineClient().getTimeline(timelineName)).pipe(
+ const cache = this.timelineCache.get(timelineName);
+ let promise: Promise<TimelineInfo>;
+ if (cache == null) {
+ promise = getHttpTimelineClient().getTimeline(timelineName);
+ this.timelineCache.set(timelineName, promise);
+ } else {
+ promise = cache;
+ }
+
+ return from(promise).pipe(
convertError(HttpTimelineNotExistError, TimelineNotExistError)
);
}
@@ -126,28 +157,290 @@ export class TimelineService {
);
}
- private _postDataSubscriptionHub = new SubscriptionHub<PostKey, BlobWithUrl>(
- (key) => `${key.timelineName}/${key.postId}`,
- async (key) => {
- const blob = (
- await getHttpTimelineClient().getPostData(
- key.timelineName,
- key.postId,
- userService.currentUser?.token
+ // post list storage structure:
+ // each timeline has a PostListInfo saved with key created by getPostListInfoKey
+ // each post of a timeline has a HttpTimelinePostInfo with key created by getPostKey
+ // each post with data has BlobWithEtag with key created by getPostDataKey
+
+ private getPostListInfoKey(timelineUniqueId: string): string {
+ return `timeline.${timelineUniqueId}.postListInfo`;
+ }
+
+ private getPostKey(timelineUniqueId: string, id: number): string {
+ return `timeline.${timelineUniqueId}.post.${id}`;
+ }
+
+ private getPostDataKey(timelineUniqueId: string, id: number): string {
+ return `timeline.${timelineUniqueId}.post.${id}.data`;
+ }
+
+ private async getCachedPostList(
+ timelineName: string
+ ): Promise<TimelinePostInfo[]> {
+ const timeline = await this.getTimeline(timelineName).toPromise();
+ if (!this.hasReadPermission(userService.currentUser, timeline)) {
+ throw new ForbiddenError(
+ 'You are not allowed to get posts of this timeline.'
+ );
+ }
+
+ const postListInfo = await dataStorage.getItem<PostListInfo | null>(
+ this.getPostListInfoKey(timeline.uniqueId)
+ );
+ if (postListInfo == null) {
+ return [];
+ } else {
+ return (
+ await Promise.all(
+ postListInfo.idList.map((postId) =>
+ dataStorage.getItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, postId)
+ )
+ )
)
- ).data;
- const url = URL.createObjectURL(blob);
- return {
- blob,
- url,
+ ).map((post) => ({ ...post, timelineName }));
+ }
+ }
+
+ async syncPostList(timelineName: string): Promise<TimelinePostInfo[]> {
+ const timeline = await this.getTimeline(timelineName).toPromise();
+ if (!this.hasReadPermission(userService.currentUser, timeline)) {
+ this._postListSubscriptionHub.update(timelineName, () =>
+ Promise.resolve({
+ state: 'forbid',
+ posts: [],
+ })
+ );
+ throw new ForbiddenError(
+ 'You are not allowed to get posts of this timeline.'
+ );
+ }
+
+ const postListInfoKey = this.getPostListInfoKey(timeline.uniqueId);
+ const postListInfo = await dataStorage.getItem<PostListInfo | null>(
+ postListInfoKey
+ );
+
+ const now = new Date();
+ let posts: TimelinePostInfo[];
+ if (postListInfo == null) {
+ let httpPosts: HttpTimelinePostInfo[];
+ try {
+ httpPosts = await getHttpTimelineClient().listPost(
+ timelineName,
+ userService.currentUser?.token
+ );
+ } catch (e) {
+ this._postListSubscriptionHub.update(timelineName, (_, old) =>
+ Promise.resolve({
+ state: 'offline',
+ posts: old.posts,
+ })
+ );
+ throw e;
+ }
+
+ await dataStorage.setItem<PostListInfo>(postListInfoKey, {
+ idList: httpPosts.map((post) => post.id),
+ lastUpdated: now.toISOString(),
+ });
+
+ for (const post of httpPosts) {
+ await dataStorage.setItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, post.id),
+ post
+ );
+ }
+
+ posts = httpPosts.map((post) => ({
+ ...post,
+ timelineName,
+ }));
+ } else {
+ let httpPosts: HttpTimelineGenericPostInfo[];
+ try {
+ httpPosts = await getHttpTimelineClient().listPost(
+ timelineName,
+ userService.currentUser?.token,
+ {
+ modifiedSince: new Date(postListInfo.lastUpdated),
+ includeDeleted: true,
+ }
+ );
+ } catch (e) {
+ this._postListSubscriptionHub.update(timelineName, (_, old) =>
+ Promise.resolve({
+ state: 'offline',
+ posts: old.posts,
+ })
+ );
+ throw e;
+ }
+
+ const newPosts: HttpTimelinePostInfo[] = [];
+
+ for (const post of httpPosts) {
+ if (post.deleted) {
+ pull(postListInfo.idList, post.id);
+ await dataStorage.removeItem(
+ this.getPostKey(timeline.uniqueId, post.id)
+ );
+ await dataStorage.removeItem(
+ this.getPostDataKey(timeline.uniqueId, post.id)
+ );
+ } else {
+ await dataStorage.setItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, post.id),
+ post
+ );
+ newPosts.push(post);
+ }
+ }
+
+ const oldIdList = postListInfo.idList;
+
+ postListInfo.idList = [...oldIdList, ...newPosts.map((post) => post.id)];
+ postListInfo.lastUpdated = now.toISOString();
+ await dataStorage.setItem<PostListInfo>(postListInfoKey, postListInfo);
+
+ posts = [
+ ...(await Promise.all(
+ oldIdList.map((postId) =>
+ dataStorage.getItem<HttpTimelinePostInfo>(
+ this.getPostKey(timeline.uniqueId, postId)
+ )
+ )
+ )),
+ ...newPosts,
+ ].map((post) => ({ ...post, timelineName }));
+ }
+
+ this._postListSubscriptionHub.update(timelineName, () =>
+ Promise.resolve({
+ state: 'synced',
+ posts,
+ })
+ );
+
+ return posts;
+ }
+
+ private _postListSubscriptionHub = new SubscriptionHub<
+ string,
+ TimelinePostListState
+ >(
+ (key) => key,
+ () => ({
+ state: 'loading',
+ posts: [],
+ }),
+ async (key) => {
+ const state: TimelinePostListState = {
+ state: 'syncing',
+ posts: await this.getCachedPostList(key),
};
+ void this.syncPostList(key);
+ return state;
+ }
+ );
+
+ get postListHub(): ISubscriptionHub<string, TimelinePostListState> {
+ return this._postListSubscriptionHub;
+ }
+
+ private async getCachePostData(
+ timelineName: string,
+ postId: number
+ ): Promise<Blob | null> {
+ const timeline = await this.getTimeline(timelineName).toPromise();
+ const cache = await dataStorage.getItem<BlobWithEtag | null>(
+ this.getPostDataKey(timeline.uniqueId, postId)
+ );
+ if (cache == null) {
+ return null;
+ } else {
+ return cache.data;
+ }
+ }
+
+ private async syncCachePostData(
+ timelineName: string,
+ postId: number
+ ): Promise<Blob | null> {
+ const timeline = await this.getTimeline(timelineName).toPromise();
+ const dataKey = this.getPostDataKey(timeline.uniqueId, postId);
+ const cache = await dataStorage.getItem<BlobWithEtag | null>(dataKey);
+
+ if (cache == null) {
+ const dataWithEtag = await getHttpTimelineClient().getPostData(
+ timelineName,
+ postId,
+ userService.currentUser?.token
+ );
+ await dataStorage.setItem<BlobWithEtag>(dataKey, dataWithEtag);
+ this._postDataSubscriptionHub.update(
+ {
+ postId,
+ timelineName,
+ },
+ () =>
+ Promise.resolve({
+ blob: dataWithEtag.data,
+ url: URL.createObjectURL(dataWithEtag.data),
+ })
+ );
+ return dataWithEtag.data;
+ } else {
+ const res = await getHttpTimelineClient().getPostData(
+ timelineName,
+ postId,
+ userService.currentUser?.token,
+ cache.etag
+ );
+ if (res instanceof NotModified) {
+ return cache.data;
+ } else {
+ await dataStorage.setItem<BlobWithEtag>(dataKey, res);
+ this._postDataSubscriptionHub.update(
+ {
+ postId,
+ timelineName,
+ },
+ () =>
+ Promise.resolve({
+ blob: res.data,
+ url: URL.createObjectURL(res.data),
+ })
+ );
+ return res.data;
+ }
+ }
+ }
+
+ private _postDataSubscriptionHub = new SubscriptionHub<
+ PostKey,
+ BlobWithUrl | null
+ >(
+ (key) => `${key.timelineName}/${key.postId}`,
+ () => null,
+ async (key) => {
+ const blob = await this.getCachePostData(key.timelineName, key.postId);
+ const result =
+ blob == null
+ ? null
+ : {
+ blob,
+ url: URL.createObjectURL(blob),
+ };
+ void this.syncCachePostData(key.timelineName, key.postId);
+ return result;
},
(_key, data) => {
- URL.revokeObjectURL(data.url);
+ if (data != null) URL.revokeObjectURL(data.url);
}
);
- get postDataHub(): ISubscriptionHub<PostKey, BlobWithUrl> {
+ get postDataHub(): ISubscriptionHub<PostKey, BlobWithUrl | null> {
return this._postDataSubscriptionHub;
}
@@ -157,14 +450,33 @@ export class TimelineService {
): Observable<TimelinePostInfo> {
const user = checkLogin();
return from(
- getHttpTimelineClient().postPost(timelineName, request, user.token)
+ getHttpTimelineClient()
+ .postPost(timelineName, request, user.token)
+ .then((res) => {
+ this._postListSubscriptionHub.update(timelineName, (_, old) => {
+ return Promise.resolve({
+ ...old,
+ posts: [...old.posts, { ...res, timelineName }],
+ });
+ });
+ return res;
+ })
).pipe(map((post) => ({ ...post, timelineName })));
}
deletePost(timelineName: string, postId: number): Observable<unknown> {
const user = checkLogin();
return from(
- getHttpTimelineClient().deletePost(timelineName, postId, user.token)
+ getHttpTimelineClient()
+ .deletePost(timelineName, postId, user.token)
+ .then(() => {
+ this._postListSubscriptionHub.update(timelineName, (_, old) => {
+ return Promise.resolve({
+ ...old,
+ posts: old.posts.filter((post) => post.id != postId),
+ });
+ });
+ })
);
}
@@ -236,6 +548,31 @@ export function validateTimelineName(name: string): boolean {
return timelineNameReg.test(name);
}
+export function usePostList(
+ timelineName: string | null | undefined
+): TimelinePostListState | undefined {
+ const [state, setState] = React.useState<TimelinePostListState | undefined>(
+ undefined
+ );
+ React.useEffect(() => {
+ if (timelineName == null) {
+ setState(undefined);
+ return;
+ }
+
+ const subscription = timelineService.postListHub.subscribe(
+ timelineName,
+ (data) => {
+ setState(data);
+ }
+ );
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineName]);
+ return state;
+}
+
export function usePostDataUrl(
enable: boolean,
timelineName: string,
@@ -253,8 +590,8 @@ export function usePostDataUrl(
timelineName,
postId,
},
- ({ url }) => {
- setUrl(url);
+ (data) => {
+ setUrl(data?.url);
}
);
return () => {
diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts
index 1be5cd3e..dec9929f 100644
--- a/Timeline/ClientApp/src/app/data/user.ts
+++ b/Timeline/ClientApp/src/app/data/user.ts
@@ -233,8 +233,12 @@ export class UserNotExistError extends Error {}
export type AvatarInfo = BlobWithUrl;
export class UserInfoService {
- private _avatarSubscriptionHub = new SubscriptionHub<string, AvatarInfo>(
+ private _avatarSubscriptionHub = new SubscriptionHub<
+ string,
+ AvatarInfo | null
+ >(
(key) => key,
+ () => null,
async (key) => {
const blob = (await getHttpUserClient().getAvatar(key)).data;
const url = URL.createObjectURL(blob);
@@ -244,7 +248,7 @@ export class UserInfoService {
};
},
(_key, data) => {
- URL.revokeObjectURL(data.url);
+ if (data != null) URL.revokeObjectURL(data.url);
}
);
@@ -265,7 +269,7 @@ export class UserInfoService {
);
}
- get avatarHub(): ISubscriptionHub<string, AvatarInfo> {
+ get avatarHub(): ISubscriptionHub<string, AvatarInfo | null> {
return this._avatarSubscriptionHub;
}
}
@@ -284,8 +288,8 @@ export function useAvatarUrl(username?: string): string | undefined {
const subscription = userInfoService.avatarHub.subscribe(
username,
- ({ url }) => {
- setAvatarUrl(url);
+ (info) => {
+ setAvatarUrl(info?.url);
}
);
return () => {
diff --git a/Timeline/ClientApp/src/app/http/mock/common.ts b/Timeline/ClientApp/src/app/http/mock/common.ts
index 11939c2b..a3ad08ee 100644
--- a/Timeline/ClientApp/src/app/http/mock/common.ts
+++ b/Timeline/ClientApp/src/app/http/mock/common.ts
@@ -22,10 +22,16 @@ export async function sha1(data: Blob): Promise<string> {
}
const disableNetworkKey = 'mockServer.disableNetwork';
+const networkLatencyKey = 'mockServer.networkLatency';
let disableNetwork: boolean =
localStorage.getItem(disableNetworkKey) === 'true' ? true : false;
+const savedNetworkLatency = localStorage.getItem(networkLatencyKey);
+
+let networkLatency: number | null =
+ savedNetworkLatency != null ? Number(savedNetworkLatency) : null;
+
Object.defineProperty(window, 'disableNetwork', {
get: () => disableNetwork,
set: (value) => {
@@ -39,10 +45,32 @@ Object.defineProperty(window, 'disableNetwork', {
},
});
+Object.defineProperty(window, 'networkLatency', {
+ get: () => networkLatency,
+ set: (value) => {
+ if (typeof value === 'number') {
+ networkLatency = value;
+ localStorage.setItem(networkLatencyKey, value.toString());
+ } else if (value == null) {
+ networkLatency = null;
+ localStorage.removeItem(networkLatencyKey);
+ }
+ },
+});
+
export async function mockPrepare(): Promise<void> {
if (disableNetwork) {
console.warn('Network is disabled for mock server.');
throw new HttpNetworkError();
}
+ if (networkLatency != null) {
+ await new Promise((resolve) => {
+ window.setTimeout(() => {
+ resolve();
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ }, networkLatency! * 1000);
+ });
+ }
+
await Promise.resolve();
}
diff --git a/Timeline/ClientApp/src/app/locales/en/translation.ts b/Timeline/ClientApp/src/app/locales/en/translation.ts
index 6abe910e..2f8fb312 100644
--- a/Timeline/ClientApp/src/app/locales/en/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/en/translation.ts
@@ -92,6 +92,11 @@ const translation: TranslationResource = {
'This is a dangerous action. If you are sure to delete timeline<1>{{name}}</1>, please input its name below and click confirm button.',
notMatch: 'Name does not match.',
},
+ postSyncState: {
+ syncing: 'Syncing',
+ synced: 'Synced',
+ offline: 'Offline',
+ },
post: {
deleteDialog: {
title: 'Confirm Delete',
diff --git a/Timeline/ClientApp/src/app/locales/scheme.ts b/Timeline/ClientApp/src/app/locales/scheme.ts
index 19ac6c31..7aa7e125 100644
--- a/Timeline/ClientApp/src/app/locales/scheme.ts
+++ b/Timeline/ClientApp/src/app/locales/scheme.ts
@@ -83,6 +83,11 @@ export default interface TranslationResource {
inputPrompt: string;
notMatch: string;
};
+ postSyncState: {
+ syncing: string;
+ synced: string;
+ offline: string;
+ };
post: {
deleteDialog: {
title: string;
diff --git a/Timeline/ClientApp/src/app/locales/zh/translation.ts b/Timeline/ClientApp/src/app/locales/zh/translation.ts
index 372979c0..35cfa38c 100644
--- a/Timeline/ClientApp/src/app/locales/zh/translation.ts
+++ b/Timeline/ClientApp/src/app/locales/zh/translation.ts
@@ -88,6 +88,11 @@ const translation: TranslationResource = {
'这是一个危险的操作。如果您确认要删除时间线<1>{{name}}</1>,请在下面输入它的名字并点击确认。',
notMatch: '名字不匹配',
},
+ postSyncState: {
+ syncing: '同步中',
+ synced: '同步成功',
+ offline: '离线',
+ },
post: {
deleteDialog: {
title: '确认删除',
diff --git a/Timeline/ClientApp/src/app/timeline/Timeline.tsx b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
index 849933cf..7c3a93fb 100644
--- a/Timeline/ClientApp/src/app/timeline/Timeline.tsx
+++ b/Timeline/ClientApp/src/app/timeline/Timeline.tsx
@@ -53,10 +53,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
return (
<div
ref={props.containerRef}
- className={clsx(
- 'container-fluid d-flex flex-column position-relative',
- props.className
- )}
+ className={clsx('container-fluid timeline', props.className)}
>
<div className="timeline-enter-animation-mask" />
{(() => {
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
index 9be7f305..a68d08c6 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { concat, without } from 'lodash';
import { of } from 'rxjs';
-import { catchError, switchMap, map } from 'rxjs/operators';
+import { catchError, map } from 'rxjs/operators';
import { ExcludeKey } from '../utilities/type';
import { pushAlert } from '../common/alert-service';
@@ -11,9 +11,10 @@ import {
timelineService,
TimelineInfo,
TimelineNotExistError,
+ usePostList,
} from '../data/timeline';
-import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline';
+import { TimelineDeleteCallback } from './Timeline';
import { TimelineMemberDialog } from './TimelineMember';
import TimelinePropertyChangeDialog from './TimelinePropertyChangeDialog';
import { TimelinePageTemplateUIProps } from './TimelinePageTemplateUI';
@@ -22,7 +23,7 @@ import { UiLogicError } from '../common';
export interface TimelinePageTemplateProps<
TManageItem,
- TTimeline extends TimelineInfo
+ TTimeline extends TimelineInfo // TODO: Remove this.
> {
name: string;
onManage: (item: TManageItem) => void;
@@ -53,53 +54,27 @@ export default function TimelinePageTemplate<
const [timeline, setTimeline] = React.useState<TimelineInfo | undefined>(
undefined
);
- const [posts, setPosts] = React.useState<
- TimelinePostInfoEx[] | 'forbid' | undefined
- >(undefined);
+
+ const postListState = usePostList(timeline?.name);
+
const [error, setError] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
- const subscription = service
- .getTimeline(name)
- .pipe(
- switchMap((ti) => {
- setTimeline(ti);
- if (!service.hasReadPermission(user, ti)) {
- setPosts('forbid');
- return of(null);
- } else {
- return service
- .getPosts(name)
- .pipe(map((ps) => ({ timeline: ti, posts: ps })));
- }
- })
- )
- .subscribe(
- (data) => {
- if (data != null) {
- setPosts(
- data.posts.map((post) => ({
- ...post,
- deletable: service.hasModifyPostPermission(
- user,
- data.timeline,
- post
- ),
- }))
- );
- }
- },
- (error) => {
- if (error instanceof TimelineNotExistError) {
- setError(t(props.notFoundI18nKey));
- } else {
- setError(
- // TODO: Convert this to a function.
- (error as { message?: string })?.message ?? 'Unknown error'
- );
- }
+ const subscription = service.getTimeline(name).subscribe(
+ (ti) => {
+ setTimeline(ti);
+ },
+ (error) => {
+ if (error instanceof TimelineNotExistError) {
+ setError(t(props.notFoundI18nKey));
+ } else {
+ setError(
+ // TODO: Convert this to a function.
+ (error as { message?: string })?.message ?? 'Unknown error'
+ );
}
- );
+ }
+ );
return () => {
subscription.unsubscribe();
};
@@ -207,41 +182,19 @@ export default function TimelinePageTemplate<
const onDelete: TimelineDeleteCallback = React.useCallback(
(index, id) => {
- service.deletePost(name, id).subscribe(
- () => {
- setPosts((oldPosts) =>
- without(
- oldPosts as TimelinePostInfoEx[],
- (oldPosts as TimelinePostInfoEx[])[index]
- )
- );
- },
- () => {
- pushAlert({
- type: 'danger',
- message: t('timeline.deletePostFailed'),
- });
- }
- );
+ service.deletePost(name, id).subscribe(null, () => {
+ pushAlert({
+ type: 'danger',
+ message: t('timeline.deletePostFailed'),
+ });
+ });
},
[service, name, t]
);
const onPost: TimelinePostSendCallback = React.useCallback(
(req) => {
- return service
- .createPost(name, req)
- .pipe(
- map((newPost) => {
- setPosts((oldPosts) =>
- concat(oldPosts as TimelinePostInfoEx[], {
- ...newPost,
- deletable: true,
- })
- );
- })
- )
- .toPromise();
+ return service.createPost(name, req).toPromise().then();
},
[service, name]
);
@@ -268,7 +221,7 @@ export default function TimelinePageTemplate<
<UiComponent
error={error}
timeline={timeline}
- posts={posts}
+ postListState={postListState}
onDelete={onDelete}
onPost={
timeline != null && service.hasPostPermission(user, timeline)
diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
index 4b3b3096..dc5bfda8 100644
--- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
+++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx
@@ -1,13 +1,21 @@
-import React from 'react';
+import React, { CSSProperties } from 'react';
import { Spinner } from 'reactstrap';
import { useTranslation } from 'react-i18next';
import { fromEvent } from 'rxjs';
import Svg from 'react-inlinesvg';
+import clsx from 'clsx';
import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract.svg';
import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg';
import { getAlertHost } from '../common/alert-service';
+import { useEventEmiiter, UiLogicError } from '../common';
+import {
+ TimelineInfo,
+ TimelinePostListState,
+ timelineService,
+} from '../data/timeline';
+import { userService } from '../data/user';
import Timeline, {
TimelinePostInfoEx,
@@ -15,8 +23,55 @@ import Timeline, {
} from './Timeline';
import AppBar from '../common/AppBar';
import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit';
-import { useEventEmiiter } from '../common';
-import { TimelineInfo } from '../data/timeline';
+
+const TimelinePostSyncStateBadge: React.FC<{
+ state: 'syncing' | 'synced' | 'offline';
+ style?: CSSProperties;
+ className?: string;
+}> = ({ state, style, className }) => {
+ const { t } = useTranslation();
+
+ return (
+ <div style={style} className={clsx('timeline-sync-state-badge', className)}>
+ {(() => {
+ switch (state) {
+ case 'syncing': {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-warning" />
+ <span className="text-warning">
+ {t('timeline.postSyncState.syncing')}
+ </span>
+ </>
+ );
+ }
+ case 'synced': {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-success" />
+ <span className="text-success">
+ {t('timeline.postSyncState.synced')}
+ </span>
+ </>
+ );
+ }
+ case 'offline': {
+ return (
+ <>
+ <span className="timeline-sync-state-badge-pin bg-danger" />
+ <span className="text-danger">
+ {t('timeline.postSyncState.offline')}
+ </span>
+ </>
+ );
+ }
+ default:
+ throw new UiLogicError('Unknown sync state.');
+ }
+ })()}
+ </div>
+ );
+};
export interface TimelineCardComponentProps<TManageItems> {
timeline: TimelineInfo;
@@ -29,7 +84,7 @@ export interface TimelineCardComponentProps<TManageItems> {
export interface TimelinePageTemplateUIProps<TManageItems> {
avatarKey?: string | number;
timeline?: TimelineInfo;
- posts?: TimelinePostInfoEx[] | 'forbid';
+ postListState?: TimelinePostListState;
CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
onMember: () => void;
onManage?: (item: TManageItems | 'property') => void;
@@ -41,7 +96,7 @@ export interface TimelinePageTemplateUIProps<TManageItems> {
export default function TimelinePageTemplateUI<TManageItems>(
props: TimelinePageTemplateUIProps<TManageItems>
): React.ReactElement | null {
- const { timeline } = props;
+ const { timeline, postListState } = props;
const { t } = useTranslation();
@@ -116,7 +171,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
subscriptions.forEach((s) => s.unsubscribe());
};
}
- }, [getResizeEvent, triggerResizeEvent, timeline, props.posts]);
+ }, [getResizeEvent, triggerResizeEvent, timeline, postListState]);
const [cardHeight, setCardHeight] = React.useState<number>(0);
@@ -142,19 +197,40 @@ export default function TimelinePageTemplateUI<TManageItems>(
} else {
if (timeline != null) {
let timelineBody: React.ReactElement;
- if (props.posts != null) {
- if (props.posts === 'forbid') {
+ if (postListState != null && postListState.state !== 'loading') {
+ if (postListState.state === 'forbid') {
timelineBody = (
<p className="text-danger">{t('timeline.messageCantSee')}</p>
);
} else {
+ const posts: TimelinePostInfoEx[] = postListState.posts.map(
+ (post) => ({
+ ...post,
+ deletable: timelineService.hasModifyPostPermission(
+ userService.currentUser,
+ timeline,
+ post
+ ),
+ })
+ );
+
+ const topHeight: string = infoCardCollapse
+ ? 'calc(68px + 1.5em)'
+ : `${cardHeight + 60}px`;
+
timelineBody = (
- <Timeline
- containerRef={timelineRef}
- posts={props.posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
+ <div>
+ <TimelinePostSyncStateBadge
+ style={{ top: topHeight }}
+ state={postListState.state}
+ />
+ <Timeline
+ containerRef={timelineRef}
+ posts={posts}
+ onDelete={props.onDelete}
+ onResize={triggerResizeEvent}
+ />
+ </div>
);
if (props.onPost != null) {
timelineBody = (
diff --git a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
index b92327bd..667c1da9 100644
--- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass
@@ -16,3 +16,20 @@
.timeline-page-top-space
transition: height 0.5s
+.timeline-sync-state-badge
+ position: fixed
+ top: 0
+ right: 0
+ z-index: 1
+ font-size: 0.8em
+ padding: 3px 8px
+ border-radius: 5px
+ background: #e8fbff
+
+.timeline-sync-state-badge-pin
+ display: inline-block
+ width: 0.4em
+ height: 0.4em
+ border-radius: 50%
+ vertical-align: middle
+ margin-right: 0.6em
diff --git a/Timeline/ClientApp/src/app/timeline/timeline.sass b/Timeline/ClientApp/src/app/timeline/timeline.sass
index 4f69295b..b224e973 100644
--- a/Timeline/ClientApp/src/app/timeline/timeline.sass
+++ b/Timeline/ClientApp/src/app/timeline/timeline.sass
@@ -1,5 +1,11 @@
@use 'sass:color'
+.timeline
+ display: flex
+ flex-direction: column
+ z-index: 0
+ position: relative
+
@keyframes timeline-enter-animation-mask-animation
to
height: 0