diff options
author | crupest <crupest@outlook.com> | 2021-01-11 21:34:57 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2021-01-11 21:34:57 +0800 |
commit | 873bb613bc2deb86a4266bac160d14be265f9609 (patch) | |
tree | 00aeea22bfef21629a1004c174a88c931a47a5ca /FrontEnd/src/app/services | |
parent | 1488919a75a67ad3992e9c66031c9079c50053f2 (diff) | |
download | timeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.gz timeline-873bb613bc2deb86a4266bac160d14be265f9609.tar.bz2 timeline-873bb613bc2deb86a4266bac160d14be265f9609.zip |
...
Diffstat (limited to 'FrontEnd/src/app/services')
-rw-r--r-- | FrontEnd/src/app/services/DataHub.ts | 225 | ||||
-rw-r--r-- | FrontEnd/src/app/services/DataHub2.ts | 10 | ||||
-rw-r--r-- | FrontEnd/src/app/services/common.ts | 19 | ||||
-rw-r--r-- | FrontEnd/src/app/services/timeline.ts | 573 | ||||
-rw-r--r-- | FrontEnd/src/app/services/user.ts | 140 |
5 files changed, 272 insertions, 695 deletions
diff --git a/FrontEnd/src/app/services/DataHub.ts b/FrontEnd/src/app/services/DataHub.ts deleted file mode 100644 index 4d618db6..00000000 --- a/FrontEnd/src/app/services/DataHub.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { pull } from "lodash"; -import { Observable, BehaviorSubject, combineLatest } from "rxjs"; -import { map } from "rxjs/operators"; - -export type Subscriber<TData> = (data: TData) => void; - -export type WithSyncStatus<T> = T & { syncing: boolean }; - -export class DataLine<TData> { - private _current: TData | undefined = undefined; - - private _syncPromise: Promise<void> | null = null; - private _syncingSubject = new BehaviorSubject<boolean>(false); - - private _observers: Subscriber<TData>[] = []; - - constructor( - private config: { - sync: () => Promise<void>; - destroyable?: (value: TData | undefined) => boolean; - disableInitSync?: boolean; - } - ) { - if (config.disableInitSync !== true) { - setTimeout(() => void this.sync()); - } - } - - private subscribe(subscriber: Subscriber<TData>): void { - this._observers.push(subscriber); - if (this._current !== undefined) { - subscriber(this._current); - } - } - - private unsubscribe(subscriber: Subscriber<TData>): void { - if (!this._observers.includes(subscriber)) return; - pull(this._observers, subscriber); - } - - getObservable(): Observable<TData> { - return new Observable<TData>((observer) => { - const f = (data: TData): void => { - observer.next(data); - }; - this.subscribe(f); - - return () => { - this.unsubscribe(f); - }; - }); - } - - getSyncStatusObservable(): Observable<boolean> { - return this._syncingSubject.asObservable(); - } - - getDataWithSyncStatusObservable(): Observable<WithSyncStatus<TData>> { - return combineLatest([ - this.getObservable(), - this.getSyncStatusObservable(), - ]).pipe( - map(([data, syncing]) => ({ - ...data, - syncing, - })) - ); - } - - get value(): TData | undefined { - return this._current; - } - - next(value: TData): void { - this._current = value; - this._observers.forEach((observer) => observer(value)); - } - - get isSyncing(): boolean { - return this._syncPromise != null; - } - - sync(): Promise<void> { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = this.config.sync().then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - syncWithAction( - syncAction: (line: DataLine<TData>) => Promise<void> - ): Promise<void> { - if (this._syncPromise == null) { - this._syncingSubject.next(true); - this._syncPromise = syncAction(this).then(() => { - this._syncingSubject.next(false); - this._syncPromise = null; - }); - } - - return this._syncPromise; - } - - get destroyable(): boolean { - const customDestroyable = this.config?.destroyable; - - return ( - this._observers.length === 0 && - !this.isSyncing && - (customDestroyable != null ? customDestroyable(this._current) : true) - ); - } -} - -export class DataHub<TKey, TData> { - private sync: (key: TKey, line: DataLine<TData>) => Promise<void>; - private keyToString: (key: TKey) => string; - private destroyable?: (key: TKey, value: TData | undefined) => boolean; - - private readonly subscriptionLineMap = new Map<string, DataLine<TData>>(); - - private cleanTimerId = 0; - - // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called. - constructor(config: { - sync: (key: TKey, line: DataLine<TData>) => Promise<void>; - keyToString?: (key: TKey) => string; - destroyable?: (key: TKey, value: TData | undefined) => boolean; - }) { - this.sync = config.sync; - this.keyToString = - config.keyToString ?? - ((value): string => { - if (typeof value === "string") return value; - else - throw new Error( - "Default keyToString function only pass string value." - ); - }); - - this.destroyable = config.destroyable; - } - - private cleanLines(): void { - const toDelete: string[] = []; - for (const [key, line] of this.subscriptionLineMap.entries()) { - if (line.destroyable) { - toDelete.push(key); - } - } - - if (toDelete.length === 0) return; - - for (const key of toDelete) { - this.subscriptionLineMap.delete(key); - } - - if (this.subscriptionLineMap.size === 0) { - window.clearInterval(this.cleanTimerId); - this.cleanTimerId = 0; - } - } - - private createLine(key: TKey, disableInitSync = false): DataLine<TData> { - const keyString = this.keyToString(key); - const { destroyable } = this; - const newLine: DataLine<TData> = new DataLine<TData>({ - sync: () => this.sync(key, newLine), - destroyable: - destroyable != null ? (value) => destroyable(key, value) : undefined, - disableInitSync: disableInitSync, - }); - this.subscriptionLineMap.set(keyString, newLine); - if (this.subscriptionLineMap.size === 1) { - this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); - } - return newLine; - } - - getObservable(key: TKey): Observable<TData> { - return this.getLineOrCreate(key).getObservable(); - } - - getSyncStatusObservable(key: TKey): Observable<boolean> { - return this.getLineOrCreate(key).getSyncStatusObservable(); - } - - getDataWithSyncStatusObservable( - key: TKey - ): Observable<WithSyncStatus<TData>> { - return this.getLineOrCreate(key).getDataWithSyncStatusObservable(); - } - - getLine(key: TKey): DataLine<TData> | null { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? null; - } - - getLineOrCreate(key: TKey): DataLine<TData> { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); - } - - getLineOrCreateWithoutInitSync(key: TKey): DataLine<TData> { - const keyString = this.keyToString(key); - return ( - this.subscriptionLineMap.get(keyString) ?? this.createLine(key, true) - ); - } - - optionalInitLineWithSyncAction( - key: TKey, - syncAction: (line: DataLine<TData>) => Promise<void> - ): Promise<void> { - const optionalLine = this.getLine(key); - if (optionalLine != null) return Promise.resolve(); - const line = this.createLine(key, true); - return line.syncWithAction(syncAction); - } -} diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts index 88849da3..50ae919b 100644 --- a/FrontEnd/src/app/services/DataHub2.ts +++ b/FrontEnd/src/app/services/DataHub2.ts @@ -2,6 +2,16 @@ import { Observable } from "rxjs"; export type DataStatus = "syncing" | "synced" | "offline"; +export function mergeDataStatus(statusList: DataStatus[]): DataStatus { + if (statusList.includes("offline")) { + return "offline"; + } else if (statusList.includes("syncing")) { + return "syncing"; + } else { + return "synced"; + } +} + export type Subscriber<TData> = (data: TData) => void; export interface DataAndStatus<TData> { diff --git a/FrontEnd/src/app/services/common.ts b/FrontEnd/src/app/services/common.ts index 3bb6b9d7..9208737b 100644 --- a/FrontEnd/src/app/services/common.ts +++ b/FrontEnd/src/app/services/common.ts @@ -1,6 +1,6 @@ import localforage from "localforage"; -import { HttpNetworkError } from "@/http/common"; +const dataVersion = 1; export const dataStorage = localforage.createInstance({ name: "data", @@ -8,16 +8,17 @@ export const dataStorage = localforage.createInstance({ driver: localforage.INDEXEDDB, }); +void (async () => { + const currentVersion = await dataStorage.getItem<number | null>("version"); + if (currentVersion !== dataVersion) { + console.log("Data storage version has changed. Clear all data."); + await dataStorage.clear(); + await dataStorage.setItem("version", dataVersion); + } +})(); + export class ForbiddenError extends Error { constructor(message?: string) { super(message); } } - -export function throwIfNotNetworkError(e: unknown): void { - if (!(e instanceof HttpNetworkError)) { - throw e; - } -} - -export type BlobOrStatus = Blob | "loading" | "error"; diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index 3b9a9072..ed24c005 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -1,8 +1,6 @@ import React from "react"; import XRegExp from "xregexp"; -import { Observable, from, combineLatest, of } from "rxjs"; -import { map, switchMap, startWith, filter } from "rxjs/operators"; -import { uniqBy } from "lodash"; +import { Observable, from } from "rxjs"; import { convertError } from "@/utilities/rxjs"; import { @@ -19,16 +17,15 @@ import { HttpTimelineNotExistError, HttpTimelineNameConflictError, } from "@/http/timeline"; -import { BlobWithEtag, NotModified, HttpForbiddenError } from "@/http/common"; -import { HttpUser } from "@/http/user"; +import { HttpForbiddenError, HttpNetworkError } from "@/http/common"; export { kTimelineVisibilities } from "@/http/timeline"; export type { TimelineVisibility } from "@/http/timeline"; -import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; -import { DataHub, WithSyncStatus } from "./DataHub"; -import { userInfoService, User, AuthUser } from "./user"; +import { dataStorage } from "./common"; +import { userInfoService, AuthUser } from "./user"; +import { DataAndStatus, DataHub2 } from "./DataHub2"; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -41,19 +38,21 @@ export type TimelinePostTextContent = HttpTimelinePostTextContent; export interface TimelinePostImageContent { type: "image"; - data: BlobOrStatus; + data: Blob; + etag: string; } export type TimelinePostContent = | TimelinePostTextContent | TimelinePostImageContent; -export interface TimelinePostInfo { - id: number; +export type TimelinePostInfo = Omit<HttpTimelinePostInfo, "content"> & { content: TimelinePostContent; - time: Date; +}; + +export interface TimelinePostsInfo { lastUpdated: Date; - author: HttpUser; + posts: TimelinePostInfo[]; } export const timelineVisibilityTooltipTranslationMap: Record< @@ -65,55 +64,23 @@ export const timelineVisibilityTooltipTranslationMap: Record< Private: "timeline.visibilityTooltip.private", }; -export class TimelineNotExistError extends Error {} export class TimelineNameConflictError extends Error {} -export type TimelineWithSyncStatus = WithSyncStatus< - | { - type: "cache"; - timeline: TimelineInfo; - } - | { - type: "offline" | "synced"; - timeline: TimelineInfo | null; - } ->; - -export type TimelinePostsWithSyncState = WithSyncStatus<{ - type: - | "cache" - | "offline" // Sync failed and use cache. - | "synced" // Sync succeeded. - | "forbid" // The list is forbidden to see. - | "notexist"; // The timeline does not exist. - posts: TimelinePostInfo[]; -}>; - type TimelineData = Omit<HttpTimelineInfo, "owner" | "members"> & { owner: string; members: string[]; }; -type TimelinePostData = Omit<HttpTimelinePostInfo, "author"> & { +type TimelinePostData = Omit<TimelinePostInfo, "author"> & { author: string; }; -export class TimelineService { - private getCachedTimeline( - timelineName: string - ): Promise<TimelineData | null> { - return dataStorage.getItem<TimelineData | null>(`timeline.${timelineName}`); - } - - private saveTimeline( - timelineName: string, - data: TimelineData - ): Promise<void> { - return dataStorage - .setItem<TimelineData>(`timeline.${timelineName}`, data) - .then(); - } +interface TimelinePostsData { + lastUpdated: Date; + posts: TimelinePostData[]; +} +export class TimelineService { private async clearTimelineData(timelineName: string): Promise<void> { const keys = (await dataStorage.keys()).filter((k) => k.startsWith(`timeline.${timelineName}`) @@ -121,6 +88,10 @@ export class TimelineService { await Promise.all(keys.map((k) => dataStorage.removeItem(k))); } + private generateTimelineDataStorageKey(timelineName: string): string { + return `timeline.${timelineName}`; + } + private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { return { ...timeline, @@ -129,95 +100,65 @@ export class TimelineService { }; } - private _timelineHub = new DataHub< - string, - | { - type: "cache"; - timeline: TimelineData; - } - | { - type: "offline" | "synced"; - timeline: TimelineData | null; - } - >({ - sync: async (key, line) => { - const cache = await this.getCachedTimeline(key); + readonly timelineHub = new DataHub2<string, HttpTimelineInfo | "notexist">({ + saveData: async (timelineName, data) => { + if (data === "notexist") return; - if (line.value == undefined) { - if (cache != null) { - line.next({ type: "cache", timeline: cache }); - } - } + userInfoService.saveUser(data.owner); + userInfoService.saveUsers(data.members); - try { - const httpTimeline = await getHttpTimelineClient().getTimeline(key); + await dataStorage.setItem<TimelineData>( + this.generateTimelineDataStorageKey(timelineName), + this.convertHttpTimelineToData(data) + ); + }, + getSavedData: async (timelineName) => { + const savedData = await dataStorage.getItem<TimelineData | null>( + this.generateTimelineDataStorageKey(timelineName) + ); - userInfoService.saveUsers([ - httpTimeline.owner, - ...httpTimeline.members, - ]); + if (savedData == null) return null; - const timeline = this.convertHttpTimelineToData(httpTimeline); + const owner = await userInfoService.getCachedUser(savedData.owner); + if (owner == null) return null; + const members = await userInfoService.getCachedUsers(savedData.members); + if (members == null) return null; - if (cache != null && timeline.uniqueId !== cache.uniqueId) { + return { ...savedData, owner, members }; + }, + fetchData: async (timelineName, savedData) => { + try { + const timeline = await getHttpTimelineClient().getTimeline( + timelineName + ); + + if ( + savedData != null && + savedData !== "notexist" && + savedData.uniqueId !== timeline.uniqueId + ) { console.log( - `Timeline with name ${key} has changed to a new one. Clear old data.` + `Timeline with name ${timelineName} has changed to a new one. Clear old data.` ); - await this.clearTimelineData(key); // If timeline has changed, clear all old data. - } - await this.saveTimeline(key, timeline); + void this.clearTimelineData(timelineName); // If timeline has changed, clear all old data. + } - line.next({ type: "synced", timeline }); + return timeline; } catch (e) { if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "synced", timeline: null }); + return "notexist"; + } else if (e instanceof HttpNetworkError) { + return null; } else { - if (cache == null) { - line.next({ type: "offline", timeline: null }); - } else { - line.next({ type: "offline", timeline: cache }); - } - throwIfNotNetworkError(e); + throw e; } } }, }); - syncTimeline(timelineName: string): Promise<void> { - return this._timelineHub.getLineOrCreate(timelineName).sync(); - } - - getTimeline$(timelineName: string): Observable<TimelineWithSyncStatus> { - return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - const { timeline } = state; - if (timeline != null) { - return combineLatest( - [timeline.owner, ...timeline.members].map((u) => - state.type === "cache" - ? from(userInfoService.getCachedUser(u)).pipe( - filter((u): u is User => u != null) - ) - : userInfoService.getUser$(u) - ) - ).pipe( - map((users) => { - return { - ...state, - timeline: { - ...timeline, - owner: users[0], - members: users.slice(1), - }, - }; - }) - ); - } else { - return of(state as TimelineWithSyncStatus); - } - }) - ); + syncTimeline(timelineName: string): void { + this.timelineHub.getLine(timelineName).sync(); } createTimeline(timelineName: string): Observable<TimelineInfo> { @@ -268,291 +209,145 @@ export class TimelineService { ); } - private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData { - return { - ...post, - author: post.author.username, - }; + private generatePostsDataStorageKey(timelineName: string): string { + return `timeline.${timelineName}.posts`; } - private convertHttpPostToDataList( - posts: HttpTimelinePostInfo[] - ): TimelinePostData[] { - return posts.map((post) => this.convertHttpPostToData(post)); - } + readonly postsHub = new DataHub2< + string, + TimelinePostsInfo | "notexist" | "forbid" + >({ + saveData: async (timelineName, data) => { + if (data === "notexist" || data === "forbid") return; - private getCachedPosts( - timelineName: string - ): Promise<TimelinePostData[] | null> { - return dataStorage.getItem<TimelinePostData[] | null>( - `timeline.${timelineName}.posts` - ); - } + const savedData: TimelinePostsData = { + ...data, + posts: data.posts.map((p) => ({ ...p, author: p.author.username })), + }; - private savePosts( - timelineName: string, - data: TimelinePostData[] - ): Promise<void> { - return dataStorage - .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data) - .then(); - } + data.posts.forEach((p) => { + userInfoService.saveUser(p.author); + }); - private syncPosts(timelineName: string): Promise<void> { - return this._postsHub.getLineOrCreate(timelineName).sync(); - } + await dataStorage.setItem<TimelinePostsData>( + this.generatePostsDataStorageKey(timelineName), + savedData + ); + }, + getSavedData: async (timelineName) => { + const savedData = await dataStorage.getItem<TimelinePostsData | null>( + this.generatePostsDataStorageKey(timelineName) + ); + if (savedData == null) return null; - private _postsHub = new DataHub< - string, - { - type: "cache" | "offline" | "synced" | "forbid" | "notexist"; - posts: TimelinePostData[]; - } - >({ - sync: async (key, line) => { - // Wait for timeline synced. In case the timeline has changed to another and old data has been cleaned. - await this.syncTimeline(key); - - if (line.value == null) { - const cache = await this.getCachedPosts(key); - if (cache != null) { - line.next({ type: "cache", posts: cache }); + const authors = await userInfoService.getCachedUsers( + savedData.posts.map((p) => p.author) + ); + + if (authors == null) return null; + + return { + ...savedData, + posts: savedData.posts.map((p, index) => ({ + ...p, + author: authors[index], + })), + }; + }, + fetchData: async (timelineName, savedData) => { + const convert = async ( + post: HttpTimelinePostInfo + ): Promise<TimelinePostInfo> => { + const { content } = post; + if (content.type === "text") { + return { ...post, content }; + } else { + const data = await getHttpTimelineClient().getPostData( + timelineName, + post.id + ); + return { + ...post, + content: { + type: "image", + data: data.data, + etag: data.etag, + }, + }; } - } + }; - const now = new Date(); + const convertList = ( + posts: HttpTimelinePostInfo[] + ): Promise<TimelinePostInfo[]> => + Promise.all(posts.map((p) => convert(p))); - const lastUpdatedTime = await dataStorage.getItem<Date | null>( - `timeline.${key}.lastUpdated` - ); + const now = new Date(); try { - if (lastUpdatedTime == null) { - const httpPosts = await getHttpTimelineClient().listPost(key); - - userInfoService.saveUsers( - uniqBy( - httpPosts.map((post) => post.author), - "username" - ) + if ( + savedData == null || + savedData === "forbid" || + savedData === "notexist" + ) { + const httpPosts = await getHttpTimelineClient().listPost( + timelineName ); - const posts = this.convertHttpPostToDataList(httpPosts); - await this.savePosts(key, posts); - await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now); - - line.next({ type: "synced", posts }); + return { + lastUpdated: now, + posts: await convertList(httpPosts), + }; } else { - const httpPosts = await getHttpTimelineClient().listPost(key, { - modifiedSince: lastUpdatedTime, - includeDeleted: true, - }); + const httpPosts = await getHttpTimelineClient().listPost( + timelineName, + { + modifiedSince: savedData.lastUpdated, + includeDeleted: true, + } + ); const deletedIds = httpPosts .filter((p) => p.deleted) .map((p) => p.id); - const changed = httpPosts.filter( - (p): p is HttpTimelinePostInfo => !p.deleted - ); - userInfoService.saveUsers( - uniqBy( - httpPosts - .map((post) => post.author) - .filter((u): u is HttpUser => u != null), - "username" - ) + const changed = await convertList( + httpPosts.filter((p): p is HttpTimelinePostInfo => !p.deleted) ); - const cache = (await this.getCachedPosts(key)) ?? []; - - const posts = cache.filter((p) => !deletedIds.includes(p.id)); + const posts = savedData.posts.filter( + (p) => !deletedIds.includes(p.id) + ); for (const changedPost of changed) { const savedChangedPostIndex = posts.findIndex( (p) => p.id === changedPost.id ); if (savedChangedPostIndex === -1) { - posts.push(this.convertHttpPostToData(changedPost)); + posts.push(await convert(changedPost)); } else { - posts[savedChangedPostIndex] = this.convertHttpPostToData( - changedPost - ); + posts[savedChangedPostIndex] = await convert(changedPost); } } - await this.savePosts(key, posts); - await dataStorage.setItem<Date>(`timeline.${key}.lastUpdated`, now); - line.next({ type: "synced", posts }); + return { lastUpdated: now, posts }; } } catch (e) { if (e instanceof HttpTimelineNotExistError) { - line.next({ type: "notexist", posts: [] }); + return "notexist"; } else if (e instanceof HttpForbiddenError) { - line.next({ type: "forbid", posts: [] }); + return "forbid"; + } else if (e instanceof HttpNetworkError) { + return null; } else { - const cache = await this.getCachedPosts(key); - if (cache == null) { - line.next({ type: "offline", posts: [] }); - } else { - line.next({ type: "offline", posts: cache }); - } - throwIfNotNetworkError(e); + throw e; } } }, }); - getPosts$(timelineName: string): Observable<TimelinePostsWithSyncState> { - return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - if (state.posts.length === 0) { - return of({ - ...state, - posts: [], - }); - } - - return combineLatest([ - combineLatest( - state.posts.map((post) => - state.type === "cache" - ? from(userInfoService.getCachedUser(post.author)).pipe( - filter((u): u is User => u != null) - ) - : userInfoService.getUser$(post.author) - ) - ), - combineLatest( - state.posts.map((post) => { - if (post.content.type === "image") { - return state.type === "cache" - ? from(this.getCachedPostData(timelineName, post.id)) - : this.getPostData$(timelineName, post.id); - } else { - return of(null); - } - }) - ), - ]).pipe( - map(([authors, datas]) => { - return { - ...state, - posts: state.posts.map((post, i) => { - const { content } = post; - - return { - ...post, - author: authors[i], - content: (() => { - if (content.type === "text") return content; - else - return { - type: "image", - data: datas[i], - } as TimelinePostImageContent; - })(), - }; - }), - }; - }) - ); - }) - ); - } - - private _getCachedPostData(key: { - timelineName: string; - postId: number; - }): Promise<BlobWithEtag | null> { - return dataStorage.getItem<BlobWithEtag | null>( - `timeline.${key.timelineName}.post.${key.postId}.data` - ); - } - - private savePostData( - key: { - timelineName: string; - postId: number; - }, - data: BlobWithEtag - ): Promise<void> { - return dataStorage - .setItem<BlobWithEtag>( - `timeline.${key.timelineName}.post.${key.postId}.data`, - data - ) - .then(); - } - - private syncPostData(key: { - timelineName: string; - postId: number; - }): Promise<void> { - return this._postDataHub.getLineOrCreate(key).sync(); - } - - private _postDataHub = new DataHub< - { timelineName: string; postId: number }, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - keyToString: (key) => `${key.timelineName}.${key.postId}`, - sync: async (key, line) => { - const cache = await this._getCachedPostData(key); - if (line.value == null) { - if (cache != null) { - line.next({ type: "cache", data: cache.data }); - } - } - - if (cache == null) { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId - ); - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId, - cache.etag - ); - if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); - } else { - await this.savePostData(key, res); - line.next({ data: res.data, type: "synced" }); - } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); - } - } - }, - }); - - getCachedPostData( - timelineName: string, - postId: number - ): Promise<Blob | null> { - return this._getCachedPostData({ timelineName, postId }).then( - (d) => d?.data ?? null - ); - } - - getPostData$(timelineName: string, postId: number): Observable<BlobOrStatus> { - return this._postDataHub.getObservable({ timelineName, postId }).pipe( - map((state): BlobOrStatus => state.data ?? "error"), - startWith("loading") - ); + syncPosts(timelineName: string): void { + this.postsHub.getLine(timelineName).sync(); } createPost( @@ -563,7 +358,7 @@ export class TimelineService { getHttpTimelineClient() .postPost(timelineName, request) .then(() => { - void this.syncPosts(timelineName); + this.syncPosts(timelineName); }) ); } @@ -573,7 +368,7 @@ export class TimelineService { getHttpTimelineClient() .deletePost(timelineName, postId) .then(() => { - void this.syncPosts(timelineName); + this.syncPosts(timelineName); }) ); } @@ -654,18 +449,22 @@ export function validateTimelineName(name: string): boolean { return timelineNameReg.test(name); } -export function useTimelineInfo( +export function useTimeline( timelineName: string ): [ - TimelineWithSyncStatus | undefined, - React.Dispatch<React.SetStateAction<TimelineWithSyncStatus | undefined>> + DataAndStatus<TimelineInfo | "notexist">, + React.Dispatch<React.SetStateAction<DataAndStatus<TimelineInfo | "notexist">>> ] { - const [state, setState] = React.useState<TimelineWithSyncStatus | undefined>( - undefined - ); + const [state, setState] = React.useState< + DataAndStatus<TimelineInfo | "notexist"> + >({ + status: "syncing", + data: null, + }); React.useEffect(() => { - const subscription = timelineService - .getTimeline$(timelineName) + const subscription = timelineService.timelineHub + .getLine(timelineName) + .getObservalble() .subscribe((data) => { setState(data); }); @@ -676,20 +475,16 @@ export function useTimelineInfo( return [state, setState]; } -export function usePostList( - timelineName: string | null | undefined -): TimelinePostsWithSyncState | undefined { +export function usePosts( + timelineName: string +): DataAndStatus<TimelinePostsInfo | "notexist" | "forbid"> { const [state, setState] = React.useState< - TimelinePostsWithSyncState | undefined - >(undefined); + DataAndStatus<TimelinePostsInfo | "notexist" | "forbid"> + >({ status: "syncing", data: null }); React.useEffect(() => { - if (timelineName == null) { - setState(undefined); - return; - } - - const subscription = timelineService - .getPosts$(timelineName) + const subscription = timelineService.postsHub + .getLine(timelineName) + .getObservalble() .subscribe((data) => { setState(data); }); diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index 3407ad02..5c4e3ae0 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -1,9 +1,7 @@ import React, { useState, useEffect } from "react"; import { BehaviorSubject, Observable, from } from "rxjs"; -import { map, filter } from "rxjs/operators"; import { UiLogicError } from "@/common"; -import { convertError } from "@/utilities/rxjs"; import { HttpNetworkError, @@ -22,10 +20,9 @@ import { UserPermission, } from "@/http/user"; -import { dataStorage, throwIfNotNetworkError } from "./common"; -import { DataHub } from "./DataHub"; -import { pushAlert } from "./alert"; import { DataHub2 } from "./DataHub2"; +import { dataStorage } from "./common"; +import { pushAlert } from "./alert"; export type User = HttpUser; @@ -259,6 +256,26 @@ export class UserInfoService { return users.forEach((user) => this.saveUser(user)); } + async getCachedUser(username: string): Promise<HttpUser | null> { + const user = await this.userHub.getLine(username).getSavedData(); + if (user == null || user === "notexist") return null; + return user; + } + + async getCachedUsers(usernames: string[]): Promise<HttpUser[] | null> { + const users = await Promise.all( + usernames.map((username) => this.userHub.getLine(username).getSavedData()) + ); + + for (const u of users) { + if (u == null || u === "notexist") { + return null; + } + } + + return users as HttpUser[]; + } + private generateUserDataStorageKey(username: string): string { return `user.${username}`; } @@ -289,80 +306,52 @@ export class UserInfoService { }, }); - private _getCachedAvatar(username: string): Promise<BlobWithEtag | null> { - return dataStorage.getItem<BlobWithEtag | null>(`user.${username}.avatar`); - } - - private saveAvatar(username: string, data: BlobWithEtag): Promise<void> { - return dataStorage - .setItem<BlobWithEtag>(`user.${username}.avatar`, data) - .then(); - } - - getCachedAvatar(username: string): Promise<Blob | null> { - return this._getCachedAvatar(username).then((d) => d?.data ?? null); + private generateAvatarDataStorageKey(username: string): string { + return `user.${username}.avatar`; } - syncAvatar(username: string): Promise<void> { - return this._avatarHub.getLineOrCreate(username).sync(); - } - - private _avatarHub = new DataHub< - string, - | { data: Blob; type: "cache" | "synced" | "offline" } - | { data?: undefined; type: "notexist" | "offline" } - >({ - sync: async (key, line) => { - const cache = await this._getCachedAvatar(key); - if (line.value == null) { - if (cache != null) { - line.next({ data: cache.data, type: "cache" }); - } - } - - if (cache == null) { - try { - const avatar = await getHttpUserClient().getAvatar(key); - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); - } catch (e) { - line.next({ type: "offline" }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpUserClient().getAvatar(key, cache.etag); + readonly avatarHub = new DataHub2<string, BlobWithEtag | "notexist">({ + saveData: async (username, data) => { + if (typeof data === "string") return; + await dataStorage.setItem<BlobWithEtag>( + this.generateAvatarDataStorageKey(username), + data + ); + }, + getSavedData: (username) => + dataStorage.getItem<BlobWithEtag | null>( + this.generateAvatarDataStorageKey(username) + ), + fetchData: async (username, savedData) => { + try { + if (savedData == null || savedData === "notexist") { + return await getHttpUserClient().getAvatar(username); + } else { + const res = await getHttpUserClient().getAvatar( + username, + savedData.etag + ); if (res instanceof NotModified) { - line.next({ data: cache.data, type: "synced" }); + return savedData; } else { - const avatar = res; - await this.saveAvatar(key, avatar); - line.next({ data: avatar.data, type: "synced" }); + return res; } - } catch (e) { - line.next({ data: cache.data, type: "offline" }); - throwIfNotNetworkError(e); + } + } catch (e) { + if (e instanceof HttpUserNotExistError) { + return "notexist"; + } else if (e instanceof HttpNetworkError) { + return null; + } else { + throw e; } } }, }); - getAvatar$(username: string): Observable<Blob> { - return this._avatarHub.getObservable(username).pipe( - map((state) => state.data), - filter((blob): blob is Blob => blob != null) - ); - } - - getUserInfo(username: string): Observable<User> { - return from(getHttpUserClient().get(username)).pipe( - convertError(HttpUserNotExistError, UserNotExistError) - ); - } - async setAvatar(username: string, blob: Blob): Promise<void> { - await getHttpUserClient().putAvatar(username, blob); - this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" }); + const etag = await getHttpUserClient().putAvatar(username, blob); + this.avatarHub.getLine(username).save({ data: blob, etag }); } async setNickname(username: string, nickname: string): Promise<void> { @@ -384,14 +373,21 @@ export function useAvatar(username?: string): Blob | undefined { return; } - const subscription = userInfoService - .getAvatar$(username) - .subscribe((blob) => { - setState(blob); + const subscription = userInfoService.avatarHub + .getLine(username) + .getObservalble() + .subscribe((data) => { + if (data.data != null && data.data !== "notexist") { + setState(data.data.data); + } else { + setState(undefined); + } }); + return () => { subscription.unsubscribe(); }; }, [username]); + return state; } |