Merge branch 'mobx'

This commit is contained in:
Paul
2021-12-24 11:45:49 +00:00
115 changed files with 3973 additions and 3311 deletions

View File

@@ -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";
@@ -204,7 +205,6 @@ export const Languages: { [key in Language]: LanguageEntry } = {
interface Props {
children: JSX.Element | JSX.Element[];
locale: Language;
}
export interface Dictionary {
@@ -222,59 +222,14 @@ export interface Dictionary {
| undefined;
}
function Locale({ children, locale }: Props) {
const [defns, setDefinition] = useState<Dictionary>(
export default observer(({ children }: Props) => {
const locale = useApplicationState().locale;
const [definitions, setDefinition] = useState<Dictionary>(
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 +243,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 +267,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 <IntlProvider definition={defns}>{children}</IntlProvider>;
return <IntlProvider definition={definitions}>{children}</IntlProvider>;
});
/**
* 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<Omit<Props, "locale">>(
Locale,
(state) => {
return {
locale: state.locale,
};
},
true,
);

View File

@@ -1,61 +0,0 @@
// This code is more or less redundant, but settings has so little state
// updates that I can't be asked to pass everything through props each
// time when I can just use the Context API.
//
// Replace references to SettingsContext with connectState in the future
// if it does cause problems though.
//
// This now also supports Audio stuff.
import defaultsDeep from "lodash.defaultsdeep";
import { createContext } from "preact";
import { useMemo } from "preact/hooks";
import { connectState } from "../redux/connector";
import {
DEFAULT_SOUNDS,
Settings,
SoundOptions,
} from "../redux/reducers/settings";
import { playSound, Sounds } from "../assets/sounds/Audio";
import { Children } from "../types/Preact";
export const SettingsContext = createContext<Settings>({});
export const SoundContext = createContext<(sound: Sounds) => void>(null!);
interface Props {
children?: Children;
settings: Settings;
}
function SettingsProvider({ settings, children }: Props) {
const play = useMemo(() => {
const enabled: SoundOptions = defaultsDeep(
settings.notification?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (sound: Sounds) => {
if (enabled[sound]) {
playSound(sound);
}
};
}, [settings.notification]);
return (
<SettingsContext.Provider value={settings}>
<SoundContext.Provider value={play}>
{children}
</SoundContext.Provider>
</SettingsContext.Provider>
);
}
export default connectState<Omit<Props, "settings">>(
SettingsProvider,
(state) => {
return {
settings: state.settings,
};
},
);

View File

@@ -1,14 +1,10 @@
import { observer } from "mobx-react-lite";
import { Helmet } from "react-helmet";
import { createGlobalStyle } from "styled-components";
import { createContext } from "preact";
import { useEffect } from "preact/hooks";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
import { fetchManifest, fetchTheme } from "../pages/settings/panes/ThemeShop";
import { getState } from "../redux";
import { useApplicationState } from "../mobx/State";
export type Variables =
| "accent"
@@ -57,6 +53,7 @@ export type Fonts =
| "Raleway"
| "Ubuntu"
| "Comic Neue";
export type MonospaceFonts =
| "Fira Code"
| "Roboto Mono"
@@ -65,9 +62,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;
@@ -227,7 +226,6 @@ export const DEFAULT_MONO_FONT = "Fira Code";
// Generated from https://gitlab.insrt.uk/revolt/community/themes
export const PRESETS: Record<string, Theme> = {
light: {
light: true,
accent: "#FD6671",
background: "#F6F6F6",
foreground: "#000000",
@@ -254,7 +252,6 @@ export const PRESETS: Record<string, Theme> = {
"status-invisible": "#A5A5A5",
},
dark: {
light: false,
accent: "#FD6671",
background: "#191919",
foreground: "#F6F6F6",
@@ -282,28 +279,6 @@ export const PRESETS: Record<string, Theme> = {
},
};
// todo: store used themes locally
export function getBaseTheme(name: string): Theme {
if (name in PRESETS) {
return PRESETS[name]
}
// TODO: properly initialize `themes` in state instead of letting it be undefined
const themes = getState().themes ?? {}
if (name in themes) {
const { theme } = themes[name];
return {
...PRESETS[theme.light ? 'light' : 'dark'],
...theme
}
}
// how did we get here
return PRESETS['dark']
}
const keys = Object.keys(PRESETS.dark);
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
:root {
@@ -315,39 +290,32 @@ 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<Theme>(PRESETS["dark"]);
interface Props {
children: Children;
options?: ThemeOptions;
}
function Theme({ children, options }: Props) {
const theme: Theme = {
...getBaseTheme(options?.base ?? 'dark'),
...options?.custom,
};
export default observer(() => {
const settings = useApplicationState().settings;
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("--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 = () =>
@@ -358,22 +326,14 @@ function Theme({ children, options }: Props) {
return () => window.removeEventListener("resize", resize);
}, [root]);
const variables = theme.getVariables();
return (
<ThemeContext.Provider value={theme}>
<>
<Helmet>
<meta name="theme-color" content={theme["background"]} />
<meta name="theme-color" content={variables["background"]} />
</Helmet>
<GlobalTheme theme={theme} />
{theme.css && (
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
)}
{children}
</ThemeContext.Provider>
<GlobalTheme theme={variables} />
<style dangerouslySetInnerHTML={{ __html: theme.getCSS() ?? "" }} />
</>
);
}
export default connectState<{ children: Children }>(Theme, (state) => {
return {
options: state.settings.theme,
};
});

View File

@@ -1,28 +1,38 @@
import { BrowserRouter as Router } from "react-router-dom";
import State from "../redux/State";
import { useEffect, useState } from "preact/hooks";
import { hydrateState } from "../mobx/State";
import Preloader from "../components/ui/Preloader";
import { Children } from "../types/Preact";
import Locale from "./Locale";
import Settings from "./Settings";
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 }) {
const [ready, setReady] = useState(false);
useEffect(() => {
hydrateState().then(() => setReady(true));
}, []);
if (!ready) return <Preloader type="spinner" />;
return (
<Router basename={import.meta.env.BASE_URL}>
<State>
<Theme>
<Settings>
<Locale>
<Intermediate>
<Client>{children}</Client>
</Intermediate>
</Locale>
</Settings>
</Theme>
</State>
<Locale>
<Intermediate>
<Client>{children}</Client>
</Intermediate>
</Locale>
<Theme />
</Router>
);
}

View File

@@ -15,7 +15,7 @@ import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { determineLink } from "../../lib/links";
import { getState } from "../../redux";
import { useApplicationState } from "../../mobx/State";
import { Action } from "../../components/ui/Modal";
@@ -132,6 +132,7 @@ interface Props {
export default function Intermediate(props: Props) {
const [screen, openScreen] = useState<Screen>({ id: "none" });
const settings = useApplicationState().settings;
const history = useHistory();
const value = {
@@ -154,10 +155,11 @@ export default function Intermediate(props: Props) {
return true;
}
case "external": {
const { trustedLinks } = getState();
if (
!trusted &&
!trustedLinks.domains?.includes(link.url.hostname)
!settings.security.isTrustedOrigin(
link.url.hostname,
)
) {
openScreen({
id: "external_link_prompt",

View File

@@ -1,6 +1,6 @@
import { Text } from "preact-i18n";
import { dispatch } from "../../../redux";
import { useApplicationState } from "../../../mobx/State";
import Modal from "../../../components/ui/Modal";
@@ -13,6 +13,7 @@ interface Props {
export function ExternalLinkModal({ onClose, link }: Props) {
const { openLink } = useIntermediate();
const settings = useApplicationState().settings;
return (
<Modal
@@ -39,13 +40,10 @@ export function ExternalLinkModal({ onClose, link }: Props) {
onClick: () => {
try {
const url = new URL(link);
dispatch({
type: "TRUSTED_LINKS_ADD_DOMAIN",
domain: url.hostname,
});
settings.security.addTrustedOrigin(url.hostname);
} catch (e) {}
openLink(link);
openLink(link, true);
onClose();
},
plain: true,

View File

@@ -1,9 +1,9 @@
import { Redirect } from "react-router-dom";
import { useContext } from "preact/hooks";
import { useApplicationState } from "../../mobx/State";
import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient";
import { useClient } from "./RevoltClient";
interface Props {
auth?: boolean;
@@ -11,11 +11,13 @@ interface Props {
}
export const CheckAuth = (props: Props) => {
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 <Redirect to="/login" />;
} else if (!props.auth && operations.ready()) {
} else if (!props.auth && ready) {
return <Redirect to="/" />;
}

View File

@@ -8,22 +8,10 @@ import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { connectState } from "../../redux/connector";
import {
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
import { useApplicationState } from "../../mobx/State";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props {
options?: NotificationOptions;
notifs: Notifications;
}
const notifications: { [key: string]: Notification } = {};
async function createNotification(
@@ -38,9 +26,11 @@ async function createNotification(
}
}
function Notifier({ options, notifs }: Props) {
function Notifier() {
const translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false;
const state = useApplicationState();
const notifs = state.notifications;
const showNotification = state.settings.get("notifications:desktop");
const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{
@@ -48,19 +38,13 @@ function Notifier({ options, notifs }: Props) {
channel: string;
}>();
const history = useHistory();
const playSound = useContext(SoundContext);
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;
const notifState = getNotificationState(notifs, msg.channel!);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound("message");
state.settings.sounds.playSound("message");
if (!showNotification) return;
const effectiveName = msg.masquerade?.name ?? msg.author?.username;
@@ -220,7 +204,7 @@ function Notifier({ options, notifs }: Props) {
channel_id,
client,
notifs,
playSound,
state,
],
);
@@ -268,7 +252,7 @@ function Notifier({ options, notifs }: Props) {
};
}, [
client,
playSound,
state,
guild_id,
channel_id,
showNotification,
@@ -296,28 +280,17 @@ function Notifier({ options, notifs }: Props) {
return null;
}
const NotifierComponent = connectState(
Notifier,
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications,
};
},
true,
);
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
<NotifierComponent />
<Notifier />
</Route>
<Route path="/channel/:channel">
<NotifierComponent />
<Notifier />
</Route>
<Route path="/">
<NotifierComponent />
<Notifier />
</Route>
</Switch>
);

View File

@@ -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<void>;
logout: (shouldRequest?: boolean) => Promise<void>;
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<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(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<Client>(
undefined as unknown as Client,
);
const [client, setClient] = useState<Client>(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()) {
setLoaded(false);
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()!)
.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.");
}
})
.finally(() => setLoaded(true));
} 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 <Preloader type="spinner" />;
}
return (
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
<LogOutContext.Provider value={logout}>
{children}
</OperationsContext.Provider>
</LogOutContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
}
export default connectState<{ children: Children }>(Context, (state) => {
return {
auth: state.auth,
sync: state.sync,
};
});
export const useClient = () => useContext(AppContext);

View File

@@ -5,45 +5,35 @@ import { Message } from "revolt.js/dist/maps/Messages";
import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { QueuedMessage } from "../../redux/reducers/queue";
import { useApplicationState } from "../../mobx/State";
import { setGlobalEmojiPack } from "../../components/common/Emoji";
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
};
function StateMonitor(props: Props) {
export default function StateMonitor() {
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,
});
if (
!state.queue.get(msg.channel_id).find((x) => x.id === msg.nonce)
)
return;
state.queue.remove(msg.nonce);
}
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [client, props.messages]);
}, [client]);
// Set global emoji pack.
useEffect(() => {
const v = state.settings.get("appearance:emoji");
v && setGlobalEmojiPack(v);
}, [state.settings.get("appearance:emoji")]);
return null;
}
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
};
});

View File

@@ -1,153 +1,37 @@
/**
* This file monitors changes to settings and syncs them to the server.
*/
import isEqual from "lodash.isequal";
import { UserSettings } from "revolt-api/types/Sync";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { useCallback, useContext, useEffect, useMemo } from "preact/hooks";
import { useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { useApplicationState } from "../../mobx/State";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
import { useClient } from "./RevoltClient";
type Props = {
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(
packet: UserSettings,
revision?: Record<string, number>,
) {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (const key of Object.keys(packet)) {
const [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue;
}
let object;
if (obj[0] === "{") {
object = JSON.parse(obj);
} else {
object = obj;
}
lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [timestamp, object];
}
return update;
}
function SyncManager(props: Props) {
const client = useContext(AppContext);
const status = useContext(StatusContext);
export default function SyncManager() {
const client = useClient();
const state = useApplicationState();
// Sync settings from Revolt.
useEffect(() => {
if (status === ClientStatus.ONLINE) {
client
.syncFetchSettings(
DEFAULT_ENABLED_SYNC.filter(
(x) => !props.sync?.disabled?.includes(x),
),
)
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
state.sync.pull(client);
}, [client]);
client
.syncFetchUnreads()
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
}
}, [client, props.sync?.disabled, status]);
const syncChange = useCallback(
(key: SyncKeys, data: unknown) => {
const timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
client.syncSetSettings(
{
[key]: data as string,
},
timestamp,
);
},
[client],
);
const disabled = useMemo(
() => props.sync.disabled ?? [],
[props.sync.disabled],
);
for (const [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, unknown][]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
lastValues[key] = object;
}, [key, syncChange, disabled, object]);
}
// Keep data synced.
useEffect(() => state.registerListeners(client), [client]);
// Take data updates from Revolt.
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
dispatch({
type: "SYNC_UPDATE",
update,
});
state.sync.apply(packet.update);
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [client, disabled, props.sync]);
}, [client]);
return null;
return <></>;
}
export default connectState(SyncManager, (state) => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});

View File

@@ -1,12 +1,10 @@
import { Client } from "revolt.js/dist";
import { Message } from "revolt.js/dist/maps/Messages";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { StateUpdater } from "preact/hooks";
import { dispatch } from "../../redux";
import Auth from "../../mobx/stores/Auth";
import { ClientOperations, ClientStatus } from "./RevoltClient";
import { ClientStatus } from "./RevoltClient";
export let preventReconnect = false;
let preventUntil = 0;
@@ -16,10 +14,12 @@ export function setReconnectDisallowed(allowed: boolean) {
}
export function registerEvents(
{ operations }: { operations: ClientOperations },
auth: Auth,
setStatus: StateUpdater<ClientStatus>,
client: Client,
) {
if (!client) return;
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
@@ -36,40 +36,19 @@ export function registerEvents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
connecting: () => setStatus(ClientStatus.CONNECTING),
dropped: () => {
if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelAck": {
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
});
break;
}
}
},
message: (message: Message) => {
if (message.mention_ids?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel_id,
message: message._id,
});
}
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
},
ready: () => setStatus(ClientStatus.ONLINE),
logout: () => {
auth.logout();
setStatus(ClientStatus.READY);
},
};
if (import.meta.env.DEV) {
@@ -89,19 +68,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);