diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/data')
-rw-r--r-- | Timeline/ClientApp/src/app/data/SubscriptionHub.ts | 125 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/common.ts | 4 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/timeline.ts | 265 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/data/user.ts | 296 |
4 files changed, 690 insertions, 0 deletions
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<TData> = (data: TData) => void;
+
+export class Subscription {
+ constructor(private _onUnsubscribe: () => void) {}
+
+ unsubscribe(): void {
+ this._onUnsubscribe();
+ }
+}
+
+class SubscriptionToken {
+ constructor(public _subscription: rxjs.Subscription) {}
+}
+
+class SubscriptionLine<TData> {
+ private _lastDataPromise: Promise<void>;
+ private _dataSubject = new rxjs.BehaviorSubject<TData | undefined>(undefined);
+ private _data$: rxjs.Observable<TData> = this._dataSubject.pipe(
+ filter((d) => d !== undefined)
+ ) as rxjs.Observable<TData>;
+ private _refCount = 0;
+
+ constructor(
+ _creator: () => Promise<TData>,
+ private _destroyer: (data: TData) => void,
+ private _onZeroRef: (self: SubscriptionLine<TData>) => void
+ ) {
+ this._lastDataPromise = _creator().then((data) => {
+ this._dataSubject.next(data);
+ });
+ }
+
+ subscribe(subscriber: Subscriber<TData>): 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<TData>): 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<TKey, TData> {
+ subscribe(key: TKey, subscriber: Subscriber<TData>): Subscription;
+}
+
+export class SubscriptionHub<TKey, TData>
+ implements ISubscriptionHub<TKey, TData> {
+ constructor(
+ public keyToString: (key: TKey) => string,
+ public creator: (key: TKey) => Promise<TData>,
+ public destroyer: (key: TKey, data: TData) => void
+ ) {}
+
+ private subscriptionLineMap = new Map<string, SubscriptionLine<TData>>();
+
+ subscribe(key: TKey, subscriber: Subscriber<TData>): Subscription {
+ const keyString = this.keyToString(key);
+ const line = (() => {
+ const savedLine = this.subscriptionLineMap.get(keyString);
+ if (savedLine == null) {
+ const newLine = new SubscriptionLine<TData>(
+ () => 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<TData>): 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<TimelineInfo> {
+ return from(getHttpTimelineClient().getTimeline(timelineName)).pipe(
+ convertError(HttpTimelineNotExistError, TimelineNotExistError)
+ );
+ }
+
+ 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)
+ );
+ }
+
+ 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)
+ );
+ }
+
+ removeMember(timelineName: string, username: string): Observable<unknown> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().memberDelete(timelineName, username, user.token)
+ );
+ }
+
+ getPosts(timelineName: string): Observable<TimelinePostInfo[]> {
+ const token = userService.currentUser?.token;
+ return from(getHttpTimelineClient().listPost(timelineName, token)).pipe(
+ map((posts) => {
+ return posts.map((post) => ({
+ ...post,
+ timelineName,
+ }));
+ })
+ );
+ }
+
+ private _postDataSubscriptionHub = new SubscriptionHub<PostKey, BlobWithUrl>(
+ (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<PostKey, BlobWithUrl> {
+ return this._postDataSubscriptionHub;
+ }
+
+ createPost(
+ timelineName: string,
+ request: TimelineCreatePostRequest
+ ): Observable<TimelinePostInfo> {
+ const user = checkLogin();
+ return from(
+ getHttpTimelineClient().postPost(timelineName, request, user.token)
+ ).pipe(map((post) => ({ ...post, timelineName })));
+ }
+
+ deletePost(timelineName: string, postId: number): Observable<unknown> {
+ 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<string | undefined>(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<UserWithToken | null | undefined>(
+ undefined
+ );
+
+ get user$(): Observable<UserWithToken | null | undefined> {
+ return this.userSubject;
+ }
+
+ get currentUser(): UserWithToken | null | undefined {
+ return this.userSubject.value;
+ }
+
+ checkLoginState(): Observable<UserWithToken | null> {
+ 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<UserWithToken> {
+ 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<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(() => {
+ 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 type AvatarInfo = BlobWithUrl;
+
+export class UserInfoService {
+ private _avatarSubscriptionHub = new SubscriptionHub<string, AvatarInfo>(
+ (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<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._avatarSubscriptionHub.update(username, () =>
+ Promise.resolve({
+ blob,
+ url: URL.createObjectURL(blob),
+ })
+ );
+ }
+
+ get avatarHub(): ISubscriptionHub<string, AvatarInfo> {
+ return this._avatarSubscriptionHub;
+ }
+}
+
+export const userInfoService = new UserInfoService();
+
+export function useAvatarUrl(username?: string): string | undefined {
+ const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>(
+ undefined
+ );
+ React.useEffect(() => {
+ if (username == null) {
+ setAvatarUrl(undefined);
+ return;
+ }
+
+ const subscription = userInfoService.avatarHub.subscribe(
+ username,
+ ({ url }) => {
+ setAvatarUrl(url);
+ }
+ );
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [username]);
+ return avatarUrl;
+}
|