From 66bfc658c32615263f7161d52b9ce9b3e59e0054 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Wed, 8 Dec 2021 20:42:20 +0000 Subject: [PATCH 01/31] chore: notes --- src/mobx/implementation notes | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/mobx/implementation notes diff --git a/src/mobx/implementation notes b/src/mobx/implementation notes new file mode 100644 index 00000000..2c052c9e --- /dev/null +++ b/src/mobx/implementation notes @@ -0,0 +1,27 @@ +need to have a way to dump or sync to local storage +need a way to rehydrate data stores +split settings per account(?) +multiple accounts need to be supported +oop +redux -> mobx migration (wipe existing redux data post-migration) +look into talking with other tabs to detect multiple instances +(also use this to tell the user to close all tabs before updating) + +write new settings data structures for server-side +(deprecate existing API and replace with new endpoints?) +alternatively: keep using current system and eventually migrate +or: handle both incoming types of data and keep newer version +need to document these data structures +handle missing languages by falling back on en_GB + +provide state globally? perform all authentication from inside mobx +mobx parent holds client information and prepares us for first render + +reasoning for global: + +- we can't and won't have more than one of the application running in a single tab +- interactions become simpler +- all accounts will be managed from one place anyways + +things such as unreads can pass through this data store providing a host of +information, such as whether there are any alerts on channels, etc From 5a41c25e3ce3ff3ecadeb705bdbef3b12ad1bb73 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 10 Dec 2021 12:53:41 +0000 Subject: [PATCH 02/31] feat(mobx): add drafts and state context --- package.json | 14 +++- .../common/messaging/MessageBox.tsx | 43 +++++++--- src/context/index.tsx | 4 + src/mobx/Persistent.ts | 16 ++++ src/mobx/State.ts | 37 +++++++++ src/mobx/TODO | 14 ++++ src/mobx/objectUtil.ts | 6 ++ src/mobx/stores/Auth.ts | 70 ++++++++++++++++ src/mobx/stores/Draft.ts | 80 +++++++++++++++++++ src/redux/State.tsx | 15 +++- 10 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 src/mobx/Persistent.ts create mode 100644 src/mobx/State.ts create mode 100644 src/mobx/TODO create mode 100644 src/mobx/objectUtil.ts create mode 100644 src/mobx/stores/Auth.ts create mode 100644 src/mobx/stores/Draft.ts 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} + + + ); } From 185f76d8504a355bdf32494d85a42c08f79d4883 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 10 Dec 2021 13:55:05 +0000 Subject: [PATCH 03/31] feat(mobx): write experiments, lastOpened and localeOptions stores --- src/mobx/stores/Auth.ts | 19 ++++--- src/mobx/stores/Experiments.ts | 86 ++++++++++++++++++++++++++++++++ src/mobx/stores/LastOpened.ts | 57 +++++++++++++++++++++ src/mobx/stores/LocaleOptions.ts | 84 +++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 src/mobx/stores/Experiments.ts create mode 100644 src/mobx/stores/LastOpened.ts create mode 100644 src/mobx/stores/LocaleOptions.ts diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 1a4d8d09..9d61d956 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from "mobx"; +import { makeAutoObservable, ObservableMap } from "mobx"; import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; @@ -15,14 +15,14 @@ interface Data { * accounts and their sessions. */ export default class Auth implements Persistent { - private sessions: Record; + private sessions: ObservableMap; private current: Nullable; /** * Construct new Auth store. */ constructor() { - this.sessions = {}; + this.sessions = new ObservableMap(); this.current = null; makeAutoObservable(this); } @@ -37,8 +37,11 @@ export default class Auth implements Persistent { // eslint-disable-next-line require-jsdoc hydrate(data: Data) { - this.sessions = data.sessions; - if (data.current && this.sessions[data.current]) { + Object.keys(data.sessions).forEach((id) => + this.sessions.set(id, data.sessions[id]), + ); + + if (data.current && this.sessions.has(data.current)) { this.current = data.current; } } @@ -48,11 +51,7 @@ export default class Auth implements Persistent { * @param session Session */ setSession(session: Session) { - this.sessions = { - ...this.sessions, - [session.user_id]: session, - }; - + this.sessions.set(session.user_id, session); this.current = session.user_id; } diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts new file mode 100644 index 00000000..bb0a35dc --- /dev/null +++ b/src/mobx/stores/Experiments.ts @@ -0,0 +1,86 @@ +import { action, computed, makeAutoObservable, ObservableSet } from "mobx"; + +import Persistent from "../Persistent"; + +export type Experiment = "search" | "theme_shop"; + +export const AVAILABLE_EXPERIMENTS: Experiment[] = ["theme_shop"]; + +export const EXPERIMENTS: { + [key in Experiment]: { title: string; description: string }; +} = { + search: { + title: "Search", + description: "Allows you to search for messages in channels.", + }, + theme_shop: { + title: "Theme Shop", + description: "Allows you to access and set user submitted themes.", + }, +}; + +interface Data { + enabled?: Experiment[]; +} + +/** + * Handles enabling and disabling client experiments. + */ +export default class Experiments implements Persistent { + private enabled: ObservableSet; + + /** + * Construct new Experiments store. + */ + constructor() { + this.enabled = new ObservableSet(); + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + enabled: this.enabled, + }; + } + + // eslint-disable-next-line require-jsdoc + @action hydrate(data: Data) { + if (data.enabled) { + for (const experiment of data.enabled) { + this.enabled.add(experiment as Experiment); + } + } + } + + /** + * Check if an experiment is enabled. + * @param experiment Experiment + */ + @computed isEnabled(experiment: Experiment) { + return this.enabled.has(experiment); + } + + /** + * Enable an experiment. + * @param experiment Experiment + */ + @action enable(experiment: Experiment) { + this.enabled.add(experiment); + } + + /** + * Disable an experiment. + * @param experiment Experiment + */ + @action disable(experiment: Experiment) { + this.enabled.delete(experiment); + } + + /** + * Reset and disable all experiments. + */ + @action reset() { + this.enabled.clear(); + } +} diff --git a/src/mobx/stores/LastOpened.ts b/src/mobx/stores/LastOpened.ts new file mode 100644 index 00000000..091bc012 --- /dev/null +++ b/src/mobx/stores/LastOpened.ts @@ -0,0 +1,57 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; + +import Persistent from "../Persistent"; + +interface Data { + server?: Record; +} + +/** + * Keeps track of the last open channels, tabs, etc. + * Handles providing good UX experience on navigating + * back and forth between different parts of the app. + */ +export default class Experiments implements Persistent { + private server: ObservableMap; + + /** + * Construct new Experiments store. + */ + constructor() { + this.server = new ObservableMap(); + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + server: this.server, + }; + } + + // eslint-disable-next-line require-jsdoc + @action hydrate(data: Data) { + if (data.server) { + Object.keys(data.server).forEach((key) => + this.server.set(key, data.server![key]), + ); + } + } + + /** + * Get last opened channel in a server. + * @param server Server ID + */ + @computed get(server: string) { + return this.server.get(server); + } + + /** + * Set last opened channel in a server. + * @param server Server ID + * @param channel Channel ID + */ + @action enable(server: string, channel: string) { + this.server.set(server, channel); + } +} diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts new file mode 100644 index 00000000..6f88d2f7 --- /dev/null +++ b/src/mobx/stores/LocaleOptions.ts @@ -0,0 +1,84 @@ +import { action, computed, makeAutoObservable } from "mobx"; + +import { Language, Languages } from "../../context/Locale"; + +import Persistent from "../Persistent"; + +interface Data { + lang: Language; +} + +/** + * Detect the browser language or match given language. + * @param lang Language to find + * @returns Matched Language + */ +export function findLanguage(lang?: string): Language { + if (!lang) { + if (typeof navigator === "undefined") { + lang = Language.ENGLISH; + } else { + lang = navigator.language; + } + } + + const code = lang.replace("-", "_"); + const short = code.split("_")[0]; + + const values = []; + for (const key in Language) { + const value = Language[key as keyof typeof Language]; + + // Skip alternative/joke languages + if (Languages[value].cat === "alt") continue; + + values.push(value); + if (value.startsWith(code)) { + return value as Language; + } + } + + for (const value of values.reverse()) { + if (value.startsWith(short)) { + return value as Language; + } + } + + return Language.ENGLISH; +} + +/** + * Keeps track of the last open channels, tabs, etc. + * Handles providing good UX experience on navigating + * back and forth between different parts of the app. + */ +export default class LocaleOptions implements Persistent { + private lang: Language; + + /** + * Construct new LocaleOptions store. + */ + constructor() { + this.lang = findLanguage(); + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + lang: this.lang, + }; + } + + // eslint-disable-next-line require-jsdoc + @action hydrate(data: Data) { + this.lang = data.lang; + } + + /** + * Get current language. + */ + @computed getLang() { + return this.lang; + } +} From 89748d7044527ea02de0ea99cc1e92ab72339309 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Fri, 10 Dec 2021 17:00:34 +0000 Subject: [PATCH 04/31] feat(mobx): start working on notif options, create blank files --- src/mobx/State.ts | 8 +++ src/mobx/stores/MessageQueue.ts | 0 src/mobx/stores/NotificationOptions.ts | 71 ++++++++++++++++++++++++++ src/mobx/stores/SectionToggle.ts | 0 src/mobx/stores/ServerConfig.ts | 0 5 files changed, 79 insertions(+) create mode 100644 src/mobx/stores/MessageQueue.ts create mode 100644 src/mobx/stores/NotificationOptions.ts create mode 100644 src/mobx/stores/SectionToggle.ts create mode 100644 src/mobx/stores/ServerConfig.ts diff --git a/src/mobx/State.ts b/src/mobx/State.ts index ca112b37..5023062a 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -6,6 +6,14 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; +interface StoreDefinition { + id: string; + instance: Record; + persistent: boolean; + synced: boolean; + global: boolean; +} + /** * Handles global application state. */ diff --git a/src/mobx/stores/MessageQueue.ts b/src/mobx/stores/MessageQueue.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts new file mode 100644 index 00000000..c79e7a79 --- /dev/null +++ b/src/mobx/stores/NotificationOptions.ts @@ -0,0 +1,71 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { Channel } from "revolt-api/types/Channels"; + +import Persistent from "../Persistent"; + +/** + * Possible notification states. + * TODO: make "muted" gray out the channel + * TODO: add server defaults + */ +export type NotificationState = "all" | "mention" | "none" | "muted"; + +/** + * Default notification states for various types of channels. + */ +export const DEFAULT_STATES: { + [key in Channel["channel_type"]]: NotificationState; +} = { + SavedMessages: "all", + DirectMessage: "all", + Group: "all", + TextChannel: "mention", + VoiceChannel: "mention", +}; + +interface Data { + server?: Record; + channel?: Record; +} + +/** + * Manages the user's notification preferences. + */ +export default class NotificationOptions implements Persistent { + private server: ObservableMap; + private channel: ObservableMap; + + /** + * Construct new Experiments store. + */ + constructor() { + this.server = new ObservableMap(); + this.channel = new ObservableMap(); + makeAutoObservable(this); + } + + // eslint-disable-next-line require-jsdoc + toJSON() { + return { + server: this.server, + channel: this.channel, + }; + } + + // eslint-disable-next-line require-jsdoc + @action hydrate(data: Data) { + if (data.server) { + Object.keys(data.server).forEach((key) => + this.server.set(key, data.server![key]), + ); + } + + if (data.channel) { + Object.keys(data.channel).forEach((key) => + this.channel.set(key, data.channel![key]), + ); + } + } + + // TODO: implement +} diff --git a/src/mobx/stores/SectionToggle.ts b/src/mobx/stores/SectionToggle.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts new file mode 100644 index 00000000..e69de29b From 87a98418854e18f2301236ae1d1485c819d652b4 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 11:56:33 +0000 Subject: [PATCH 05/31] feat(mobx): implement locale options --- src/components/common/LocaleSelector.tsx | 26 ++--- src/context/Locale.tsx | 130 +++++++++++------------ src/mobx/State.ts | 3 + src/mobx/stores/LocaleOptions.ts | 12 ++- src/pages/settings/panes/Languages.tsx | 128 ++++++++++++---------- 5 files changed, 155 insertions(+), 144 deletions(-) diff --git a/src/components/common/LocaleSelector.tsx b/src/components/common/LocaleSelector.tsx index 8099f510..5fdaef24 100644 --- a/src/components/common/LocaleSelector.tsx +++ b/src/components/common/LocaleSelector.tsx @@ -1,23 +1,21 @@ -import { dispatch } from "../../redux"; -import { connectState } from "../../redux/connector"; +import { useApplicationState } from "../../mobx/State"; import { Language, Languages } from "../../context/Locale"; import ComboBox from "../ui/ComboBox"; -type Props = { - locale: string; -}; +/** + * Component providing a language selector combobox. + * Note: this is not an observer but this is fine as we are just using a combobox. + */ +export default function LocaleSelector() { + const locale = useApplicationState().locale; -export function LocaleSelector(props: Props) { return ( - dispatch({ - type: "SET_LOCALE", - locale: e.currentTarget.value as Language, - }) + locale.setLanguage(e.currentTarget.value as Language) }> {Object.keys(Languages).map((x) => { const l = Languages[x as keyof typeof Languages]; @@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) { ); } - -export default connectState(LocaleSelector, (state) => { - return { - locale: state.locale, - }; -}); diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index fe5504f3..7a3e9fac 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar"; import format from "dayjs/plugin/localizedFormat"; import update from "dayjs/plugin/updateLocale"; import defaultsDeep from "lodash.defaultsdeep"; +import { observer } from "mobx-react-lite"; import { IntlProvider } from "preact-i18n"; import { useCallback, useEffect, useState } from "preact/hooks"; -import { connectState } from "../redux/connector"; +import { useApplicationState } from "../mobx/State"; import definition from "../../external/lang/en.json"; @@ -222,59 +223,14 @@ export interface Dictionary { | undefined; } -function Locale({ children, locale }: Props) { - const [defns, setDefinition] = useState( +export default observer(({ children }: Props) => { + const locale = useApplicationState().locale; + const [definitions, setDefinition] = useState( definition as Dictionary, ); - // Load relevant language information, fallback to English if invalid. - const lang = Languages[locale] ?? Languages.en; - - function transformLanguage(source: Dictionary) { - // Fallback untranslated strings to English (UK) - const obj = defaultsDeep(source, definition); - - // Take relevant objects out, dayjs and defaults - // should exist given we just took defaults above. - const { dayjs } = obj; - const { defaults } = dayjs; - - // Determine whether we are using 12-hour clock. - const twelvehour = defaults?.twelvehour - ? defaults.twelvehour === "yes" - : false; - - // Determine what date separator we are using. - const separator: string = defaults?.date_separator ?? "/"; - - // Determine what date format we are using. - const date: "traditional" | "simplified" | "ISO8601" = - defaults?.date_format ?? "traditional"; - - // Available date formats. - const DATE_FORMATS = { - traditional: `DD${separator}MM${separator}YYYY`, - simplified: `MM${separator}DD${separator}YYYY`, - ISO8601: "YYYY-MM-DD", - }; - - // Replace data in dayjs object, make sure to provide fallbacks. - dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional; - dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm"; - - // Replace {{time}} format string in dayjs strings with the time format. - Object.keys(dayjs) - .filter((k) => typeof dayjs[k] === "string") - .forEach( - (k) => - (dayjs[k] = dayjs[k].replace( - /{{time}}/g, - dayjs["timeFormat"], - )), - ); - - return obj; - } + const lang = locale.getLanguage(); + const source = Languages[lang]; const loadLanguage = useCallback( (locale: string) => { @@ -288,13 +244,13 @@ function Locale({ children, locale }: Props) { return; } - import(`../../external/lang/${lang.i18n}.json`).then( + import(`../../external/lang/${source.i18n}.json`).then( async (lang_file) => { // Transform the definitions data. const defn = transformLanguage(lang_file.default); // Determine and load dayjs locales. - const target = lang.dayjs ?? lang.i18n; + const target = source.dayjs ?? source.i18n; const dayjs_locale = await import( `../../node_modules/dayjs/esm/locale/${target}.js` ); @@ -312,25 +268,63 @@ function Locale({ children, locale }: Props) { }, ); }, - [lang.dayjs, lang.i18n], + [source.dayjs, source.i18n], ); - useEffect(() => loadLanguage(locale), [locale, lang, loadLanguage]); + useEffect(() => loadLanguage(lang), [lang, source, loadLanguage]); useEffect(() => { // Apply RTL language format. - document.body.style.direction = lang.rtl ? "rtl" : ""; - }, [lang.rtl]); + document.body.style.direction = source.rtl ? "rtl" : ""; + }, [source.rtl]); - return {children}; + return {children}; +}); + +/** + * Apply defaults and process dayjs entries for a langauge. + * @param source Dictionary definition to transform + * @returns Transformed dictionary definition + */ +function transformLanguage(source: Dictionary) { + // Fallback untranslated strings to English (UK) + const obj = defaultsDeep(source, definition); + + // Take relevant objects out, dayjs and defaults + // should exist given we just took defaults above. + const { dayjs } = obj; + const { defaults } = dayjs; + + // Determine whether we are using 12-hour clock. + const twelvehour = defaults?.twelvehour + ? defaults.twelvehour === "yes" + : false; + + // Determine what date separator we are using. + const separator: string = defaults?.date_separator ?? "/"; + + // Determine what date format we are using. + const date: "traditional" | "simplified" | "ISO8601" = + defaults?.date_format ?? "traditional"; + + // Available date formats. + const DATE_FORMATS = { + traditional: `DD${separator}MM${separator}YYYY`, + simplified: `MM${separator}DD${separator}YYYY`, + ISO8601: "YYYY-MM-DD", + }; + + // Replace data in dayjs object, make sure to provide fallbacks. + dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional; + dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm"; + + // Replace {{time}} format string in dayjs strings with the time format. + Object.keys(dayjs) + .filter((k) => typeof dayjs[k] === "string") + .forEach( + (k) => + (dayjs[k] = dayjs[k].replace(/{{time}}/g, dayjs["timeFormat"])), + ); + + return obj; } - -export default connectState>( - Locale, - (state) => { - return { - locale: state.locale, - }; - }, - true, -); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 5023062a..752f2cdf 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -5,6 +5,7 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; +import LocaleOptions from "./stores/LocaleOptions"; interface StoreDefinition { id: string; @@ -20,6 +21,7 @@ interface StoreDefinition { export default class State { auth: Auth; draft: Draft; + locale: LocaleOptions; /** * Construct new State. @@ -27,6 +29,7 @@ export default class State { constructor() { this.auth = new Auth(); this.draft = new Draft(); + this.locale = new LocaleOptions(); makeAutoObservable(this); } diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index 6f88d2f7..aed6e70a 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -72,13 +72,21 @@ export default class LocaleOptions implements Persistent { // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { - this.lang = data.lang; + this.setLanguage(data.lang); } /** * Get current language. */ - @computed getLang() { + @computed getLanguage() { return this.lang; } + + /** + * Set current language. + */ + @action setLanguage(language: Language) { + if (typeof Languages[language] === "undefined") return; + this.lang = language; + } } diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx index 873b4611..98f76e0d 100644 --- a/src/pages/settings/panes/Languages.tsx +++ b/src/pages/settings/panes/Languages.tsx @@ -1,6 +1,13 @@ +import { observer } from "mobx-react-lite"; + import styles from "./Panes.module.scss"; import { Text } from "preact-i18n"; +import { useMemo } from "preact/hooks"; +import PaintCounter from "../../../lib/PaintCounter"; + +import { useApplicationState } from "../../../mobx/State"; +import LocaleOptions from "../../../mobx/stores/LocaleOptions"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; @@ -17,26 +24,25 @@ import enchantingTableWEBP from "../assets/enchanting_table.webp"; import tamilFlagPNG from "../assets/tamil_nadu_flag.png"; import tokiponaSVG from "../assets/toki_pona.svg"; -type Props = { - locale: Language; -}; +type Key = [Language, LanguageEntry]; -type Key = [string, LanguageEntry]; +interface Props { + entry: Key; + selected: boolean; + onSelect: () => void; +} -function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) { +/** + * Component providing individual language entries. + * @param param0 Entry data + */ +function Entry({ entry: [x, lang], selected, onSelect }: Props) { return ( { - if (v) { - dispatch({ - type: "SET_LOCALE", - locale: x as Language, - }); - } - }}> + checked={selected} + onChange={onSelect}>
{lang.i18n === "ta" ? ( [ - x, - Langs[x as keyof typeof Langs], - ]) as Key[]; +/** + * Component providing the language selection menu. + */ +export const Languages = observer(() => { + const locale = useApplicationState().locale; + const language = locale.getLanguage(); - // Get the user's system language. Check for exact - // matches first, otherwise check for partial matches - const preferredLanguage = - navigator.languages.filter((lang) => - languages.find((l) => l[0].replace(/_/g, "-") == lang), - )?.[0] || - navigator.languages - ?.map((x) => x.split("-")[0]) - ?.filter((lang) => languages.find((l) => l[0] == lang))?.[0] - ?.split("-")[0]; + // Generate languages array. + const languages = useMemo(() => { + const languages = Object.keys(Langs).map((x) => [ + x, + Langs[x as keyof typeof Langs], + ]) as Key[]; - if (preferredLanguage) { - // This moves the user's system language to the top of the language list - const prefLangKey = languages.find( - (lang) => lang[0].replace(/_/g, "-") == preferredLanguage, - ); - if (prefLangKey) { - languages.splice( - 0, - 0, - languages.splice(languages.indexOf(prefLangKey), 1)[0], + // Get the user's system language. Check for exact + // matches first, otherwise check for partial matches + const preferredLanguage = + navigator.languages.filter((lang) => + languages.find((l) => l[0].replace(/_/g, "-") == lang), + )?.[0] || + navigator.languages + ?.map((x) => x.split("-")[0]) + ?.filter((lang) => languages.find((l) => l[0] == lang))?.[0] + ?.split("-")[0]; + + if (preferredLanguage) { + // This moves the user's system language to the top of the language list + const prefLangKey = languages.find( + (lang) => lang[0].replace(/_/g, "-") == preferredLanguage, ); + + if (prefLangKey) { + languages.splice( + 0, + 0, + languages.splice(languages.indexOf(prefLangKey), 1)[0], + ); + } } - } + + return languages; + }, []); + + // Creates entries with given key. + const EntryFactory = ([x, lang]: Key) => ( + locale.setLanguage(x)} + /> + ); return (
@@ -98,11 +126,7 @@ export function Component(props: Props) {
- {languages - .filter(([, lang]) => !lang.cat) - .map(([x, lang]) => ( - - ))} + {languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}

@@ -110,9 +134,7 @@ export function Component(props: Props) {
{languages .filter(([, lang]) => lang.cat === "const") - .map(([x, lang]) => ( - - ))} + .map(EntryFactory)}

@@ -120,9 +142,7 @@ export function Component(props: Props) {
{languages .filter(([, lang]) => lang.cat === "alt") - .map(([x, lang]) => ( - - ))} + .map(EntryFactory)}
@@ -137,10 +157,4 @@ export function Component(props: Props) {

); -} - -export const Languages = connectState(Component, (state) => { - return { - locale: state.locale, - }; }); From b36cde771e68f5e01bca4440c69f2a1e16b7cdb0 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 11:58:07 +0000 Subject: [PATCH 06/31] feat(mobx): expose application state to window --- src/redux/State.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/redux/State.tsx b/src/redux/State.tsx index 46474ab7..fddb34b2 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -1,7 +1,7 @@ import localForage from "localforage"; import { Provider } from "react-redux"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import MobXState, { StateContextProvider } from "../mobx/State"; @@ -18,7 +18,12 @@ interface Props { */ export default function StateLoader(props: Props) { const [loaded, setLoaded] = useState(false); - const [state] = useState(new MobXState()); + const { current: state } = useRef(new MobXState()); + + // Globally expose the application state. + useEffect(() => { + (window as unknown as Record).state = state; + }, [state]); useEffect(() => { localForage.getItem("state").then((state) => { From 49f45aa5aaf902d70084f3a0bb8db7039659e1cd Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 11:59:26 +0000 Subject: [PATCH 07/31] chore(mobx): remove extra util class --- src/mobx/objectUtil.ts | 6 ------ src/mobx/stores/Auth.ts | 3 +-- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 src/mobx/objectUtil.ts diff --git a/src/mobx/objectUtil.ts b/src/mobx/objectUtil.ts deleted file mode 100644 index 2de0fa1a..00000000 --- a/src/mobx/objectUtil.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 index 9d61d956..153f83d6 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -3,7 +3,6 @@ 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; @@ -60,7 +59,7 @@ export default class Auth implements Persistent { * @param user_id User ID tied to session */ removeSession(user_id: string) { - this.sessions = deleteKey(this.sessions, user_id); + this.sessions.delete(user_id); if (user_id == this.current) { this.current = null; From 830b24a393d147edd9f92cf014858be301ba119b Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 12:08:43 +0000 Subject: [PATCH 08/31] chore(mobx): clean up documentation --- package.json | 3 +++ src/mobx/stores/Auth.ts | 2 -- src/mobx/stores/Draft.ts | 2 -- src/mobx/stores/Experiments.ts | 19 +++++++++++++------ src/mobx/stores/LastOpened.ts | 2 -- src/mobx/stores/LocaleOptions.ts | 2 -- src/mobx/stores/NotificationOptions.ts | 2 -- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index e92fd235..76746ba4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "ClassDeclaration": true, "ArrowFunctionExpression": false, "FunctionExpression": false + }, + "ignore": { + "MethodDefinition": ["toJSON", "hydrate"] } } ] diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 153f83d6..36e16057 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -26,7 +26,6 @@ export default class Auth implements Persistent { makeAutoObservable(this); } - // eslint-disable-next-line require-jsdoc toJSON() { return { sessions: this.sessions, @@ -34,7 +33,6 @@ export default class Auth implements Persistent { }; } - // eslint-disable-next-line require-jsdoc hydrate(data: Data) { Object.keys(data.sessions).forEach((id) => this.sessions.set(id, data.sessions[id]), diff --git a/src/mobx/stores/Draft.ts b/src/mobx/stores/Draft.ts index 5bb51d26..593fb6a3 100644 --- a/src/mobx/stores/Draft.ts +++ b/src/mobx/stores/Draft.ts @@ -20,14 +20,12 @@ export default class Draft implements Persistent { 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]), diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index bb0a35dc..d3058c44 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -2,16 +2,25 @@ import { action, computed, makeAutoObservable, ObservableSet } from "mobx"; import Persistent from "../Persistent"; -export type Experiment = "search" | "theme_shop"; +/** + * Union type of available experiments. + */ +export type Experiment = "dummy" | "theme_shop"; +/** + * Currently active experiments. + */ export const AVAILABLE_EXPERIMENTS: Experiment[] = ["theme_shop"]; +/** + * Definitions for experiments listed by {@link Experiment}. + */ export const EXPERIMENTS: { [key in Experiment]: { title: string; description: string }; } = { - search: { - title: "Search", - description: "Allows you to search for messages in channels.", + dummy: { + title: "Dummy Experiment", + description: "This is a dummy experiment.", }, theme_shop: { title: "Theme Shop", @@ -37,14 +46,12 @@ export default class Experiments implements Persistent { makeAutoObservable(this); } - // eslint-disable-next-line require-jsdoc toJSON() { return { enabled: this.enabled, }; } - // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { if (data.enabled) { for (const experiment of data.enabled) { diff --git a/src/mobx/stores/LastOpened.ts b/src/mobx/stores/LastOpened.ts index 091bc012..e0ff249f 100644 --- a/src/mobx/stores/LastOpened.ts +++ b/src/mobx/stores/LastOpened.ts @@ -22,14 +22,12 @@ export default class Experiments implements Persistent { makeAutoObservable(this); } - // eslint-disable-next-line require-jsdoc toJSON() { return { server: this.server, }; } - // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { if (data.server) { Object.keys(data.server).forEach((key) => diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index aed6e70a..2812b1b7 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -63,14 +63,12 @@ export default class LocaleOptions implements Persistent { makeAutoObservable(this); } - // eslint-disable-next-line require-jsdoc toJSON() { return { lang: this.lang, }; } - // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { this.setLanguage(data.lang); } diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index c79e7a79..28ba0a03 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -44,7 +44,6 @@ export default class NotificationOptions implements Persistent { makeAutoObservable(this); } - // eslint-disable-next-line require-jsdoc toJSON() { return { server: this.server, @@ -52,7 +51,6 @@ export default class NotificationOptions implements Persistent { }; } - // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { if (data.server) { Object.keys(data.server).forEach((key) => From f87ecfcbd7aa3dd6e07834e52dee4307da945831 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 13:23:01 +0000 Subject: [PATCH 09/31] feat(mobx): add experiments store --- src/mobx/State.ts | 3 +++ src/mobx/stores/Experiments.ts | 15 ++++++++++- src/pages/settings/Settings.tsx | 14 ++++++----- src/pages/settings/panes/Experiments.tsx | 32 ++++++------------------ 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 752f2cdf..0b36de10 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -5,6 +5,7 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; +import Experiments from "./stores/Experiments"; import LocaleOptions from "./stores/LocaleOptions"; interface StoreDefinition { @@ -22,6 +23,7 @@ export default class State { auth: Auth; draft: Draft; locale: LocaleOptions; + experiments: Experiments; /** * Construct new State. @@ -30,6 +32,7 @@ export default class State { this.auth = new Auth(); this.draft = new Draft(); this.locale = new LocaleOptions(); + this.experiments = new Experiments(); makeAutoObservable(this); } diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index d3058c44..685a2a0b 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -10,7 +10,7 @@ export type Experiment = "dummy" | "theme_shop"; /** * Currently active experiments. */ -export const AVAILABLE_EXPERIMENTS: Experiment[] = ["theme_shop"]; +export const AVAILABLE_EXPERIMENTS: Experiment[] = ["dummy", "theme_shop"]; /** * Definitions for experiments listed by {@link Experiment}. @@ -84,6 +84,19 @@ export default class Experiments implements Persistent { this.enabled.delete(experiment); } + /** + * Set the state of an experiment. + * @param key Experiment + * @param enabled Whether this experiment is enabled. + */ + @computed setEnabled(key: Experiment, enabled: boolean): void { + if (enabled) { + this.enable(key); + } else { + this.disable(key); + } + } + /** * Reset and disable all experiments. */ diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 594421d9..d11b281b 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -18,6 +18,7 @@ import { Speaker, Store, } from "@styled-icons/boxicons-solid"; +import { observer } from "mobx-react-lite"; import { Route, Switch, useHistory } from "react-router-dom"; import { LIBRARY_VERSION } from "revolt.js"; @@ -25,7 +26,7 @@ import styles from "./Settings.module.scss"; import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; -import { isExperimentEnabled } from "../../redux/reducers/experiments"; +import { useApplicationState } from "../../mobx/State"; import RequiresOnline from "../../context/revoltjs/RequiresOnline"; import { @@ -53,10 +54,11 @@ import { Sessions } from "./panes/Sessions"; import { Sync } from "./panes/Sync"; import { ThemeShop } from "./panes/ThemeShop"; -export default function Settings() { +export default observer(() => { const history = useHistory(); const client = useContext(AppContext); const operations = useContext(OperationsContext); + const experiments = useApplicationState().experiments; function switchPage(to?: string) { if (to) { @@ -127,14 +129,14 @@ export default function Settings() { title: , }, { - divider: !isExperimentEnabled("theme_shop"), + divider: !experiments.isEnabled("theme_shop"), category: "revolt", id: "bots", icon: , title: , }, { - hidden: !isExperimentEnabled("theme_shop"), + hidden: !experiments.isEnabled("theme_shop"), divider: true, id: "theme_shop", icon: , @@ -180,7 +182,7 @@ export default function Settings() { - {isExperimentEnabled("theme_shop") && ( + {experiments.isEnabled("theme_shop") && ( @@ -260,4 +262,4 @@ export default function Settings() { } /> ); -} +}); diff --git a/src/pages/settings/panes/Experiments.tsx b/src/pages/settings/panes/Experiments.tsx index 7e50c892..b91e756f 100644 --- a/src/pages/settings/panes/Experiments.tsx +++ b/src/pages/settings/panes/Experiments.tsx @@ -1,22 +1,19 @@ +import { observer } from "mobx-react-lite"; + import styles from "./Panes.module.scss"; import { Text } from "preact-i18n"; -import { dispatch } from "../../../redux"; -import { connectState } from "../../../redux/connector"; +import { useApplicationState } from "../../../mobx/State"; import { AVAILABLE_EXPERIMENTS, - ExperimentOptions, EXPERIMENTS, - isExperimentEnabled, -} from "../../../redux/reducers/experiments"; +} from "../../../mobx/stores/Experiments"; import Checkbox from "../../../components/ui/Checkbox"; -interface Props { - options?: ExperimentOptions; -} +export const ExperimentsPage = observer(() => { + const experiments = useApplicationState().experiments; -export function Component(props: Props) { return (

@@ -25,15 +22,8 @@ export function Component(props: Props) { {AVAILABLE_EXPERIMENTS.map((key) => ( - dispatch({ - type: enabled - ? "EXPERIMENTS_ENABLE" - : "EXPERIMENTS_DISABLE", - key, - }) - } + checked={experiments.isEnabled(key)} + onChange={(enabled) => experiments.setEnabled(key, enabled)} description={EXPERIMENTS[key].description}> {EXPERIMENTS[key].title} @@ -45,10 +35,4 @@ export function Component(props: Props) { )}

); -} - -export const ExperimentsPage = connectState(Component, (state) => { - return { - options: state.experiments, - }; }); From a8491267a45acdbfced06fe57952fc26feceb33c Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 14:34:12 +0000 Subject: [PATCH 10/31] feat(mobx): add layout (paths + sections) --- src/components/common/CollapsibleSection.tsx | 26 +-- .../navigation/BottomNavigation.tsx | 25 +-- .../navigation/left/HomeSidebar.tsx | 13 +- .../navigation/left/ServerListSidebar.tsx | 14 +- .../navigation/left/ServerSidebar.tsx | 11 +- src/mobx/State.ts | 3 + src/mobx/stores/LastOpened.ts | 55 ------ src/mobx/stores/Layout.ts | 161 ++++++++++++++++++ src/mobx/stores/SectionToggle.ts | 0 src/mobx/stores/ServerConfig.ts | 0 src/pages/channels/Channel.tsx | 32 ++-- 11 files changed, 208 insertions(+), 132 deletions(-) delete mode 100644 src/mobx/stores/LastOpened.ts create mode 100644 src/mobx/stores/Layout.ts delete mode 100644 src/mobx/stores/SectionToggle.ts delete mode 100644 src/mobx/stores/ServerConfig.ts diff --git a/src/components/common/CollapsibleSection.tsx b/src/components/common/CollapsibleSection.tsx index ac2d9809..cea03b06 100644 --- a/src/components/common/CollapsibleSection.tsx +++ b/src/components/common/CollapsibleSection.tsx @@ -1,7 +1,6 @@ import { ChevronDown } from "@styled-icons/boxicons-regular"; -import { State, store } from "../../redux"; -import { Action } from "../../redux/reducers"; +import { useApplicationState } from "../../mobx/State"; import Details from "../ui/Details"; @@ -25,27 +24,14 @@ export default function CollapsibleSection({ children, ...detailsProps }: Props) { - const state: State = store.getState(); - - function setState(state: boolean) { - if (state === defaultValue) { - store.dispatch({ - type: "SECTION_TOGGLE_UNSET", - id, - } as Action); - } else { - store.dispatch({ - type: "SECTION_TOGGLE_SET", - id, - state, - } as Action); - } - } + const layout = useApplicationState().layout; return (
setState(e.currentTarget.open)} + open={layout.getSectionState(id, defaultValue)} + onToggle={(e) => + layout.setSectionState(id, e.currentTarget.open, defaultValue) + } {...detailsProps}>
diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 16ec584d..48d684a3 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -5,8 +5,7 @@ import styled, { css } from "styled-components"; import ConditionalLink from "../../lib/ConditionalLink"; -import { connectState } from "../../redux/connector"; -import { LastOpened } from "../../redux/reducers/last_opened"; +import { useApplicationState } from "../../mobx/State"; import { useClient } from "../../context/revoltjs/RevoltClient"; @@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>` `} `; -interface Props { - lastOpened: LastOpened; -} - -export const BottomNavigation = observer(({ lastOpened }: Props) => { +export default observer(() => { const client = useClient(); + const layout = useApplicationState().layout; const user = client.users.get(client.user!._id); const history = useHistory(); const path = useLocation().pathname; - const channel_id = lastOpened["home"]; - const friendsActive = path.startsWith("/friends"); const settingsActive = path.startsWith("/settings"); const homeActive = !(friendsActive || settingsActive); @@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { if (settingsActive) { if (history.length > 0) { history.goBack(); + return; } } - if (channel_id) { - history.push(`/channel/${channel_id}`); - } else { - history.push("/"); - } + history.push(layout.getLastHomePath()); }}> @@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { ); }); - -export default connectState(BottomNavigation, (state) => { - return { - lastOpened: state.lastOpened, - }; -}); diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 8c66fc7c..837638ad 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -15,6 +15,7 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { Unreads } from "../../../redux/reducers/unreads"; @@ -37,6 +38,7 @@ type Props = { const HomeSidebar = observer((props: Props) => { const { pathname } = useLocation(); const client = useContext(AppContext); + const layout = useApplicationState().layout; const { channel } = useParams<{ channel: string }>(); const { openScreen } = useIntermediate(); @@ -52,15 +54,8 @@ const HomeSidebar = observer((props: Props) => { if (channel && !obj) return ; if (obj) useUnreads({ ...props, channel: obj }); - useEffect(() => { - if (!channel) return; - - dispatch({ - type: "LAST_OPENED_SET", - parent: "home", - child: channel, - }); - }, [channel]); + // Track what page the user was last on (in home page). + useEffect(() => layout.setLastHomePath(pathname), [pathname]); channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 3eae8741..4385755d 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -12,8 +12,8 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { connectState } from "../../../redux/connector"; -import { LastOpened } from "../../../redux/reducers/last_opened"; import { Unreads } from "../../../redux/reducers/unreads"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; @@ -195,11 +195,11 @@ function Swoosh() { interface Props { unreads: Unreads; - lastOpened: LastOpened; } -export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { +export const ServerListSidebar = observer(({ unreads }: Props) => { const client = useClient(); + const layout = useApplicationState().layout; const { server: server_id } = useParams<{ server?: string }>(); const server = server_id ? client.servers.get(server_id) : undefined; @@ -268,7 +268,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { + to={layout.getLastHomePath()}>
{ {servers.map((entry) => { const active = entry.server._id === server?._id; - const id = lastOpened[entry.server._id]; return ( + to={layout.getServerPath(entry.server._id)}> { export default connectState(ServerListSidebar, (state) => { return { unreads: state.unreads, - lastOpened: state.lastOpened, }; }); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 97bdc59c..2bff3d89 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -10,6 +10,7 @@ import PaintCounter from "../../../lib/PaintCounter"; import { internalEmit } from "../../../lib/eventEmitter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../../mobx/State"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; import { Notifications } from "../../../redux/reducers/notifications"; @@ -58,6 +59,7 @@ const ServerList = styled.div` const ServerSidebar = observer((props: Props) => { const client = useClient(); + const layout = useApplicationState().layout; const { server: server_id, channel: channel_id } = useParams<{ server: string; channel?: string }>(); @@ -75,16 +77,15 @@ const ServerSidebar = observer((props: Props) => { ); if (channel_id && !channel) return ; + // Handle unreads; FIXME: should definitely not be here if (channel) useUnreads({ ...props, channel }); + // Track which channel the user was last on. useEffect(() => { if (!channel_id) return; + if (!server_id) return; - dispatch({ - type: "LAST_OPENED_SET", - parent: server_id!, - child: channel_id!, - }); + layout.setLastOpened(server_id, channel_id); }, [channel_id, server_id]); const uncategorised = new Set(server.channel_ids); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 0b36de10..8cd5dea0 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -6,6 +6,7 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; +import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; interface StoreDefinition { @@ -24,6 +25,7 @@ export default class State { draft: Draft; locale: LocaleOptions; experiments: Experiments; + layout: Layout; /** * Construct new State. @@ -33,6 +35,7 @@ export default class State { this.draft = new Draft(); this.locale = new LocaleOptions(); this.experiments = new Experiments(); + this.layout = new Layout(); makeAutoObservable(this); } diff --git a/src/mobx/stores/LastOpened.ts b/src/mobx/stores/LastOpened.ts deleted file mode 100644 index e0ff249f..00000000 --- a/src/mobx/stores/LastOpened.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; - -import Persistent from "../Persistent"; - -interface Data { - server?: Record; -} - -/** - * Keeps track of the last open channels, tabs, etc. - * Handles providing good UX experience on navigating - * back and forth between different parts of the app. - */ -export default class Experiments implements Persistent { - private server: ObservableMap; - - /** - * Construct new Experiments store. - */ - constructor() { - this.server = new ObservableMap(); - makeAutoObservable(this); - } - - toJSON() { - return { - server: this.server, - }; - } - - @action hydrate(data: Data) { - if (data.server) { - Object.keys(data.server).forEach((key) => - this.server.set(key, data.server![key]), - ); - } - } - - /** - * Get last opened channel in a server. - * @param server Server ID - */ - @computed get(server: string) { - return this.server.get(server); - } - - /** - * Set last opened channel in a server. - * @param server Server ID - * @param channel Channel ID - */ - @action enable(server: string, channel: string) { - this.server.set(server, channel); - } -} diff --git a/src/mobx/stores/Layout.ts b/src/mobx/stores/Layout.ts new file mode 100644 index 00000000..9d4a7361 --- /dev/null +++ b/src/mobx/stores/Layout.ts @@ -0,0 +1,161 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; + +import Persistent from "../Persistent"; + +interface Data { + lastSection?: "home" | "server"; + lastHomePath?: string; + lastOpened?: Record; + openSections?: Record; +} + +/** + * Keeps track of the last open channels, tabs, etc. + * Handles providing good UX experience on navigating + * back and forth between different parts of the app. + */ +export default class Layout implements Persistent { + /** + * The last 'major section' that the user had open. + * This is either the home tab or a channel ID (for a server channel). + */ + private lastSection: "home" | string; + + /** + * The last path the user had open in the home tab. + */ + private lastHomePath: string; + + /** + * Map of last channels viewed in servers. + */ + private lastOpened: ObservableMap; + + /** + * Map of section IDs to their current state. + */ + private openSections: ObservableMap; + + /** + * Construct new Layout store. + */ + constructor() { + this.lastSection = "home"; + this.lastHomePath = "/"; + this.lastOpened = new ObservableMap(); + this.openSections = new ObservableMap(); + makeAutoObservable(this); + } + + toJSON() { + return { + lastSection: this.lastSection, + lastHomePath: this.lastHomePath, + lastOpened: this.lastOpened, + openSections: this.openSections, + }; + } + + @action hydrate(data: Data) { + if (data.lastSection) { + this.lastSection = data.lastSection; + } + + if (data.lastHomePath) { + this.lastHomePath = data.lastHomePath; + } + + if (data.lastOpened) { + Object.keys(data.lastOpened).forEach((key) => + this.lastOpened.set(key, data.lastOpened![key]), + ); + } + + if (data.openSections) { + Object.keys(data.openSections).forEach((key) => + this.openSections.set(key, data.openSections![key]), + ); + } + } + + /** + * Get the last 'major section' the user had open. + * @returns Last open section + */ + @computed getLastSection() { + return this.lastSection; + } + + /** + * Get last opened channel in a server. + * @param server Server ID + */ + @computed getLastOpened(server: string) { + return this.lastOpened.get(server); + } + + /** + * Get the path to a server (as seen on sidebar). + * @param server Server ID + * @returns Pathname + */ + @computed getServerPath(server: string) { + let path = `/server/${server}`; + if (this.lastOpened.has(server)) { + path += `/channel/${this.getLastOpened(server)}`; + } + + return path; + } + + /** + * Set last opened channel in a server. + * @param server Server ID + * @param channel Channel ID + */ + @action setLastOpened(server: string, channel: string) { + this.lastOpened.set(server, channel); + this.lastSection = "server"; + } + + /** + * Get the last path the user had open in the home tab. + * @returns Last home path + */ + @computed getLastHomePath() { + return this.lastHomePath; + } + + /** + * Set the current path open in the home tab. + * @param path Pathname + */ + @action setLastHomePath(path: string) { + this.lastHomePath = path; + this.lastSection = "home"; + } + + /** + * + * @param id Section ID + * @returns Whether the section is open + * @param def Default state value + */ + @computed getSectionState(id: string, def?: boolean) { + return this.openSections.get(id) ?? def ?? false; + } + + /** + * Set the state of a section. + * @param id Section ID + * @param value New state value + * @param def Default state value + */ + @action setSectionState(id: string, value: boolean, def?: boolean) { + if (value === def) { + this.openSections.delete(id); + } else { + this.openSections.set(id, value); + } + } +} diff --git a/src/mobx/stores/SectionToggle.ts b/src/mobx/stores/SectionToggle.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index bf1c5f76..9f4767f0 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -1,21 +1,20 @@ +import { Hash } from "@styled-icons/boxicons-regular"; +import { Ghost } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; import { useParams } from "react-router-dom"; import { Channel as ChannelI } from "revolt.js/dist/maps/Channels"; import styled from "styled-components"; import { Text } from "preact-i18n"; - -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import { useApplicationState } from "../../mobx/State"; import { dispatch, getState } from "../../redux"; import { useClient } from "../../context/revoltjs/RevoltClient"; -import { Hash } from "@styled-icons/boxicons-regular"; -import { Ghost } from "@styled-icons/boxicons-solid"; - import AgeGate from "../../components/common/AgeGate"; import MessageBox from "../../components/common/messaging/MessageBox"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; @@ -52,19 +51,19 @@ const PlaceholderBase = styled.div` justify-content: center; text-align: center; margin: auto; - + .primary { color: var(--secondary-foreground); font-weight: 700; font-size: 22px; margin: 0 0 5px 0; } - + .secondary { color: var(--tertiary-foreground); font-weight: 400; } - + svg { margin: 2em auto; fill-opacity: 0.8; @@ -94,7 +93,6 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => { getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true, ); - const id = channel._id; return ( { }} toggleChannelSidebar={() => { if (isTouchscreenDevice) { - return + return; } setChannels(!showChannels); @@ -147,7 +145,7 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => { /> - + @@ -173,13 +171,19 @@ function ChannelPlaceholder() {
- + + +
-
-
+
+ +
+
+ +
); From 2b55770ecc6d6001ff70df411136869563076998 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 14:36:26 +0000 Subject: [PATCH 11/31] chore(mobx): refactor into interfaces --- src/mobx/{ => interfaces}/Persistent.ts | 0 src/mobx/stores/Auth.ts | 2 +- src/mobx/stores/Draft.ts | 2 +- src/mobx/stores/Experiments.ts | 2 +- src/mobx/stores/Layout.ts | 2 +- src/mobx/stores/LocaleOptions.ts | 2 +- src/mobx/stores/NotificationOptions.ts | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename src/mobx/{ => interfaces}/Persistent.ts (100%) diff --git a/src/mobx/Persistent.ts b/src/mobx/interfaces/Persistent.ts similarity index 100% rename from src/mobx/Persistent.ts rename to src/mobx/interfaces/Persistent.ts diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 36e16057..e76d9f18 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -2,7 +2,7 @@ import { makeAutoObservable, ObservableMap } from "mobx"; import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; interface Data { sessions: Record; diff --git a/src/mobx/stores/Draft.ts b/src/mobx/stores/Draft.ts index 593fb6a3..225353b6 100644 --- a/src/mobx/stores/Draft.ts +++ b/src/mobx/stores/Draft.ts @@ -1,6 +1,6 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; interface Data { drafts: Record; diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index 685a2a0b..0e62d4bc 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -1,6 +1,6 @@ import { action, computed, makeAutoObservable, ObservableSet } from "mobx"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; /** * Union type of available experiments. diff --git a/src/mobx/stores/Layout.ts b/src/mobx/stores/Layout.ts index 9d4a7361..b1cf2793 100644 --- a/src/mobx/stores/Layout.ts +++ b/src/mobx/stores/Layout.ts @@ -1,6 +1,6 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; interface Data { lastSection?: "home" | "server"; diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index 2812b1b7..f1d4c3ec 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -2,7 +2,7 @@ import { action, computed, makeAutoObservable } from "mobx"; import { Language, Languages } from "../../context/Locale"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; interface Data { lang: Language; diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 28ba0a03..eda1140b 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,7 +1,7 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { Channel } from "revolt-api/types/Channels"; -import Persistent from "../Persistent"; +import Persistent from "../interfaces/Persistent"; /** * Possible notification states. From bc799931a86943708ce3659ba60bd63647e9e48d Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 16:24:23 +0000 Subject: [PATCH 12/31] feat(mobx): add persistence --- src/lib/conversion.ts | 8 ++++ src/mobx/State.ts | 65 ++++++++++++++++++++++---- src/mobx/TODO | 14 ------ src/mobx/interfaces/Persistent.ts | 7 +-- src/mobx/interfaces/Store.ts | 3 ++ src/mobx/stores/Auth.ts | 11 +++-- src/mobx/stores/Draft.ts | 11 ++++- src/mobx/stores/Experiments.ts | 18 +++++-- src/mobx/stores/Layout.ts | 13 ++++-- src/mobx/stores/LocaleOptions.ts | 7 ++- src/mobx/stores/NotificationOptions.ts | 13 ++++-- src/redux/State.tsx | 11 +++-- 12 files changed, 136 insertions(+), 45 deletions(-) delete mode 100644 src/mobx/TODO create mode 100644 src/mobx/interfaces/Store.ts diff --git a/src/lib/conversion.ts b/src/lib/conversion.ts index 48a6aaf9..f0840bb0 100644 --- a/src/lib/conversion.ts +++ b/src/lib/conversion.ts @@ -7,3 +7,11 @@ export function urlBase64ToUint8Array(base64String: string) { return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } + +export function mapToRecord( + map: Map, +) { + let record = {} as Record; + map.forEach((v, k) => (record[k] = v)); + return record; +} diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 8cd5dea0..006cae11 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -1,22 +1,16 @@ -import { makeAutoObservable } from "mobx"; +import localforage from "localforage"; +import { autorun, makeAutoObservable, reaction } from "mobx"; import { createContext } from "preact"; import { useContext } from "preact/hooks"; +import Persistent from "./interfaces/Persistent"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; -interface StoreDefinition { - id: string; - instance: Record; - persistent: boolean; - synced: boolean; - global: boolean; -} - /** * Handles global application state. */ @@ -27,6 +21,8 @@ export default class State { experiments: Experiments; layout: Layout; + private persistent: [string, Persistent][] = []; + /** * Construct new State. */ @@ -38,6 +34,57 @@ export default class State { this.layout = new Layout(); makeAutoObservable(this); + this.registerListeners = this.registerListeners.bind(this); + this.register(); + } + + private register() { + for (const key of Object.keys(this)) { + const obj = ( + this as unknown as Record> + )[key]; + + // Check if this is an object. + if (typeof obj === "object") { + // Check if this is a Store. + if (typeof obj.id === "string") { + const id = obj.id; + + // Check if this is a Persistent + if ( + typeof obj.hydrate === "function" && + typeof obj.toJSON === "function" + ) { + this.persistent.push([ + id, + obj as unknown as Persistent, + ]); + } + } + } + } + } + + registerListeners() { + const listeners = this.persistent.map(([id, store]) => { + return reaction( + () => store.toJSON(), + (value) => { + localforage.setItem(id, value); + }, + ); + }); + + return () => listeners.forEach((x) => x()); + } + + async hydrate() { + for (const [id, store] of this.persistent) { + const data = await localforage.getItem(id); + if (typeof data === "object" && data !== null) { + store.hydrate(data); + } + } } } diff --git a/src/mobx/TODO b/src/mobx/TODO deleted file mode 100644 index 63d63932..00000000 --- a/src/mobx/TODO +++ /dev/null @@ -1,14 +0,0 @@ -auth -drafts -experiments -last opened -locale -notifications -queue -section toggle -serevr config -settings -sync -themes -trusted links -unreads diff --git a/src/mobx/interfaces/Persistent.ts b/src/mobx/interfaces/Persistent.ts index 576e6133..83061097 100644 --- a/src/mobx/interfaces/Persistent.ts +++ b/src/mobx/interfaces/Persistent.ts @@ -1,10 +1,11 @@ +import Store from "./Store"; + /** * A data store which is persistent and should cache its data locally. */ -export default interface Persistent { +export default interface Persistent extends Store { /** - * Override toJSON to serialise this data store. - * This will also force all subclasses to implement this method. + * Serialise this data store. */ toJSON(): unknown; diff --git a/src/mobx/interfaces/Store.ts b/src/mobx/interfaces/Store.ts new file mode 100644 index 00000000..af78fb4d --- /dev/null +++ b/src/mobx/interfaces/Store.ts @@ -0,0 +1,3 @@ +export default interface Store { + get id(): string; +} diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index e76d9f18..9cea243e 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -3,6 +3,7 @@ import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { sessions: Record; @@ -13,7 +14,7 @@ interface Data { * Handles account authentication, managing multiple * accounts and their sessions. */ -export default class Auth implements Persistent { +export default class Auth implements Store, Persistent { private sessions: ObservableMap; private current: Nullable; @@ -26,10 +27,14 @@ export default class Auth implements Persistent { makeAutoObservable(this); } + get id() { + return "auth"; + } + toJSON() { return { - sessions: this.sessions, - current: this.current, + sessions: [...this.sessions], + current: this.current ?? undefined, }; } diff --git a/src/mobx/stores/Draft.ts b/src/mobx/stores/Draft.ts index 225353b6..e245ee15 100644 --- a/src/mobx/stores/Draft.ts +++ b/src/mobx/stores/Draft.ts @@ -1,6 +1,9 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { drafts: Record; @@ -9,7 +12,7 @@ interface Data { /** * Handles storing draft (currently being written) messages. */ -export default class Draft implements Persistent { +export default class Draft implements Store, Persistent { private drafts: ObservableMap; /** @@ -20,9 +23,13 @@ export default class Draft implements Persistent { makeAutoObservable(this); } + get id() { + return "draft"; + } + toJSON() { return { - drafts: this.drafts, + drafts: mapToRecord(this.drafts), }; } diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index 0e62d4bc..8e564cc0 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -1,6 +1,13 @@ -import { action, computed, makeAutoObservable, ObservableSet } from "mobx"; +import { + action, + autorun, + computed, + makeAutoObservable, + ObservableSet, +} from "mobx"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; /** * Union type of available experiments. @@ -35,7 +42,7 @@ interface Data { /** * Handles enabling and disabling client experiments. */ -export default class Experiments implements Persistent { +export default class Experiments implements Store, Persistent { private enabled: ObservableSet; /** @@ -43,12 +50,17 @@ export default class Experiments implements Persistent { */ constructor() { this.enabled = new ObservableSet(); + makeAutoObservable(this); } + get id() { + return "experiments"; + } + toJSON() { return { - enabled: this.enabled, + enabled: [...this.enabled], }; } diff --git a/src/mobx/stores/Layout.ts b/src/mobx/stores/Layout.ts index b1cf2793..6f8ce3e7 100644 --- a/src/mobx/stores/Layout.ts +++ b/src/mobx/stores/Layout.ts @@ -1,6 +1,9 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { lastSection?: "home" | "server"; @@ -14,7 +17,7 @@ interface Data { * Handles providing good UX experience on navigating * back and forth between different parts of the app. */ -export default class Layout implements Persistent { +export default class Layout implements Store, Persistent { /** * The last 'major section' that the user had open. * This is either the home tab or a channel ID (for a server channel). @@ -47,12 +50,16 @@ export default class Layout implements Persistent { makeAutoObservable(this); } + get id() { + return "layout"; + } + toJSON() { return { lastSection: this.lastSection, lastHomePath: this.lastHomePath, - lastOpened: this.lastOpened, - openSections: this.openSections, + lastOpened: mapToRecord(this.lastOpened), + openSections: mapToRecord(this.openSections), }; } diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index f1d4c3ec..a4bd249f 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -3,6 +3,7 @@ import { action, computed, makeAutoObservable } from "mobx"; import { Language, Languages } from "../../context/Locale"; import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; interface Data { lang: Language; @@ -52,7 +53,7 @@ export function findLanguage(lang?: string): Language { * Handles providing good UX experience on navigating * back and forth between different parts of the app. */ -export default class LocaleOptions implements Persistent { +export default class LocaleOptions implements Store, Persistent { private lang: Language; /** @@ -63,6 +64,10 @@ export default class LocaleOptions implements Persistent { makeAutoObservable(this); } + get id() { + return "locale"; + } + toJSON() { return { lang: this.lang, diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index eda1140b..0d8bc6ea 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,7 +1,10 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { Channel } from "revolt-api/types/Channels"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; /** * Possible notification states. @@ -31,7 +34,7 @@ interface Data { /** * Manages the user's notification preferences. */ -export default class NotificationOptions implements Persistent { +export default class NotificationOptions implements Store, Persistent { private server: ObservableMap; private channel: ObservableMap; @@ -44,10 +47,14 @@ export default class NotificationOptions implements Persistent { makeAutoObservable(this); } + get id() { + return "notifications"; + } + toJSON() { return { - server: this.server, - channel: this.channel, + server: mapToRecord(this.server), + channel: mapToRecord(this.channel), }; } diff --git a/src/redux/State.tsx b/src/redux/State.tsx index fddb34b2..7edfd4b7 100644 --- a/src/redux/State.tsx +++ b/src/redux/State.tsx @@ -26,16 +26,19 @@ export default function StateLoader(props: Props) { }, [state]); useEffect(() => { - localForage.getItem("state").then((state) => { - if (state !== null) { - dispatch({ type: "__INIT", state: state as State }); + localForage.getItem("state").then((s) => { + if (s !== null) { + dispatch({ type: "__INIT", state: s as State }); } - setLoaded(true); + state.hydrate().then(() => setLoaded(true)); }); }, []); if (!loaded) return null; + + useEffect(state.registerListeners); + return ( From f8b8d96d3d34a7e13b0c8f62c09c01f54d247daa Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 21:04:12 +0000 Subject: [PATCH 13/31] feat(mobx): migrate auth and config --- index.html | 10 +- package.json | 7 +- .../navigation/left/ServerListSidebar.tsx | 4 +- src/context/Locale.tsx | 1 - src/context/revoltjs/CheckAuth.tsx | 12 +- src/context/revoltjs/RevoltClient.tsx | 182 ++++-------------- src/context/revoltjs/SyncManager.tsx | 8 +- src/context/revoltjs/events.ts | 37 ++-- src/lib/ContextMenus.tsx | 58 +++--- src/mobx/State.ts | 23 ++- src/mobx/stores/Auth.ts | 61 ++++-- src/mobx/stores/ServerConfig.ts | 75 ++++++++ src/pages/login/Login.tsx | 14 +- src/pages/login/forms/CaptchaBlock.tsx | 19 +- src/pages/login/forms/Form.tsx | 28 ++- src/pages/login/forms/FormCreate.tsx | 7 +- src/pages/login/forms/FormLogin.tsx | 47 ++++- src/pages/login/forms/FormReset.tsx | 5 +- src/pages/login/forms/FormVerify.tsx | 5 +- src/pages/login/forms/MailProvider.tsx | 1 - src/pages/settings/Settings.tsx | 9 +- yarn.lock | 8 +- 22 files changed, 342 insertions(+), 279 deletions(-) create mode 100644 src/mobx/stores/ServerConfig.ts diff --git a/index.html b/index.html index 26041f77..ec592bdd 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,13 @@ - + + + Revolt +
- diff --git a/package.json b/package.json index 76746ba4..6f14588c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,10 @@ "FunctionExpression": false }, "ignore": { - "MethodDefinition": ["toJSON", "hydrate"] + "MethodDefinition": [ + "toJSON", + "hydrate" + ] } } ] @@ -140,7 +143,7 @@ "react-virtuoso": "^1.10.4", "redux": "^4.1.0", "revolt-api": "0.5.3-alpha.10", - "revolt.js": "^5.1.0-alpha.10", + "revolt.js": "5.1.0-alpha.15", "rimraf": "^3.0.2", "sass": "^1.35.1", "shade-blend-color": "^1.0.0", diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 4385755d..ed9042f0 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -276,13 +276,13 @@ export const ServerListSidebar = observer(({ unreads }: Props) => { onClick={() => homeActive && history.push("/settings") }> - + { - const operations = useContext(OperationsContext); + const auth = useApplicationState().auth; + const client = useClient(); + const ready = auth.isLoggedIn() && typeof client?.user !== "undefined"; - if (props.auth && !operations.ready()) { + if (props.auth && !ready) { return ; - } else if (!props.auth && operations.ready()) { + } else if (!props.auth && ready) { return ; } diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 4708d966..9124885b 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -1,26 +1,22 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Session } from "revolt-api/types/Auth"; +import { observer } from "mobx-react-lite"; import { Client } from "revolt.js"; -import { Route } from "revolt.js/dist/api/routes"; import { createContext } from "preact"; import { useContext, useEffect, useMemo, useState } from "preact/hooks"; -import { dispatch } from "../../redux"; -import { connectState } from "../../redux/connector"; -import { AuthState } from "../../redux/reducers/auth"; +import { useApplicationState } from "../../mobx/State"; import Preloader from "../../components/ui/Preloader"; import { Children } from "../../types/Preact"; import { useIntermediate } from "../intermediate/Intermediate"; -import { registerEvents, setReconnectDisallowed } from "./events"; +import { registerEvents } from "./events"; import { takeError } from "./util"; export enum ClientStatus { - INIT, - LOADING, READY, + LOADING, OFFLINE, DISCONNECTED, CONNECTING, @@ -29,179 +25,75 @@ export enum ClientStatus { } export interface ClientOperations { - login: ( - data: Route<"POST", "/auth/session/login">["data"], - ) => Promise; logout: (shouldRequest?: boolean) => Promise; - loggedIn: () => boolean; - ready: () => boolean; } -// By the time they are used, they should all be initialized. -// Currently the app does not render until a client is built and the other two are always initialized on first render. -// - insert's words export const AppContext = createContext(null!); export const StatusContext = createContext(null!); export const OperationsContext = createContext(null!); +export const LogOutContext = createContext(() => {}); type Props = { - auth: AuthState; children: Children; }; -function Context({ auth, children }: Props) { +export default observer(({ children }: Props) => { + const state = useApplicationState(); const { openScreen } = useIntermediate(); - const [status, setStatus] = useState(ClientStatus.INIT); - const [client, setClient] = useState( - undefined as unknown as Client, - ); + const [client, setClient] = useState(null!); + const [status, setStatus] = useState(ClientStatus.LOADING); + const [loaded, setLoaded] = useState(false); + + function logout() { + setLoaded(false); + client.logout(false); + } useEffect(() => { - (async () => { - const client = new Client({ - autoReconnect: false, - apiURL: import.meta.env.VITE_API_URL, - debug: import.meta.env.DEV, - }); - - setClient(client); - setStatus(ClientStatus.LOADING); - })(); + if (navigator.onLine) { + new Client().req("GET", "/").then(state.config.set); + } }, []); - if (status === ClientStatus.INIT) return null; - - const operations: ClientOperations = useMemo(() => { - return { - login: async (data) => { - setReconnectDisallowed(true); - - try { - const onboarding = await client.login(data); - setReconnectDisallowed(false); - const login = () => - dispatch({ - type: "LOGIN", - session: client.session as Session, - }); - - if (onboarding) { - openScreen({ - id: "onboarding", - callback: async (username: string) => - onboarding(username, true).then(login), - }); - } else { - login(); - } - } catch (err) { - setReconnectDisallowed(false); - throw err; - } - }, - logout: async (shouldRequest) => { - dispatch({ type: "LOGOUT" }); - - client.reset(); - dispatch({ type: "RESET" }); - - openScreen({ id: "none" }); - setStatus(ClientStatus.READY); - - client.websocket.disconnect(); - - if (shouldRequest) { - try { - await client.logout(); - } catch (err) { - console.error(err); - } - } - }, - loggedIn: () => typeof auth.active !== "undefined", - ready: () => - operations.loggedIn() && typeof client.user !== "undefined", - }; - }, [client, auth.active, openScreen]); - - useEffect( - () => registerEvents({ operations }, setStatus, client), - [client, operations], - ); - useEffect(() => { - (async () => { - if (auth.active) { - dispatch({ type: "QUEUE_FAIL_ALL" }); + if (state.auth.isLoggedIn()) { + const client = state.config.createClient(); + setClient(client); - const active = auth.accounts[auth.active]; - client.user = client.users.get(active.session.user_id); - if (!navigator.onLine) { - return setStatus(ClientStatus.OFFLINE); - } - - if (operations.ready()) setStatus(ClientStatus.CONNECTING); - - if (navigator.onLine) { - await client - .fetchConfiguration() - .catch(() => - console.error("Failed to connect to API server."), - ); - } - - try { - await client.fetchConfiguration(); - const callback = await client.useExistingSession( - active.session, - ); - - if (callback) { - openScreen({ id: "onboarding", callback }); - } - } catch (err) { - setStatus(ClientStatus.DISCONNECTED); + client + .useExistingSession(state.auth.getSession()!) + .then(() => setLoaded(true)) + .catch((err) => { const error = takeError(err); if (error === "Forbidden" || error === "Unauthorized") { - operations.logout(true); + client.logout(true); openScreen({ id: "signed_out" }); } else { + setStatus(ClientStatus.DISCONNECTED); openScreen({ id: "error", error }); } - } - } else { - try { - await client.fetchConfiguration(); - } catch (err) { - console.error("Failed to connect to API server."); - } + }); + } else { + setStatus(ClientStatus.READY); + setLoaded(true); + } + }, [state.auth.getSession()]); - setStatus(ClientStatus.READY); - } - })(); - // eslint-disable-next-line - }, []); + useEffect(() => registerEvents(state.auth, setStatus, client), [client]); - if (status === ClientStatus.LOADING) { + if (!loaded || status === ClientStatus.LOADING) { return ; } return ( - + {children} - + ); -} - -export default connectState<{ children: Children }>(Context, (state) => { - return { - auth: state.auth, - sync: state.sync, - }; }); export const useClient = () => useContext(AppContext); diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index 1ab81d74..f4af77e0 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -21,7 +21,7 @@ import { import { Language } from "../Locale"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; -type Props = { +/*type Props = { settings: Settings; locale: Language; sync: SyncOptions; @@ -150,4 +150,8 @@ export default connectState(SyncManager, (state) => { sync: state.sync, notifications: state.notifications, }; -}); +});*/ + +function SyncManager() { + return <>; +} diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts index 9823b491..2556e76f 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -4,9 +4,10 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications" import { StateUpdater } from "preact/hooks"; +import Auth from "../../mobx/stores/Auth"; import { dispatch } from "../../redux"; -import { ClientOperations, ClientStatus } from "./RevoltClient"; +import { ClientStatus } from "./RevoltClient"; export let preventReconnect = false; let preventUntil = 0; @@ -16,10 +17,12 @@ export function setReconnectDisallowed(allowed: boolean) { } export function registerEvents( - { operations }: { operations: ClientOperations }, + auth: Auth, setStatus: StateUpdater, client: Client, ) { + if (!client) return; + function attemptReconnect() { if (preventReconnect) return; function reconnect() { @@ -36,14 +39,11 @@ export function registerEvents( // eslint-disable-next-line @typescript-eslint/no-explicit-any let listeners: Record void> = { - connecting: () => - operations.ready() && setStatus(ClientStatus.CONNECTING), + connecting: () => setStatus(ClientStatus.CONNECTING), dropped: () => { - if (operations.ready()) { - setStatus(ClientStatus.DISCONNECTED); - attemptReconnect(); - } + setStatus(ClientStatus.DISCONNECTED); + attemptReconnect(); }, packet: (packet: ClientboundNotification) => { @@ -70,6 +70,11 @@ export function registerEvents( }, ready: () => setStatus(ClientStatus.ONLINE), + + logout: () => { + auth.logout(); + setStatus(ClientStatus.READY); + }, }; if (import.meta.env.DEV) { @@ -89,19 +94,15 @@ export function registerEvents( } const online = () => { - if (operations.ready()) { - setStatus(ClientStatus.RECONNECTING); - setReconnectDisallowed(false); - attemptReconnect(); - } + setStatus(ClientStatus.RECONNECTING); + setReconnectDisallowed(false); + attemptReconnect(); }; const offline = () => { - if (operations.ready()) { - setReconnectDisallowed(true); - client.websocket.disconnect(); - setStatus(ClientStatus.OFFLINE); - } + setReconnectDisallowed(true); + client.websocket.disconnect(); + setStatus(ClientStatus.OFFLINE); }; window.addEventListener("online", online); diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index cb42b013..6a93bde1 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -105,14 +105,14 @@ type Action = | { action: "create_channel"; target: Server } | { action: "create_category"; target: Server } | { - action: "create_invite"; - target: Channel; - } + action: "create_invite"; + target: Channel; + } | { action: "leave_group"; target: Channel } | { - action: "delete_channel"; - target: Channel; - } + action: "delete_channel"; + target: Channel; + } | { action: "close_dm"; target: Channel } | { action: "leave_server"; target: Server } | { action: "delete_server"; target: Server } @@ -123,10 +123,10 @@ type Action = | { action: "open_server_settings"; id: string } | { action: "open_server_channel_settings"; server: string; id: string } | { - action: "set_notification_state"; - key: string; - state?: NotificationState; - }; + action: "set_notification_state"; + key: string; + state?: NotificationState; + }; type Props = { notifications: Notifications; @@ -488,8 +488,9 @@ function ContextMenus(props: Props) { elements.push( {tip &&
{tip}
}
, @@ -545,8 +546,8 @@ function ContextMenus(props: Props) { const user = uid ? client.users.get(uid) : undefined; const serverChannel = targetChannel && - (targetChannel.channel_type === "TextChannel" || - targetChannel.channel_type === "VoiceChannel") + (targetChannel.channel_type === "TextChannel" || + targetChannel.channel_type === "VoiceChannel") ? targetChannel : undefined; @@ -558,8 +559,8 @@ function ContextMenus(props: Props) { (server ? server.permission : serverChannel - ? serverChannel.server?.permission - : 0) || 0; + ? serverChannel.server?.permission + : 0) || 0; const userPermissions = (user ? user.permission : 0) || 0; if (unread) { @@ -705,7 +706,8 @@ function ContextMenus(props: Props) { if (message && !queued) { const sendPermission = message.channel && - message.channel.permission & ChannelPermission.SendMessage + message.channel.permission & + ChannelPermission.SendMessage; if (sendPermission) { generateAction({ @@ -741,7 +743,7 @@ function ContextMenus(props: Props) { if ( message.author_id === userId || channelPermissions & - ChannelPermission.ManageMessages + ChannelPermission.ManageMessages ) { generateAction({ action: "delete_message", @@ -765,8 +767,8 @@ function ContextMenus(props: Props) { type === "Image" ? "open_image" : type === "Video" - ? "open_video" - : "open_file", + ? "open_video" + : "open_file", ); generateAction( @@ -777,8 +779,8 @@ function ContextMenus(props: Props) { type === "Image" ? "save_image" : type === "Video" - ? "save_video" - : "save_file", + ? "save_video" + : "save_file", ); generateAction( @@ -930,9 +932,9 @@ function ContextMenus(props: Props) { if ( serverPermissions & - ServerPermission.ChangeNickname || + ServerPermission.ChangeNickname || serverPermissions & - ServerPermission.ChangeAvatar + ServerPermission.ChangeAvatar ) generateAction( { action: "edit_identity", target: server }, @@ -976,10 +978,10 @@ function ContextMenus(props: Props) { sid ? "copy_sid" : cid - ? "copy_cid" - : message - ? "copy_mid" - : "copy_uid", + ? "copy_cid" + : message + ? "copy_mid" + : "copy_uid", ); } diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 006cae11..c4d808ed 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -10,6 +10,7 @@ import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; +import ServerConfig from "./stores/ServerConfig"; /** * Handles global application state. @@ -20,6 +21,7 @@ export default class State { locale: LocaleOptions; experiments: Experiments; layout: Layout; + config: ServerConfig; private persistent: [string, Persistent][] = []; @@ -32,12 +34,16 @@ export default class State { this.locale = new LocaleOptions(); this.experiments = new Experiments(); this.layout = new Layout(); + this.config = new ServerConfig(); makeAutoObservable(this); this.registerListeners = this.registerListeners.bind(this); this.register(); } + /** + * Categorise and register stores referenced on this object. + */ private register() { for (const key of Object.keys(this)) { const obj = ( @@ -65,12 +71,22 @@ export default class State { } } + /** + * Register reaction listeners for persistent data stores. + * @returns Function to dispose of listeners + */ registerListeners() { const listeners = this.persistent.map(([id, store]) => { return reaction( () => store.toJSON(), - (value) => { - localforage.setItem(id, value); + async (value) => { + try { + await localforage.setItem(id, value); + } catch (err) { + console.error("Failed to serialise!"); + console.error(err); + console.error(value); + } }, ); }); @@ -78,6 +94,9 @@ export default class State { return () => listeners.forEach((x) => x()); } + /** + * Load data stores from local storage. + */ async hydrate() { for (const [id, store] of this.persistent) { const data = await localforage.getItem(id); diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 9cea243e..042484bc 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -1,12 +1,18 @@ -import { makeAutoObservable, ObservableMap } from "mobx"; +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; +import { mapToRecord } from "../../lib/conversion"; + import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; +interface Account { + session: Session; +} + interface Data { - sessions: Record; + sessions: Record | [string, Account][]; current?: string; } @@ -15,7 +21,7 @@ interface Data { * accounts and their sessions. */ export default class Auth implements Store, Persistent { - private sessions: ObservableMap; + private sessions: ObservableMap; private current: Nullable; /** @@ -31,17 +37,27 @@ export default class Auth implements Store, Persistent { return "auth"; } - toJSON() { + @action toJSON() { return { - sessions: [...this.sessions], + sessions: JSON.parse(JSON.stringify(this.sessions)), current: this.current ?? undefined, }; } - hydrate(data: Data) { - Object.keys(data.sessions).forEach((id) => - this.sessions.set(id, data.sessions[id]), - ); + @action hydrate(data: Data) { + if (Array.isArray(data.sessions)) { + data.sessions.forEach(([key, value]) => + this.sessions.set(key, value), + ); + } else if ( + typeof data.sessions === "object" && + data.sessions !== null + ) { + let v = data.sessions; + Object.keys(data.sessions).forEach((id) => + this.sessions.set(id, v[id]), + ); + } if (data.current && this.sessions.has(data.current)) { this.current = data.current; @@ -52,8 +68,8 @@ export default class Auth implements Store, Persistent { * Add a new session to the auth manager. * @param session Session */ - setSession(session: Session) { - this.sessions.set(session.user_id, session); + @action setSession(session: Session) { + this.sessions.set(session.user_id, { session }); this.current = session.user_id; } @@ -61,11 +77,28 @@ export default class Auth implements Store, Persistent { * Remove existing session by user ID. * @param user_id User ID tied to session */ - removeSession(user_id: string) { - this.sessions.delete(user_id); - + @action removeSession(user_id: string) { if (user_id == this.current) { this.current = null; } + + this.sessions.delete(user_id); + } + + @action logout() { + this.current && this.removeSession(this.current); + } + + @computed getSession() { + if (!this.current) return; + return this.sessions.get(this.current)!.session; + } + + /** + * Check whether we are currently logged in. + * @returns Whether we are logged in + */ + @computed isLoggedIn() { + return this.current !== null; } } diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts new file mode 100644 index 00000000..1b1d0498 --- /dev/null +++ b/src/mobx/stores/ServerConfig.ts @@ -0,0 +1,75 @@ +import { action, computed, makeAutoObservable } from "mobx"; +import { RevoltConfiguration } from "revolt-api/types/Core"; +import { Client } from "revolt.js"; +import { Nullable } from "revolt.js/dist/util/null"; + +import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; + +interface Data { + config?: RevoltConfiguration; +} + +/** + * Stores server configuration data. + */ +export default class ServerConfig + implements Store, Persistent +{ + private config: Nullable; + + /** + * Construct new ServerConfig store. + */ + constructor() { + this.config = null; + makeAutoObservable(this); + this.set = this.set.bind(this); + } + + get id() { + return "server_conf"; + } + + toJSON() { + return JSON.parse(JSON.stringify(this.config)); + } + + @action hydrate(data: RevoltConfiguration) { + this.config = data; + } + + /** + * Create a new Revolt client. + * @returns Revolt client + */ + createClient() { + const client = new Client({ + autoReconnect: false, + apiURL: import.meta.env.VITE_API_URL, + debug: import.meta.env.DEV, + }); + + if (this.config !== null) { + client.configuration = this.config; + } + + return client; + } + + /** + * Get server configuration. + * @returns Server configuration + */ + @computed get() { + return this.config; + } + + /** + * Set server configuration. + * @param config Server configuration + */ + @action set(config: RevoltConfiguration) { + this.config = config; + } +} diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index 9b38d13a..9633eb48 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react-lite"; import { Helmet } from "react-helmet"; import { Route, Switch } from "react-router-dom"; import { LIBRARY_VERSION } from "revolt.js"; @@ -6,22 +7,24 @@ import styles from "./Login.module.scss"; import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; +import { useApplicationState } from "../../mobx/State"; + import { ThemeContext } from "../../context/Theme"; import { AppContext } from "../../context/revoltjs/RevoltClient"; import LocaleSelector from "../../components/common/LocaleSelector"; +import background from "./background.jpg"; import { Titlebar } from "../../components/native/Titlebar"; import { APP_VERSION } from "../../version"; -import background from "./background.jpg"; import { FormCreate } from "./forms/FormCreate"; import { FormLogin } from "./forms/FormLogin"; import { FormReset, FormSendReset } from "./forms/FormReset"; import { FormResend, FormVerify } from "./forms/FormVerify"; -export default function Login() { +export default observer(() => { const theme = useContext(ThemeContext); - const client = useContext(AppContext); + const configuration = useApplicationState().config.get(); return ( <> @@ -35,8 +38,7 @@ export default function Login() {
- API:{" "} - {client.configuration?.revolt ?? "???"}{" "} + API: {configuration?.revolt ?? "???"}{" "} · revolt.js: {LIBRARY_VERSION}{" "} · App: {APP_VERSION} @@ -80,4 +82,4 @@ export default function Login() {
); -} +}); diff --git a/src/pages/login/forms/CaptchaBlock.tsx b/src/pages/login/forms/CaptchaBlock.tsx index 51548c6b..09addd16 100644 --- a/src/pages/login/forms/CaptchaBlock.tsx +++ b/src/pages/login/forms/CaptchaBlock.tsx @@ -1,10 +1,11 @@ import HCaptcha from "@hcaptcha/react-hcaptcha"; +import { observer } from "mobx-react-lite"; import styles from "../Login.module.scss"; import { Text } from "preact-i18n"; -import { useContext, useEffect } from "preact/hooks"; +import { useEffect } from "preact/hooks"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { useApplicationState } from "../../../mobx/State"; import Preloader from "../../../components/ui/Preloader"; @@ -13,22 +14,22 @@ export interface CaptchaProps { onCancel: () => void; } -export function CaptchaBlock(props: CaptchaProps) { - const client = useContext(AppContext); +export const CaptchaBlock = observer((props: CaptchaProps) => { + const configuration = useApplicationState().config.get(); useEffect(() => { - if (!client.configuration?.features.captcha.enabled) { + if (!configuration?.features.captcha.enabled) { props.onSuccess(); } - }, [client.configuration?.features.captcha.enabled, props]); + }, [configuration?.features.captcha.enabled, props]); - if (!client.configuration?.features.captcha.enabled) + if (!configuration?.features.captcha.enabled) return ; return (
props.onSuccess(token)} />
@@ -38,4 +39,4 @@ export function CaptchaBlock(props: CaptchaProps) {
); -} +}); diff --git a/src/pages/login/forms/Form.tsx b/src/pages/login/forms/Form.tsx index b11dbe91..35a6e14e 100644 --- a/src/pages/login/forms/Form.tsx +++ b/src/pages/login/forms/Form.tsx @@ -6,6 +6,8 @@ import styles from "../Login.module.scss"; import { Text } from "preact-i18n"; import { useContext, useState } from "preact/hooks"; +import { useApplicationState } from "../../../mobx/State"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { takeError } from "../../../context/revoltjs/util"; @@ -44,7 +46,7 @@ interface FormInputs { } export function Form({ page, callback }: Props) { - const client = useContext(AppContext); + const configuration = useApplicationState().config.get(); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(undefined); @@ -80,10 +82,7 @@ export function Form({ page, callback }: Props) { } try { - if ( - client.configuration?.features.captcha.enabled && - page !== "reset" - ) { + if (configuration?.features.captcha.enabled && page !== "reset") { setCaptcha({ onSuccess: async (captcha) => { setCaptcha(undefined); @@ -111,7 +110,7 @@ export function Form({ page, callback }: Props) { if (typeof success !== "undefined") { return (
- {client.configuration?.features.email ? ( + {configuration?.features.email ? ( <>

@@ -172,15 +171,14 @@ export function Form({ page, callback }: Props) { error={errors.password?.message} /> )} - {client.configuration?.features.invite_only && - page === "create" && ( - - )} + {configuration?.features.invite_only && page === "create" && ( + + )} {error && ( diff --git a/src/pages/login/forms/FormCreate.tsx b/src/pages/login/forms/FormCreate.tsx index 677aeba3..8d2e9ed7 100644 --- a/src/pages/login/forms/FormCreate.tsx +++ b/src/pages/login/forms/FormCreate.tsx @@ -1,10 +1,9 @@ -import { useContext } from "preact/hooks"; - -import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { useApplicationState } from "../../../mobx/State"; import { Form } from "./Form"; export function FormCreate() { - const client = useContext(AppContext); + const config = useApplicationState().config; + const client = config.createClient(); return
client.register(data)} />; } diff --git a/src/pages/login/forms/FormLogin.tsx b/src/pages/login/forms/FormLogin.tsx index 2eb3bbf5..d196ead7 100644 --- a/src/pages/login/forms/FormLogin.tsx +++ b/src/pages/login/forms/FormLogin.tsx @@ -1,15 +1,16 @@ import { detect } from "detect-browser"; -import { useHistory } from "react-router-dom"; +import { Session } from "revolt-api/types/Auth"; +import { Client } from "revolt.js"; -import { useContext } from "preact/hooks"; +import { useApplicationState } from "../../../mobx/State"; -import { OperationsContext } from "../../../context/revoltjs/RevoltClient"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { Form } from "./Form"; export function FormLogin() { - const { login } = useContext(OperationsContext); - const history = useHistory(); + const auth = useApplicationState().auth; + const { openScreen } = useIntermediate(); return ( + client + .completeOnboarding({ username }, false) + .then(login), + }); + } else { + login(); + } }} /> ); diff --git a/src/pages/login/forms/FormReset.tsx b/src/pages/login/forms/FormReset.tsx index 78b4a14a..fbce0e09 100644 --- a/src/pages/login/forms/FormReset.tsx +++ b/src/pages/login/forms/FormReset.tsx @@ -2,12 +2,15 @@ import { useHistory, useParams } from "react-router-dom"; import { useContext } from "preact/hooks"; +import { useApplicationState } from "../../../mobx/State"; + import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { Form } from "./Form"; export function FormSendReset() { - const client = useContext(AppContext); + const config = useApplicationState().config; + const client = config.createClient(); return ( { const history = useHistory(); const client = useContext(AppContext); - const operations = useContext(OperationsContext); + const logout = useContext(LogOutContext); const experiments = useApplicationState().experiments; function switchPage(to?: string) { @@ -220,7 +217,7 @@ export default observer(() => { operations.logout()} + onClick={logout} className={styles.logOut} compact> diff --git a/yarn.lock b/yarn.lock index 52f2a349..a538d37b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3765,10 +3765,10 @@ revolt-api@^0.5.3-alpha.9: resolved "https://registry.yarnpkg.com/revolt-api/-/revolt-api-0.5.3-alpha.9.tgz#46e75b7d8f9c6702df39039b829dddbb7897f237" integrity sha512-L8K9uPV3ME8bLdtWm8L9iPQvFM0GghA+5LzmWFjd6Gbn56u22ZYub2lABi4iHrWgeA2X41dGSsuSBgHSlts9Og== -revolt.js@^5.1.0-alpha.10: - version "5.1.0-alpha.10" - resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.1.0-alpha.10.tgz#e393ac8524e629d3359135651b23b044c0cc9b7b" - integrity sha512-wEmBMJkZE/oWy6mzVZg1qw5QC9CE+Gb7sTFlJl+C4pbXfTJWAtY311Tjbd2tX8w3ohYDmN338bVfCW4cOQ8GXQ== +revolt.js@5.1.0-alpha.15: + version "5.1.0-alpha.15" + resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.1.0-alpha.15.tgz#a2be1f29de93f1ec18f0e502ecb65ade55c0070d" + integrity sha512-1gGcGDv1+J5NlmnX099XafKugCebACg9ke0NA754I4hLTNMMwkZyphyvYWWWkI394qn2mA3NG7WgEmrIoZUtgw== dependencies: axios "^0.21.4" eventemitter3 "^4.0.7" From 413bf6949b98e8fd988f290c67606e29fb14e3ad Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 11 Dec 2021 23:34:46 +0000 Subject: [PATCH 14/31] feat(mobx): server notification options + data store --- .../navigation/items/Item.module.scss | 2 +- .../navigation/left/ServerListSidebar.tsx | 7 +- .../navigation/left/ServerSidebar.tsx | 8 +- src/context/revoltjs/Notifications.tsx | 9 +- src/context/revoltjs/SyncManager.tsx | 2 +- src/lib/ContextMenus.tsx | 95 +++----------- src/lib/contextmenu/CMNotifications.tsx | 119 ++++++++++++++++++ src/mobx/State.ts | 3 + src/mobx/stores/LocaleOptions.ts | 4 +- src/mobx/stores/NotificationOptions.ts | 90 +++++++++++-- 10 files changed, 237 insertions(+), 102 deletions(-) create mode 100644 src/lib/contextmenu/CMNotifications.tsx diff --git a/src/components/navigation/items/Item.module.scss b/src/components/navigation/items/Item.module.scss index a0148f7c..42bed90f 100644 --- a/src/components/navigation/items/Item.module.scss +++ b/src/components/navigation/items/Item.module.scss @@ -117,7 +117,7 @@ } &[data-muted="true"] { - color: var(--tertiary-foreground); + opacity: 0.4; } &[data-alert="true"], diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index ed9042f0..3a27f84b 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -199,7 +199,7 @@ interface Props { export const ServerListSidebar = observer(({ unreads }: Props) => { const client = useClient(); - const layout = useApplicationState().layout; + const state = useApplicationState(); const { server: server_id } = useParams<{ server?: string }>(); const server = server_id ? client.servers.get(server_id) : undefined; @@ -210,6 +210,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => { const unreadChannels = channels .filter((x) => x.unread) + .filter((x) => !state.notifications.isMuted(x.channel)) .map((x) => x.channel?._id); const servers = activeServers.map((server) => { @@ -268,7 +269,7 @@ export const ServerListSidebar = observer(({ unreads }: Props) => { + to={state.layout.getLastHomePath()}>
{ + to={state.layout.getServerPath(entry.server._id)}> { const client = useClient(); - const layout = useApplicationState().layout; + const state = useApplicationState(); const { server: server_id, channel: channel_id } = useParams<{ server: string; channel?: string }>(); @@ -85,7 +84,7 @@ const ServerSidebar = observer((props: Props) => { if (!channel_id) return; if (!server_id) return; - layout.setLastOpened(server_id, channel_id); + state.layout.setLastOpened(server_id, channel_id); }, [channel_id, server_id]); const uncategorised = new Set(server.channel_ids); @@ -96,7 +95,6 @@ const ServerSidebar = observer((props: Props) => { if (!entry) return; const active = channel?._id === entry._id; - const muted = props.notifications[id] === "none"; return ( { // ! FIXME: pull it out directly alert={mapChannelWithUnread(entry, props.unreads).unread} compact - muted={muted} + muted={state.notifications.isMuted(entry)} /> ); diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index 153b1160..f4c451a0 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -8,6 +8,7 @@ import { useCallback, useContext, useEffect } from "preact/hooks"; import { useTranslation } from "../../lib/i18n"; +import { useApplicationState } from "../../mobx/State"; import { connectState } from "../../redux/connector"; import { getNotificationState, @@ -21,7 +22,6 @@ import { AppContext } from "./RevoltClient"; interface Props { options?: NotificationOptions; - notifs: Notifications; } const notifications: { [key: string]: Notification } = {}; @@ -38,9 +38,10 @@ async function createNotification( } } -function Notifier({ options, notifs }: Props) { +function Notifier({ options }: Props) { const translate = useTranslation(); const showNotification = options?.desktopEnabled ?? false; + const notifs = useApplicationState().notifications; const client = useContext(AppContext); const { guild: guild_id, channel: channel_id } = useParams<{ @@ -57,8 +58,7 @@ function Notifier({ options, notifs }: Props) { if (client.user!.status?.presence === Presence.Busy) return; if (msg.author?.relationship === RelationshipStatus.Blocked) return; - const notifState = getNotificationState(notifs, msg.channel!); - if (!shouldNotify(notifState, msg, client.user!._id)) return; + if (!notifs.shouldNotify(msg)) return; playSound("message"); if (!showNotification) return; @@ -294,7 +294,6 @@ const NotifierComponent = connectState( (state) => { return { options: state.settings.notification, - notifs: state.notifications, }; }, true, diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx index f4af77e0..ec272ea5 100644 --- a/src/context/revoltjs/SyncManager.tsx +++ b/src/context/revoltjs/SyncManager.tsx @@ -152,6 +152,6 @@ export default connectState(SyncManager, (state) => { }; });*/ -function SyncManager() { +export default function SyncManager() { return <>; } diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index 6a93bde1..eb6e67d1 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -48,6 +48,7 @@ import { StatusContext, } from "../context/revoltjs/RevoltClient"; import { takeError } from "../context/revoltjs/util"; +import CMNotifications from "./contextmenu/CMNotifications"; import Tooltip from "../components/common/Tooltip"; import UserStatus from "../components/common/user/UserStatus"; @@ -117,7 +118,11 @@ type Action = | { action: "leave_server"; target: Server } | { action: "delete_server"; target: Server } | { action: "edit_identity"; target: Server } - | { action: "open_notification_options"; channel: Channel } + | { + action: "open_notification_options"; + channel?: Channel; + server?: Server; + } | { action: "open_settings" } | { action: "open_channel_settings"; id: string } | { action: "open_server_settings"; id: string } @@ -128,13 +133,9 @@ type Action = state?: NotificationState; }; -type Props = { - notifications: Notifications; -}; - // ! FIXME: I dare someone to re-write this // Tip: This should just be split into separate context menus per logical area. -function ContextMenus(props: Props) { +export default function ContextMenus() { const { openScreen, writeClipboard } = useIntermediate(); const client = useContext(AppContext); const userId = client.user!._id; @@ -427,6 +428,7 @@ function ContextMenus(props: Props) { case "open_notification_options": { openContextMenu("NotificationOptions", { channel: data.channel, + server: data.server, }); break; } @@ -921,6 +923,16 @@ function ContextMenus(props: Props) { } if (sid && server) { + generateAction( + { + action: "open_notification_options", + server, + }, + undefined, + undefined, + , + ); + if (server.channels[0] !== undefined) generateAction( { @@ -1085,76 +1097,7 @@ function ContextMenus(props: Props) { ); }} - - {({ channel }: { channel: Channel }) => { - const state = props.notifications[channel._id]; - const actual = getNotificationState( - props.notifications, - channel, - ); - - const elements: Children[] = [ - - -
- {state !== undefined && } - {state === undefined && ( - - )} -
-
, - ]; - - function generate(key: string, icon: Children) { - elements.push( - - {icon} - - {state === undefined && actual === key && ( -
- -
- )} - {state === key && ( -
- -
- )} -
, - ); - } - - generate("all", ); - generate("mention", ); - generate("muted", ); - generate("none", ); - - return elements; - }} -
+ ); } - -export default connectState(ContextMenus, (state) => { - return { - notifications: state.notifications, - }; -}); diff --git a/src/lib/contextmenu/CMNotifications.tsx b/src/lib/contextmenu/CMNotifications.tsx new file mode 100644 index 00000000..a107b611 --- /dev/null +++ b/src/lib/contextmenu/CMNotifications.tsx @@ -0,0 +1,119 @@ +import { + At, + Bell, + BellOff, + Check, + CheckSquare, + Block, + Square, + LeftArrowAlt, +} from "@styled-icons/boxicons-regular"; +import { observer } from "mobx-react-lite"; +import { Channel } from "revolt.js/dist/maps/Channels"; +import { Server } from "revolt.js/dist/maps/Servers"; + +import { ContextMenuWithData, MenuItem } from "preact-context-menu"; +import { Text } from "preact-i18n"; + +import { useApplicationState } from "../../mobx/State"; +import { NotificationState } from "../../mobx/stores/NotificationOptions"; + +import LineDivider from "../../components/ui/LineDivider"; + +import { Children } from "../../types/Preact"; + +interface Action { + key: string; + type: "channel" | "server"; + state?: NotificationState; +} + +/** + * Provides a context menu for controlling notification options. + */ +export default observer(() => { + const notifications = useApplicationState().notifications; + + const contextClick = (data?: Action) => + data && + (data.type === "channel" + ? notifications.setChannelState(data.key, data.state) + : notifications.setServerState(data.key, data.state)); + + return ( + + {({ channel, server }: { channel?: Channel; server?: Server }) => { + // Find the computed and actual state values for channel / server. + const state = channel + ? notifications.getChannelState(channel._id) + : notifications.computeForServer(server!._id); + + const actual = channel + ? notifications.computeForChannel(channel) + : undefined; + + // If we're editing channel, show a default option too. + const elements: Children[] = channel + ? [ + + +
+ {state !== undefined && } + {state === undefined && ( + + )} +
+
, + , + ] + : []; + + /** + * Generate a new entry we can select. + * @param key Notification state + * @param icon Icon for this state + */ + function generate(key: string, icon: Children) { + elements.push( + + {icon} + + {state === undefined && actual === key && ( +
+ +
+ )} + {state === key && ( +
+ +
+ )} +
, + ); + } + + generate("all", ); + generate("mention", ); + generate("none", ); + generate("muted", ); + + return elements; + }} +
+ ); +}); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index c4d808ed..aff12eb8 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -10,6 +10,7 @@ import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; +import NotificationOptions from "./stores/NotificationOptions"; import ServerConfig from "./stores/ServerConfig"; /** @@ -22,6 +23,7 @@ export default class State { experiments: Experiments; layout: Layout; config: ServerConfig; + notifications: NotificationOptions; private persistent: [string, Persistent][] = []; @@ -35,6 +37,7 @@ export default class State { this.experiments = new Experiments(); this.layout = new Layout(); this.config = new ServerConfig(); + this.notifications = new NotificationOptions(); makeAutoObservable(this); this.registerListeners = this.registerListeners.bind(this); diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index a4bd249f..842a3616 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -49,9 +49,7 @@ export function findLanguage(lang?: string): Language { } /** - * Keeps track of the last open channels, tabs, etc. - * Handles providing good UX experience on navigating - * back and forth between different parts of the app. + * Keeps track of user's language settings. */ export default class LocaleOptions implements Store, Persistent { private lang: Language; diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 0d8bc6ea..44e4ef4d 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,5 +1,7 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import { Channel } from "revolt-api/types/Channels"; +import { Channel } from "revolt.js/dist/maps/Channels"; +import { Message } from "revolt.js/dist/maps/Messages"; +import { Server } from "revolt.js/dist/maps/Servers"; import { mapToRecord } from "../../lib/conversion"; @@ -22,21 +24,26 @@ export const DEFAULT_STATES: { SavedMessages: "all", DirectMessage: "all", Group: "all", - TextChannel: "mention", - VoiceChannel: "mention", + TextChannel: undefined!, + VoiceChannel: undefined!, }; +/** + * Default state for servers. + */ +export const DEFAULT_SERVER_STATE: NotificationState = "mention"; + interface Data { - server?: Record; - channel?: Record; + server?: Record; + channel?: Record; } /** * Manages the user's notification preferences. */ export default class NotificationOptions implements Store, Persistent { - private server: ObservableMap; - private channel: ObservableMap; + private server: ObservableMap; + private channel: ObservableMap; /** * Construct new Experiments store. @@ -72,5 +79,72 @@ export default class NotificationOptions implements Store, Persistent { } } - // TODO: implement + computeForChannel(channel: Channel) { + if (this.channel.has(channel._id)) { + return this.channel.get(channel._id); + } + + if (channel.server_id) { + return this.computeForServer(channel.server_id); + } + + return DEFAULT_STATES[channel.channel_type]; + } + + shouldNotify(message: Message) { + const state = this.computeForChannel(message.channel!); + + switch (state) { + case "muted": + case "none": + return false; + case "mention": + if (!message.mention_ids?.includes(message.client.user!._id)) + return false; + } + + return true; + } + + computeForServer(server_id: string) { + if (this.server.has(server_id)) { + return this.server.get(server_id); + } + + return DEFAULT_SERVER_STATE; + } + + getChannelState(channel_id: string) { + return this.channel.get(channel_id); + } + + setChannelState(channel_id: string, state?: NotificationState) { + if (state) { + this.channel.set(channel_id, state); + } else { + this.channel.delete(channel_id); + } + } + + getServerState(server_id: string) { + return this.server.get(server_id); + } + + setServerState(server_id: string, state?: NotificationState) { + if (state) { + this.server.set(server_id, state); + } else { + this.server.delete(server_id); + } + } + + isMuted(target?: Channel | Server) { + if (target instanceof Channel) { + return this.computeForChannel(target) === "muted"; + } else if (target instanceof Server) { + return this.computeForServer(target._id) === "muted"; + } else { + return false; + } + } } From ec83230c595be1b88f63663b2e25f77ce187eaf0 Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 12 Dec 2021 12:26:45 +0000 Subject: [PATCH 15/31] chore(mobx): write jsdoc for notif opt. --- src/context/revoltjs/Notifications.tsx | 4 -- src/mobx/implementation notes | 14 +++--- src/mobx/stores/NotificationOptions.ts | 64 ++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx index f4c451a0..04d5c5f9 100644 --- a/src/context/revoltjs/Notifications.tsx +++ b/src/context/revoltjs/Notifications.tsx @@ -53,11 +53,7 @@ function Notifier({ options }: Props) { const message = useCallback( async (msg: Message) => { - if (msg.author_id === client.user!._id) return; if (msg.channel_id === channel_id && document.hasFocus()) return; - if (client.user!.status?.presence === Presence.Busy) return; - if (msg.author?.relationship === RelationshipStatus.Blocked) return; - if (!notifs.shouldNotify(msg)) return; playSound("message"); diff --git a/src/mobx/implementation notes b/src/mobx/implementation notes index 2c052c9e..36cefd3b 100644 --- a/src/mobx/implementation notes +++ b/src/mobx/implementation notes @@ -1,18 +1,16 @@ -need to have a way to dump or sync to local storage -need a way to rehydrate data stores split settings per account(?) multiple accounts need to be supported -oop + redux -> mobx migration (wipe existing redux data post-migration) -look into talking with other tabs to detect multiple instances -(also use this to tell the user to close all tabs before updating) + +> look into talking with other tabs to detect multiple instances +> (also use this to tell the user to close all tabs before updating) write new settings data structures for server-side -(deprecate existing API and replace with new endpoints?) +---- (deprecate existing API and replace with new endpoints?) alternatively: keep using current system and eventually migrate -or: handle both incoming types of data and keep newer version +or: handle both incoming types of data and keep newer version (v1_prefix) need to document these data structures -handle missing languages by falling back on en_GB provide state globally? perform all authentication from inside mobx mobx parent holds client information and prepares us for first render diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 44e4ef4d..eb4ee908 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,4 +1,5 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { Presence, RelationshipStatus } from "revolt-api/types/Users"; import { Channel } from "revolt.js/dist/maps/Channels"; import { Message } from "revolt.js/dist/maps/Messages"; import { Server } from "revolt.js/dist/maps/Servers"; @@ -79,6 +80,11 @@ export default class NotificationOptions implements Store, Persistent { } } + /** + * Compute the actual notification state for a given Channel. + * @param channel Channel + * @returns Notification state + */ computeForChannel(channel: Channel) { if (this.channel.has(channel._id)) { return this.channel.get(channel._id); @@ -91,21 +97,46 @@ export default class NotificationOptions implements Store, Persistent { return DEFAULT_STATES[channel.channel_type]; } + /** + * Check whether an incoming message should notify the user. + * @param message Message + * @returns Whether it should notify the user + */ shouldNotify(message: Message) { - const state = this.computeForChannel(message.channel!); + // Make sure the author is not blocked. + if (message.author?.relationship === RelationshipStatus.Blocked) { + return false; + } - switch (state) { + // Check if the message was sent by us. + const user = message.client.user!; + if (message.author_id === user._id) { + return false; + } + + // Check whether we are busy. + if (user.status?.presence === Presence.Busy) { + return false; + } + + switch (this.computeForChannel(message.channel!)) { case "muted": case "none": + // Ignore if muted. return false; case "mention": - if (!message.mention_ids?.includes(message.client.user!._id)) - return false; + // Ignore if it doesn't mention us. + if (!message.mention_ids?.includes(user._id)) return false; } return true; } + /** + * Compute the notification state for a given server. + * @param server_id Server ID + * @returns Notification state + */ computeForServer(server_id: string) { if (this.server.has(server_id)) { return this.server.get(server_id); @@ -114,10 +145,20 @@ export default class NotificationOptions implements Store, Persistent { return DEFAULT_SERVER_STATE; } + /** + * Get the notification state of a channel. + * @param channel_id Channel ID + * @returns Notification state + */ getChannelState(channel_id: string) { return this.channel.get(channel_id); } + /** + * Set the notification state of a channel. + * @param channel_id Channel ID + * @param state Notification state + */ setChannelState(channel_id: string, state?: NotificationState) { if (state) { this.channel.set(channel_id, state); @@ -126,10 +167,20 @@ export default class NotificationOptions implements Store, Persistent { } } + /** + * Get the notification state of a server. + * @param server_id Server ID + * @returns Notification state + */ getServerState(server_id: string) { return this.server.get(server_id); } + /** + * Set the notification state of a server. + * @param server_id Server ID + * @param state Notification state + */ setServerState(server_id: string, state?: NotificationState) { if (state) { this.server.set(server_id, state); @@ -138,6 +189,11 @@ export default class NotificationOptions implements Store, Persistent { } } + /** + * Check whether a Channel or Server is muted. + * @param target Channel or Server + * @returns Whether this object is muted + */ isMuted(target?: Channel | Server) { if (target instanceof Channel) { return this.computeForChannel(target) === "muted"; From faca4ac32b932ddf50510c2b1203692492d8f3b0 Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 12 Dec 2021 15:33:47 +0000 Subject: [PATCH 16/31] feat(mobx): add message queue store --- .../common/messaging/MessageBox.tsx | 58 +++++-------- src/context/revoltjs/StateMonitor.tsx | 15 +--- src/lib/ContextMenus.tsx | 18 ++-- src/mobx/State.ts | 3 + src/mobx/stores/MessageQueue.ts | 84 +++++++++++++++++++ .../channels/messaging/MessageRenderer.tsx | 16 +--- 6 files changed, 118 insertions(+), 76 deletions(-) diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 8b4d5b15..91b823a2 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -116,7 +116,7 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$"); export const CAN_UPLOAD_AT_ONCE = 4; export default observer(({ channel }: Props) => { - const drafts = useApplicationState().draft; + const state = useApplicationState(); const [uploadState, setUploadState] = useState({ type: "none", @@ -149,24 +149,10 @@ export default observer(({ channel }: Props) => { ); } + // Push message content to draft. const setMessage = useCallback( - (content?: string) => { - drafts.set(channel._id, content); - - if (content) { - dispatch({ - type: "SET_DRAFT", - channel: channel._id, - content, - }); - } else { - dispatch({ - type: "CLEAR_DRAFT", - channel: channel._id, - }); - } - }, - [drafts, channel._id], + (content?: string) => state.draft.set(channel._id, content), + [state.draft, channel._id], ); useEffect(() => { @@ -184,10 +170,10 @@ export default observer(({ channel }: Props) => { .join("\n")}\n\n` : `${content} `; - if (!drafts.has(channel._id)) { + if (!state.draft.has(channel._id)) { setMessage(text); } else { - setMessage(`${drafts.get(channel._id)}\n${text}`); + setMessage(`${state.draft.get(channel._id)}\n${text}`); } } @@ -196,7 +182,7 @@ export default observer(({ channel }: Props) => { "append", append as (...args: unknown[]) => void, ); - }, [drafts, channel._id, setMessage]); + }, [state.draft, channel._id, setMessage]); /** * Trigger send message. @@ -205,7 +191,7 @@ export default observer(({ channel }: Props) => { if (uploadState.type === "uploading" || uploadState.type === "sending") return; - const content = drafts.get(channel._id)?.trim() ?? ""; + const content = state.draft.get(channel._id)?.trim() ?? ""; if (uploadState.type === "attached") return sendFile(content); if (content.length === 0) return; @@ -258,18 +244,13 @@ export default observer(({ channel }: Props) => { } else { playSound("outbound"); - dispatch({ - type: "QUEUE_ADD", - nonce, + state.queue.add(nonce, channel._id, { + _id: nonce, channel: channel._id, - message: { - _id: nonce, - channel: channel._id, - author: client.user!._id, + author: client.user!._id, - content, - replies, - }, + content, + replies, }); defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE)); @@ -281,11 +262,7 @@ export default observer(({ channel }: Props) => { replies, }); } catch (error) { - dispatch({ - type: "QUEUE_FAIL", - error: takeError(error), - nonce, - }); + state.queue.fail(nonce, takeError(error)); } } } @@ -525,7 +502,7 @@ export default observer(({ channel }: Props) => { id="message" maxLength={2000} onKeyUp={onKeyUp} - value={drafts.get(channel._id) ?? ""} + value={state.draft.get(channel._id) ?? ""} padding="var(--message-box-padding)" onKeyDown={(e) => { if (e.ctrlKey && e.key === "Enter") { @@ -535,7 +512,10 @@ export default observer(({ channel }: Props) => { if (onKeyDown(e)) return; - if (e.key === "ArrowUp" && !drafts.has(channel._id)) { + if ( + e.key === "ArrowUp" && + !state.draft.has(channel._id) + ) { e.preventDefault(); internalEmit("MessageRenderer", "edit_last"); return; diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx index 038ea706..757e3c66 100644 --- a/src/context/revoltjs/StateMonitor.tsx +++ b/src/context/revoltjs/StateMonitor.tsx @@ -5,7 +5,7 @@ import { Message } from "revolt.js/dist/maps/Messages"; import { useContext, useEffect } from "preact/hooks"; -import { dispatch } from "../../redux"; +import { useApplicationState } from "../../mobx/State"; import { connectState } from "../../redux/connector"; import { QueuedMessage } from "../../redux/reducers/queue"; @@ -17,22 +17,13 @@ type Props = { function StateMonitor(props: Props) { const client = useContext(AppContext); - - useEffect(() => { - dispatch({ - type: "QUEUE_DROP_ALL", - }); - }, []); + const state = useApplicationState(); useEffect(() => { function add(msg: Message) { if (!msg.nonce) return; if (!props.messages.find((x) => x.id === msg.nonce)) return; - - dispatch({ - type: "QUEUE_REMOVE", - nonce: msg.nonce, - }); + state.queue.remove(msg.nonce); } client.addListener("message", add); diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index eb6e67d1..0b48eab7 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -32,6 +32,7 @@ import { import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; +import { useApplicationState } from "../mobx/State"; import { dispatch } from "../redux"; import { connectState } from "../redux/connector"; import { @@ -141,6 +142,7 @@ export default function ContextMenus() { const userId = client.user!._id; const status = useContext(StatusContext); const isOnline = status === ClientStatus.ONLINE; + const state = useApplicationState(); const history = useHistory(); function contextClick(data?: Action) { @@ -196,11 +198,7 @@ export default function ContextMenus() { { const nonce = data.message.id; const fail = (error: string) => - dispatch({ - type: "QUEUE_FAIL", - nonce, - error, - }); + state.queue.fail(nonce, error); client.channels .get(data.message.channel)! @@ -211,19 +209,13 @@ export default function ContextMenus() { }) .catch(fail); - dispatch({ - type: "QUEUE_START", - nonce, - }); + state.queue.start(nonce); } break; case "cancel_message": { - dispatch({ - type: "QUEUE_REMOVE", - nonce: data.message.id, - }); + state.queue.remove(data.message.id); } break; diff --git a/src/mobx/State.ts b/src/mobx/State.ts index aff12eb8..041ebb2e 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -10,6 +10,7 @@ import Draft from "./stores/Draft"; import Experiments from "./stores/Experiments"; import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; +import MessageQueue from "./stores/MessageQueue"; import NotificationOptions from "./stores/NotificationOptions"; import ServerConfig from "./stores/ServerConfig"; @@ -24,6 +25,7 @@ export default class State { layout: Layout; config: ServerConfig; notifications: NotificationOptions; + queue: MessageQueue; private persistent: [string, Persistent][] = []; @@ -38,6 +40,7 @@ export default class State { this.layout = new Layout(); this.config = new ServerConfig(); this.notifications = new NotificationOptions(); + this.queue = new MessageQueue(); makeAutoObservable(this); this.registerListeners = this.registerListeners.bind(this); diff --git a/src/mobx/stores/MessageQueue.ts b/src/mobx/stores/MessageQueue.ts index e69de29b..c1505435 100644 --- a/src/mobx/stores/MessageQueue.ts +++ b/src/mobx/stores/MessageQueue.ts @@ -0,0 +1,84 @@ +import { + action, + computed, + IObservableArray, + makeAutoObservable, + observable, +} from "mobx"; + +import Store from "../interfaces/Store"; + +export enum QueueStatus { + SENDING = "sending", + ERRORED = "errored", +} + +export interface Reply { + id: string; + mention: boolean; +} + +export type QueuedMessageData = { + _id: string; + author: string; + channel: string; + + content: string; + replies: Reply[]; +}; + +export interface QueuedMessage { + id: string; + channel: string; + data: QueuedMessageData; + status: QueueStatus; + error?: string; +} + +/** + * Handles waiting for messages to send and send failure. + */ +export default class MessageQueue implements Store { + private messages: IObservableArray; + + /** + * Construct new MessageQueue store. + */ + constructor() { + this.messages = observable.array([]); + makeAutoObservable(this); + } + + get id() { + return "queue"; + } + + @action add(id: string, channel: string, data: QueuedMessageData) { + this.messages.push({ + id, + channel, + data, + status: QueueStatus.SENDING, + }); + } + + @action fail(id: string, error: string) { + const entry = this.messages.find((x) => x.id === id)!; + entry.status = QueueStatus.ERRORED; + entry.error = error; + } + + @action start(id: string) { + const entry = this.messages.find((x) => x.id === id)!; + entry.status = QueueStatus.SENDING; + } + + @action remove(id: string) { + const entry = this.messages.find((x) => x.id === id)!; + this.messages.remove(entry); + } + + @computed get(channel: string) { + return this.messages.filter((x) => x.channel === channel); + } +} diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index 270cae70..47f0f1ae 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -16,6 +16,7 @@ import { useEffect, useState } from "preact/hooks"; import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; import { ChannelRenderer } from "../../../lib/renderer/Singleton"; +import { useApplicationState } from "../../../mobx/State"; import { connectState } from "../../../redux/connector"; import { QueuedMessage } from "../../../redux/reducers/queue"; @@ -33,7 +34,6 @@ import MessageEditor from "./MessageEditor"; interface Props { highlight?: string; - queue: QueuedMessage[]; renderer: ChannelRenderer; } @@ -48,9 +48,10 @@ const BlockedMessage = styled.div` } `; -const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => { +export default observer(({ renderer, highlight }: Props) => { const client = useClient(); const userId = client.user!._id; + const queue = useApplicationState().queue; const [editing, setEditing] = useState(undefined); const stopEditing = () => { @@ -192,8 +193,7 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => { const nonces = renderer.messages.map((x) => x.nonce); if (renderer.atBottom) { - for (const msg of queue) { - if (msg.channel !== renderer.channel._id) continue; + for (const msg of queue.get(renderer.channel._id)) { if (nonces.includes(msg.id)) continue; if (previous) { @@ -237,11 +237,3 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => { return <>{render}; }); - -export default memo( - connectState>(MessageRenderer, (state) => { - return { - queue: state.queue, - }; - }), -); From fef2c5997fcc4a5ea218c05a23cb2e92982b91ea Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 12 Dec 2021 15:47:15 +0000 Subject: [PATCH 17/31] chore(mobx): write jsdoc for auth / mqueue --- src/mobx/stores/Auth.ts | 9 +++++++-- src/mobx/stores/MessageQueue.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 042484bc..da989274 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -2,8 +2,6 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { Session } from "revolt-api/types/Auth"; import { Nullable } from "revolt.js/dist/util/null"; -import { mapToRecord } from "../../lib/conversion"; - import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; @@ -85,10 +83,17 @@ export default class Auth implements Store, Persistent { this.sessions.delete(user_id); } + /** + * Remove current session. + */ @action logout() { this.current && this.removeSession(this.current); } + /** + * Get current session. + * @returns Current session + */ @computed getSession() { if (!this.current) return; return this.sessions.get(this.current)!.session; diff --git a/src/mobx/stores/MessageQueue.ts b/src/mobx/stores/MessageQueue.ts index c1505435..953427ed 100644 --- a/src/mobx/stores/MessageQueue.ts +++ b/src/mobx/stores/MessageQueue.ts @@ -53,6 +53,12 @@ export default class MessageQueue implements Store { return "queue"; } + /** + * Add a message to the queue. + * @param id Nonce value + * @param channel Channel ID + * @param data Message data + */ @action add(id: string, channel: string, data: QueuedMessageData) { this.messages.push({ id, @@ -62,22 +68,40 @@ export default class MessageQueue implements Store { }); } + /** + * Fail a queued message. + * @param id Nonce value + * @param error Error string + */ @action fail(id: string, error: string) { const entry = this.messages.find((x) => x.id === id)!; entry.status = QueueStatus.ERRORED; entry.error = error; } + /** + * Mark a queued message as sending. + * @param id Nonce value + */ @action start(id: string) { const entry = this.messages.find((x) => x.id === id)!; entry.status = QueueStatus.SENDING; } + /** + * Remove a queued message. + * @param id Nonce value + */ @action remove(id: string) { const entry = this.messages.find((x) => x.id === id)!; this.messages.remove(entry); } + /** + * Get all queued messages for a channel. + * @param channel Channel ID + * @returns Array of queued messages + */ @computed get(channel: string) { return this.messages.filter((x) => x.channel === channel); } From 26a34032f9667d08e4b8c35fd6a2d3e92c751a9e Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 12 Dec 2021 23:55:58 +0000 Subject: [PATCH 18/31] feat(mobx): start work on settings store --- src/context/Theme.tsx | 43 +++++++++--------- src/mobx/State.ts | 3 ++ src/mobx/stores/Settings.ts | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 22 deletions(-) create mode 100644 src/mobx/stores/Settings.ts diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 49b527f0..263d939d 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -4,11 +4,11 @@ import { createGlobalStyle } from "styled-components"; import { createContext } from "preact"; import { useEffect } from "preact/hooks"; +import { useApplicationState } from "../mobx/State"; +import { getState } from "../redux"; import { connectState } from "../redux/connector"; import { Children } from "../types/Preact"; -import { fetchManifest, fetchTheme } from "../pages/settings/panes/ThemeShop"; -import { getState } from "../redux"; export type Variables = | "accent" @@ -57,6 +57,7 @@ export type Fonts = | "Raleway" | "Ubuntu" | "Comic Neue"; + export type MonospaceFonts = | "Fira Code" | "Roboto Mono" @@ -285,23 +286,23 @@ export const PRESETS: Record = { // todo: store used themes locally export function getBaseTheme(name: string): Theme { if (name in PRESETS) { - return PRESETS[name] + return PRESETS[name]; } // TODO: properly initialize `themes` in state instead of letting it be undefined - const themes = getState().themes ?? {} + const themes = getState().themes ?? {}; if (name in themes) { const { theme } = themes[name]; return { - ...PRESETS[theme.light ? 'light' : 'dark'], - ...theme - } + ...PRESETS[theme.light ? "light" : "dark"], + ...theme, + }; } // how did we get here - return PRESETS['dark'] + return PRESETS["dark"]; } const keys = Object.keys(PRESETS.dark); @@ -315,21 +316,22 @@ export const generateVariables = (theme: Theme) => { return (Object.keys(theme) as Variables[]).map((key) => { if (!keys.includes(key)) return; return `--${key}: ${theme[key]};`; - }) -} + }); +}; // Load the default default them and apply extras later export const ThemeContext = createContext(PRESETS["dark"]); interface Props { children: Children; - options?: ThemeOptions; } -function Theme({ children, options }: Props) { +export default function Theme({ children }: Props) { + const settings = useApplicationState().settings; + const theme: Theme = { - ...getBaseTheme(options?.base ?? 'dark'), - ...options?.custom, + ...getBaseTheme(settings.get("appearance:theme:base") ?? "dark"), + ...settings.get("appearance:theme:custom"), }; const root = document.documentElement.style; @@ -346,8 +348,11 @@ function Theme({ children, options }: Props) { }, [root, theme.monospaceFont]); useEffect(() => { - root.setProperty("--ligatures", options?.ligatures ? "normal" : "none"); - }, [root, options?.ligatures]); + root.setProperty( + "--ligatures", + settings.get("appearance:ligatures") ? "normal" : "none", + ); + }, [root, settings.get("appearance:ligatures")]); useEffect(() => { const resize = () => @@ -371,9 +376,3 @@ function Theme({ children, options }: Props) { ); } - -export default connectState<{ children: Children }>(Theme, (state) => { - return { - options: state.settings.theme, - }; -}); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 041ebb2e..9c94cee3 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -13,6 +13,7 @@ import LocaleOptions from "./stores/LocaleOptions"; import MessageQueue from "./stores/MessageQueue"; import NotificationOptions from "./stores/NotificationOptions"; import ServerConfig from "./stores/ServerConfig"; +import Settings from "./stores/Settings"; /** * Handles global application state. @@ -26,6 +27,7 @@ export default class State { config: ServerConfig; notifications: NotificationOptions; queue: MessageQueue; + settings: Settings; private persistent: [string, Persistent][] = []; @@ -41,6 +43,7 @@ export default class State { this.config = new ServerConfig(); this.notifications = new NotificationOptions(); this.queue = new MessageQueue(); + this.settings = new Settings(); makeAutoObservable(this); this.registerListeners = this.registerListeners.bind(this); diff --git a/src/mobx/stores/Settings.ts b/src/mobx/stores/Settings.ts new file mode 100644 index 00000000..9828becd --- /dev/null +++ b/src/mobx/stores/Settings.ts @@ -0,0 +1,87 @@ +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; + +import { mapToRecord } from "../../lib/conversion"; + +import { Theme } from "../../context/Theme"; + +import { Sounds } from "../../assets/sounds/Audio"; +import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; + +export type SoundOptions = { + [key in Sounds]?: boolean; +}; + +export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji"; + +interface ISettings { + "notifications:desktop": boolean; + "notifications:sounds": SoundOptions; + + "appearance:emoji": EmojiPack; + "appearance:ligatures": boolean; + "appearance:theme:base": string; + "appearance:theme:custom": Partial; +} + +/*const Schema: { + [key in keyof ISettings]: + | "string" + | "number" + | "boolean" + | "object" + | "function"; +} = { + "notifications:desktop": "boolean", + "notifications:sounds": "object", + + "appearance:emoji": "string", + "appearance:ligatures": "boolean", + "appearance:theme:base": "string", + "appearance:theme:custom": "object", +};*/ + +/** + * Manages user settings. + */ +export default class Settings implements Store, Persistent { + private data: ObservableMap; + + /** + * Construct new Layout store. + */ + constructor() { + this.data = new ObservableMap(); + makeAutoObservable(this); + } + + get id() { + return "layout"; + } + + toJSON() { + return JSON.parse(JSON.stringify(mapToRecord(this.data))); + } + + @action hydrate(data: ISettings) { + Object.keys(data).forEach((key) => + this.data.set(key, (data as any)[key]), + ); + } + + @action set(key: T, value: ISettings[T]) { + return this.data.set(key, value); + } + + @computed get(key: T) { + return this.data.get(key) as ISettings[T] | undefined; + } + + @action setUnchecked(key: string, value: unknown) { + return this.data.set(key, value); + } + + @computed getUnchecked(key: string) { + return this.data.get(key); + } +} From bd4369cf29ebbf566a7987547ab91e783bfa0a5e Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Mon, 13 Dec 2021 17:27:06 +0000 Subject: [PATCH 19/31] feat(mobx): start implementing theme store --- src/components/common/UpdateIndicator.tsx | 13 ++-- src/components/common/user/UserIcon.tsx | 14 ++-- src/context/Theme.tsx | 48 ++++-------- src/context/index.tsx | 17 ++-- src/mobx/stores/Settings.ts | 61 +++++++++------ src/mobx/stores/helpers/STheme.ts | 94 +++++++++++++++++++++++ src/pages/login/Login.tsx | 14 ++-- src/pages/settings/GenericSettings.tsx | 8 +- src/pages/settings/panes/Appearance.tsx | 50 ++++++------ 9 files changed, 207 insertions(+), 112 deletions(-) create mode 100644 src/mobx/stores/helpers/STheme.ts diff --git a/src/components/common/UpdateIndicator.tsx b/src/components/common/UpdateIndicator.tsx index e4898b4e..cbd003dc 100644 --- a/src/components/common/UpdateIndicator.tsx +++ b/src/components/common/UpdateIndicator.tsx @@ -1,11 +1,11 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { Download, CloudDownload } from "@styled-icons/boxicons-regular"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { internalSubscribe } from "../../lib/eventEmitter"; -import { ThemeContext } from "../../context/Theme"; +import { useApplicationState } from "../../mobx/State"; import IconButton from "../ui/IconButton"; @@ -27,7 +27,7 @@ export default function UpdateIndicator({ style }: Props) { }); if (!pending) return null; - const theme = useContext(ThemeContext); + const theme = useApplicationState().settings.theme; if (style === "titlebar") { return ( @@ -36,7 +36,10 @@ export default function UpdateIndicator({ style }: Props) { content="A new update is available!" placement="bottom">
updateSW(true)}> - +
@@ -47,7 +50,7 @@ export default function UpdateIndicator({ style }: Props) { return ( updateSW(true)}> - + ); } diff --git a/src/components/common/user/UserIcon.tsx b/src/components/common/user/UserIcon.tsx index 13a4911e..595a6760 100644 --- a/src/components/common/user/UserIcon.tsx +++ b/src/components/common/user/UserIcon.tsx @@ -5,12 +5,10 @@ import { useParams } from "react-router-dom"; import { Masquerade } from "revolt-api/types/Channels"; import { Presence } from "revolt-api/types/Users"; import { User } from "revolt.js/dist/maps/Users"; -import { Nullable } from "revolt.js/dist/util/null"; import styled, { css } from "styled-components"; -import { useContext } from "preact/hooks"; +import { useApplicationState } from "../../../mobx/State"; -import { ThemeContext } from "../../../context/Theme"; import { useClient } from "../../../context/revoltjs/RevoltClient"; import fallback from "../assets/user.png"; @@ -26,15 +24,15 @@ interface Props extends IconBaseProps { } export function useStatusColour(user?: User) { - const theme = useContext(ThemeContext); + const theme = useApplicationState().settings.theme; return user?.online && user?.status?.presence !== Presence.Invisible ? user?.status?.presence === Presence.Idle - ? theme["status-away"] + ? theme.getVariable("status-away") : user?.status?.presence === Presence.Busy - ? theme["status-busy"] - : theme["status-online"] - : theme["status-invisible"]; + ? theme.getVariable("status-busy") + : theme.getVariable("status-online") + : theme.getVariable("status-invisible"); } const VoiceIndicator = styled.div<{ status: VoiceStatus }>` diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 263d939d..cd6edfb7 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -6,9 +6,6 @@ import { useEffect } from "preact/hooks"; import { useApplicationState } from "../mobx/State"; import { getState } from "../redux"; -import { connectState } from "../redux/connector"; - -import { Children } from "../types/Preact"; export type Variables = | "accent" @@ -66,9 +63,11 @@ export type MonospaceFonts = | "Ubuntu Mono" | "JetBrains Mono"; -export type Theme = { +export type Overrides = { [variable in Variables]: string; -} & { +}; + +export type Theme = Overrides & { light?: boolean; font?: Fonts; css?: string; @@ -228,7 +227,6 @@ export const DEFAULT_MONO_FONT = "Fira Code"; // Generated from https://gitlab.insrt.uk/revolt/community/themes export const PRESETS: Record = { light: { - light: true, accent: "#FD6671", background: "#F6F6F6", foreground: "#000000", @@ -255,7 +253,6 @@ export const PRESETS: Record = { "status-invisible": "#A5A5A5", }, dark: { - light: false, accent: "#FD6671", background: "#191919", foreground: "#F6F6F6", @@ -319,33 +316,22 @@ export const generateVariables = (theme: Theme) => { }); }; -// Load the default default them and apply extras later -export const ThemeContext = createContext(PRESETS["dark"]); - -interface Props { - children: Children; -} - -export default function Theme({ children }: Props) { +export default function Theme() { const settings = useApplicationState().settings; - - const theme: Theme = { - ...getBaseTheme(settings.get("appearance:theme:base") ?? "dark"), - ...settings.get("appearance:theme:custom"), - }; + const theme = settings.theme; const root = document.documentElement.style; useEffect(() => { - const font = theme.font ?? DEFAULT_FONT; + const font = theme.getFont() ?? DEFAULT_FONT; root.setProperty("--font", `"${font}"`); FONTS[font].load(); - }, [root, theme.font]); + }, [root, theme.getFont()]); useEffect(() => { - const font = theme.monospaceFont ?? DEFAULT_MONO_FONT; + const font = theme.getMonospaceFont() ?? DEFAULT_MONO_FONT; root.setProperty("--monospace-font", `"${font}"`); MONOSPACE_FONTS[font].load(); - }, [root, theme.monospaceFont]); + }, [root, theme.getMonospaceFont()]); useEffect(() => { root.setProperty( @@ -363,16 +349,14 @@ export default function Theme({ children }: Props) { return () => window.removeEventListener("resize", resize); }, [root]); + const variables = theme.getVariables(); return ( - + <> - + - - {theme.css && ( -