From cc0cc154b9506d1961d08cb29fbc29ad815bad69 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 24 Aug 2020 22:59:45 +0800 Subject: ... --- Timeline/ClientApp/src/app/data/DataHub.ts | 396 ++++---- Timeline/ClientApp/src/app/data/common.ts | 46 +- Timeline/ClientApp/src/app/data/timeline.ts | 1394 +++++++++++++-------------- Timeline/ClientApp/src/app/data/user.ts | 778 +++++++-------- 4 files changed, 1307 insertions(+), 1307 deletions(-) (limited to 'Timeline/ClientApp/src/app/data') diff --git a/Timeline/ClientApp/src/app/data/DataHub.ts b/Timeline/ClientApp/src/app/data/DataHub.ts index bfb96d1a..e6be740d 100644 --- a/Timeline/ClientApp/src/app/data/DataHub.ts +++ b/Timeline/ClientApp/src/app/data/DataHub.ts @@ -1,198 +1,198 @@ -import { pull } from 'lodash'; -import { Observable, BehaviorSubject, combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; - -export type Subscriber = (data: TData) => void; - -export type WithSyncStatus = T & { syncing: boolean }; - -export class DataLine { - private _current: TData | undefined = undefined; - - private _syncingSubject = new BehaviorSubject(false); - - private _observers: Subscriber[] = []; - - constructor( - private config?: { destroyable?: (value: TData | undefined) => boolean } - ) {} - - private subscribe(subscriber: Subscriber): void { - this._observers.push(subscriber); - if (this._current !== undefined) { - subscriber(this._current); - } - } - - private unsubscribe(subscriber: Subscriber): void { - if (!this._observers.includes(subscriber)) return; - pull(this._observers, subscriber); - } - - getObservable(): Observable { - return new Observable((observer) => { - const f = (data: TData): void => { - observer.next(data); - }; - this.subscribe(f); - - return () => { - this.unsubscribe(f); - }; - }); - } - - getSyncStatusObservable(): Observable { - return this._syncingSubject.asObservable(); - } - - getDataWithSyncStatusObservable(): Observable> { - 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._syncingSubject.value; - } - - beginSync(): void { - if (!this._syncingSubject.value) { - this._syncingSubject.next(true); - } - } - - endSync(): void { - if (this._syncingSubject.value) { - this._syncingSubject.next(false); - } - } - - get destroyable(): boolean { - const customDestroyable = this.config?.destroyable; - - return ( - this._observers.length === 0 && - !this._syncingSubject.value && - (customDestroyable != null ? customDestroyable(this._current) : true) - ); - } - - endSyncAndNext(value: TData): void { - this.endSync(); - this.next(value); - } -} - -export class DataHub { - private keyToString: (key: TKey) => string; - private setup?: (key: TKey, line: DataLine) => (() => void) | void; - private destroyable?: (key: TKey, value: TData | undefined) => boolean; - - private readonly subscriptionLineMap = new Map>(); - - 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?: { - keyToString?: (key: TKey) => string; - setup?: (key: TKey, line: DataLine) => void; - destroyable?: (key: TKey, value: TData | undefined) => boolean; - }) { - this.keyToString = - config?.keyToString ?? - ((value): string => { - if (typeof value === 'string') return value; - else - throw new Error( - 'Default keyToString function only pass string value.' - ); - }); - - this.setup = config?.setup; - 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, useSetup = true): DataLine { - const keyString = this.keyToString(key); - const { setup, destroyable } = this; - const newLine = new DataLine({ - destroyable: - destroyable != null ? (value) => destroyable(key, value) : undefined, - }); - this.subscriptionLineMap.set(keyString, newLine); - if (useSetup) { - setup?.(key, newLine); - } - if (this.subscriptionLineMap.size === 1) { - this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); - } - return newLine; - } - - getObservable(key: TKey): Observable { - return this.getLineOrCreateWithSetup(key).getObservable(); - } - - getSyncStatusObservable(key: TKey): Observable { - return this.getLineOrCreateWithSetup(key).getSyncStatusObservable(); - } - - getDataWithSyncStatusObservable( - key: TKey - ): Observable> { - return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable(); - } - - getLine(key: TKey): DataLine | null { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? null; - } - - getLineOrCreateWithSetup(key: TKey): DataLine { - const keyString = this.keyToString(key); - return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); - } - - getLineOrCreateWithoutSetup(key: TKey): DataLine { - const keyString = this.keyToString(key); - return ( - this.subscriptionLineMap.get(keyString) ?? this.createLine(key, false) - ); - } -} +import { pull } from "lodash"; +import { Observable, BehaviorSubject, combineLatest } from "rxjs"; +import { map } from "rxjs/operators"; + +export type Subscriber = (data: TData) => void; + +export type WithSyncStatus = T & { syncing: boolean }; + +export class DataLine { + private _current: TData | undefined = undefined; + + private _syncingSubject = new BehaviorSubject(false); + + private _observers: Subscriber[] = []; + + constructor( + private config?: { destroyable?: (value: TData | undefined) => boolean } + ) {} + + private subscribe(subscriber: Subscriber): void { + this._observers.push(subscriber); + if (this._current !== undefined) { + subscriber(this._current); + } + } + + private unsubscribe(subscriber: Subscriber): void { + if (!this._observers.includes(subscriber)) return; + pull(this._observers, subscriber); + } + + getObservable(): Observable { + return new Observable((observer) => { + const f = (data: TData): void => { + observer.next(data); + }; + this.subscribe(f); + + return () => { + this.unsubscribe(f); + }; + }); + } + + getSyncStatusObservable(): Observable { + return this._syncingSubject.asObservable(); + } + + getDataWithSyncStatusObservable(): Observable> { + 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._syncingSubject.value; + } + + beginSync(): void { + if (!this._syncingSubject.value) { + this._syncingSubject.next(true); + } + } + + endSync(): void { + if (this._syncingSubject.value) { + this._syncingSubject.next(false); + } + } + + get destroyable(): boolean { + const customDestroyable = this.config?.destroyable; + + return ( + this._observers.length === 0 && + !this._syncingSubject.value && + (customDestroyable != null ? customDestroyable(this._current) : true) + ); + } + + endSyncAndNext(value: TData): void { + this.endSync(); + this.next(value); + } +} + +export class DataHub { + private keyToString: (key: TKey) => string; + private setup?: (key: TKey, line: DataLine) => (() => void) | void; + private destroyable?: (key: TKey, value: TData | undefined) => boolean; + + private readonly subscriptionLineMap = new Map>(); + + 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?: { + keyToString?: (key: TKey) => string; + setup?: (key: TKey, line: DataLine) => void; + destroyable?: (key: TKey, value: TData | undefined) => boolean; + }) { + this.keyToString = + config?.keyToString ?? + ((value): string => { + if (typeof value === "string") return value; + else + throw new Error( + "Default keyToString function only pass string value." + ); + }); + + this.setup = config?.setup; + 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, useSetup = true): DataLine { + const keyString = this.keyToString(key); + const { setup, destroyable } = this; + const newLine = new DataLine({ + destroyable: + destroyable != null ? (value) => destroyable(key, value) : undefined, + }); + this.subscriptionLineMap.set(keyString, newLine); + if (useSetup) { + setup?.(key, newLine); + } + if (this.subscriptionLineMap.size === 1) { + this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); + } + return newLine; + } + + getObservable(key: TKey): Observable { + return this.getLineOrCreateWithSetup(key).getObservable(); + } + + getSyncStatusObservable(key: TKey): Observable { + return this.getLineOrCreateWithSetup(key).getSyncStatusObservable(); + } + + getDataWithSyncStatusObservable( + key: TKey + ): Observable> { + return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable(); + } + + getLine(key: TKey): DataLine | null { + const keyString = this.keyToString(key); + return this.subscriptionLineMap.get(keyString) ?? null; + } + + getLineOrCreateWithSetup(key: TKey): DataLine { + const keyString = this.keyToString(key); + return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); + } + + getLineOrCreateWithoutSetup(key: TKey): DataLine { + const keyString = this.keyToString(key); + return ( + this.subscriptionLineMap.get(keyString) ?? this.createLine(key, false) + ); + } +} diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts index 87984e21..8d52abe5 100644 --- a/Timeline/ClientApp/src/app/data/common.ts +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -1,23 +1,23 @@ -import localforage from 'localforage'; - -import { HttpNetworkError } from '../http/common'; - -export const dataStorage = localforage.createInstance({ - name: 'data', - description: 'Database for offline data.', - driver: localforage.INDEXEDDB, -}); - -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'; +import localforage from "localforage"; + +import { HttpNetworkError } from "../http/common"; + +export const dataStorage = localforage.createInstance({ + name: "data", + description: "Database for offline data.", + driver: localforage.INDEXEDDB, +}); + +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/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts index 216d903c..ed6cffd6 100644 --- a/Timeline/ClientApp/src/app/data/timeline.ts +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -1,697 +1,697 @@ -import React from 'react'; -import XRegExp from 'xregexp'; -import { Observable, from, combineLatest, of } from 'rxjs'; -import { map, switchMap, startWith } from 'rxjs/operators'; -import { uniqBy } from 'lodash'; - -import { convertError } from '../utilities/rxjs'; - -import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from './common'; -import { DataHub, WithSyncStatus } from './DataHub'; - -import { UserAuthInfo, checkLogin, userService, userInfoService } from './user'; - -export { kTimelineVisibilities } from '../http/timeline'; - -export type { TimelineVisibility } from '../http/timeline'; - -import { - TimelineVisibility, - HttpTimelineInfo, - HttpTimelinePatchRequest, - HttpTimelinePostPostRequest, - HttpTimelinePostPostRequestContent, - HttpTimelinePostPostRequestTextContent, - HttpTimelinePostPostRequestImageContent, - HttpTimelinePostInfo, - HttpTimelinePostTextContent, - getHttpTimelineClient, - HttpTimelineNotExistError, - HttpTimelineNameConflictError, -} from '../http/timeline'; -import { BlobWithEtag, NotModified, HttpForbiddenError } from '../http/common'; -import { HttpUser } from '../http/user'; - -export type TimelineInfo = HttpTimelineInfo; -export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; -export type TimelineCreatePostRequest = HttpTimelinePostPostRequest; -export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent; -export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent; -export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent; - -export type TimelinePostTextContent = HttpTimelinePostTextContent; - -export interface TimelinePostImageContent { - type: 'image'; - data: BlobOrStatus; -} - -export type TimelinePostContent = - | TimelinePostTextContent - | TimelinePostImageContent; - -export interface TimelinePostInfo { - id: number; - content: TimelinePostContent; - time: Date; - lastUpdated: Date; - author: HttpUser; -} - -export const timelineVisibilityTooltipTranslationMap: Record< - TimelineVisibility, - string -> = { - Public: 'timeline.visibilityTooltip.public', - Register: 'timeline.visibilityTooltip.register', - 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 & { - owner: string; - members: string[]; -}; - -type TimelinePostData = Omit & { - author: string; -}; - -export class TimelineService { - private getCachedTimeline( - timelineName: string - ): Promise { - return dataStorage.getItem(`timeline.${timelineName}`); - } - - private saveTimeline( - timelineName: string, - data: TimelineData - ): Promise { - return dataStorage - .setItem(`timeline.${timelineName}`, data) - .then(); - } - - private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { - return { - ...timeline, - owner: timeline.owner.username, - members: timeline.members.map((m) => m.username), - }; - } - - private async syncTimeline(timelineName: string): Promise { - const line = this._timelineHub.getLineOrCreateWithoutSetup(timelineName); - if (line.isSyncing) return; - - if (line.value == undefined) { - const cache = await this.getCachedTimeline(timelineName); - if (cache != null) { - line.next({ type: 'cache', timeline: cache }); - } - } - - try { - const httpTimeline = await getHttpTimelineClient().getTimeline( - timelineName - ); - - [httpTimeline.owner, ...httpTimeline.members].forEach( - (user) => void userInfoService.saveUser(user) - ); - - const timeline = this.convertHttpTimelineToData(httpTimeline); - await this.saveTimeline(timelineName, timeline); - line.endSyncAndNext({ type: 'synced', timeline }); - } catch (e) { - if (e instanceof HttpTimelineNotExistError) { - line.endSyncAndNext({ type: 'synced', timeline: null }); - } else { - const cache = await this.getCachedTimeline(timelineName); - if (cache == null) { - line.endSyncAndNext({ type: 'offline', timeline: null }); - } else { - line.endSyncAndNext({ type: 'offline', timeline: cache }); - } - throwIfNotNetworkError(e); - } - } - } - - private _timelineHub = new DataHub< - string, - | { - type: 'cache'; - timeline: TimelineData; - } - | { - type: 'offline' | 'synced'; - timeline: TimelineData | null; - } - >({ - setup: (key) => { - void this.syncTimeline(key); - }, - }); - - getTimeline$(timelineName: string): Observable { - return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - const { timeline } = state; - if (timeline != null) { - return combineLatest( - [timeline.owner, ...timeline.members].map((u) => - userInfoService.getUser$(u) - ) - ).pipe( - map((users) => { - return { - ...state, - timeline: { - ...timeline, - owner: users[0], - members: users.slice(1), - }, - }; - }) - ); - } else { - return of(state as TimelineWithSyncStatus); - } - }) - ); - } - - createTimeline(timelineName: string): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient().postTimeline( - { - name: timelineName, - }, - user.token - ) - ).pipe( - convertError(HttpTimelineNameConflictError, TimelineNameConflictError) - ); - } - - changeTimelineProperty( - timelineName: string, - req: TimelineChangePropertyRequest - ): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .patchTimeline(timelineName, req, user.token) - .then((timeline) => { - void this.syncTimeline(timelineName); - return timeline; - }) - ); - } - - deleteTimeline(timelineName: string): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient().deleteTimeline(timelineName, user.token) - ); - } - - addMember(timelineName: string, username: string): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .memberPut(timelineName, username, user.token) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); - } - - removeMember(timelineName: string, username: string): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .memberDelete(timelineName, username, user.token) - .then(() => { - void this.syncTimeline(timelineName); - }) - ); - } - - private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData { - return { - ...post, - author: post.author.username, - }; - } - - private convertHttpPostToDataList( - posts: HttpTimelinePostInfo[] - ): TimelinePostData[] { - return posts.map((post) => this.convertHttpPostToData(post)); - } - - private getCachedPosts( - timelineName: string - ): Promise { - return dataStorage.getItem( - `timeline.${timelineName}.posts` - ); - } - - private savePosts( - timelineName: string, - data: TimelinePostData[] - ): Promise { - return dataStorage - .setItem(`timeline.${timelineName}.posts`, data) - .then(); - } - - private async syncPosts(timelineName: string): Promise { - const line = this._postsHub.getLineOrCreateWithoutSetup(timelineName); - if (line.isSyncing) return; - line.beginSync(); - - if (line.value == null) { - const cache = await this.getCachedPosts(timelineName); - if (cache != null) { - line.next({ type: 'cache', posts: cache }); - } - } - - const now = new Date(); - - const lastUpdatedTime = await dataStorage.getItem( - `timeline.${timelineName}.lastUpdated` - ); - - try { - if (lastUpdatedTime == null) { - const httpPosts = await getHttpTimelineClient().listPost( - timelineName, - userService.currentUser?.token - ); - - uniqBy( - httpPosts.map((post) => post.author), - 'username' - ).forEach((user) => void userInfoService.saveUser(user)); - - const posts = this.convertHttpPostToDataList(httpPosts); - await this.savePosts(timelineName, posts); - await dataStorage.setItem( - `timeline.${timelineName}.lastUpdated`, - now - ); - - line.endSyncAndNext({ type: 'synced', posts }); - } else { - const httpPosts = await getHttpTimelineClient().listPost( - timelineName, - userService.currentUser?.token, - { - modifiedSince: lastUpdatedTime, - includeDeleted: true, - } - ); - - const deletedIds = httpPosts.filter((p) => p.deleted).map((p) => p.id); - const changed = httpPosts.filter( - (p): p is HttpTimelinePostInfo => !p.deleted - ); - - uniqBy( - httpPosts - .map((post) => post.author) - .filter((u): u is HttpUser => u != null), - 'username' - ).forEach((user) => void userInfoService.saveUser(user)); - - const cache = (await this.getCachedPosts(timelineName)) ?? []; - - const posts = cache.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)); - } else { - posts[savedChangedPostIndex] = this.convertHttpPostToData( - changedPost - ); - } - } - - await this.savePosts(timelineName, posts); - await dataStorage.setItem( - `timeline.${timelineName}.lastUpdated`, - now - ); - line.endSyncAndNext({ type: 'synced', posts }); - } - } catch (e) { - if (e instanceof HttpTimelineNotExistError) { - line.endSyncAndNext({ type: 'notexist', posts: [] }); - } else if (e instanceof HttpForbiddenError) { - line.endSyncAndNext({ type: 'forbid', posts: [] }); - } else { - const cache = await this.getCachedPosts(timelineName); - if (cache == null) { - line.endSyncAndNext({ type: 'offline', posts: [] }); - } else { - line.endSyncAndNext({ type: 'offline', posts: cache }); - } - throwIfNotNetworkError(e); - } - } - } - - private _postsHub = new DataHub< - string, - { - type: 'cache' | 'offline' | 'synced' | 'forbid' | 'notexist'; - posts: TimelinePostData[]; - } - >({ - setup: (key) => { - void this.syncPosts(key); - }, - }); - - getPosts$(timelineName: string): Observable { - return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe( - switchMap((state) => { - if (state.posts.length === 0) { - return of({ - ...state, - posts: [], - }); - } - - return combineLatest([ - combineLatest( - state.posts.map((post) => userInfoService.getUser$(post.author)) - ), - combineLatest( - state.posts.map((post) => { - if (post.content.type === 'image') { - return 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 { - return dataStorage.getItem( - `timeline.${key.timelineName}.post.${key.postId}.data` - ); - } - - private savePostData( - key: { - timelineName: string; - postId: number; - }, - data: BlobWithEtag - ): Promise { - return dataStorage - .setItem( - `timeline.${key.timelineName}.post.${key.postId}.data`, - data - ) - .then(); - } - - private async syncPostData(key: { - timelineName: string; - postId: number; - }): Promise { - const line = this._postDataHub.getLineOrCreateWithoutSetup(key); - if (line.isSyncing) return; - line.beginSync(); - - 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.endSyncAndNext({ data: res.data, type: 'synced' }); - } catch (e) { - line.endSyncAndNext({ type: 'offline' }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpTimelineClient().getPostData( - key.timelineName, - key.postId, - cache.etag - ); - if (res instanceof NotModified) { - line.endSyncAndNext({ data: cache.data, type: 'synced' }); - } else { - await this.savePostData(key, res); - line.endSyncAndNext({ data: res.data, type: 'synced' }); - } - } catch (e) { - line.endSyncAndNext({ data: cache.data, type: 'offline' }); - throwIfNotNetworkError(e); - } - } - } - - 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}`, - setup: (key) => { - void this.syncPostData(key); - }, - }); - - getPostData$(timelineName: string, postId: number): Observable { - return this._postDataHub.getObservable({ timelineName, postId }).pipe( - map((state): BlobOrStatus => state.data ?? 'error'), - startWith('loading') - ); - } - - createPost( - timelineName: string, - request: TimelineCreatePostRequest - ): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .postPost(timelineName, request, user.token) - .then(() => { - void this.syncPosts(timelineName); - }) - ); - } - - deletePost(timelineName: string, postId: number): Observable { - const user = checkLogin(); - return from( - getHttpTimelineClient() - .deletePost(timelineName, postId, user.token) - .then(() => { - void this.syncPosts(timelineName); - }) - ); - } - - isMemberOf(username: string, timeline: TimelineInfo): boolean { - return timeline.members.findIndex((m) => m.username == username) >= 0; - } - - hasReadPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - const { visibility } = timeline; - if (visibility === 'Public') { - return true; - } else if (visibility === 'Register') { - if (user != null) return true; - } else if (visibility === 'Private') { - if ( - user != null && - (user.username === timeline.owner.username || - this.isMemberOf(user.username, timeline)) - ) { - return true; - } - } - return false; - } - - hasPostPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - return ( - user != null && - (timeline.owner.username === user.username || - this.isMemberOf(user.username, timeline)) - ); - } - - hasManagePermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo - ): boolean { - if (user != null && user.administrator) return true; - - return user != null && user.username == timeline.owner.username; - } - - hasModifyPostPermission( - user: UserAuthInfo | null | undefined, - timeline: TimelineInfo, - post: TimelinePostInfo - ): boolean { - if (user != null && user.administrator) return true; - - return ( - user != null && - (user.username === timeline.owner.username || - user.username === post.author.username) - ); - } -} - -export const timelineService = new TimelineService(); - -const timelineNameReg = XRegExp('^[-_\\p{L}]*$', 'u'); - -export function validateTimelineName(name: string): boolean { - return timelineNameReg.test(name); -} - -export function useTimelineInfo( - timelineName: string -): TimelineWithSyncStatus | undefined { - const [state, setState] = React.useState( - undefined - ); - React.useEffect(() => { - const subscription = timelineService - .getTimeline$(timelineName) - .subscribe((data) => { - setState(data); - }); - return () => { - subscription.unsubscribe(); - }; - }, [timelineName]); - return state; -} - -export function usePostList( - timelineName: string | null | undefined -): TimelinePostsWithSyncState | undefined { - const [state, setState] = React.useState< - TimelinePostsWithSyncState | undefined - >(undefined); - React.useEffect(() => { - if (timelineName == null) { - setState(undefined); - return; - } - - const subscription = timelineService - .getPosts$(timelineName) - .subscribe((data) => { - setState(data); - }); - return () => { - subscription.unsubscribe(); - }; - }, [timelineName]); - return state; -} - -export async function getAllCachedTimelineNames(): Promise { - const keys = await dataStorage.keys(); - return keys - .filter( - (key) => - key.startsWith('timeline.') && (key.match(/\./g) ?? []).length === 1 - ) - .map((key) => key.substr('timeline.'.length)); -} +import React from "react"; +import XRegExp from "xregexp"; +import { Observable, from, combineLatest, of } from "rxjs"; +import { map, switchMap, startWith } from "rxjs/operators"; +import { uniqBy } from "lodash"; + +import { convertError } from "../utilities/rxjs"; + +import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; +import { DataHub, WithSyncStatus } from "./DataHub"; + +import { UserAuthInfo, checkLogin, userService, userInfoService } from "./user"; + +export { kTimelineVisibilities } from "../http/timeline"; + +export type { TimelineVisibility } from "../http/timeline"; + +import { + TimelineVisibility, + HttpTimelineInfo, + HttpTimelinePatchRequest, + HttpTimelinePostPostRequest, + HttpTimelinePostPostRequestContent, + HttpTimelinePostPostRequestTextContent, + HttpTimelinePostPostRequestImageContent, + HttpTimelinePostInfo, + HttpTimelinePostTextContent, + getHttpTimelineClient, + HttpTimelineNotExistError, + HttpTimelineNameConflictError, +} from "../http/timeline"; +import { BlobWithEtag, NotModified, HttpForbiddenError } from "../http/common"; +import { HttpUser } from "../http/user"; + +export type TimelineInfo = HttpTimelineInfo; +export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; +export type TimelineCreatePostRequest = HttpTimelinePostPostRequest; +export type TimelineCreatePostContent = HttpTimelinePostPostRequestContent; +export type TimelineCreatePostTextContent = HttpTimelinePostPostRequestTextContent; +export type TimelineCreatePostImageContent = HttpTimelinePostPostRequestImageContent; + +export type TimelinePostTextContent = HttpTimelinePostTextContent; + +export interface TimelinePostImageContent { + type: "image"; + data: BlobOrStatus; +} + +export type TimelinePostContent = + | TimelinePostTextContent + | TimelinePostImageContent; + +export interface TimelinePostInfo { + id: number; + content: TimelinePostContent; + time: Date; + lastUpdated: Date; + author: HttpUser; +} + +export const timelineVisibilityTooltipTranslationMap: Record< + TimelineVisibility, + string +> = { + Public: "timeline.visibilityTooltip.public", + Register: "timeline.visibilityTooltip.register", + 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 & { + owner: string; + members: string[]; +}; + +type TimelinePostData = Omit & { + author: string; +}; + +export class TimelineService { + private getCachedTimeline( + timelineName: string + ): Promise { + return dataStorage.getItem(`timeline.${timelineName}`); + } + + private saveTimeline( + timelineName: string, + data: TimelineData + ): Promise { + return dataStorage + .setItem(`timeline.${timelineName}`, data) + .then(); + } + + private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { + return { + ...timeline, + owner: timeline.owner.username, + members: timeline.members.map((m) => m.username), + }; + } + + private async syncTimeline(timelineName: string): Promise { + const line = this._timelineHub.getLineOrCreateWithoutSetup(timelineName); + if (line.isSyncing) return; + + if (line.value == undefined) { + const cache = await this.getCachedTimeline(timelineName); + if (cache != null) { + line.next({ type: "cache", timeline: cache }); + } + } + + try { + const httpTimeline = await getHttpTimelineClient().getTimeline( + timelineName + ); + + [httpTimeline.owner, ...httpTimeline.members].forEach( + (user) => void userInfoService.saveUser(user) + ); + + const timeline = this.convertHttpTimelineToData(httpTimeline); + await this.saveTimeline(timelineName, timeline); + line.endSyncAndNext({ type: "synced", timeline }); + } catch (e) { + if (e instanceof HttpTimelineNotExistError) { + line.endSyncAndNext({ type: "synced", timeline: null }); + } else { + const cache = await this.getCachedTimeline(timelineName); + if (cache == null) { + line.endSyncAndNext({ type: "offline", timeline: null }); + } else { + line.endSyncAndNext({ type: "offline", timeline: cache }); + } + throwIfNotNetworkError(e); + } + } + } + + private _timelineHub = new DataHub< + string, + | { + type: "cache"; + timeline: TimelineData; + } + | { + type: "offline" | "synced"; + timeline: TimelineData | null; + } + >({ + setup: (key) => { + void this.syncTimeline(key); + }, + }); + + getTimeline$(timelineName: string): Observable { + return this._timelineHub.getDataWithSyncStatusObservable(timelineName).pipe( + switchMap((state) => { + const { timeline } = state; + if (timeline != null) { + return combineLatest( + [timeline.owner, ...timeline.members].map((u) => + userInfoService.getUser$(u) + ) + ).pipe( + map((users) => { + return { + ...state, + timeline: { + ...timeline, + owner: users[0], + members: users.slice(1), + }, + }; + }) + ); + } else { + return of(state as TimelineWithSyncStatus); + } + }) + ); + } + + createTimeline(timelineName: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().postTimeline( + { + name: timelineName, + }, + user.token + ) + ).pipe( + convertError(HttpTimelineNameConflictError, TimelineNameConflictError) + ); + } + + changeTimelineProperty( + timelineName: string, + req: TimelineChangePropertyRequest + ): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .patchTimeline(timelineName, req, user.token) + .then((timeline) => { + void this.syncTimeline(timelineName); + return timeline; + }) + ); + } + + deleteTimeline(timelineName: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().deleteTimeline(timelineName, user.token) + ); + } + + addMember(timelineName: string, username: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .memberPut(timelineName, username, user.token) + .then(() => { + void this.syncTimeline(timelineName); + }) + ); + } + + removeMember(timelineName: string, username: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .memberDelete(timelineName, username, user.token) + .then(() => { + void this.syncTimeline(timelineName); + }) + ); + } + + private convertHttpPostToData(post: HttpTimelinePostInfo): TimelinePostData { + return { + ...post, + author: post.author.username, + }; + } + + private convertHttpPostToDataList( + posts: HttpTimelinePostInfo[] + ): TimelinePostData[] { + return posts.map((post) => this.convertHttpPostToData(post)); + } + + private getCachedPosts( + timelineName: string + ): Promise { + return dataStorage.getItem( + `timeline.${timelineName}.posts` + ); + } + + private savePosts( + timelineName: string, + data: TimelinePostData[] + ): Promise { + return dataStorage + .setItem(`timeline.${timelineName}.posts`, data) + .then(); + } + + private async syncPosts(timelineName: string): Promise { + const line = this._postsHub.getLineOrCreateWithoutSetup(timelineName); + if (line.isSyncing) return; + line.beginSync(); + + if (line.value == null) { + const cache = await this.getCachedPosts(timelineName); + if (cache != null) { + line.next({ type: "cache", posts: cache }); + } + } + + const now = new Date(); + + const lastUpdatedTime = await dataStorage.getItem( + `timeline.${timelineName}.lastUpdated` + ); + + try { + if (lastUpdatedTime == null) { + const httpPosts = await getHttpTimelineClient().listPost( + timelineName, + userService.currentUser?.token + ); + + uniqBy( + httpPosts.map((post) => post.author), + "username" + ).forEach((user) => void userInfoService.saveUser(user)); + + const posts = this.convertHttpPostToDataList(httpPosts); + await this.savePosts(timelineName, posts); + await dataStorage.setItem( + `timeline.${timelineName}.lastUpdated`, + now + ); + + line.endSyncAndNext({ type: "synced", posts }); + } else { + const httpPosts = await getHttpTimelineClient().listPost( + timelineName, + userService.currentUser?.token, + { + modifiedSince: lastUpdatedTime, + includeDeleted: true, + } + ); + + const deletedIds = httpPosts.filter((p) => p.deleted).map((p) => p.id); + const changed = httpPosts.filter( + (p): p is HttpTimelinePostInfo => !p.deleted + ); + + uniqBy( + httpPosts + .map((post) => post.author) + .filter((u): u is HttpUser => u != null), + "username" + ).forEach((user) => void userInfoService.saveUser(user)); + + const cache = (await this.getCachedPosts(timelineName)) ?? []; + + const posts = cache.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)); + } else { + posts[savedChangedPostIndex] = this.convertHttpPostToData( + changedPost + ); + } + } + + await this.savePosts(timelineName, posts); + await dataStorage.setItem( + `timeline.${timelineName}.lastUpdated`, + now + ); + line.endSyncAndNext({ type: "synced", posts }); + } + } catch (e) { + if (e instanceof HttpTimelineNotExistError) { + line.endSyncAndNext({ type: "notexist", posts: [] }); + } else if (e instanceof HttpForbiddenError) { + line.endSyncAndNext({ type: "forbid", posts: [] }); + } else { + const cache = await this.getCachedPosts(timelineName); + if (cache == null) { + line.endSyncAndNext({ type: "offline", posts: [] }); + } else { + line.endSyncAndNext({ type: "offline", posts: cache }); + } + throwIfNotNetworkError(e); + } + } + } + + private _postsHub = new DataHub< + string, + { + type: "cache" | "offline" | "synced" | "forbid" | "notexist"; + posts: TimelinePostData[]; + } + >({ + setup: (key) => { + void this.syncPosts(key); + }, + }); + + getPosts$(timelineName: string): Observable { + return this._postsHub.getDataWithSyncStatusObservable(timelineName).pipe( + switchMap((state) => { + if (state.posts.length === 0) { + return of({ + ...state, + posts: [], + }); + } + + return combineLatest([ + combineLatest( + state.posts.map((post) => userInfoService.getUser$(post.author)) + ), + combineLatest( + state.posts.map((post) => { + if (post.content.type === "image") { + return 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 { + return dataStorage.getItem( + `timeline.${key.timelineName}.post.${key.postId}.data` + ); + } + + private savePostData( + key: { + timelineName: string; + postId: number; + }, + data: BlobWithEtag + ): Promise { + return dataStorage + .setItem( + `timeline.${key.timelineName}.post.${key.postId}.data`, + data + ) + .then(); + } + + private async syncPostData(key: { + timelineName: string; + postId: number; + }): Promise { + const line = this._postDataHub.getLineOrCreateWithoutSetup(key); + if (line.isSyncing) return; + line.beginSync(); + + 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.endSyncAndNext({ data: res.data, type: "synced" }); + } catch (e) { + line.endSyncAndNext({ type: "offline" }); + throwIfNotNetworkError(e); + } + } else { + try { + const res = await getHttpTimelineClient().getPostData( + key.timelineName, + key.postId, + cache.etag + ); + if (res instanceof NotModified) { + line.endSyncAndNext({ data: cache.data, type: "synced" }); + } else { + await this.savePostData(key, res); + line.endSyncAndNext({ data: res.data, type: "synced" }); + } + } catch (e) { + line.endSyncAndNext({ data: cache.data, type: "offline" }); + throwIfNotNetworkError(e); + } + } + } + + 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}`, + setup: (key) => { + void this.syncPostData(key); + }, + }); + + getPostData$(timelineName: string, postId: number): Observable { + return this._postDataHub.getObservable({ timelineName, postId }).pipe( + map((state): BlobOrStatus => state.data ?? "error"), + startWith("loading") + ); + } + + createPost( + timelineName: string, + request: TimelineCreatePostRequest + ): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .postPost(timelineName, request, user.token) + .then(() => { + void this.syncPosts(timelineName); + }) + ); + } + + deletePost(timelineName: string, postId: number): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .deletePost(timelineName, postId, user.token) + .then(() => { + void this.syncPosts(timelineName); + }) + ); + } + + isMemberOf(username: string, timeline: TimelineInfo): boolean { + return timeline.members.findIndex((m) => m.username == username) >= 0; + } + + hasReadPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + const { visibility } = timeline; + if (visibility === "Public") { + return true; + } else if (visibility === "Register") { + if (user != null) return true; + } else if (visibility === "Private") { + if ( + user != null && + (user.username === timeline.owner.username || + this.isMemberOf(user.username, timeline)) + ) { + return true; + } + } + return false; + } + + hasPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + return ( + user != null && + (timeline.owner.username === user.username || + this.isMemberOf(user.username, timeline)) + ); + } + + hasManagePermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo + ): boolean { + if (user != null && user.administrator) return true; + + return user != null && user.username == timeline.owner.username; + } + + hasModifyPostPermission( + user: UserAuthInfo | null | undefined, + timeline: TimelineInfo, + post: TimelinePostInfo + ): boolean { + if (user != null && user.administrator) return true; + + return ( + user != null && + (user.username === timeline.owner.username || + user.username === post.author.username) + ); + } +} + +export const timelineService = new TimelineService(); + +const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); + +export function validateTimelineName(name: string): boolean { + return timelineNameReg.test(name); +} + +export function useTimelineInfo( + timelineName: string +): TimelineWithSyncStatus | undefined { + const [state, setState] = React.useState( + undefined + ); + React.useEffect(() => { + const subscription = timelineService + .getTimeline$(timelineName) + .subscribe((data) => { + setState(data); + }); + return () => { + subscription.unsubscribe(); + }; + }, [timelineName]); + return state; +} + +export function usePostList( + timelineName: string | null | undefined +): TimelinePostsWithSyncState | undefined { + const [state, setState] = React.useState< + TimelinePostsWithSyncState | undefined + >(undefined); + React.useEffect(() => { + if (timelineName == null) { + setState(undefined); + return; + } + + const subscription = timelineService + .getPosts$(timelineName) + .subscribe((data) => { + setState(data); + }); + return () => { + subscription.unsubscribe(); + }; + }, [timelineName]); + return state; +} + +export async function getAllCachedTimelineNames(): Promise { + const keys = await dataStorage.keys(); + return keys + .filter( + (key) => + key.startsWith("timeline.") && (key.match(/\./g) ?? []).length === 1 + ) + .map((key) => key.substr("timeline.".length)); +} diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts index 419cff18..8aee0c5f 100644 --- a/Timeline/ClientApp/src/app/data/user.ts +++ b/Timeline/ClientApp/src/app/data/user.ts @@ -1,389 +1,389 @@ -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 { pushAlert } from '../common/alert-service'; - -import { dataStorage, throwIfNotNetworkError } from './common'; -import { DataHub } from './DataHub'; - -import { HttpNetworkError, BlobWithEtag, NotModified } from '../http/common'; -import { - getHttpTokenClient, - HttpCreateTokenBadCredentialError, -} from '../http/token'; -import { - getHttpUserClient, - HttpUserNotExistError, - HttpUser, -} from '../http/user'; - -export type User = HttpUser; - -export interface UserAuthInfo { - username: string; - administrator: boolean; -} - -export interface UserWithToken extends User { - token: string; -} - -export interface LoginCredentials { - username: string; - password: string; -} - -export class BadCredentialError { - message = 'login.badCredential'; -} - -const USER_STORAGE_KEY = 'currentuser'; - -export class UserService { - private userSubject = new BehaviorSubject( - undefined - ); - - get user$(): Observable { - return this.userSubject; - } - - get currentUser(): UserWithToken | null | undefined { - return this.userSubject.value; - } - - async checkLoginState(): Promise { - if (this.currentUser !== undefined) { - console.warn("Already checked user. Can't check twice."); - } - - const savedUser = await dataStorage.getItem( - USER_STORAGE_KEY - ); - - if (savedUser == null) { - this.userSubject.next(null); - return null; - } - - this.userSubject.next(savedUser); - - const savedToken = savedUser.token; - try { - const res = await getHttpTokenClient().verify({ token: savedToken }); - const user: UserWithToken = { ...res.user, token: savedToken }; - await dataStorage.setItem(USER_STORAGE_KEY, user); - this.userSubject.next(user); - pushAlert({ - type: 'success', - message: { - type: 'i18n', - key: 'user.welcomeBack', - }, - }); - return user; - } catch (error) { - if (error instanceof HttpNetworkError) { - pushAlert({ - type: 'danger', - message: { type: 'i18n', key: 'user.verifyTokenFailedNetwork' }, - }); - return savedUser; - } else { - await dataStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - pushAlert({ - type: 'danger', - message: { type: 'i18n', key: 'user.verifyTokenFailed' }, - }); - return null; - } - } - } - - async login( - credentials: LoginCredentials, - rememberMe: boolean - ): Promise { - if (this.currentUser) { - throw new UiLogicError('Already login.'); - } - try { - const res = await getHttpTokenClient().create({ - ...credentials, - expire: 30, - }); - const user: UserWithToken = { - ...res.user, - token: res.token, - }; - if (rememberMe) { - await dataStorage.setItem(USER_STORAGE_KEY, user); - } - this.userSubject.next(user); - } catch (e) { - if (e instanceof HttpCreateTokenBadCredentialError) { - throw new BadCredentialError(); - } else { - throw e; - } - } - } - - async logout(): Promise { - if (this.currentUser === undefined) { - throw new UiLogicError('Please check user first.'); - } - if (this.currentUser === null) { - throw new UiLogicError('No login.'); - } - await dataStorage.removeItem(USER_STORAGE_KEY); - this.userSubject.next(null); - } - - changePassword( - oldPassword: string, - newPassword: string - ): Observable { - if (this.currentUser == undefined) { - throw new UiLogicError("Not login or checked now, can't log out."); - } - const $ = from( - getHttpUserClient().changePassword( - { - oldPassword, - newPassword, - }, - this.currentUser.token - ) - ); - $.subscribe(() => { - void this.logout(); - }); - return $; - } -} - -export const userService = new UserService(); - -export function useRawUser(): UserWithToken | null | undefined { - const [user, setUser] = useState( - userService.currentUser - ); - useEffect(() => { - const subscription = userService.user$.subscribe((u) => setUser(u)); - return () => { - subscription.unsubscribe(); - }; - }); - return user; -} - -export function useUser(): UserWithToken | null { - const [user, setUser] = useState(() => { - const initUser = userService.currentUser; - if (initUser === undefined) { - throw new UiLogicError( - "This is a logic error in user module. Current user can't be undefined in useUser." - ); - } - return initUser; - }); - useEffect(() => { - const sub = userService.user$.subscribe((u) => { - if (u === undefined) { - throw new UiLogicError( - "This is a logic error in user module. User emitted can't be undefined later." - ); - } - setUser(u); - }); - return () => { - sub.unsubscribe(); - }; - }); - return user; -} - -export function useUserLoggedIn(): UserWithToken { - const user = useUser(); - if (user == null) { - throw new UiLogicError('You assert user has logged in but actually not.'); - } - return user; -} - -export function checkLogin(): UserWithToken { - const user = userService.currentUser; - if (user == null) { - throw new UiLogicError('You must login to perform the operation.'); - } - return user; -} - -export class UserNotExistError extends Error {} - -export class UserInfoService { - async saveUser(user: HttpUser): Promise { - const key = user.username; - const line = this._userHub.getLineOrCreateWithoutSetup(key); - if (line.isSyncing) return; - line.beginSync(); - await this.doSaveUser(user); - line.endSyncAndNext({ user, type: 'synced' }); - } - - private getCachedUser(username: string): Promise { - return dataStorage.getItem(`user.${username}`); - } - - private doSaveUser(user: HttpUser): Promise { - return dataStorage.setItem(`user.${user.username}`, user).then(); - } - - private async syncUser(username: string): Promise { - const line = this._userHub.getLineOrCreateWithoutSetup(username); - if (line.isSyncing) return; - line.beginSync(); - - if (line.value == undefined) { - const cache = await this.getCachedUser(username); - if (cache != null) { - line.next({ user: cache, type: 'cache' }); - } - } - - try { - const res = await getHttpUserClient().get(username); - await this.doSaveUser(res); - line.endSyncAndNext({ user: res, type: 'synced' }); - } catch (e) { - if (e instanceof HttpUserNotExistError) { - line.endSyncAndNext({ type: 'notexist' }); - } else { - const cache = await this.getCachedUser(username); - line.endSyncAndNext({ user: cache ?? undefined, type: 'offline' }); - throwIfNotNetworkError(e); - } - } - } - - private _userHub = new DataHub< - string, - | { user: User; type: 'cache' | 'synced' | 'offline' } - | { user?: undefined; type: 'notexist' | 'offline' } - >({ - setup: (key) => { - void this.syncUser(key); - }, - }); - - getUser$(username: string): Observable { - return this._userHub.getObservable(username).pipe( - map((state) => state?.user), - filter((user): user is User => user != null) - ); - } - - private getCachedAvatar(username: string): Promise { - return dataStorage.getItem(`user.${username}.avatar`); - } - - private saveAvatar(username: string, data: BlobWithEtag): Promise { - return dataStorage - .setItem(`user.${username}.avatar`, data) - .then(); - } - - private async syncAvatar(username: string): Promise { - const line = this._avatarHub.getLineOrCreateWithoutSetup(username); - if (line.isSyncing) return; - line.beginSync(); - - const cache = await this.getCachedAvatar(username); - if (line.value == null) { - if (cache != null) { - line.next({ data: cache.data, type: 'cache' }); - } - } - - if (cache == null) { - try { - const avatar = await getHttpUserClient().getAvatar(username); - await this.saveAvatar(username, avatar); - line.endSyncAndNext({ data: avatar.data, type: 'synced' }); - } catch (e) { - line.endSyncAndNext({ type: 'offline' }); - throwIfNotNetworkError(e); - } - } else { - try { - const res = await getHttpUserClient().getAvatar(username, cache.etag); - if (res instanceof NotModified) { - line.endSyncAndNext({ data: cache.data, type: 'synced' }); - } else { - const avatar = res; - await this.saveAvatar(username, avatar); - line.endSyncAndNext({ data: avatar.data, type: 'synced' }); - } - } catch (e) { - line.endSyncAndNext({ data: cache.data, type: 'offline' }); - throwIfNotNetworkError(e); - } - } - } - - private _avatarHub = new DataHub< - string, - | { data: Blob; type: 'cache' | 'synced' | 'offline' } - | { data?: undefined; type: 'notexist' | 'offline' } - >({ - setup: (key) => { - void this.syncAvatar(key); - }, - }); - - getAvatar$(username: string): Observable { - return this._avatarHub.getObservable(username).pipe( - map((state) => state.data), - filter((blob): blob is Blob => blob != null) - ); - } - - getUserInfo(username: string): Observable { - return from(getHttpUserClient().get(username)).pipe( - convertError(HttpUserNotExistError, UserNotExistError) - ); - } - - async setAvatar(username: string, blob: Blob): Promise { - const user = checkLogin(); - await getHttpUserClient().putAvatar(username, blob, user.token); - this._avatarHub.getLine(username)?.next({ data: blob, type: 'synced' }); - } -} - -export const userInfoService = new UserInfoService(); - -export function useAvatar(username?: string): Blob | undefined { - const [state, setState] = React.useState(undefined); - React.useEffect(() => { - if (username == null) { - setState(undefined); - return; - } - - const subscription = userInfoService - .getAvatar$(username) - .subscribe((blob) => { - setState(blob); - }); - return () => { - subscription.unsubscribe(); - }; - }, [username]); - return state; -} +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 { pushAlert } from "../common/alert-service"; + +import { dataStorage, throwIfNotNetworkError } from "./common"; +import { DataHub } from "./DataHub"; + +import { HttpNetworkError, BlobWithEtag, NotModified } from "../http/common"; +import { + getHttpTokenClient, + HttpCreateTokenBadCredentialError, +} from "../http/token"; +import { + getHttpUserClient, + HttpUserNotExistError, + HttpUser, +} from "../http/user"; + +export type User = HttpUser; + +export interface UserAuthInfo { + username: string; + administrator: boolean; +} + +export interface UserWithToken extends User { + token: string; +} + +export interface LoginCredentials { + username: string; + password: string; +} + +export class BadCredentialError { + message = "login.badCredential"; +} + +const USER_STORAGE_KEY = "currentuser"; + +export class UserService { + private userSubject = new BehaviorSubject( + undefined + ); + + get user$(): Observable { + return this.userSubject; + } + + get currentUser(): UserWithToken | null | undefined { + return this.userSubject.value; + } + + async checkLoginState(): Promise { + if (this.currentUser !== undefined) { + console.warn("Already checked user. Can't check twice."); + } + + const savedUser = await dataStorage.getItem( + USER_STORAGE_KEY + ); + + if (savedUser == null) { + this.userSubject.next(null); + return null; + } + + this.userSubject.next(savedUser); + + const savedToken = savedUser.token; + try { + const res = await getHttpTokenClient().verify({ token: savedToken }); + const user: UserWithToken = { ...res.user, token: savedToken }; + await dataStorage.setItem(USER_STORAGE_KEY, user); + this.userSubject.next(user); + pushAlert({ + type: "success", + message: { + type: "i18n", + key: "user.welcomeBack", + }, + }); + return user; + } catch (error) { + if (error instanceof HttpNetworkError) { + pushAlert({ + type: "danger", + message: { type: "i18n", key: "user.verifyTokenFailedNetwork" }, + }); + return savedUser; + } else { + await dataStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + pushAlert({ + type: "danger", + message: { type: "i18n", key: "user.verifyTokenFailed" }, + }); + return null; + } + } + } + + async login( + credentials: LoginCredentials, + rememberMe: boolean + ): Promise { + if (this.currentUser) { + throw new UiLogicError("Already login."); + } + try { + const res = await getHttpTokenClient().create({ + ...credentials, + expire: 30, + }); + const user: UserWithToken = { + ...res.user, + token: res.token, + }; + if (rememberMe) { + await dataStorage.setItem(USER_STORAGE_KEY, user); + } + this.userSubject.next(user); + } catch (e) { + if (e instanceof HttpCreateTokenBadCredentialError) { + throw new BadCredentialError(); + } else { + throw e; + } + } + } + + async logout(): Promise { + if (this.currentUser === undefined) { + throw new UiLogicError("Please check user first."); + } + if (this.currentUser === null) { + throw new UiLogicError("No login."); + } + await dataStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + } + + changePassword( + oldPassword: string, + newPassword: string + ): Observable { + if (this.currentUser == undefined) { + throw new UiLogicError("Not login or checked now, can't log out."); + } + const $ = from( + getHttpUserClient().changePassword( + { + oldPassword, + newPassword, + }, + this.currentUser.token + ) + ); + $.subscribe(() => { + void this.logout(); + }); + return $; + } +} + +export const userService = new UserService(); + +export function useRawUser(): UserWithToken | null | undefined { + const [user, setUser] = useState( + userService.currentUser + ); + useEffect(() => { + const subscription = userService.user$.subscribe((u) => setUser(u)); + return () => { + subscription.unsubscribe(); + }; + }); + return user; +} + +export function useUser(): UserWithToken | null { + const [user, setUser] = useState(() => { + const initUser = userService.currentUser; + if (initUser === undefined) { + throw new UiLogicError( + "This is a logic error in user module. Current user can't be undefined in useUser." + ); + } + return initUser; + }); + useEffect(() => { + const sub = userService.user$.subscribe((u) => { + if (u === undefined) { + throw new UiLogicError( + "This is a logic error in user module. User emitted can't be undefined later." + ); + } + setUser(u); + }); + return () => { + sub.unsubscribe(); + }; + }); + return user; +} + +export function useUserLoggedIn(): UserWithToken { + const user = useUser(); + if (user == null) { + throw new UiLogicError("You assert user has logged in but actually not."); + } + return user; +} + +export function checkLogin(): UserWithToken { + const user = userService.currentUser; + if (user == null) { + throw new UiLogicError("You must login to perform the operation."); + } + return user; +} + +export class UserNotExistError extends Error {} + +export class UserInfoService { + async saveUser(user: HttpUser): Promise { + const key = user.username; + const line = this._userHub.getLineOrCreateWithoutSetup(key); + if (line.isSyncing) return; + line.beginSync(); + await this.doSaveUser(user); + line.endSyncAndNext({ user, type: "synced" }); + } + + private getCachedUser(username: string): Promise { + return dataStorage.getItem(`user.${username}`); + } + + private doSaveUser(user: HttpUser): Promise { + return dataStorage.setItem(`user.${user.username}`, user).then(); + } + + private async syncUser(username: string): Promise { + const line = this._userHub.getLineOrCreateWithoutSetup(username); + if (line.isSyncing) return; + line.beginSync(); + + if (line.value == undefined) { + const cache = await this.getCachedUser(username); + if (cache != null) { + line.next({ user: cache, type: "cache" }); + } + } + + try { + const res = await getHttpUserClient().get(username); + await this.doSaveUser(res); + line.endSyncAndNext({ user: res, type: "synced" }); + } catch (e) { + if (e instanceof HttpUserNotExistError) { + line.endSyncAndNext({ type: "notexist" }); + } else { + const cache = await this.getCachedUser(username); + line.endSyncAndNext({ user: cache ?? undefined, type: "offline" }); + throwIfNotNetworkError(e); + } + } + } + + private _userHub = new DataHub< + string, + | { user: User; type: "cache" | "synced" | "offline" } + | { user?: undefined; type: "notexist" | "offline" } + >({ + setup: (key) => { + void this.syncUser(key); + }, + }); + + getUser$(username: string): Observable { + return this._userHub.getObservable(username).pipe( + map((state) => state?.user), + filter((user): user is User => user != null) + ); + } + + private getCachedAvatar(username: string): Promise { + return dataStorage.getItem(`user.${username}.avatar`); + } + + private saveAvatar(username: string, data: BlobWithEtag): Promise { + return dataStorage + .setItem(`user.${username}.avatar`, data) + .then(); + } + + private async syncAvatar(username: string): Promise { + const line = this._avatarHub.getLineOrCreateWithoutSetup(username); + if (line.isSyncing) return; + line.beginSync(); + + const cache = await this.getCachedAvatar(username); + if (line.value == null) { + if (cache != null) { + line.next({ data: cache.data, type: "cache" }); + } + } + + if (cache == null) { + try { + const avatar = await getHttpUserClient().getAvatar(username); + await this.saveAvatar(username, avatar); + line.endSyncAndNext({ data: avatar.data, type: "synced" }); + } catch (e) { + line.endSyncAndNext({ type: "offline" }); + throwIfNotNetworkError(e); + } + } else { + try { + const res = await getHttpUserClient().getAvatar(username, cache.etag); + if (res instanceof NotModified) { + line.endSyncAndNext({ data: cache.data, type: "synced" }); + } else { + const avatar = res; + await this.saveAvatar(username, avatar); + line.endSyncAndNext({ data: avatar.data, type: "synced" }); + } + } catch (e) { + line.endSyncAndNext({ data: cache.data, type: "offline" }); + throwIfNotNetworkError(e); + } + } + } + + private _avatarHub = new DataHub< + string, + | { data: Blob; type: "cache" | "synced" | "offline" } + | { data?: undefined; type: "notexist" | "offline" } + >({ + setup: (key) => { + void this.syncAvatar(key); + }, + }); + + getAvatar$(username: string): Observable { + return this._avatarHub.getObservable(username).pipe( + map((state) => state.data), + filter((blob): blob is Blob => blob != null) + ); + } + + getUserInfo(username: string): Observable { + return from(getHttpUserClient().get(username)).pipe( + convertError(HttpUserNotExistError, UserNotExistError) + ); + } + + async setAvatar(username: string, blob: Blob): Promise { + const user = checkLogin(); + await getHttpUserClient().putAvatar(username, blob, user.token); + this._avatarHub.getLine(username)?.next({ data: blob, type: "synced" }); + } +} + +export const userInfoService = new UserInfoService(); + +export function useAvatar(username?: string): Blob | undefined { + const [state, setState] = React.useState(undefined); + React.useEffect(() => { + if (username == null) { + setState(undefined); + return; + } + + const subscription = userInfoService + .getAvatar$(username) + .subscribe((blob) => { + setState(blob); + }); + return () => { + subscription.unsubscribe(); + }; + }, [username]); + return state; +} -- cgit v1.2.3