diff --git a/package.json b/package.json index 2ff94818..e92fd235 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "pull": "node scripts/setup_assets.js", "build": "rimraf build && node scripts/setup_assets.js --check && vite build", "preview": "vite preview", - "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", + "lint": "eslint src/**/*.{js,jsx,ts,tsx}", "fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", "typecheck": "tsc --noEmit", "start": "sirv dist --cors --single --host", @@ -37,6 +37,18 @@ { "varsIgnorePattern": "^_" } + ], + "require-jsdoc": [ + "error", + { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": false, + "FunctionExpression": false + } + } ] } }, diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 409f2439..8b4d5b15 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -20,6 +20,7 @@ import { SMOOTH_SCROLL_ON_RECEIVE, } from "../../../lib/renderer/Singleton"; +import { useApplicationState } from "../../../mobx/State"; import { dispatch, getState } from "../../../redux"; import { Reply } from "../../../redux/reducers/queue"; @@ -115,7 +116,7 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$"); export const CAN_UPLOAD_AT_ONCE = 4; export default observer(({ channel }: Props) => { - const [draft, setDraft] = useState(getState().drafts[channel._id] ?? ""); + const drafts = useApplicationState().draft; const [uploadState, setUploadState] = useState({ type: "none", @@ -150,7 +151,7 @@ export default observer(({ channel }: Props) => { const setMessage = useCallback( (content?: string) => { - setDraft(content ?? ""); + drafts.set(channel._id, content); if (content) { dispatch({ @@ -165,10 +166,15 @@ export default observer(({ channel }: Props) => { }); } }, - [channel._id], + [drafts, channel._id], ); useEffect(() => { + /** + * + * @param content + * @param action + */ function append(content: string, action: "quote" | "mention") { const text = action === "quote" @@ -178,10 +184,10 @@ export default observer(({ channel }: Props) => { .join("\n")}\n\n` : `${content} `; - if (!draft || draft.length === 0) { + if (!drafts.has(channel._id)) { setMessage(text); } else { - setMessage(`${draft}\n${text}`); + setMessage(`${drafts.get(channel._id)}\n${text}`); } } @@ -190,13 +196,16 @@ export default observer(({ channel }: Props) => { "append", append as (...args: unknown[]) => void, ); - }, [draft, setMessage]); + }, [drafts, channel._id, setMessage]); + /** + * Trigger send message. + */ async function send() { if (uploadState.type === "uploading" || uploadState.type === "sending") return; - const content = draft?.trim() ?? ""; + const content = drafts.get(channel._id)?.trim() ?? ""; if (uploadState.type === "attached") return sendFile(content); if (content.length === 0) return; @@ -281,6 +290,11 @@ export default observer(({ channel }: Props) => { } } + /** + * + * @param content + * @returns + */ async function sendFile(content: string) { if (uploadState.type !== "attached") return; const attachments: string[] = []; @@ -372,6 +386,10 @@ export default observer(({ channel }: Props) => { } } + /** + * + * @returns + */ function startTyping() { if (typeof typing === "number" && +new Date() < typing) return; @@ -385,6 +403,10 @@ export default observer(({ channel }: Props) => { } } + /** + * + * @param force + */ function stopTyping(force?: boolean) { if (force || typing) { const ws = client.websocket; @@ -503,7 +525,7 @@ export default observer(({ channel }: Props) => { id="message" maxLength={2000} onKeyUp={onKeyUp} - value={draft ?? ""} + value={drafts.get(channel._id) ?? ""} padding="var(--message-box-padding)" onKeyDown={(e) => { if (e.ctrlKey && e.key === "Enter") { @@ -513,10 +535,7 @@ export default observer(({ channel }: Props) => { if (onKeyDown(e)) return; - if ( - e.key === "ArrowUp" && - (!draft || draft.length === 0) - ) { + if (e.key === "ArrowUp" && !drafts.has(channel._id)) { e.preventDefault(); internalEmit("MessageRenderer", "edit_last"); return; diff --git a/src/context/index.tsx b/src/context/index.tsx index 0abdbdb8..e7e2cab8 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -9,6 +9,10 @@ import Theme from "./Theme"; import Intermediate from "./intermediate/Intermediate"; import Client from "./revoltjs/RevoltClient"; +/** + * This component provides all of the application's context layers. + * @param param0 Provided children + */ export default function Context({ children }: { children: Children }) { return ( diff --git a/src/mobx/Persistent.ts b/src/mobx/Persistent.ts new file mode 100644 index 00000000..576e6133 --- /dev/null +++ b/src/mobx/Persistent.ts @@ -0,0 +1,16 @@ +/** + * A data store which is persistent and should cache its data locally. + */ +export default interface Persistent { + /** + * Override toJSON to serialise this data store. + * This will also force all subclasses to implement this method. + */ + toJSON(): unknown; + + /** + * Hydrate this data store using given data. + * @param data Given data + */ + hydrate(data: T): void; +} diff --git a/src/mobx/State.ts b/src/mobx/State.ts new file mode 100644 index 00000000..ca112b37 --- /dev/null +++ b/src/mobx/State.ts @@ -0,0 +1,37 @@ +import { makeAutoObservable } from "mobx"; + +import { createContext } from "preact"; +import { useContext } from "preact/hooks"; + +import Auth from "./stores/Auth"; +import Draft from "./stores/Draft"; + +/** + * Handles global application state. + */ +export default class State { + auth: Auth; + draft: Draft; + + /** + * Construct new State. + */ + constructor() { + this.auth = new Auth(); + this.draft = new Draft(); + + makeAutoObservable(this); + } +} + +const StateContext = createContext(null!); + +export const StateContextProvider = StateContext.Provider; + +/** + * Get the application state + * @returns Application state + */ +export function useApplicationState() { + return useContext(StateContext); +} diff --git a/src/mobx/TODO b/src/mobx/TODO new file mode 100644 index 00000000..63d63932 --- /dev/null +++ b/src/mobx/TODO @@ -0,0 +1,14 @@ +auth +drafts +experiments +last opened +locale +notifications +queue +section toggle +serevr config +settings +sync +themes +trusted links +unreads diff --git a/src/mobx/objectUtil.ts b/src/mobx/objectUtil.ts new file mode 100644 index 00000000..2de0fa1a --- /dev/null +++ b/src/mobx/objectUtil.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function deleteKey(object: any, key: string) { + const newObject = { ...object }; + delete newObject[key]; + return newObject; +} diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts new file mode 100644 index 00000000..1a4d8d09 --- /dev/null +++ b/src/mobx/stores/Auth.ts @@ -0,0 +1,70 @@ +import { makeAutoObservable } from "mobx"; +import { Session } from "revolt-api/types/Auth"; +import { Nullable } from "revolt.js/dist/util/null"; + +import Persistent from "../Persistent"; +import { deleteKey } from "../objectUtil"; + +interface Data { + sessions: Record; + current?: string; +} + +/** + * Handles account authentication, managing multiple + * accounts and their sessions. + */ +export default class Auth implements Persistent { + private sessions: Record; + private current: Nullable; + + /** + * Construct new Auth store. + */ + constructor() { + this.sessions = {}; + this.current = null; + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + sessions: this.sessions, + current: this.current, + }; + } + + // eslint-disable-next-line require-jsdoc + hydrate(data: Data) { + this.sessions = data.sessions; + if (data.current && this.sessions[data.current]) { + this.current = data.current; + } + } + + /** + * Add a new session to the auth manager. + * @param session Session + */ + setSession(session: Session) { + this.sessions = { + ...this.sessions, + [session.user_id]: session, + }; + + this.current = session.user_id; + } + + /** + * Remove existing session by user ID. + * @param user_id User ID tied to session + */ + removeSession(user_id: string) { + this.sessions = deleteKey(this.sessions, user_id); + + if (user_id == this.current) { + this.current = null; + } + } +} diff --git a/src/mobx/stores/Draft.ts b/src/mobx/stores/Draft.ts new file mode 100644 index 00000000..5bb51d26 --- /dev/null +++ b/src/mobx/stores/Draft.ts @@ -0,0 +1,80 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; + +import Persistent from "../Persistent"; + +interface Data { + drafts: Record; +} + +/** + * Handles storing draft (currently being written) messages. + */ +export default class Draft implements Persistent { + private drafts: ObservableMap; + + /** + * Construct new Draft store. + */ + constructor() { + this.drafts = new ObservableMap(); + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + drafts: this.drafts, + }; + } + + // eslint-disable-next-line require-jsdoc + @action hydrate(data: Data) { + Object.keys(data.drafts).forEach((key) => + this.drafts.set(key, data.drafts[key]), + ); + } + + /** + * Get draft for a channel. + * @param channel Channel ID + */ + @computed get(channel: string) { + return this.drafts.get(channel); + } + + /** + * Check whether a channel has a draft. + * @param channel Channel ID + */ + @computed has(channel: string) { + return this.drafts.has(channel) && this.drafts.get(channel)!.length > 0; + } + + /** + * Set draft for a channel. + * @param channel Channel ID + * @param content Draft content + */ + @action set(channel: string, content?: string) { + if (typeof content === "undefined") { + return this.clear(channel); + } + + this.drafts.set(channel, content); + } + + /** + * Clear draft from a channel. + * @param channel Channel ID + */ + @action clear(channel: string) { + this.drafts.delete(channel); + } + + /** + * Reset and clear all drafts. + */ + @action reset() { + this.drafts.clear(); + } +} diff --git a/src/redux/State.tsx b/src/redux/State.tsx index bd89e25f..46474ab7 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -3,6 +3,8 @@ import { Provider } from "react-redux"; import { useEffect, useState } from "preact/hooks"; +import MobXState, { StateContextProvider } from "../mobx/State"; + import { dispatch, State, store } from "."; import { Children } from "../types/Preact"; @@ -10,8 +12,13 @@ interface Props { children: Children; } +/** + * Component for loading application state. + * @param props Provided children + */ export default function StateLoader(props: Props) { const [loaded, setLoaded] = useState(false); + const [state] = useState(new MobXState()); useEffect(() => { localForage.getItem("state").then((state) => { @@ -24,5 +31,11 @@ export default function StateLoader(props: Props) { }, []); if (!loaded) return null; - return {props.children}; + return ( + + + {props.children} + + + ); }