From 47587812b809fee2a95c76266d9d0e42fc4ac1ca Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 15 Jun 2021 14:14:28 +0800 Subject: ... --- FrontEnd/src/services/TimelinePostBuilder.ts | 116 ++++++++++++++ FrontEnd/src/services/alert.ts | 63 ++++++++ FrontEnd/src/services/timeline.ts | 85 ++++++++++ FrontEnd/src/services/user.ts | 228 +++++++++++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 FrontEnd/src/services/TimelinePostBuilder.ts create mode 100644 FrontEnd/src/services/alert.ts create mode 100644 FrontEnd/src/services/timeline.ts create mode 100644 FrontEnd/src/services/user.ts (limited to 'FrontEnd/src/services') diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts new file mode 100644 index 00000000..fe4c7a9a --- /dev/null +++ b/FrontEnd/src/services/TimelinePostBuilder.ts @@ -0,0 +1,116 @@ +import { Remarkable } from "remarkable"; + +import { UiLogicError } from "@/common"; + +import { base64 } from "http/common"; +import { HttpTimelinePostPostRequest } from "http/timeline"; + +export default class TimelinePostBuilder { + private _onChange: () => void; + private _text = ""; + private _images: { file: File; url: string }[] = []; + private _md: Remarkable = new Remarkable(); + + constructor(onChange: () => void) { + this._onChange = onChange; + const oldImageRenderer = this._md.renderer.rules.image; + this._md.renderer.rules.image = (( + _t: TimelinePostBuilder + ): Remarkable.Rule => + function (tokens, idx, options /*, env */) { + const i = parseInt(tokens[idx].src); + if (!isNaN(i) && i > 0 && i <= _t._images.length) { + tokens[idx].src = _t._images[i - 1].url; + } + return oldImageRenderer(tokens, idx, options); + })(this); + } + + setMarkdownText(text: string): void { + this._text = text; + this._onChange(); + } + + appendImage(file: File): void { + this._images = this._images.slice(); + this._images.push({ + file, + url: URL.createObjectURL(file), + }); + this._onChange(); + } + + moveImage(oldIndex: number, newIndex: number): void { + if (oldIndex < 0 || oldIndex >= this._images.length) { + throw new UiLogicError("Old index out of range."); + } + + if (newIndex < 0) { + newIndex = 0; + } + + if (newIndex >= this._images.length) { + newIndex = this._images.length - 1; + } + + this._images = this._images.slice(); + + const [old] = this._images.splice(oldIndex, 1); + this._images.splice(newIndex, 0, old); + + this._onChange(); + } + + deleteImage(index: number): void { + if (index < 0 || index >= this._images.length) { + throw new UiLogicError("Old index out of range."); + } + + this._images = this._images.slice(); + + URL.revokeObjectURL(this._images[index].url); + this._images.splice(index, 1); + + this._onChange(); + } + + get text(): string { + return this._text; + } + + get images(): { file: File; url: string }[] { + return this._images; + } + + get isEmpty(): boolean { + return this._text.length === 0 && this._images.length === 0; + } + + renderHtml(): string { + return this._md.render(this._text); + } + + dispose(): void { + for (const image of this._images) { + URL.revokeObjectURL(image.url); + } + this._images = []; + } + + async build(): Promise { + return [ + { + contentType: "text/markdown", + data: await base64(this._text), + }, + ...(await Promise.all( + this._images.map((image) => + base64(image.file).then((data) => ({ + contentType: image.file.type, + data, + })) + ) + )), + ]; + } +} diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts new file mode 100644 index 00000000..48d482ea --- /dev/null +++ b/FrontEnd/src/services/alert.ts @@ -0,0 +1,63 @@ +import React from "react"; +import pull from "lodash/pull"; + +import { BootstrapThemeColor, I18nText } from "@/common"; + +export interface AlertInfo { + type?: BootstrapThemeColor; + message: React.FC | I18nText; + dismissTime?: number | "never"; +} + +export interface AlertInfoEx extends AlertInfo { + id: number; +} + +export type AlertConsumer = (alerts: AlertInfoEx) => void; + +export class AlertService { + private consumers: AlertConsumer[] = []; + private savedAlerts: AlertInfoEx[] = []; + private currentId = 1; + + private produce(alert: AlertInfoEx): void { + for (const consumer of this.consumers) { + consumer(alert); + } + } + + registerConsumer(consumer: AlertConsumer): void { + this.consumers.push(consumer); + if (this.savedAlerts.length !== 0) { + for (const alert of this.savedAlerts) { + this.produce(alert); + } + this.savedAlerts = []; + } + } + + unregisterConsumer(consumer: AlertConsumer): void { + pull(this.consumers, consumer); + } + + push(alert: AlertInfo): void { + const newAlert: AlertInfoEx = { ...alert, id: this.currentId++ }; + if (this.consumers.length === 0) { + this.savedAlerts.push(newAlert); + } else { + this.produce(newAlert); + } + } +} + +export const alertService = new AlertService(); + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} + +export const kAlertHostId = "alert-host"; + +export function getAlertHost(): HTMLElement | null { + return document.getElementById(kAlertHostId); +} diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts new file mode 100644 index 00000000..4ebb705d --- /dev/null +++ b/FrontEnd/src/services/timeline.ts @@ -0,0 +1,85 @@ +import { TimelineVisibility } from "http/timeline"; +import XRegExp from "xregexp"; +import { Observable } from "rxjs"; +import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; + +import { getHttpToken } from "http/common"; + +const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); + +export function validateTimelineName(name: string): boolean { + return timelineNameReg.test(name); +} + +export const timelineVisibilityTooltipTranslationMap: Record< + TimelineVisibility, + string +> = { + Public: "timeline.visibilityTooltip.public", + Register: "timeline.visibilityTooltip.register", + Private: "timeline.visibilityTooltip.private", +}; + +export function getTimelinePostUpdate$( + timelineName: string +): Observable<{ update: boolean; state: HubConnectionState }> { + return new Observable((subscriber) => { + subscriber.next({ + update: false, + state: HubConnectionState.Connecting, + }); + + const token = getHttpToken(); + const connection = new HubConnectionBuilder() + .withUrl("/api/hub/timeline", { + accessTokenFactory: token == null ? undefined : () => token, + }) + .withAutomaticReconnect() + .build(); + + const handler = (tn: string): void => { + if (timelineName === tn) { + subscriber.next({ update: true, state: connection.state }); + } + }; + + connection.onclose(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Disconnected, + }); + }); + + connection.onreconnecting(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Reconnecting, + }); + }); + + connection.onreconnected(() => { + subscriber.next({ + update: false, + state: HubConnectionState.Connected, + }); + }); + + connection.on("OnTimelinePostChanged", handler); + + void connection.start().then(() => { + subscriber.next({ update: false, state: HubConnectionState.Connected }); + + return connection.invoke("SubscribeTimelinePostChange", timelineName); + }); + + return () => { + connection.off("OnTimelinePostChanged", handler); + + if (connection.state === HubConnectionState.Connected) { + void connection + .invoke("UnsubscribeTimelinePostChange", timelineName) + .then(() => connection.stop()); + } + }; + }); +} diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts new file mode 100644 index 00000000..3375c88a --- /dev/null +++ b/FrontEnd/src/services/user.ts @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { UiLogicError } from "@/common"; + +import { HttpNetworkError, setHttpToken } from "http/common"; +import { + getHttpTokenClient, + HttpCreateTokenBadCredentialError, +} from "http/token"; +import { getHttpUserClient, HttpUser, UserPermission } from "http/user"; + +import { pushAlert } from "./alert"; + +interface IAuthUser extends HttpUser { + token: string; +} + +export class AuthUser implements IAuthUser { + constructor(user: HttpUser, public token: string) { + this.uniqueId = user.uniqueId; + this.username = user.username; + this.permissions = user.permissions; + this.nickname = user.nickname; + } + + uniqueId: string; + username: string; + permissions: UserPermission[]; + nickname: string; + + get hasAdministrationPermission(): boolean { + return this.permissions.length !== 0; + } + + get hasAllTimelineAdministrationPermission(): boolean { + return this.permissions.includes("AllTimelineManagement"); + } + + get hasHighlightTimelineAdministrationPermission(): boolean { + return this.permissions.includes("HighlightTimelineManagement"); + } +} + +export interface LoginCredentials { + username: string; + password: string; +} + +export class BadCredentialError { + message = "login.badCredential"; +} + +const USER_STORAGE_KEY = "currentuser"; + +export class UserService { + constructor() { + this.userSubject.subscribe((u) => { + setHttpToken(u?.token ?? null); + }); + } + + private userSubject = new BehaviorSubject( + undefined + ); + + get user$(): Observable { + return this.userSubject; + } + + get currentUser(): AuthUser | null | undefined { + return this.userSubject.value; + } + + async checkLoginState(): Promise { + if (this.currentUser !== undefined) { + console.warn("Already checked user. Can't check twice."); + } + + const savedUserString = localStorage.getItem(USER_STORAGE_KEY); + + const savedAuthUserData = + savedUserString == null + ? null + : (JSON.parse(savedUserString) as IAuthUser); + + const savedUser = + savedAuthUserData == null + ? null + : new AuthUser(savedAuthUserData, savedAuthUserData.token); + + 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 = new AuthUser(res.user, savedToken); + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + this.userSubject.next(user); + pushAlert({ + type: "success", + message: "user.welcomeBack", + }); + return user; + } catch (error) { + if (error instanceof HttpNetworkError) { + pushAlert({ + type: "danger", + message: "user.verifyTokenFailedNetwork", + }); + return savedUser; + } else { + localStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + pushAlert({ + type: "danger", + message: "user.verifyTokenFailed", + }); + return null; + } + } + } + + async login( + credentials: LoginCredentials, + rememberMe: boolean + ): Promise { + if (this.currentUser) { + throw new UiLogicError("Already login."); + } + try { + const res = await getHttpTokenClient().create({ + ...credentials, + expire: 30, + }); + const user = new AuthUser(res.user, res.token); + if (rememberMe) { + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); + } + this.userSubject.next(user); + } catch (e) { + if (e instanceof HttpCreateTokenBadCredentialError) { + throw new BadCredentialError(); + } else { + throw e; + } + } + } + + logout(): Promise { + if (this.currentUser === undefined) { + throw new UiLogicError("Please check user first."); + } + if (this.currentUser === null) { + throw new UiLogicError("No login."); + } + localStorage.removeItem(USER_STORAGE_KEY); + this.userSubject.next(null); + return Promise.resolve(); + } + + changePassword(oldPassword: string, newPassword: string): Promise { + if (this.currentUser == undefined) { + throw new UiLogicError("Not login or checked now, can't log out."); + } + + return getHttpUserClient() + .changePassword({ + oldPassword, + newPassword, + }) + .then(() => this.logout()); + } +} + +export const userService = new UserService(); + +export function useRawUser(): AuthUser | 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(): AuthUser | 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(): AuthUser { + const user = useUser(); + if (user == null) { + throw new UiLogicError("You assert user has logged in but actually not."); + } + return user; +} -- cgit v1.2.3