mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-25 16:40:58 -05:00
feat(mobx): add drafts and state context
This commit is contained in:
parent
66bfc658c3
commit
5a41c25e3c
10 changed files with 285 additions and 14 deletions
14
package.json
14
package.json
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
16
src/mobx/Persistent.ts
Normal 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
37
src/mobx/State.ts
Normal 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
14
src/mobx/TODO
Normal 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
6
src/mobx/objectUtil.ts
Normal 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
70
src/mobx/stores/Auth.ts
Normal 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
80
src/mobx/stores/Draft.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue