feat(mobx): add drafts and state context

This commit is contained in:
Paul Makles 2021-12-10 12:53:41 +00:00
parent 66bfc658c3
commit 5a41c25e3c
10 changed files with 285 additions and 14 deletions

View file

@ -5,7 +5,7 @@
"pull": "node scripts/setup_assets.js", "pull": "node scripts/setup_assets.js",
"build": "rimraf build && node scripts/setup_assets.js --check && vite build", "build": "rimraf build && node scripts/setup_assets.js --check && vite build",
"preview": "vite preview", "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}'", "fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "sirv dist --cors --single --host", "start": "sirv dist --cors --single --host",
@ -37,6 +37,18 @@
{ {
"varsIgnorePattern": "^_" "varsIgnorePattern": "^_"
} }
],
"require-jsdoc": [
"error",
{
"require": {
"FunctionDeclaration": true,
"MethodDefinition": true,
"ClassDeclaration": true,
"ArrowFunctionExpression": false,
"FunctionExpression": false
}
}
] ]
} }
}, },

View file

@ -20,6 +20,7 @@ import {
SMOOTH_SCROLL_ON_RECEIVE, SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton"; } from "../../../lib/renderer/Singleton";
import { useApplicationState } from "../../../mobx/State";
import { dispatch, getState } from "../../../redux"; import { dispatch, getState } from "../../../redux";
import { Reply } from "../../../redux/reducers/queue"; import { Reply } from "../../../redux/reducers/queue";
@ -115,7 +116,7 @@ const RE_SED = new RegExp("^s/([^])*/([^])*$");
export const CAN_UPLOAD_AT_ONCE = 4; export const CAN_UPLOAD_AT_ONCE = 4;
export default observer(({ channel }: Props) => { export default observer(({ channel }: Props) => {
const [draft, setDraft] = useState(getState().drafts[channel._id] ?? ""); const drafts = useApplicationState().draft;
const [uploadState, setUploadState] = useState<UploadState>({ const [uploadState, setUploadState] = useState<UploadState>({
type: "none", type: "none",
@ -150,7 +151,7 @@ export default observer(({ channel }: Props) => {
const setMessage = useCallback( const setMessage = useCallback(
(content?: string) => { (content?: string) => {
setDraft(content ?? ""); drafts.set(channel._id, content);
if (content) { if (content) {
dispatch({ dispatch({
@ -165,10 +166,15 @@ export default observer(({ channel }: Props) => {
}); });
} }
}, },
[channel._id], [drafts, channel._id],
); );
useEffect(() => { useEffect(() => {
/**
*
* @param content
* @param action
*/
function append(content: string, action: "quote" | "mention") { function append(content: string, action: "quote" | "mention") {
const text = const text =
action === "quote" action === "quote"
@ -178,10 +184,10 @@ export default observer(({ channel }: Props) => {
.join("\n")}\n\n` .join("\n")}\n\n`
: `${content} `; : `${content} `;
if (!draft || draft.length === 0) { if (!drafts.has(channel._id)) {
setMessage(text); setMessage(text);
} else { } else {
setMessage(`${draft}\n${text}`); setMessage(`${drafts.get(channel._id)}\n${text}`);
} }
} }
@ -190,13 +196,16 @@ export default observer(({ channel }: Props) => {
"append", "append",
append as (...args: unknown[]) => void, append as (...args: unknown[]) => void,
); );
}, [draft, setMessage]); }, [drafts, channel._id, setMessage]);
/**
* Trigger send message.
*/
async function send() { async function send() {
if (uploadState.type === "uploading" || uploadState.type === "sending") if (uploadState.type === "uploading" || uploadState.type === "sending")
return; return;
const content = draft?.trim() ?? ""; const content = drafts.get(channel._id)?.trim() ?? "";
if (uploadState.type === "attached") return sendFile(content); if (uploadState.type === "attached") return sendFile(content);
if (content.length === 0) return; if (content.length === 0) return;
@ -281,6 +290,11 @@ export default observer(({ channel }: Props) => {
} }
} }
/**
*
* @param content
* @returns
*/
async function sendFile(content: string) { async function sendFile(content: string) {
if (uploadState.type !== "attached") return; if (uploadState.type !== "attached") return;
const attachments: string[] = []; const attachments: string[] = [];
@ -372,6 +386,10 @@ export default observer(({ channel }: Props) => {
} }
} }
/**
*
* @returns
*/
function startTyping() { function startTyping() {
if (typeof typing === "number" && +new Date() < typing) return; if (typeof typing === "number" && +new Date() < typing) return;
@ -385,6 +403,10 @@ export default observer(({ channel }: Props) => {
} }
} }
/**
*
* @param force
*/
function stopTyping(force?: boolean) { function stopTyping(force?: boolean) {
if (force || typing) { if (force || typing) {
const ws = client.websocket; const ws = client.websocket;
@ -503,7 +525,7 @@ export default observer(({ channel }: Props) => {
id="message" id="message"
maxLength={2000} maxLength={2000}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
value={draft ?? ""} value={drafts.get(channel._id) ?? ""}
padding="var(--message-box-padding)" padding="var(--message-box-padding)"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") { if (e.ctrlKey && e.key === "Enter") {
@ -513,10 +535,7 @@ export default observer(({ channel }: Props) => {
if (onKeyDown(e)) return; if (onKeyDown(e)) return;
if ( if (e.key === "ArrowUp" && !drafts.has(channel._id)) {
e.key === "ArrowUp" &&
(!draft || draft.length === 0)
) {
e.preventDefault(); e.preventDefault();
internalEmit("MessageRenderer", "edit_last"); internalEmit("MessageRenderer", "edit_last");
return; return;

View file

@ -9,6 +9,10 @@ import Theme from "./Theme";
import Intermediate from "./intermediate/Intermediate"; import Intermediate from "./intermediate/Intermediate";
import Client from "./revoltjs/RevoltClient"; 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 }) { export default function Context({ children }: { children: Children }) {
return ( return (
<Router basename={import.meta.env.BASE_URL}> <Router basename={import.meta.env.BASE_URL}>

16
src/mobx/Persistent.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* A data store which is persistent and should cache its data locally.
*/
export default interface Persistent<T> {
/**
* 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;
}

37
src/mobx/State.ts Normal file
View file

@ -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<State>(null!);
export const StateContextProvider = StateContext.Provider;
/**
* Get the application state
* @returns Application state
*/
export function useApplicationState() {
return useContext(StateContext);
}

14
src/mobx/TODO Normal file
View file

@ -0,0 +1,14 @@
auth
drafts
experiments
last opened
locale
notifications
queue
section toggle
serevr config
settings
sync
themes
trusted links
unreads

6
src/mobx/objectUtil.ts Normal file
View file

@ -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;
}

70
src/mobx/stores/Auth.ts Normal file
View file

@ -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<string, Session>;
current?: string;
}
/**
* Handles account authentication, managing multiple
* accounts and their sessions.
*/
export default class Auth implements Persistent<Data> {
private sessions: Record<string, Session>;
private current: Nullable<string>;
/**
* 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;
}
}
}

80
src/mobx/stores/Draft.ts Normal file
View file

@ -0,0 +1,80 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import Persistent from "../Persistent";
interface Data {
drafts: Record<string, string>;
}
/**
* Handles storing draft (currently being written) messages.
*/
export default class Draft implements Persistent<Data> {
private drafts: ObservableMap<string, string>;
/**
* 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();
}
}

View file

@ -3,6 +3,8 @@ import { Provider } from "react-redux";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import MobXState, { StateContextProvider } from "../mobx/State";
import { dispatch, State, store } from "."; import { dispatch, State, store } from ".";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
@ -10,8 +12,13 @@ interface Props {
children: Children; children: Children;
} }
/**
* Component for loading application state.
* @param props Provided children
*/
export default function StateLoader(props: Props) { export default function StateLoader(props: Props) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [state] = useState(new MobXState());
useEffect(() => { useEffect(() => {
localForage.getItem("state").then((state) => { localForage.getItem("state").then((state) => {
@ -24,5 +31,11 @@ export default function StateLoader(props: Props) {
}, []); }, []);
if (!loaded) return null; if (!loaded) return null;
return <Provider store={store}>{props.children}</Provider>; return (
<Provider store={store}>
<StateContextProvider value={state}>
{props.children}
</StateContextProvider>
</Provider>
);
} }