From be9eb313ccad0832cb37e1c63e03608c47c2d171 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 5 Aug 2020 23:29:14 +0800 Subject: Refactor a lot. --- Timeline/ClientApp/src/app/data/timeline.ts | 389 +++++++++++++++++++--------- 1 file changed, 273 insertions(+), 116 deletions(-) (limited to 'Timeline/ClientApp/src/app/data/timeline.ts') diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index fb8a3874..22b10ca8 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -7,7 +7,8 @@ import { pull } from 'lodash'; import { convertError } from '../utilities/rxjs'; import { dataStorage } from './common'; -import { SubscriptionHub, ISubscriptionHub, NoValue } from './SubscriptionHub'; +import { queue } from './queue'; +import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; import { UserAuthInfo, checkLogin, userService, userInfoService } from './user'; @@ -31,6 +32,7 @@ import { } from '../http/timeline'; import { BlobWithEtag, NotModified, HttpNetworkError } from '../http/common'; import { HttpUser } from '../http/user'; +import { ExcludeKey } from '../utilities/type'; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -71,8 +73,13 @@ export class TimelineNotExistError extends Error {} export class TimelineNameConflictError extends Error {} export type TimelineWithSyncState = + | { + syncState: 'loadcache'; // Loading cache now. + timeline?: undefined; + } | { syncState: + | 'syncing' // Cache loaded and syncing for the first time. | 'offline' // Sync failed and use cache. Null timeline means no cache. | 'synced'; // Sync succeeded. Null timeline means the timeline does not exist. timeline: TimelineInfo | null; @@ -82,14 +89,36 @@ export type TimelineWithSyncState = timeline: TimelineInfo; }; -export interface TimelinePostsWithSyncState { +export interface TimelinePostsTimelineWithSyncState { state: - | 'forbid' // The list is forbidden to see. + | 'loadcache' + | 'syncing' // Syncing now. + | 'offline' // Sync failed and use cache. | 'synced' // Sync succeeded. - | 'offline'; // Sync failed and use cache. + | 'forbid'; // The list is forbidden to see. posts: TimelinePostInfo[]; + timelineUniqueId: string; +} + +export interface TimelinePostsNoTimelineWithSyncState { + state: 'timeline-offline' | 'timeline-notexist'; + posts?: undefined; + timelineUniqueId?: undefined; } +export type TimelinePostsWithSyncState = + | TimelinePostsTimelineWithSyncState + | TimelinePostsNoTimelineWithSyncState; + +type FetchAndCacheTimelineResult = + | { timeline: TimelineInfo; type: 'new' | 'cache' | 'synced' } + | 'offline' + | 'notexist'; + +type FetchAndCachePostsResult = + | { posts: TimelinePostInfo[]; type: 'synced' | 'cache' } + | 'offline'; + interface TimelineCache { timeline: TimelineInfo; lastUpdated: string; @@ -108,13 +137,25 @@ export class TimelineService { return `timeline.${timelineName}`; } - private async fetchAndCacheTimeline( + private getCachedTimeline( + timelineName: string + ): Promise { + return dataStorage + .getItem(timelineName) + .then((cache) => cache?.timeline ?? null); + } + + private fetchAndCacheTimeline( + timelineName: string + ): Promise { + return queue(`TimelineService.fetchAndCacheTimeline.${timelineName}`, () => + this.doFetchAndCacheTimeline(timelineName) + ); + } + + private async doFetchAndCacheTimeline( timelineName: string - ): Promise< - | { timeline: TimelineInfo; type: 'new' | 'cache' | 'synced' } - | 'offline' - | 'notexist' - > { + ): Promise { const cache = await dataStorage.getItem(timelineName); const key = this.getTimelineKey(timelineName); @@ -169,28 +210,60 @@ export class TimelineService { } } + private async syncTimeline(timelineName: string): Promise { + const line = this._timelineSubscriptionHub.getLine(timelineName); + + if (line == null) { + console.log('No subscription, skip sync!'); + return; + } + + const old = line.value; + + if ( + old != null && + (old.syncState === 'loadcache' || old.syncState === 'syncing') + ) { + return; + } + + const next = line.next.bind(line); + + if (old == undefined) { + next({ syncState: 'loadcache' }); + const timeline = await this.getCachedTimeline(timelineName); + next({ syncState: 'syncing', timeline }); + } else { + next({ syncState: 'syncing', timeline: old?.timeline }); + } + + const result = await this.fetchAndCacheTimeline(timelineName); + + if (result === 'offline') { + next({ syncState: 'offline', timeline: null }); + } else if (result === 'notexist') { + next({ syncState: 'synced', timeline: null }); + } else { + const { type, timeline } = result; + if (type === 'cache') { + next({ syncState: 'offline', timeline }); + } else if (type === 'synced') { + next({ syncState: 'synced', timeline }); + } else { + next({ syncState: 'new', timeline }); + } + } + } + private _timelineSubscriptionHub = new SubscriptionHub< string, TimelineWithSyncState >({ - setup: (key, next) => { - void this.fetchAndCacheTimeline(key).then((result) => { - if (result === 'offline') { - next({ syncState: 'offline', timeline: null }); - } else if (result === 'notexist') { - next({ syncState: 'synced', timeline: null }); - } else { - const { type, timeline } = result; - if (type === 'cache') { - next({ syncState: 'offline', timeline }); - } else if (type === 'synced') { - next({ syncState: 'synced', timeline }); - } else { - next({ syncState: 'new', timeline }); - } - } - }); + setup: (key) => { + void this.syncTimeline(key); }, + destroyable: (_, value) => + value?.syncState !== 'loadcache' && value?.syncState !== 'syncing', }); get timelineHub(): ISubscriptionHub { @@ -220,10 +293,7 @@ export class TimelineService { getHttpTimelineClient() .patchTimeline(timelineName, req, user.token) .then((timeline) => { - this._timelineSubscriptionHub.update(timelineName, { - syncState: 'synced', - timeline, - }); + void this.syncTimeline(timelineName); return timeline; }) ); @@ -242,19 +312,8 @@ export class TimelineService { getHttpTimelineClient() .memberPut(timelineName, username, user.token) .then(() => { - userInfoService.getUserInfo(username).subscribe((newUser) => { - this._timelineSubscriptionHub.updateWithOld(timelineName, (old) => { - if (old instanceof NoValue || old.timeline == null) - throw new Error('Timeline not loaded.'); - - return { - ...old, - timeline: { - ...old.timeline, - members: [...old.timeline.members, newUser], - }, - }; - }); + userInfoService.getUserInfo(username).subscribe(() => { + void this.syncTimeline(timelineName); }); }) ); @@ -266,20 +325,7 @@ export class TimelineService { getHttpTimelineClient() .memberDelete(timelineName, username, user.token) .then(() => { - this._timelineSubscriptionHub.updateWithOld(timelineName, (old) => { - if (old instanceof NoValue || old.timeline == null) - throw new Error('Timeline not loaded.'); - - return { - ...old, - timeline: { - ...old.timeline, - members: old.timeline.members.filter( - (u) => u.username !== username - ), - }, - }; - }); + void this.syncTimeline(timelineName); }) ); } @@ -324,35 +370,70 @@ export class TimelineService { } }; - async fetchAndCachePosts( - timeline: TimelineInfo - ): Promise< - | { posts: TimelinePostInfo[]; type: 'synced' | 'cache' } - | 'forbid' - | 'offline' - > { - if (!this.hasReadPermission(userService.currentUser, timeline)) { - return 'forbid'; - } + private convertPostList = ( + posts: HttpTimelinePostInfo[], + dataProvider: ( + post: HttpTimelinePostInfo, + index: number + ) => Promise + ): Promise => { + return Promise.all( + posts.map((post, index) => + this.convertPost(post, () => dataProvider(post, index)) + ) + ); + }; + private async getCachedPosts(timeline: { + name: string; + uniqueId: string; + }): Promise { const postsInfoKey = this.getPostsInfoKey(timeline.uniqueId); const postsInfo = await dataStorage.getItem( postsInfoKey ); - const convertPostList = ( - posts: HttpTimelinePostInfo[], - dataProvider: ( - post: HttpTimelinePostInfo, - index: number - ) => Promise - ): Promise => { - return Promise.all( - posts.map((post, index) => - this.convertPost(post, () => dataProvider(post, index)) + if (postsInfo == null) return []; + + const httpPosts = await Promise.all( + postsInfo.idList.map((postId) => + dataStorage.getItem( + this.getPostKey(timeline.uniqueId, postId) ) - ); - }; + ) + ); + + const posts = await this.convertPostList(httpPosts, (post) => + dataStorage + .getItem( + this.getPostDataKey(timeline.uniqueId, post.id) + ) + .then((d) => d?.data) + ); + + return posts; + } + + private fetchAndCachePosts(timeline: { + name: string; + uniqueId: string; + }): Promise { + return queue( + `TimelineService.fetchAndCachePosts.${timeline.uniqueId}`, + () => this.doFetchAndCachePosts(timeline) + ); + } + + private async doFetchAndCachePosts(timeline: { + name: string; + uniqueId: string; + }): Promise { + const postsInfoKey = this.getPostsInfoKey(timeline.uniqueId); + const postsInfo = await dataStorage.getItem( + postsInfoKey + ); + + const convertPostList = this.convertPostList.bind(this); const now = new Date(); if (postsInfo == null) { @@ -521,32 +602,124 @@ export class TimelineService { } } + private syncPosts(timelineName: string): Promise { + const line = this._postsSubscriptionHub.getLine(timelineName); + if (line == null) return Promise.resolve(); + + const { value } = line; + + if ( + value != null && + value.timelineUniqueId != null && + value.state !== 'forbid' + ) { + return this.syncPostsWithUniqueId({ + name: timelineName, + uniqueId: value.timelineUniqueId, + }); + } else { + return Promise.resolve(); + } + } + + private async syncPostsWithUniqueId(timeline: { + name: string; + uniqueId: string; + }): Promise { + const line = this._postsSubscriptionHub.getLine(timeline.name); + if (line == null) return; + + if ( + line.value != null && + line.value.timelineUniqueId == timeline.uniqueId && + (line.value.state === 'loadcache' || line.value.state === 'syncing') + ) { + return; + } + + const next = ( + value: ExcludeKey + ): void => { + line.next({ + ...value, + timelineUniqueId: timeline.uniqueId, + }); + }; + + const uniqueIdChanged = (): boolean => { + return line.value?.timelineUniqueId !== timeline.uniqueId; + }; + + if (line.value == null) { + next({ + state: 'loadcache', + posts: [], + }); + const posts = await this.getCachedPosts(timeline); + if (uniqueIdChanged()) { + return; + } + next({ + state: 'syncing', + posts, + }); + } else { + next({ + state: 'syncing', + posts: line.value?.posts ?? [], + }); + } + + const result = await this.fetchAndCachePosts(timeline); + if (uniqueIdChanged()) { + return; + } + + if (result === 'offline') { + next({ state: 'offline', posts: [] }); + } else if (result.type === 'synced') { + next({ state: 'synced', posts: result.posts }); + } else { + next({ state: 'offline', posts: result.posts }); + } + } + private _postsSubscriptionHub = new SubscriptionHub< string, TimelinePostsWithSyncState >({ - setup: (key, next) => { + setup: (key, line) => { const sub = this.timelineHub.subscribe(key, (timelineState) => { - if (timelineState.timeline == null) { - if (timelineState.syncState === 'offline') { - next({ state: 'offline', posts: [] }); + if (timelineState.timeline != null) { + if ( + !this.hasReadPermission( + userService.currentUser, + timelineState.timeline + ) + ) { + line.next({ + state: 'forbid', + posts: [], + timelineUniqueId: timelineState.timeline.uniqueId, + }); } else { - next({ state: 'synced', posts: [] }); + if ( + line.value == null || + line.value.timelineUniqueId !== timelineState.timeline.uniqueId + ) { + void this.syncPostsWithUniqueId(timelineState.timeline); + } } } else { - void this.fetchAndCachePosts(timelineState.timeline).then( - (result) => { - if (result === 'forbid') { - next({ state: 'forbid', posts: [] }); - } else if (result === 'offline') { - next({ state: 'offline', posts: [] }); - } else if (result.type === 'synced') { - next({ state: 'synced', posts: result.posts }); - } else { - next({ state: 'offline', posts: result.posts }); - } - } - ); + if (timelineState.syncState === 'synced') { + line.next({ + state: 'timeline-notexist', + }); + } else if (timelineState.syncState === 'offline') { + line.next({ + state: 'timeline-offline', + }); + } } }); return () => { @@ -575,15 +748,7 @@ export class TimelineService { ) ) .then((post) => { - this._postsSubscriptionHub.updateWithOld(timelineName, (old) => { - if (old instanceof NoValue) { - throw new Error('Posts has not been loaded.'); - } - return { - ...old, - posts: [...old.posts, post], - }; - }); + void this.syncPosts(timelineName); return post; }) ).pipe(map((post) => ({ ...post, timelineName }))); @@ -595,15 +760,7 @@ export class TimelineService { getHttpTimelineClient() .deletePost(timelineName, postId, user.token) .then(() => { - this._postsSubscriptionHub.updateWithOld(timelineName, (old) => { - if (old instanceof NoValue) { - throw new Error('Posts has not been loaded.'); - } - return { - ...old, - posts: old.posts.filter((post) => post.id != postId), - }; - }); + void this.syncPosts(timelineName); }) ); } -- cgit v1.2.3