diff options
author | crupest <crupest@outlook.com> | 2020-08-24 22:59:45 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2020-08-24 22:59:45 +0800 |
commit | de1d582bf2ed7062fd400459f30d463d47ef9982 (patch) | |
tree | 777e27f954c9fd7beab36aad61bb767a28d65a89 /Timeline/ClientApp/src/app/data | |
parent | 5a8fb35c2791a921d8833beb37aa2edd5047da4c (diff) | |
download | timeline-de1d582bf2ed7062fd400459f30d463d47ef9982.tar.gz timeline-de1d582bf2ed7062fd400459f30d463d47ef9982.tar.bz2 timeline-de1d582bf2ed7062fd400459f30d463d47ef9982.zip |
...
Diffstat (limited to 'Timeline/ClientApp/src/app/data')
-rw-r--r-- | Timeline/ClientApp/src/app/data/DataHub.ts | 396 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/common.ts | 46 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/timeline.ts | 1394 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/user.ts | 778 |
4 files changed, 1307 insertions, 1307 deletions
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<TData> = (data: TData) => void;
-
-export type WithSyncStatus<T> = T & { syncing: boolean };
-
-export class DataLine<TData> {
- private _current: TData | undefined = undefined;
-
- private _syncingSubject = new BehaviorSubject<boolean>(false);
-
- private _observers: Subscriber<TData>[] = [];
-
- constructor(
- private config?: { destroyable?: (value: TData | undefined) => boolean }
- ) {}
-
- 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._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<TKey, TData> {
- private keyToString: (key: TKey) => string;
- private setup?: (key: TKey, line: DataLine<TData>) => (() => void) | void;
- 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?: {
- keyToString?: (key: TKey) => string;
- setup?: (key: TKey, line: DataLine<TData>) => 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<TData> {
- const keyString = this.keyToString(key);
- const { setup, destroyable } = this;
- const newLine = new DataLine<TData>({
- 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<TData> {
- return this.getLineOrCreateWithSetup(key).getObservable();
- }
-
- getSyncStatusObservable(key: TKey): Observable<boolean> {
- return this.getLineOrCreateWithSetup(key).getSyncStatusObservable();
- }
-
- getDataWithSyncStatusObservable(
- key: TKey
- ): Observable<WithSyncStatus<TData>> {
- return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable();
- }
-
- getLine(key: TKey): DataLine<TData> | null {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? null;
- }
-
- getLineOrCreateWithSetup(key: TKey): DataLine<TData> {
- const keyString = this.keyToString(key);
- return this.subscriptionLineMap.get(keyString) ?? this.createLine(key);
- }
-
- getLineOrCreateWithoutSetup(key: TKey): DataLine<TData> {
- 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<TData> = (data: TData) => void; + +export type WithSyncStatus<T> = T & { syncing: boolean }; + +export class DataLine<TData> { + private _current: TData | undefined = undefined; + + private _syncingSubject = new BehaviorSubject<boolean>(false); + + private _observers: Subscriber<TData>[] = []; + + constructor( + private config?: { destroyable?: (value: TData | undefined) => boolean } + ) {} + + 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._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<TKey, TData> { + private keyToString: (key: TKey) => string; + private setup?: (key: TKey, line: DataLine<TData>) => (() => void) | void; + 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?: { + keyToString?: (key: TKey) => string; + setup?: (key: TKey, line: DataLine<TData>) => 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<TData> { + const keyString = this.keyToString(key); + const { setup, destroyable } = this; + const newLine = new DataLine<TData>({ + 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<TData> { + return this.getLineOrCreateWithSetup(key).getObservable(); + } + + getSyncStatusObservable(key: TKey): Observable<boolean> { + return this.getLineOrCreateWithSetup(key).getSyncStatusObservable(); + } + + getDataWithSyncStatusObservable( + key: TKey + ): Observable<WithSyncStatus<TData>> { + return this.getLineOrCreateWithSetup(key).getDataWithSyncStatusObservable(); + } + + getLine(key: TKey): DataLine<TData> | null { + const keyString = this.keyToString(key); + return this.subscriptionLineMap.get(keyString) ?? null; + } + + getLineOrCreateWithSetup(key: TKey): DataLine<TData> { + const keyString = this.keyToString(key); + return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); + } + + getLineOrCreateWithoutSetup(key: TKey): DataLine<TData> { + 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<HttpTimelineInfo, 'owner' | 'members'> & {
- owner: string;
- members: string[];
-};
-
-type TimelinePostData = Omit<HttpTimelinePostInfo, '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();
- }
-
- private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData {
- return {
- ...timeline,
- owner: timeline.owner.username,
- members: timeline.members.map((m) => m.username),
- };
- }
-
- private async syncTimeline(timelineName: string): Promise<void> {
- 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<TimelineWithSyncStatus> {
- 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<TimelineInfo> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient().postTimeline(
- {
- name: timelineName,
- },
- user.token
- )
- ).pipe(
- convertError(HttpTimelineNameConflictError, TimelineNameConflictError)
- );
- }
-
- changeTimelineProperty(
- timelineName: string,
- req: TimelineChangePropertyRequest
- ): Observable<TimelineInfo> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient()
- .patchTimeline(timelineName, req, user.token)
- .then((timeline) => {
- void this.syncTimeline(timelineName);
- return timeline;
- })
- );
- }
-
- deleteTimeline(timelineName: string): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient().deleteTimeline(timelineName, user.token)
- );
- }
-
- addMember(timelineName: string, username: string): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient()
- .memberPut(timelineName, username, user.token)
- .then(() => {
- void this.syncTimeline(timelineName);
- })
- );
- }
-
- removeMember(timelineName: string, username: string): Observable<unknown> {
- 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<TimelinePostData[] | null> {
- return dataStorage.getItem<TimelinePostData[] | null>(
- `timeline.${timelineName}.posts`
- );
- }
-
- private savePosts(
- timelineName: string,
- data: TimelinePostData[]
- ): Promise<void> {
- return dataStorage
- .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data)
- .then();
- }
-
- private async syncPosts(timelineName: string): Promise<void> {
- 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<Date | null>(
- `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<Date>(
- `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<Date>(
- `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<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) => 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<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 async syncPostData(key: {
- timelineName: string;
- postId: number;
- }): Promise<void> {
- 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<BlobOrStatus> {
- return this._postDataHub.getObservable({ timelineName, postId }).pipe(
- map((state): BlobOrStatus => state.data ?? 'error'),
- startWith('loading')
- );
- }
-
- createPost(
- timelineName: string,
- request: TimelineCreatePostRequest
- ): Observable<unknown> {
- const user = checkLogin();
- return from(
- getHttpTimelineClient()
- .postPost(timelineName, request, user.token)
- .then(() => {
- void this.syncPosts(timelineName);
- })
- );
- }
-
- deletePost(timelineName: string, postId: number): Observable<unknown> {
- 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<TimelineWithSyncStatus | undefined>(
- 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<string[]> {
- 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<HttpTimelineInfo, "owner" | "members"> & { + owner: string; + members: string[]; +}; + +type TimelinePostData = Omit<HttpTimelinePostInfo, "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(); + } + + private convertHttpTimelineToData(timeline: HttpTimelineInfo): TimelineData { + return { + ...timeline, + owner: timeline.owner.username, + members: timeline.members.map((m) => m.username), + }; + } + + private async syncTimeline(timelineName: string): Promise<void> { + 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<TimelineWithSyncStatus> { + 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<TimelineInfo> { + const user = checkLogin(); + return from( + getHttpTimelineClient().postTimeline( + { + name: timelineName, + }, + user.token + ) + ).pipe( + convertError(HttpTimelineNameConflictError, TimelineNameConflictError) + ); + } + + changeTimelineProperty( + timelineName: string, + req: TimelineChangePropertyRequest + ): Observable<TimelineInfo> { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .patchTimeline(timelineName, req, user.token) + .then((timeline) => { + void this.syncTimeline(timelineName); + return timeline; + }) + ); + } + + deleteTimeline(timelineName: string): Observable<unknown> { + const user = checkLogin(); + return from( + getHttpTimelineClient().deleteTimeline(timelineName, user.token) + ); + } + + addMember(timelineName: string, username: string): Observable<unknown> { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .memberPut(timelineName, username, user.token) + .then(() => { + void this.syncTimeline(timelineName); + }) + ); + } + + removeMember(timelineName: string, username: string): Observable<unknown> { + 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<TimelinePostData[] | null> { + return dataStorage.getItem<TimelinePostData[] | null>( + `timeline.${timelineName}.posts` + ); + } + + private savePosts( + timelineName: string, + data: TimelinePostData[] + ): Promise<void> { + return dataStorage + .setItem<TimelinePostData[]>(`timeline.${timelineName}.posts`, data) + .then(); + } + + private async syncPosts(timelineName: string): Promise<void> { + 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<Date | null>( + `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<Date>( + `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<Date>( + `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<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) => 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<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 async syncPostData(key: { + timelineName: string; + postId: number; + }): Promise<void> { + 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<BlobOrStatus> { + return this._postDataHub.getObservable({ timelineName, postId }).pipe( + map((state): BlobOrStatus => state.data ?? "error"), + startWith("loading") + ); + } + + createPost( + timelineName: string, + request: TimelineCreatePostRequest + ): Observable<unknown> { + const user = checkLogin(); + return from( + getHttpTimelineClient() + .postPost(timelineName, request, user.token) + .then(() => { + void this.syncPosts(timelineName); + }) + ); + } + + deletePost(timelineName: string, postId: number): Observable<unknown> { + 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<TimelineWithSyncStatus | undefined>( + 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<string[]> { + 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<UserWithToken | null | undefined>(
- undefined
- );
-
- get user$(): Observable<UserWithToken | null | undefined> {
- return this.userSubject;
- }
-
- get currentUser(): UserWithToken | null | undefined {
- return this.userSubject.value;
- }
-
- async checkLoginState(): Promise<UserWithToken | null> {
- if (this.currentUser !== undefined) {
- console.warn("Already checked user. Can't check twice.");
- }
-
- const savedUser = await dataStorage.getItem<UserWithToken | null>(
- 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<UserWithToken>(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<void> {
- 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<UserWithToken>(USER_STORAGE_KEY, user);
- }
- this.userSubject.next(user);
- } catch (e) {
- if (e instanceof HttpCreateTokenBadCredentialError) {
- throw new BadCredentialError();
- } else {
- throw e;
- }
- }
- }
-
- async logout(): Promise<void> {
- 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<unknown> {
- 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<UserWithToken | null | undefined>(
- 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<UserWithToken | null>(() => {
- 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<void> {
- 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<User | null> {
- return dataStorage.getItem<HttpUser | null>(`user.${username}`);
- }
-
- private doSaveUser(user: HttpUser): Promise<void> {
- return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then();
- }
-
- private async syncUser(username: string): Promise<void> {
- 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<User> {
- return this._userHub.getObservable(username).pipe(
- map((state) => state?.user),
- filter((user): user is User => user != null)
- );
- }
-
- 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();
- }
-
- private async syncAvatar(username: string): Promise<void> {
- 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<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> {
- 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<Blob | undefined>(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<UserWithToken | null | undefined>( + undefined + ); + + get user$(): Observable<UserWithToken | null | undefined> { + return this.userSubject; + } + + get currentUser(): UserWithToken | null | undefined { + return this.userSubject.value; + } + + async checkLoginState(): Promise<UserWithToken | null> { + if (this.currentUser !== undefined) { + console.warn("Already checked user. Can't check twice."); + } + + const savedUser = await dataStorage.getItem<UserWithToken | null>( + 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<UserWithToken>(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<void> { + 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<UserWithToken>(USER_STORAGE_KEY, user); + } + this.userSubject.next(user); + } catch (e) { + if (e instanceof HttpCreateTokenBadCredentialError) { + throw new BadCredentialError(); + } else { + throw e; + } + } + } + + async logout(): Promise<void> { + 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<unknown> { + 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<UserWithToken | null | undefined>( + 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<UserWithToken | null>(() => { + 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<void> { + 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<User | null> { + return dataStorage.getItem<HttpUser | null>(`user.${username}`); + } + + private doSaveUser(user: HttpUser): Promise<void> { + return dataStorage.setItem<HttpUser>(`user.${user.username}`, user).then(); + } + + private async syncUser(username: string): Promise<void> { + 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<User> { + return this._userHub.getObservable(username).pipe( + map((state) => state?.user), + filter((user): user is User => user != null) + ); + } + + 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(); + } + + private async syncAvatar(username: string): Promise<void> { + 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<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> { + 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<Blob | undefined>(undefined); + React.useEffect(() => { + if (username == null) { + setState(undefined); + return; + } + + const subscription = userInfoService + .getAvatar$(username) + .subscribe((blob) => { + setState(blob); + }); + return () => { + subscription.unsubscribe(); + }; + }, [username]); + return state; +} |