From b78d21a524f7a11ad29b4bd230f23825f80c3ed7 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 26 Jul 2020 15:02:55 +0800 Subject: Merge front end repo --- Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 125 +++++++++ Timeline/ClientApp/src/app/data/common.ts | 4 + Timeline/ClientApp/src/app/data/timeline.ts | 265 ++++++++++++++++++ Timeline/ClientApp/src/app/data/user.ts | 296 +++++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 Timeline/ClientApp/src/app/data/SubscriptionHub.ts create mode 100644 Timeline/ClientApp/src/app/data/common.ts create mode 100644 Timeline/ClientApp/src/app/data/timeline.ts create mode 100644 Timeline/ClientApp/src/app/data/user.ts (limited to 'Timeline/ClientApp/src/app/data') diff --git a/Timeline/ClientApp/src/app/data/SubscriptionHub.ts b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts new file mode 100644 index 00000000..2bc6de56 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/SubscriptionHub.ts @@ -0,0 +1,125 @@ +// Remarks for SubscriptionHub: +// 1. Compared with 'push' sematics in rxjs subject, we need 'pull'. In other words, no subscription, no updating. +// 2. We need a way to finalize the last object. For example, if it has an object url, we need to revoke it. +// 3. Make api easier to use and write less boilerplate codes. +// +// There might be some bugs, especially memory leaks and in asynchronization codes. + +import * as rxjs from 'rxjs'; +import { filter } from 'rxjs/operators'; + +export type Subscriber = (data: TData) => void; + +export class Subscription { + constructor(private _onUnsubscribe: () => void) {} + + unsubscribe(): void { + this._onUnsubscribe(); + } +} + +class SubscriptionToken { + constructor(public _subscription: rxjs.Subscription) {} +} + +class SubscriptionLine { + private _lastDataPromise: Promise; + private _dataSubject = new rxjs.BehaviorSubject(undefined); + private _data$: rxjs.Observable = this._dataSubject.pipe( + filter((d) => d !== undefined) + ) as rxjs.Observable; + private _refCount = 0; + + constructor( + _creator: () => Promise, + private _destroyer: (data: TData) => void, + private _onZeroRef: (self: SubscriptionLine) => void + ) { + this._lastDataPromise = _creator().then((data) => { + this._dataSubject.next(data); + }); + } + + subscribe(subscriber: Subscriber): SubscriptionToken { + const subscription = this._data$.subscribe(subscriber); + this._refCount += 1; + return new SubscriptionToken(subscription); + } + + unsubscribe(token: SubscriptionToken): void { + token._subscription.unsubscribe(); + this._refCount -= 1; + if (this._refCount === 0) { + void this._lastDataPromise.then(() => { + const last = this._dataSubject.value; + if (last !== undefined) { + this._destroyer(last); + } + }); + this._onZeroRef(this); + } + } + + next(updator: () => Promise): void { + this._lastDataPromise = this._lastDataPromise + .then(() => updator()) + .then((data) => { + const last = this._dataSubject.value; + if (last !== undefined) { + this._destroyer(last); + } + this._dataSubject.next(data); + }); + } +} + +export interface ISubscriptionHub { + subscribe(key: TKey, subscriber: Subscriber): Subscription; +} + +export class SubscriptionHub + implements ISubscriptionHub { + constructor( + public keyToString: (key: TKey) => string, + public creator: (key: TKey) => Promise, + public destroyer: (key: TKey, data: TData) => void + ) {} + + private subscriptionLineMap = new Map>(); + + subscribe(key: TKey, subscriber: Subscriber): Subscription { + const keyString = this.keyToString(key); + const line = (() => { + const savedLine = this.subscriptionLineMap.get(keyString); + if (savedLine == null) { + const newLine = new SubscriptionLine( + () => this.creator(key), + (data) => { + this.destroyer(key, data); + }, + () => { + this.subscriptionLineMap.delete(keyString); + } + ); + this.subscriptionLineMap.set(keyString, newLine); + return newLine; + } else { + return savedLine; + } + })(); + const token = line.subscribe(subscriber); + return new Subscription(() => { + line.unsubscribe(token); + }); + } + + // Old data is destroyed automatically. + // updator is called only if there is subscription. + update(key: TKey, updator: (key: TKey) => Promise): void { + const keyString = this.keyToString(key); + const line = this.subscriptionLineMap.get(keyString); + if (line != null) { + line.next(() => updator(key)); + } + } +} diff --git a/Timeline/ClientApp/src/app/data/common.ts b/Timeline/ClientApp/src/app/data/common.ts new file mode 100644 index 00000000..7f3f4e93 --- /dev/null +++ b/Timeline/ClientApp/src/app/data/common.ts @@ -0,0 +1,4 @@ +export interface BlobWithUrl { + blob: Blob; + url: string; +} diff --git a/Timeline/ClientApp/src/app/data/timeline.ts b/Timeline/ClientApp/src/app/data/timeline.ts new file mode 100644 index 00000000..dde204be --- /dev/null +++ b/Timeline/ClientApp/src/app/data/timeline.ts @@ -0,0 +1,265 @@ +import React from 'react'; +import XRegExp from 'xregexp'; +import { Observable, from } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UserAuthInfo, checkLogin, userService } from './user'; + +import { BlobWithUrl } from './common'; +import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; + +export { kTimelineVisibilities } from '../http/timeline'; + +export type { TimelineVisibility } from '../http/timeline'; + +import { + TimelineVisibility, + HttpTimelineInfo, + HttpTimelinePatchRequest, + HttpTimelinePostPostRequest, + HttpTimelinePostPostRequestContent, + HttpTimelinePostPostRequestTextContent, + HttpTimelinePostPostRequestImageContent, + HttpTimelinePostInfo, + HttpTimelinePostContent, + HttpTimelinePostTextContent, + HttpTimelinePostImageContent, + getHttpTimelineClient, + HttpTimelineNotExistError, + HttpTimelineNameConflictError, +} from '../http/timeline'; +import { convertError } from '../utilities/rxjs'; + +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 interface TimelinePostInfo extends HttpTimelinePostInfo { + timelineName: string; +} + +export type TimelinePostContent = HttpTimelinePostContent; +export type TimelinePostTextContent = HttpTimelinePostTextContent; +export type TimelinePostImageContent = HttpTimelinePostImageContent; + +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 interface PostKey { + timelineName: string; + postId: number; +} + +export class TimelineService { + getTimeline(timelineName: string): Observable { + return from(getHttpTimelineClient().getTimeline(timelineName)).pipe( + convertError(HttpTimelineNotExistError, TimelineNotExistError) + ); + } + + 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) + ); + } + + 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) + ); + } + + removeMember(timelineName: string, username: string): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().memberDelete(timelineName, username, user.token) + ); + } + + getPosts(timelineName: string): Observable { + const token = userService.currentUser?.token; + return from(getHttpTimelineClient().listPost(timelineName, token)).pipe( + map((posts) => { + return posts.map((post) => ({ + ...post, + timelineName, + })); + }) + ); + } + + private _postDataSubscriptionHub = new SubscriptionHub( + (key) => `${key.timelineName}/${key.postId}`, + async (key) => { + const blob = ( + await getHttpTimelineClient().getPostData( + key.timelineName, + key.postId, + userService.currentUser?.token + ) + ).data; + const url = URL.createObjectURL(blob); + return { + blob, + url, + }; + }, + (_key, data) => { + URL.revokeObjectURL(data.url); + } + ); + + get postDataHub(): ISubscriptionHub { + return this._postDataSubscriptionHub; + } + + createPost( + timelineName: string, + request: TimelineCreatePostRequest + ): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().postPost(timelineName, request, user.token) + ).pipe(map((post) => ({ ...post, timelineName }))); + } + + deletePost(timelineName: string, postId: number): Observable { + const user = checkLogin(); + return from( + getHttpTimelineClient().deletePost(timelineName, postId, user.token) + ); + } + + 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 && 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 usePostDataUrl( + enable: boolean, + timelineName: string, + postId: number +): string | undefined { + const [url, setUrl] = React.useState(undefined); + React.useEffect(() => { + if (!enable) { + setUrl(undefined); + return; + } + + const subscription = timelineService.postDataHub.subscribe( + { + timelineName, + postId, + }, + ({ url }) => { + setUrl(url); + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [timelineName, postId, enable]); + return url; +} diff --git a/Timeline/ClientApp/src/app/data/user.ts b/Timeline/ClientApp/src/app/data/user.ts new file mode 100644 index 00000000..1be5cd3e --- /dev/null +++ b/Timeline/ClientApp/src/app/data/user.ts @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { BehaviorSubject, Observable, of, from } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UiLogicError } from '../common'; +import { convertError } from '../utilities/rxjs'; +import { pushAlert } from '../common/alert-service'; + +import { SubscriptionHub, ISubscriptionHub } from './SubscriptionHub'; + +import { HttpNetworkError } from '../http/common'; +import { + getHttpTokenClient, + HttpCreateTokenBadCredentialError, +} from '../http/token'; +import { + getHttpUserClient, + HttpUserNotExistError, + HttpUser, +} from '../http/user'; + +import { BlobWithUrl } from './common'; + +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 TOKEN_STORAGE_KEY = 'token'; + +export class UserService { + private userSubject = new BehaviorSubject( + undefined + ); + + get user$(): Observable { + return this.userSubject; + } + + get currentUser(): UserWithToken | null | undefined { + return this.userSubject.value; + } + + checkLoginState(): Observable { + if (this.currentUser !== undefined) + throw new UiLogicError("Already checked user. Can't check twice."); + + const savedToken = window.localStorage.getItem(TOKEN_STORAGE_KEY); + if (savedToken) { + const u$ = from(getHttpTokenClient().verify({ token: savedToken })).pipe( + map( + (res) => + ({ + ...res.user, + token: savedToken, + } as UserWithToken) + ) + ); + u$.subscribe( + (user) => { + if (user != null) { + pushAlert({ + type: 'success', + message: { + type: 'i18n', + key: 'user.welcomeBack', + }, + }); + } + this.userSubject.next(user); + }, + (error) => { + if (error instanceof HttpNetworkError) { + pushAlert({ + type: 'danger', + message: { type: 'i18n', key: 'user.verifyTokenFailedNetwork' }, + }); + } else { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + pushAlert({ + type: 'danger', + message: { type: 'i18n', key: 'user.verifyTokenFailed' }, + }); + } + this.userSubject.next(null); + } + ); + return u$; + } + this.userSubject.next(null); + return of(null); + } + + login( + credentials: LoginCredentials, + rememberMe: boolean + ): Observable { + if (this.currentUser) { + throw new UiLogicError('Already login.'); + } + const u$ = from( + getHttpTokenClient().create({ + ...credentials, + expire: 30, + }) + ).pipe( + map( + (res) => + ({ + ...res.user, + token: res.token, + } as UserWithToken) + ), + convertError(HttpCreateTokenBadCredentialError, BadCredentialError) + ); + u$.subscribe((user) => { + if (rememberMe) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, user.token); + } + this.userSubject.next(user); + }); + return u$; + } + + logout(): void { + if (this.currentUser === undefined) { + throw new UiLogicError('Please check user first.'); + } + if (this.currentUser === null) { + throw new UiLogicError('No login.'); + } + window.localStorage.removeItem(TOKEN_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(() => { + 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 type AvatarInfo = BlobWithUrl; + +export class UserInfoService { + private _avatarSubscriptionHub = new SubscriptionHub( + (key) => key, + async (key) => { + const blob = (await getHttpUserClient().getAvatar(key)).data; + const url = URL.createObjectURL(blob); + return { + blob, + url, + }; + }, + (_key, data) => { + URL.revokeObjectURL(data.url); + } + ); + + 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._avatarSubscriptionHub.update(username, () => + Promise.resolve({ + blob, + url: URL.createObjectURL(blob), + }) + ); + } + + get avatarHub(): ISubscriptionHub { + return this._avatarSubscriptionHub; + } +} + +export const userInfoService = new UserInfoService(); + +export function useAvatarUrl(username?: string): string | undefined { + const [avatarUrl, setAvatarUrl] = React.useState( + undefined + ); + React.useEffect(() => { + if (username == null) { + setAvatarUrl(undefined); + return; + } + + const subscription = userInfoService.avatarHub.subscribe( + username, + ({ url }) => { + setAvatarUrl(url); + } + ); + return () => { + subscription.unsubscribe(); + }; + }, [username]); + return avatarUrl; +} -- cgit v1.2.3