From da0ba10c5f28b494f93b28956ccb96a4d6bd3b94 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 22:23:56 +0800 Subject: Add timeline hub. --- Timeline/ClientApp/src/app/data/timeline.ts | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index 84eb3764..d84f7bc4 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -76,15 +76,77 @@ export interface TimelinePostListState { posts: TimelinePostInfo[]; } +export interface TimelineInfoLoadingState { + state: 'loading'; // Loading from cache. + timeline: null; +} + +export interface TimelineInfoNonLoadingState { + state: + | 'syncing' // Cache loaded and syncing now. If null means there is no cache for the timeline. + | 'offline' // Sync failed and use cache. + | 'synced' // Sync succeeded. If null means the timeline does not exist. + | 'new'; // This is a new timeline different from cached one. If null means the timeline does not exist. + timeline: TimelineInfo | null; +} + +export type TimelineInfoState = + | TimelineInfoLoadingState + | TimelineInfoNonLoadingState; + interface PostListInfo { idList: number[]; lastUpdated: string; } export class TimelineService { + // timeline storage structure: + // each timeline has a TimelineInfo saved with key created by getTimelineKey + + private getTimelineKey(timelineName: string): string { + return `timeline.${timelineName}`; + } + + private getCachedTimeline( + timelineName: string + ): Promise { + return dataStorage.getItem( + this.getTimelineKey(timelineName) + ); + } + + private syncTimeline(timelineName: string): Promise { + // TODO: Implement this. + throw new Error('Not implemented.'); + } + + private _timelineSubscriptionHub = new SubscriptionHub< + string, + TimelineInfoState + >( + (key) => key, + () => ({ + state: 'loading', + timeline: null, + }), + async (key) => { + const result = await this.getCachedTimeline(key); + void this.syncTimeline(key); + return { + state: 'syncing', + timeline: result, + }; + } + ); + + get timelineHub(): ISubscriptionHub { + return this._timelineSubscriptionHub; + } + // 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>(); + // TODO: Remove this. getTimeline(timelineName: string): Observable { const cache = this.timelineCache.get(timelineName); let promise: Promise; @@ -145,6 +207,7 @@ export class TimelineService { ); } + // TODO: Remove this. getPosts(timelineName: string): Observable { const token = userService.currentUser?.token; return from(getHttpTimelineClient().listPost(timelineName, token)).pipe( -- cgit v1.2.3 From 9014a5b52e9810eb71bc88b3645b0e716a8576ca Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 31 Jul 2020 23:13:03 +0800 Subject: Add http get timeline api query params. --- Timeline/ClientApp/src/app/http/common.ts | 10 ++++ Timeline/ClientApp/src/app/http/mock/timeline.ts | 62 +++++++++++++++++++- Timeline/ClientApp/src/app/http/timeline.ts | 74 ++++++++++++++++++++++-- Timeline/ClientApp/src/app/utilities/url.ts | 13 ++++- 4 files changed, 150 insertions(+), 9 deletions(-) (limited to 'Timeline/ClientApp/src') diff --git a/Timeline/ClientApp/src/app/http/common.ts b/Timeline/ClientApp/src/app/http/common.ts index 8fb8eb69..3c2f2ba6 100644 --- a/Timeline/ClientApp/src/app/http/common.ts +++ b/Timeline/ClientApp/src/app/http/common.ts @@ -119,6 +119,16 @@ export function convertToNetworkError( } } +export function extractDataOrConvert304ToNotModified( + res: AxiosResponse +): T | NotModified { + if (res.status === 304) { + return new NotModified(); + } else { + return res.data; + } +} + export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { return { data: res.data, diff --git a/Timeline/ClientApp/src/app/http/mock/timeline.ts b/Timeline/ClientApp/src/app/http/mock/timeline.ts index 2a34ef10..911da2f2 100644 --- a/Timeline/ClientApp/src/app/http/mock/timeline.ts +++ b/Timeline/ClientApp/src/app/http/mock/timeline.ts @@ -31,6 +31,7 @@ async function setTimelineNameList(newOne: string[]): Promise { type TimelinePropertyKey = | 'uniqueId' + | 'lastModified' | 'owner' | 'description' | 'visibility' @@ -61,6 +62,14 @@ function setTimelinePropertyValue( .then(); } +function updateTimelineLastModified(name: string): Promise { + return setTimelinePropertyValue( + name, + 'lastModified', + new Date().toISOString() + ); +} + interface HttpTimelineInfoEx extends HttpTimelineInfo { memberUsernames: string[]; } @@ -98,6 +107,7 @@ async function getTimelineInfo(name: string): Promise { if (optionalUniqueId == null) { await setTimelineNameList([...(await getTimelineNameList()), name]); await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + await updateTimelineLastModified(name); } } else { const optionalOwnerUsername = await getTimelinePropertyValue( @@ -131,6 +141,9 @@ async function getTimelineInfo(name: string): Promise { name, 'visibility' )) ?? 'Register', + lastModified: new Date( + await getTimelinePropertyValue(name, 'lastModified') + ), members, memberUsernames, }; @@ -148,6 +161,7 @@ async function createTimeline(name: string, owner: string): Promise { await setTimelineNameList([...(await getTimelineNameList()), name]); await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); await setTimelinePropertyValue(name, 'owner', owner); + await updateTimelineLastModified(name); } type TimelinePostPropertyKey = @@ -304,10 +318,46 @@ export class MockHttpTimelineClient implements IHttpTimelineClient { }); } - async getTimeline(timelineName: string): Promise { + getTimeline(timelineName: string): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + } + ): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + ifModifiedSince: Date; + } + ): Promise; + async getTimeline( + timelineName: string, + query?: { + checkUniqueId?: string; + ifModifiedSince?: Date; + } + ): Promise { await mockPrepare(); try { - return await getTimelineInfo(timelineName); + const timeline = await getTimelineInfo(timelineName); + if (query != null && query.ifModifiedSince != null) { + if (timeline.lastModified >= query.ifModifiedSince) { + return timeline; + } else { + if ( + query.checkUniqueId != null && + timeline.uniqueId != query.checkUniqueId + ) { + return timeline; + } else { + return new NotModified(); + } + } + } + + return timeline; } catch (e) { if ( e instanceof MockTimelineNotExistError || @@ -342,7 +392,9 @@ export class MockHttpTimelineClient implements IHttpTimelineClient { _token: string ): Promise { await mockPrepare(); + let modified = false; if (req.description != null) { + modified = true; await setTimelinePropertyValue( timelineName, 'description', @@ -350,12 +402,16 @@ export class MockHttpTimelineClient implements IHttpTimelineClient { ); } if (req.visibility != null) { + modified = true; await setTimelinePropertyValue( timelineName, 'visibility', req.visibility ); } + if (modified) { + await updateTimelineLastModified(timelineName); + } return await getTimelineInfo(timelineName); } @@ -387,6 +443,7 @@ export class MockHttpTimelineClient implements IHttpTimelineClient { ...oldMembers, username, ]); + await updateTimelineLastModified(timelineName); } } @@ -407,6 +464,7 @@ export class MockHttpTimelineClient implements IHttpTimelineClient { 'members', without(oldMembers, username) ); + await updateTimelineLastModified(timelineName); } } diff --git a/Timeline/ClientApp/src/app/http/timeline.ts b/Timeline/ClientApp/src/app/http/timeline.ts index 458ea6e6..bfe0d1ad 100644 --- a/Timeline/ClientApp/src/app/http/timeline.ts +++ b/Timeline/ClientApp/src/app/http/timeline.ts @@ -24,6 +24,7 @@ export interface HttpTimelineInfo { description: string; owner: HttpUser; visibility: TimelineVisibility; + lastModified: Date; members: HttpUser[]; } @@ -115,6 +116,16 @@ export class HttpTimelineNameConflictError extends Error { //-------------------- begin: internal model -------------------- +interface RawTimelineInfo { + uniqueId: string; + name: string; + description: string; + owner: HttpUser; + visibility: TimelineVisibility; + lastModified: string; + members: HttpUser[]; +} + interface RawTimelinePostTextContent { type: 'text'; text: string; @@ -171,6 +182,13 @@ interface RawTimelinePostPostRequest { //-------------------- end: internal model -------------------- +function processRawTimelineInfo(raw: RawTimelineInfo): HttpTimelineInfo { + return { + ...raw, + lastModified: new Date(raw.lastModified), + }; +} + function processRawTimelinePostInfo( raw: RawTimelinePostInfo ): HttpTimelinePostInfo; @@ -190,6 +208,19 @@ function processRawTimelinePostInfo( export interface IHttpTimelineClient { listTimeline(query: HttpTimelineListQuery): Promise; getTimeline(timelineName: string): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + } + ): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + ifModifiedSince: Date; + } + ): Promise; postTimeline( req: HttpTimelinePostRequest, token: string @@ -256,17 +287,46 @@ export interface IHttpTimelineClient { export class HttpTimelineClient implements IHttpTimelineClient { listTimeline(query: HttpTimelineListQuery): Promise { return axios - .get( + .get( applyQueryParameters(`${apiBaseUrl}/timelines`, query) ) .then(extractResponseData) + .then((list) => list.map(processRawTimelineInfo)) .catch(convertToNetworkError); } - getTimeline(timelineName: string): Promise { + getTimeline(timelineName: string): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + } + ): Promise; + getTimeline( + timelineName: string, + query: { + checkUniqueId?: string; + ifModifiedSince: Date; + } + ): Promise; + getTimeline( + timelineName: string, + query?: { + checkUniqueId?: string; + ifModifiedSince?: Date; + } + ): Promise { return axios - .get(`${apiBaseUrl}/timelines/${timelineName}`) - .then(extractResponseData) + .get( + applyQueryParameters(`${apiBaseUrl}/timelines/${timelineName}`, query) + ) + .then((res) => { + if (res.status === 304) { + return new NotModified(); + } else { + return processRawTimelineInfo(res.data); + } + }) .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) .catch(convertToNetworkError); } @@ -276,8 +336,9 @@ export class HttpTimelineClient implements IHttpTimelineClient { token: string ): Promise { return axios - .post(`${apiBaseUrl}/timelines?token=${token}`, req) + .post(`${apiBaseUrl}/timelines?token=${token}`, req) .then(extractResponseData) + .then(processRawTimelineInfo) .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) .catch(convertToNetworkError); } @@ -288,11 +349,12 @@ export class HttpTimelineClient implements IHttpTimelineClient { token: string ): Promise { return axios - .patch( + .patch( `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, req ) .then(extractResponseData) + .then(processRawTimelineInfo) .catch(convertToNetworkError); } diff --git a/Timeline/ClientApp/src/app/utilities/url.ts b/Timeline/ClientApp/src/app/utilities/url.ts index 0b8623a2..90923fd2 100644 --- a/Timeline/ClientApp/src/app/utilities/url.ts +++ b/Timeline/ClientApp/src/app/utilities/url.ts @@ -34,8 +34,19 @@ export function updateQueryString( } export function applyQueryParameters(url: string, query: T): string { + if (query == null) return url; + for (const [key, value] of Object.entries(query)) { - url = updateQueryString(key, String(value), url); + if (typeof value === 'string') url = updateQueryString(key, value, url); + else if (typeof value === 'number') + url = updateQueryString(key, String(value), url); + else if (typeof value === 'boolean') + url = updateQueryString(key, value ? 'true' : 'false', url); + else if (value instanceof Date) + url = updateQueryString(key, value.toISOString(), url); + else { + console.error('Unknown query parameter type. Param: ', value); + } } return url; } -- cgit v1.2.3 From 0a456da9e9cc0cf0244e80a3da5af26debd20841 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 2 Aug 2020 18:42:12 +0800 Subject: Implement sync timeline. --- Timeline/ClientApp/src/app/data/timeline.ts | 75 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 7 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 d84f7bc4..b30f3a7d 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -94,6 +94,11 @@ export type TimelineInfoState = | TimelineInfoLoadingState | TimelineInfoNonLoadingState; +interface TimelineCache { + timeline: TimelineInfo; + lastUpdated: string; +} + interface PostListInfo { idList: number[]; lastUpdated: string; @@ -101,7 +106,7 @@ interface PostListInfo { export class TimelineService { // timeline storage structure: - // each timeline has a TimelineInfo saved with key created by getTimelineKey + // each timeline has a TimelineCache saved with key created by getTimelineKey private getTimelineKey(timelineName: string): string { return `timeline.${timelineName}`; @@ -110,14 +115,70 @@ export class TimelineService { private getCachedTimeline( timelineName: string ): Promise { - return dataStorage.getItem( - this.getTimelineKey(timelineName) - ); + return dataStorage + .getItem(this.getTimelineKey(timelineName)) + .then((cache) => cache?.timeline ?? null); } - private syncTimeline(timelineName: string): Promise { - // TODO: Implement this. - throw new Error('Not implemented.'); + private async syncTimeline(timelineName: string): Promise { + const cache = await dataStorage.getItem(timelineName); + + const save = (cache: TimelineCache): Promise => + dataStorage.setItem( + this.getTimelineKey(timelineName), + cache + ); + const push = (state: TimelineInfoState): void => { + this._timelineSubscriptionHub.update(timelineName, () => + Promise.resolve(state) + ); + }; + + let result: TimelineInfo; + const now = new Date(); + if (cache == null) { + try { + const res = await getHttpTimelineClient().getTimeline(timelineName); + result = res; + await save({ timeline: result, lastUpdated: now.toISOString() }); + push({ state: 'synced', timeline: result }); + } catch (e) { + if (e instanceof HttpTimelineNotExistError) { + push({ state: 'synced', timeline: null }); + } else { + push({ state: 'offline', timeline: null }); + } + throw e; + } + } else { + try { + const res = await getHttpTimelineClient().getTimeline(timelineName, { + checkUniqueId: cache.timeline.uniqueId, + ifModifiedSince: new Date(cache.lastUpdated), + }); + if (res instanceof NotModified) { + result = cache.timeline; + await save({ timeline: result, lastUpdated: now.toISOString() }); + push({ state: 'synced', timeline: result }); + } else { + result = res; + await save({ timeline: result, lastUpdated: now.toISOString() }); + if (res.uniqueId === cache.timeline.uniqueId) { + push({ state: 'synced', timeline: result }); + } else { + push({ state: 'new', timeline: result }); + } + } + } catch (e) { + if (e instanceof HttpTimelineNotExistError) { + push({ state: 'new', timeline: null }); + } else { + push({ state: 'offline', timeline: cache.timeline }); + } + throw e; + } + } + return result; } private _timelineSubscriptionHub = new SubscriptionHub< -- cgit v1.2.3