From c10218d8d5ec01ae29c0b2880a8d6af371a562e5 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 27 Jul 2020 17:43:46 +0800 Subject: Add post list subscription hub. --- Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 32 +++++++++++++--------- Timeline/ClientApp/src/app/data/timeline.ts | 22 +++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts index 2bc6de56..92a54bc7 100644 --- a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts +++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts @@ -3,6 +3,8 @@ // 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'; @@ -23,7 +25,7 @@ class SubscriptionToken { } class SubscriptionLine { - private _lastDataPromise: Promise; + private _lastDataPromise: Promise; private _dataSubject = new rxjs.BehaviorSubject(undefined); private _data$: rxjs.Observable = this._dataSubject.pipe( filter((d) => d !== undefined) @@ -32,11 +34,12 @@ class SubscriptionLine { constructor( _creator: () => Promise, - private _destroyer: (data: TData) => void, + private _destroyer: ((data: TData) => void) | undefined, private _onZeroRef: (self: SubscriptionLine) => void ) { this._lastDataPromise = _creator().then((data) => { this._dataSubject.next(data); + return data; }); } @@ -50,25 +53,25 @@ class SubscriptionLine { 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); + void this._lastDataPromise.then((data) => { + if (this._destroyer != null && data !== undefined) { + this._destroyer(data); } }); this._onZeroRef(this); } } - next(updator: () => Promise): void { + next(updator: (old: TData) => Promise): 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 && last !== undefined) { this._destroyer(last); } this._dataSubject.next(data); + return data; }); } } @@ -82,7 +85,7 @@ export class SubscriptionHub constructor( public keyToString: (key: TKey) => string, public creator: (key: TKey) => Promise, - public destroyer: (key: TKey, data: TData) => void + public destroyer?: (key: TKey, data: TData) => void ) {} private subscriptionLineMap = new Map>(); @@ -92,11 +95,14 @@ export class SubscriptionHub const line = (() => { const savedLine = this.subscriptionLineMap.get(keyString); if (savedLine == null) { + const { destroyer } = this; const newLine = new SubscriptionLine( () => this.creator(key), - (data) => { - this.destroyer(key, data); - }, + destroyer != null + ? (data) => { + destroyer(key, data); + } + : undefined, () => { this.subscriptionLineMap.delete(keyString); } diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index dde204be..f2c3fdda 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -126,6 +126,28 @@ export class TimelineService { ); } + private _postListSubscriptionHub = new SubscriptionHub< + string, + TimelinePostInfo[] + >( + (key) => key, + async (key) => { + return ( + await getHttpTimelineClient().listPost( + key, + userService.currentUser?.token + ) + ).map((post) => ({ + ...post, + timelineName: key, + })); + } + ); + + get postListSubscriptionHub(): ISubscriptionHub { + return this._postListSubscriptionHub; + } + private _postDataSubscriptionHub = new SubscriptionHub( (key) => `${key.timelineName}/${key.postId}`, async (key) => { -- cgit v1.2.3 From 0863e0d139f12c444a2a01bb899bc3148c52e7ce Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 27 Jul 2020 18:18:20 +0800 Subject: Refactor SubscriptionHub. --- Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 43 ++++++++++++---------- Timeline/ClientApp/src/app/data/timeline.ts | 15 +++++--- Timeline/ClientApp/src/app/data/user.ts | 14 ++++--- 3 files changed, 42 insertions(+), 30 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts index 92a54bc7..f09cd9f8 100644 --- a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts +++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts @@ -8,7 +8,6 @@ // 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 = (data: TData) => void; @@ -26,25 +25,25 @@ class SubscriptionToken { class SubscriptionLine { private _lastDataPromise: Promise; - private _dataSubject = new rxjs.BehaviorSubject(undefined); - private _data$: rxjs.Observable = this._dataSubject.pipe( - filter((d) => d !== undefined) - ) as rxjs.Observable; + private _dataSubject: rxjs.BehaviorSubject; private _refCount = 0; constructor( - _creator: () => Promise, + defaultValueProvider: () => TData, + setup: ((old: TData) => Promise) | undefined, private _destroyer: ((data: TData) => void) | undefined, private _onZeroRef: (self: SubscriptionLine) => void ) { - this._lastDataPromise = _creator().then((data) => { - this._dataSubject.next(data); - return data; - }); + const initValue = defaultValueProvider(); + this._lastDataPromise = Promise.resolve(initValue); + this._dataSubject = new rxjs.BehaviorSubject(initValue); + if (setup != null) { + this.next(setup); + } } subscribe(subscriber: Subscriber): SubscriptionToken { - const subscription = this._data$.subscribe(subscriber); + const subscription = this._dataSubject.subscribe(subscriber); this._refCount += 1; return new SubscriptionToken(subscription); } @@ -53,11 +52,12 @@ class SubscriptionLine { token._subscription.unsubscribe(); this._refCount -= 1; if (this._refCount === 0) { - void this._lastDataPromise.then((data) => { - if (this._destroyer != null && data !== undefined) { - this._destroyer(data); - } - }); + const { _destroyer: destroyer } = this; + if (destroyer != null) { + void this._lastDataPromise.then((data) => { + destroyer(data); + }); + } this._onZeroRef(this); } } @@ -67,7 +67,7 @@ class SubscriptionLine { .then((old) => updator(old)) .then((data) => { const last = this._dataSubject.value; - if (this._destroyer != null && last !== undefined) { + if (this._destroyer != null) { this._destroyer(last); } this._dataSubject.next(data); @@ -82,9 +82,11 @@ export interface ISubscriptionHub { export class SubscriptionHub implements ISubscriptionHub { + // 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, + public defaultValueProvider: (key: TKey) => TData, + public setup?: (key: TKey) => Promise, public destroyer?: (key: TKey, data: TData) => void ) {} @@ -95,9 +97,10 @@ export class SubscriptionHub const line = (() => { const savedLine = this.subscriptionLineMap.get(keyString); if (savedLine == null) { - const { destroyer } = this; + const { setup, destroyer } = this; const newLine = new SubscriptionLine( - () => this.creator(key), + () => this.defaultValueProvider(key), + setup != null ? () => setup(key) : undefined, destroyer != null ? (data) => { destroyer(key, data); diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index f2c3fdda..7ade8f56 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -131,6 +131,7 @@ export class TimelineService { TimelinePostInfo[] >( (key) => key, + () => [], async (key) => { return ( await getHttpTimelineClient().listPost( @@ -148,8 +149,12 @@ export class TimelineService { return this._postListSubscriptionHub; } - private _postDataSubscriptionHub = new SubscriptionHub( + private _postDataSubscriptionHub = new SubscriptionHub< + PostKey, + BlobWithUrl | null + >( (key) => `${key.timelineName}/${key.postId}`, + () => null, async (key) => { const blob = ( await getHttpTimelineClient().getPostData( @@ -165,11 +170,11 @@ export class TimelineService { }; }, (_key, data) => { - URL.revokeObjectURL(data.url); + if (data != null) URL.revokeObjectURL(data.url); } ); - get postDataHub(): ISubscriptionHub { + get postDataHub(): ISubscriptionHub { return this._postDataSubscriptionHub; } @@ -275,8 +280,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( + 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 { + get avatarHub(): ISubscriptionHub { 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 () => { -- cgit v1.2.3 From d3085ff3483c063b07b9553b539f3836b666cac8 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 27 Jul 2020 23:27:56 +0800 Subject: Add timeline post list state. --- Timeline/ClientApp/src/app/data/timeline.ts | 43 +++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index 7ade8f56..ab01c86c 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -62,6 +62,15 @@ export interface PostKey { postId: number; } +export interface TimelinePostListState { + state: + | 'loading' // Loading posts from cache. `posts` is empty array. + | 'syncing' // Cache loaded and syncing now. + | 'synced' // Sync succeeded. + | 'offline'; // Sync failed and use cache. + posts: TimelinePostInfo[]; +} + export class TimelineService { getTimeline(timelineName: string): Observable { return from(getHttpTimelineClient().getTimeline(timelineName)).pipe( @@ -128,24 +137,34 @@ export class TimelineService { private _postListSubscriptionHub = new SubscriptionHub< string, - TimelinePostInfo[] + TimelinePostListState >( (key) => key, - () => [], + () => ({ + state: 'loading', + posts: [], + }), async (key) => { - return ( - await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token - ) - ).map((post) => ({ - ...post, - timelineName: key, - })); + // TODO: Implement cache + return { + state: 'synced', + posts: ( + await getHttpTimelineClient().listPost( + key, + userService.currentUser?.token + ) + ).map((post) => ({ + ...post, + timelineName: key, + })), + }; } ); - get postListSubscriptionHub(): ISubscriptionHub { + get postListSubscriptionHub(): ISubscriptionHub< + string, + TimelinePostListState + > { return this._postListSubscriptionHub; } -- cgit v1.2.3 From 2afc8d5d6b1e7cb466e62d0e59e2c05143d664c6 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 30 Jul 2020 23:12:55 +0800 Subject: Save post list locally. --- Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 4 +- Timeline/ClientApp/src/app/data/common.ts | 8 + Timeline/ClientApp/src/app/data/timeline.ts | 187 +++++++++++++++++++-- 3 files changed, 181 insertions(+), 18 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts index f09cd9f8..406d293f 100644 --- a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts +++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts @@ -124,11 +124,11 @@ export class SubscriptionHub // Old data is destroyed automatically. // updator is called only if there is subscription. - update(key: TKey, updator: (key: TKey) => Promise): void { + update(key: TKey, updator: (key: TKey, old: TData) => Promise): 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..e9b56970 100644 --- a/Timeline/ClientApp/src/app/data/common.ts +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -1,3 +1,11 @@ +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; diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index ab01c86c..04b63de2 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 } 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,8 @@ import { getHttpTimelineClient, HttpTimelineNotExistError, HttpTimelineNameConflictError, + HttpTimelineGenericPostInfo, } from '../http/timeline'; -import { convertError } from '../utilities/rxjs'; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -71,9 +74,26 @@ export interface TimelinePostListState { 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>(); + getTimeline(timelineName: string): Observable { - return from(getHttpTimelineClient().getTimeline(timelineName)).pipe( + const cache = this.timelineCache.get(timelineName); + let promise: Promise; + if (cache == null) { + promise = getHttpTimelineClient().getTimeline(timelineName); + this.timelineCache.set(timelineName, promise); + } else { + promise = cache; + } + + return from(promise).pipe( convertError(HttpTimelineNotExistError, TimelineNotExistError) ); } @@ -135,6 +155,148 @@ export class TimelineService { ); } + // 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 + + private getPostListInfoKey(timelineUniqueId: string): string { + return `timeline.${timelineUniqueId}.postListInfo`; + } + + private getPostKey(timelineUniqueId: string, id: number): string { + return `timeline.${timelineUniqueId}.post.${id}`; + } + + private async getCachedPostList( + timelineName: string + ): Promise { + const timeline = await this.getTimeline(timelineName).toPromise(); + const postListInfo = await dataStorage.getItem( + this.getPostListInfoKey(timeline.uniqueId) + ); + if (postListInfo == null) { + return []; + } else { + return ( + await Promise.all( + postListInfo.idList.map((postId) => + dataStorage.getItem( + this.getPostKey(timeline.uniqueId, postId) + ) + ) + ) + ).map((post) => ({ ...post, timelineName })); + } + } + + async syncPostList(timelineName: string): Promise { + const timeline = await this.getTimeline(timelineName).toPromise(); + const postListInfoKey = this.getPostListInfoKey(timeline.uniqueId); + const postListInfo = await dataStorage.getItem( + 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(postListInfoKey, { + idList: httpPosts.map((post) => post.id), + lastUpdated: now.toISOString(), + }); + + for (const post of httpPosts) { + await dataStorage.setItem( + 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) + ); + } else { + await dataStorage.setItem( + 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(postListInfoKey, postListInfo); + + posts = [ + ...(await Promise.all( + oldIdList.map((postId) => + dataStorage.getItem( + 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 @@ -145,19 +307,12 @@ export class TimelineService { posts: [], }), async (key) => { - // TODO: Implement cache - return { - state: 'synced', - posts: ( - await getHttpTimelineClient().listPost( - key, - userService.currentUser?.token - ) - ).map((post) => ({ - ...post, - timelineName: key, - })), + const state: TimelinePostListState = { + state: 'syncing', + posts: await this.getCachedPostList(key), }; + void this.syncPostList(key); + return state; } ); -- cgit v1.2.3 From dd44dc2bd3dada15d9b0b676cc5a5b1ef0686559 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 30 Jul 2020 23:35:27 +0800 Subject: Save post data locally. --- Timeline/ClientApp/src/app/data/timeline.ts | 100 ++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 12 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index 04b63de2..50d45aa5 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -32,6 +32,7 @@ import { HttpTimelineNameConflictError, HttpTimelineGenericPostInfo, } from '../http/timeline'; +import { BlobWithEtag, NotModified } from '../http/common'; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -158,6 +159,7 @@ export class TimelineService { // 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`; @@ -167,6 +169,10 @@ export class TimelineService { return `timeline.${timelineUniqueId}.post.${id}`; } + private getPostDataKey(timelineUniqueId: string, id: number): string { + return `timeline.${timelineUniqueId}.post.${id}.data`; + } + private async getCachedPostList( timelineName: string ): Promise { @@ -260,6 +266,9 @@ export class TimelineService { await dataStorage.removeItem( this.getPostKey(timeline.uniqueId, post.id) ); + await dataStorage.removeItem( + this.getPostDataKey(timeline.uniqueId, post.id) + ); } else { await dataStorage.setItem( this.getPostKey(timeline.uniqueId, post.id), @@ -323,6 +332,75 @@ export class TimelineService { return this._postListSubscriptionHub; } + private async getCachePostData( + timelineName: string, + postId: number + ): Promise { + const timeline = await this.getTimeline(timelineName).toPromise(); + const cache = await dataStorage.getItem( + this.getPostDataKey(timeline.uniqueId, postId) + ); + if (cache == null) { + return null; + } else { + return cache.data; + } + } + + private async syncCachePostData( + timelineName: string, + postId: number + ): Promise { + const timeline = await this.getTimeline(timelineName).toPromise(); + const dataKey = this.getPostDataKey(timeline.uniqueId, postId); + const cache = await dataStorage.getItem(dataKey); + + if (cache == null) { + const dataWithEtag = await getHttpTimelineClient().getPostData( + timelineName, + postId, + userService.currentUser?.token + ); + await dataStorage.setItem(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(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 @@ -330,18 +408,16 @@ export class TimelineService { (key) => `${key.timelineName}/${key.postId}`, () => null, async (key) => { - const blob = ( - await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId, - userService.currentUser?.token - ) - ).data; - const url = URL.createObjectURL(blob); - return { - blob, - url, - }; + 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) => { if (data != null) URL.revokeObjectURL(data.url); -- cgit v1.2.3 From 5a90a7d0de9ae8410ef8c23a6994fdba7657666d Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 30 Jul 2020 23:57:13 +0800 Subject: ... --- Timeline/ClientApp/src/app/data/common.ts | 6 ++ Timeline/ClientApp/src/app/data/timeline.ts | 51 ++++++++++++++-- .../src/app/timeline/TimelinePageTemplate.tsx | 67 +++++++--------------- 3 files changed, 74 insertions(+), 50 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts index e9b56970..9f985ce6 100644 --- a/Timeline/ClientApp/src/app/data/common.ts +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -10,3 +10,9 @@ 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 50d45aa5..88a13381 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -6,7 +6,7 @@ import { pull } from 'lodash'; import { convertError } from '../utilities/rxjs'; -import { BlobWithUrl, dataStorage } from './common'; +import { BlobWithUrl, dataStorage, ForbiddenError } from './common'; import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; import { UserAuthInfo, checkLogin, userService } from './user'; @@ -69,6 +69,7 @@ export interface PostKey { 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. @@ -177,6 +178,12 @@ export class TimelineService { timelineName: string ): Promise { 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( this.getPostListInfoKey(timeline.uniqueId) ); @@ -197,6 +204,18 @@ export class TimelineService { async syncPostList(timelineName: string): Promise { 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( postListInfoKey @@ -325,10 +344,7 @@ export class TimelineService { } ); - get postListSubscriptionHub(): ISubscriptionHub< - string, - TimelinePostListState - > { + get postListHub(): ISubscriptionHub { return this._postListSubscriptionHub; } @@ -513,6 +529,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( + 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, diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx index 9be7f305..88066b76 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,6 +11,7 @@ import { timelineService, TimelineInfo, TimelineNotExistError, + usePostList, } from '../data/timeline'; import { TimelinePostInfoEx, TimelineDeleteCallback } from './Timeline'; @@ -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( undefined ); - const [posts, setPosts] = React.useState< - TimelinePostInfoEx[] | 'forbid' | undefined - >(undefined); + + const rawPosts = usePostList(timeline?.name); + const [error, setError] = React.useState(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(); }; @@ -209,6 +184,7 @@ export default function TimelinePageTemplate< (index, id) => { service.deletePost(name, id).subscribe( () => { + // TODO: Remove this. setPosts((oldPosts) => without( oldPosts as TimelinePostInfoEx[], @@ -233,6 +209,7 @@ export default function TimelinePageTemplate< .createPost(name, req) .pipe( map((newPost) => { + // TODO: Remove this. setPosts((oldPosts) => concat(oldPosts as TimelinePostInfoEx[], { ...newPost, -- cgit v1.2.3 From 9e500f240a76bd0e16c8c63b764dd81c01f46f78 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 00:14:32 +0800 Subject: Update post list when create or delete post. --- Timeline/ClientApp/src/app/data/timeline.ts | 23 ++++++++++- .../src/app/timeline/TimelinePageTemplate.tsx | 44 +++++----------------- .../src/app/timeline/TimelinePageTemplateUI.tsx | 32 ++++++++++++---- 3 files changed, 55 insertions(+), 44 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index 88a13381..84eb3764 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -450,14 +450,33 @@ export class TimelineService { ): Observable { 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 { 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), + }); + }); + }) ); } diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx index 88066b76..a68d08c6 100644 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplate.tsx @@ -14,7 +14,7 @@ import { 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'; @@ -55,7 +55,7 @@ export default function TimelinePageTemplate< undefined ); - const rawPosts = usePostList(timeline?.name); + const postListState = usePostList(timeline?.name); const [error, setError] = React.useState(undefined); @@ -182,43 +182,19 @@ export default function TimelinePageTemplate< const onDelete: TimelineDeleteCallback = React.useCallback( (index, id) => { - service.deletePost(name, id).subscribe( - () => { - // TODO: Remove this. - 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) => { - // TODO: Remove this. - setPosts((oldPosts) => - concat(oldPosts as TimelinePostInfoEx[], { - ...newPost, - deletable: true, - }) - ); - }) - ) - .toPromise(); + return service.createPost(name, req).toPromise().then(); }, [service, name] ); @@ -245,7 +221,7 @@ export default function TimelinePageTemplate< { timeline: TimelineInfo; @@ -29,7 +34,7 @@ export interface TimelineCardComponentProps { export interface TimelinePageTemplateUIProps { avatarKey?: string | number; timeline?: TimelineInfo; - posts?: TimelinePostInfoEx[] | 'forbid'; + postListState?: TimelinePostListState; CardComponent: React.ComponentType>; onMember: () => void; onManage?: (item: TManageItems | 'property') => void; @@ -41,7 +46,7 @@ export interface TimelinePageTemplateUIProps { export default function TimelinePageTemplateUI( props: TimelinePageTemplateUIProps ): React.ReactElement | null { - const { timeline } = props; + const { timeline, postListState } = props; const { t } = useTranslation(); @@ -116,7 +121,7 @@ export default function TimelinePageTemplateUI( subscriptions.forEach((s) => s.unsubscribe()); }; } - }, [getResizeEvent, triggerResizeEvent, timeline, props.posts]); + }, [getResizeEvent, triggerResizeEvent, timeline, postListState]); const [cardHeight, setCardHeight] = React.useState(0); @@ -142,16 +147,27 @@ export default function TimelinePageTemplateUI( } else { if (timeline != null) { let timelineBody: React.ReactElement; - if (props.posts != null) { - if (props.posts === 'forbid') { + if (postListState != null) { + if (postListState.state === 'forbid') { timelineBody = (

{t('timeline.messageCantSee')}

); } else { + const posts: TimelinePostInfoEx[] = postListState.posts.map( + (post) => ({ + ...post, + deletable: timelineService.hasModifyPostPermission( + userService.currentUser, + timeline, + post + ), + }) + ); + timelineBody = ( -- cgit v1.2.3 From 4e5a42bfb82aacfcc6d2d1be24aaceb73b4b154d Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 00:25:50 +0800 Subject: Add mock network latency. --- Timeline/ClientApp/src/app/http/mock/common.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) (limited to 'Timeline/ClientApp/src') 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 { } 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 { 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(); } -- cgit v1.2.3 From 4d17746be0daff8f566ec102d4d119321cda8c53 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 00:57:22 +0800 Subject: Add sync state badge. --- .../ClientApp/src/app/locales/en/translation.ts | 5 ++ Timeline/ClientApp/src/app/locales/scheme.ts | 5 ++ .../ClientApp/src/app/locales/zh/translation.ts | 5 ++ Timeline/ClientApp/src/app/timeline/Timeline.tsx | 5 +- .../src/app/timeline/TimelinePageTemplateUI.tsx | 66 +++++++++++++++++++--- .../ClientApp/src/app/timeline/timeline-ui.sass | 18 ++++++ Timeline/ClientApp/src/app/timeline/timeline.sass | 6 ++ 7 files changed, 98 insertions(+), 12 deletions(-) (limited to 'Timeline/ClientApp/src') 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}}, 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}},请在下面输入它的名字并点击确认。', 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 = (props) => { return (
{(() => { diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx index 3c8e312c..0d8ad278 100644 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -8,7 +8,7 @@ import arrowsAngleContractIcon from 'bootstrap-icons/icons/arrows-angle-contract import arrowsAngleExpandIcon from 'bootstrap-icons/icons/arrows-angle-expand.svg'; import { getAlertHost } from '../common/alert-service'; -import { useEventEmiiter } from '../common'; +import { useEventEmiiter, UiLogicError } from '../common'; import { TimelineInfo, TimelinePostListState, @@ -23,6 +23,53 @@ import Timeline, { import AppBar from '../common/AppBar'; import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; +const TimelinePostSyncStateBadge: React.FC<{ + state: 'syncing' | 'synced' | 'offline'; +}> = ({ state }) => { + const { t } = useTranslation(); + + return ( +
+ {(() => { + switch (state) { + case 'syncing': { + return ( + <> + + + {t('timeline.postSyncState.syncing')} + + + ); + } + case 'synced': { + return ( + <> + + + {t('timeline.postSyncState.synced')} + + + ); + } + case 'offline': { + return ( + <> + + + {t('timeline.postSyncState.offline')} + + + ); + } + default: + throw new UiLogicError('Unknown sync state.'); + } + })()} +
+ ); +}; + export interface TimelineCardComponentProps { timeline: TimelineInfo; onManage?: (item: TManageItems | 'property') => void; @@ -147,7 +194,7 @@ export default function TimelinePageTemplateUI( } else { if (timeline != null) { let timelineBody: React.ReactElement; - if (postListState != null) { + if (postListState != null && postListState.state !== 'loading') { if (postListState.state === 'forbid') { timelineBody = (

{t('timeline.messageCantSee')}

@@ -165,12 +212,15 @@ export default function TimelinePageTemplateUI( ); timelineBody = ( - +
+ + +
); 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..952e4659 100644 --- a/Timeline/ClientApp/src/app/timeline/timeline-ui.sass +++ b/Timeline/ClientApp/src/app/timeline/timeline-ui.sass @@ -16,3 +16,21 @@ .timeline-page-top-space transition: height 0.5s +.timeline-sync-state-badge + position: absolute + top: 0 + right: 0 + z-index: 1 + font-size: 0.8em + margin-top: 4px + 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 -- cgit v1.2.3 From d48ca755bae9f24378eda3c0a25285ec4b97a761 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 01:11:13 +0800 Subject: Make sync state badge fixed. --- .../src/app/timeline/TimelinePageTemplateUI.tsx | 20 +++++++++++++++----- Timeline/ClientApp/src/app/timeline/timeline-ui.sass | 3 +-- 2 files changed, 16 insertions(+), 7 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx index 0d8ad278..dc5bfda8 100644 --- a/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx +++ b/Timeline/ClientApp/src/app/timeline/TimelinePageTemplateUI.tsx @@ -1,8 +1,9 @@ -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'; @@ -25,11 +26,13 @@ import TimelinePostEdit, { TimelinePostSendCallback } from './TimelinePostEdit'; const TimelinePostSyncStateBadge: React.FC<{ state: 'syncing' | 'synced' | 'offline'; -}> = ({ state }) => { + style?: CSSProperties; + className?: string; +}> = ({ state, style, className }) => { const { t } = useTranslation(); return ( -
+
{(() => { switch (state) { case 'syncing': { @@ -211,9 +214,16 @@ export default function TimelinePageTemplateUI( }) ); + const topHeight: string = infoCardCollapse + ? 'calc(68px + 1.5em)' + : `${cardHeight + 60}px`; + timelineBody = ( -
- +
+