aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/services')
-rw-r--r--FrontEnd/src/services/TimelinePostBuilder.ts116
-rw-r--r--FrontEnd/src/services/alert.ts63
-rw-r--r--FrontEnd/src/services/timeline.ts85
-rw-r--r--FrontEnd/src/services/user.ts228
4 files changed, 492 insertions, 0 deletions
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<Remarkable.ImageToken, string> =>
+ 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<HttpTimelinePostPostRequest["dataList"]> {
+ 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<unknown> | 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<AuthUser | null | undefined>(
+ undefined
+ );
+
+ get user$(): Observable<AuthUser | null | undefined> {
+ return this.userSubject;
+ }
+
+ get currentUser(): AuthUser | null | undefined {
+ return this.userSubject.value;
+ }
+
+ async checkLoginState(): Promise<AuthUser | null> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<AuthUser | null | undefined>(
+ 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<AuthUser | 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(): AuthUser {
+ const user = useUser();
+ if (user == null) {
+ throw new UiLogicError("You assert user has logged in but actually not.");
+ }
+ return user;
+}