feat(mobx): add sync back (do not look at the code)

This commit is contained in:
Paul 2021-12-24 02:05:18 +00:00
parent cc0e45526c
commit e89bbb7455
18 changed files with 341 additions and 166 deletions

3
.vscode/launch.json vendored
View file

@ -10,8 +10,7 @@
"name": "Launch Chrome against localhost", "name": "Launch Chrome against localhost",
"url": "http://local.revolt.chat:3000", "url": "http://local.revolt.chat:3000",
"webRoot": "${workspaceFolder}", "webRoot": "${workspaceFolder}",
"runtimeExecutable": "/usr/bin/chromium", "runtimeExecutable": "/usr/bin/chromium"
"userDataDir": "${workspaceFolder}/.vscode/vscode-chrome-debug-userdatadir"
} }
] ]
} }

View file

@ -117,6 +117,7 @@
"eslint-config-preact": "^1.1.4", "eslint-config-preact": "^1.1.4",
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"highlight.js": "^11.0.1", "highlight.js": "^11.0.1",
"json-stringify-deterministic": "^1.0.2",
"localforage": "^1.9.0", "localforage": "^1.9.0",
"lodash.defaultsdeep": "^4.6.1", "lodash.defaultsdeep": "^4.6.1",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",

View file

@ -1,5 +1,11 @@
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import { useEffect, useState } from "preact/hooks";
import { hydrateState } from "../mobx/State";
import Preloader from "../components/ui/Preloader";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
import Locale from "./Locale"; import Locale from "./Locale";
import Theme from "./Theme"; import Theme from "./Theme";
@ -11,6 +17,14 @@ import Client from "./revoltjs/RevoltClient";
* @param param0 Provided children * @param param0 Provided children
*/ */
export default function Context({ children }: { children: 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 ( return (
<Router basename={import.meta.env.BASE_URL}> <Router basename={import.meta.env.BASE_URL}>
<Locale> <Locale>

View file

@ -1,138 +1,37 @@
/** /**
* This file monitors changes to settings and syncs them to the server. * This file monitors changes to settings and syncs them to the server.
*/ */
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
/*type Props = { import { useEffect } from "preact/hooks";
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
const lastValues: { [key in SyncKeys]?: unknown } = {}; import { useApplicationState } from "../../mobx/State";
export function mapSync( import { useClient } from "./RevoltClient";
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; export default function SyncManager() {
if (obj[0] === "{") { const client = useClient();
object = JSON.parse(obj); const state = useApplicationState();
} 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);
// Sync settings from Revolt.
useEffect(() => { useEffect(() => {
if (status === ClientStatus.ONLINE) { state.sync.pull(client);
client }, [client]);
.syncFetchSettings(
DEFAULT_ENABLED_SYNC.filter(
(x) => !props.sync?.disabled?.includes(x),
),
)
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
client // Keep data synced.
.syncFetchUnreads() useEffect(() => state.registerListeners(client), [client]);
.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]);
}
// Take data updates from Revolt.
useEffect(() => { useEffect(() => {
function onPacket(packet: ClientboundNotification) { function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") { if (packet.type === "UserSettingsUpdate") {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } = state.sync.apply(packet.update);
mapSync(packet.update, props.sync.revision);
dispatch({
type: "SYNC_UPDATE",
update,
});
} }
} }
client.addListener("packet", onPacket); client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket); return () => client.removeListener("packet", onPacket);
}, [client, disabled, props.sync]); }, [client]);
return null;
}
export default connectState(SyncManager, (state) => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});*/
export default function SyncManager() {
return <></>; return <></>;
} }

View file

@ -1,6 +1,4 @@
import { Client } from "revolt.js/dist"; 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 { StateUpdater } from "preact/hooks";

View file

@ -1,10 +1,11 @@
// @ts-expect-error No typings.
import stringify from "json-stringify-deterministic";
import localforage from "localforage"; import localforage from "localforage";
import { autorun, makeAutoObservable, reaction } from "mobx"; import { makeAutoObservable, reaction } from "mobx";
import { Client } from "revolt.js";
import { createContext } from "preact";
import { useContext } from "preact/hooks";
import Persistent from "./interfaces/Persistent"; import Persistent from "./interfaces/Persistent";
import Syncable from "./interfaces/Syncable";
import Auth from "./stores/Auth"; import Auth from "./stores/Auth";
import Draft from "./stores/Draft"; import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments"; import Experiments from "./stores/Experiments";
@ -14,7 +15,11 @@ import MessageQueue from "./stores/MessageQueue";
import NotificationOptions from "./stores/NotificationOptions"; import NotificationOptions from "./stores/NotificationOptions";
import ServerConfig from "./stores/ServerConfig"; import ServerConfig from "./stores/ServerConfig";
import Settings from "./stores/Settings"; import Settings from "./stores/Settings";
import Sync from "./stores/Sync"; import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync";
export const MIGRATIONS = {
REDUX: 1640305719826,
};
/** /**
* Handles global application state. * Handles global application state.
@ -32,6 +37,7 @@ export default class State {
sync: Sync; sync: Sync;
private persistent: [string, Persistent<unknown>][] = []; private persistent: [string, Persistent<unknown>][] = [];
private disabled: Set<string> = new Set();
/** /**
* Construct new State. * Construct new State.
@ -46,11 +52,11 @@ export default class State {
this.notifications = new NotificationOptions(); this.notifications = new NotificationOptions();
this.queue = new MessageQueue(); this.queue = new MessageQueue();
this.settings = new Settings(); this.settings = new Settings();
this.sync = new Sync(); this.sync = new Sync(this);
makeAutoObservable(this); makeAutoObservable(this);
this.registerListeners = this.registerListeners.bind(this);
this.register(); this.register();
this.setDisabled = this.setDisabled.bind(this);
} }
/** /**
@ -83,17 +89,78 @@ export default class State {
} }
} }
setDisabled(key: string) {
this.disabled.add(key);
}
/** /**
* Register reaction listeners for persistent data stores. * Register reaction listeners for persistent data stores.
* @returns Function to dispose of listeners * @returns Function to dispose of listeners
*/ */
registerListeners() { registerListeners(client: Client) {
const listeners = this.persistent.map(([id, store]) => { const listeners = this.persistent.map(([id, store]) => {
return reaction( return reaction(
() => store.toJSON(), () => stringify(store.toJSON()),
async (value) => { async (value) => {
try { try {
await localforage.setItem(id, value); await localforage.setItem(id, JSON.parse(value));
if (id === "sync") return;
const revision = +new Date();
switch (id) {
case "settings": {
const { appearance, theme } =
this.settings.toSyncable();
const obj: Record<string, unknown> = {};
if (this.sync.isEnabled("appearance")) {
if (this.disabled.has("appearance")) {
this.disabled.delete("appearance");
} else {
obj["appearance"] = appearance;
this.sync.setRevision(
"appearance",
revision,
);
}
}
if (this.sync.isEnabled("theme")) {
if (this.disabled.has("theme")) {
this.disabled.delete("theme");
} else {
obj["theme"] = theme;
this.sync.setRevision(
"theme",
revision,
);
}
}
if (Object.keys(obj).length > 0) {
client.syncSetSettings(
obj as any,
revision,
);
}
break;
}
default: {
if (this.sync.isEnabled(id as SyncKeys)) {
if (this.disabled.has(id)) {
this.disabled.delete(id);
}
this.sync.setRevision(id, revision);
client.syncSetSettings(
(
store as unknown as Syncable
).toSyncable(),
revision,
);
}
}
}
} catch (err) { } catch (err) {
console.error("Failed to serialise!"); console.error("Failed to serialise!");
console.error(err); console.error(err);
@ -110,10 +177,15 @@ export default class State {
* Load data stores from local storage. * Load data stores from local storage.
*/ */
async hydrate() { async hydrate() {
for (const [id, store] of this.persistent) { const sync = (await localforage.getItem("sync")) as DataSync;
const data = await localforage.getItem(id); if (sync) {
if (typeof data === "object" && data !== null) { const { revision } = sync;
store.hydrate(data); for (const [id, store] of this.persistent) {
if (id === "sync") continue;
const data = await localforage.getItem(id);
if (typeof data === "object" && data !== null) {
store.hydrate(data, revision[id]);
}
} }
} }
} }
@ -121,12 +193,16 @@ export default class State {
var state: State; var state: State;
export async function hydrateState() {
state = new State();
await state.hydrate();
}
/** /**
* Get the application state * Get the application state
* @returns Application state * @returns Application state
*/ */
export function useApplicationState() { export function useApplicationState() {
if (!state) state = new State();
return state; return state;
} }

View file

@ -1,11 +0,0 @@
import Store from "./Store";
/**
* A data store which is migrated forwards.
*/
export default interface Migrate<K extends string> extends Store {
/**
* Migrate this data store.
*/
migrate(key: K, data: Record<string, unknown>, rev: number): void;
}

View file

@ -13,5 +13,5 @@ export default interface Persistent<T> extends Store {
* Hydrate this data store using given data. * Hydrate this data store using given data.
* @param data Given data * @param data Given data
*/ */
hydrate(data: T): void; hydrate(data: T, revision: number): void;
} }

View file

@ -0,0 +1,9 @@
import Store from "./Store";
/**
* A data store which syncs data to Revolt.
*/
export default interface Syncable extends Store {
apply(key: string, data: unknown, revision: number): void;
toSyncable(): { [key: string]: object };
}

View file

@ -62,20 +62,22 @@ export interface LegacyAuthState {
active?: string; active?: string;
} }
function legacyMigrateAuth(auth: LegacyAuthState): DataAuth { export function legacyMigrateAuth(auth: LegacyAuthState): DataAuth {
return { return {
current: auth.active, current: auth.active,
sessions: auth.accounts, sessions: auth.accounts,
}; };
} }
function legacyMigrateLocale(lang: Language): DataLocaleOptions { export function legacyMigrateLocale(lang: Language): DataLocaleOptions {
return { return {
lang, lang,
}; };
} }
function legacyMigrateTheme(theme: LegacyThemeOptions): Partial<ISettings> { export function legacyMigrateTheme(
theme: LegacyThemeOptions,
): Partial<ISettings> {
const { light, font, css, monospaceFont, ...variables } = const { light, font, css, monospaceFont, ...variables } =
theme.custom ?? {}; theme.custom ?? {};
@ -90,7 +92,7 @@ function legacyMigrateTheme(theme: LegacyThemeOptions): Partial<ISettings> {
}; };
} }
function legacyMigrateAppearance( export function legacyMigrateAppearance(
appearance: LegacyAppearanceOptions, appearance: LegacyAppearanceOptions,
): Partial<ISettings> { ): Partial<ISettings> {
return { return {
@ -98,7 +100,7 @@ function legacyMigrateAppearance(
}; };
} }
function legacyMigrateNotification( export function legacyMigrateNotification(
channel: LegacyNotifications, channel: LegacyNotifications,
): DataNotificationOptions { ): DataNotificationOptions {
return { return {
@ -106,7 +108,7 @@ function legacyMigrateNotification(
}; };
} }
function legacyMigrateSync(sync: LegacySyncOptions): DataSync { export function legacyMigrateSync(sync: LegacySyncOptions): DataSync {
return { return {
disabled: sync.disabled ?? [], disabled: sync.disabled ?? [],
revision: { revision: {

View file

@ -30,6 +30,20 @@ export default class Auth implements Store, Persistent<Data> {
constructor() { constructor() {
this.sessions = new ObservableMap(); this.sessions = new ObservableMap();
this.current = null; this.current = null;
// Inject session token if it is provided.
if (import.meta.env.VITE_SESSION_TOKEN) {
this.sessions.set("0", {
session: {
name: "0",
user_id: "0",
token: import.meta.env.VITE_SESSION_TOKEN as string,
},
});
this.current = "0";
}
makeAutoObservable(this); makeAutoObservable(this);
} }

View file

@ -6,8 +6,15 @@ import { Server } from "revolt.js/dist/maps/Servers";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import {
legacyMigrateNotification,
LegacyNotifications,
} from "../legacy/redux";
import { MIGRATIONS } from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable";
/** /**
* Possible notification states. * Possible notification states.
@ -42,7 +49,9 @@ export interface Data {
/** /**
* Manages the user's notification preferences. * Manages the user's notification preferences.
*/ */
export default class NotificationOptions implements Store, Persistent<Data> { export default class NotificationOptions
implements Store, Persistent<Data>, Syncable
{
private server: ObservableMap<string, NotificationState>; private server: ObservableMap<string, NotificationState>;
private channel: ObservableMap<string, NotificationState>; private channel: ObservableMap<string, NotificationState>;
@ -208,4 +217,18 @@ export default class NotificationOptions implements Store, Persistent<Data> {
return false; return false;
} }
@action apply(_key: "notifications", data: unknown, revision: number) {
if (revision < MIGRATIONS.REDUX) {
data = legacyMigrateNotification(data as LegacyNotifications);
}
this.hydrate(data as Data);
}
@computed toSyncable() {
return {
notifications: this.toJSON(),
};
}
} }

View file

@ -2,12 +2,22 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import {
LegacyAppearanceOptions,
legacyMigrateAppearance,
legacyMigrateTheme,
LegacyTheme,
LegacyThemeOptions,
} from "../legacy/redux";
import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme"; import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme";
import { EmojiPack } from "../../components/common/Emoji"; import { EmojiPack } from "../../components/common/Emoji";
import { MIGRATIONS } from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable";
import SAudio, { SoundOptions } from "./helpers/SAudio"; import SAudio, { SoundOptions } from "./helpers/SAudio";
import SSecurity from "./helpers/SSecurity"; import SSecurity from "./helpers/SSecurity";
import STheme from "./helpers/STheme"; import STheme from "./helpers/STheme";
@ -32,7 +42,9 @@ export interface ISettings {
/** /**
* Manages user settings. * Manages user settings.
*/ */
export default class Settings implements Store, Persistent<ISettings> { export default class Settings
implements Store, Persistent<ISettings>, Syncable
{
private data: ObservableMap<string, unknown>; private data: ObservableMap<string, unknown>;
theme: STheme; theme: STheme;
@ -109,4 +121,60 @@ export default class Settings implements Store, Persistent<ISettings> {
@computed getUnchecked(key: string) { @computed getUnchecked(key: string) {
return this.data.get(key); return this.data.get(key);
} }
@action apply(
key: "appearance" | "theme",
data: unknown,
revision: number,
) {
if (revision < MIGRATIONS.REDUX) {
if (key === "appearance") {
data = legacyMigrateAppearance(data as LegacyAppearanceOptions);
} else {
data = legacyMigrateTheme(data as LegacyThemeOptions);
}
}
if (key === "appearance") {
this.remove("appearance:emoji");
} else {
this.remove("appearance:ligatures");
this.remove("appearance:theme:base");
this.remove("appearance:theme:css");
this.remove("appearance:theme:font");
this.remove("appearance:theme:light");
this.remove("appearance:theme:monoFont");
this.remove("appearance:theme:overrides");
}
this.hydrate(data as ISettings);
}
@computed private pullKeys(keys: (keyof ISettings)[]) {
const obj: Partial<ISettings> = {};
keys.forEach((key) => {
let value = this.get(key);
if (!value) return;
(obj as any)[key] = value;
});
return obj;
}
@computed toSyncable() {
const data: Record<"appearance" | "theme", Partial<ISettings>> = {
appearance: this.pullKeys(["appearance:emoji"]),
theme: this.pullKeys([
"appearance:ligatures",
"appearance:theme:base",
"appearance:theme:css",
"appearance:theme:font",
"appearance:theme:light",
"appearance:theme:monoFont",
"appearance:theme:overrides",
]),
};
return data;
}
} }

View file

@ -4,11 +4,14 @@ import {
makeAutoObservable, makeAutoObservable,
ObservableMap, ObservableMap,
ObservableSet, ObservableSet,
runInAction,
} from "mobx"; } from "mobx";
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { UserSettings } from "revolt.js/node_modules/revolt-api/types/Sync";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import State from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
@ -32,13 +35,15 @@ export interface Data {
* Handles syncing settings data. * Handles syncing settings data.
*/ */
export default class Sync implements Store, Persistent<Data> { export default class Sync implements Store, Persistent<Data> {
private state: State;
private disabled: ObservableSet<SyncKeys>; private disabled: ObservableSet<SyncKeys>;
private revision: ObservableMap<SyncKeys, number>; private revision: ObservableMap<string, number>;
/** /**
* Construct new Sync store. * Construct new Sync store.
*/ */
constructor() { constructor(state: State) {
this.state = state;
this.disabled = new ObservableSet(); this.disabled = new ObservableSet();
this.revision = new ObservableMap(); this.revision = new ObservableMap();
makeAutoObservable(this); makeAutoObservable(this);
@ -62,6 +67,12 @@ export default class Sync implements Store, Persistent<Data> {
this.disabled.add(key as SyncKeys); this.disabled.add(key as SyncKeys);
} }
} }
if (data.revision) {
for (const key of Object.keys(data.revision)) {
this.setRevision(key, data.revision[key]);
}
}
} }
@action enable(key: SyncKeys) { @action enable(key: SyncKeys) {
@ -81,12 +92,74 @@ export default class Sync implements Store, Persistent<Data> {
} }
@computed isEnabled(key: SyncKeys) { @computed isEnabled(key: SyncKeys) {
return !this.disabled.has(key); return !this.disabled.has(key) && SYNC_KEYS.includes(key);
}
@action setRevision(key: string, revision: number) {
if (revision < (this.getRevision(key) ?? 0)) return;
this.revision.set(key, revision);
}
@computed getRevision(key: string) {
return this.revision.get(key);
}
@action apply(data: UserSettings) {
const tryRead = (key: string) => {
if (key in data) {
const revision = data[key][0];
if (revision <= (this.getRevision(key) ?? 0)) {
return;
}
let parsed;
try {
parsed = JSON.parse(data[key][1]);
} catch (err) {
parsed = data[key][1];
}
return [revision, parsed];
}
};
runInAction(() => {
const appearance = tryRead("appearance");
if (appearance) {
this.state.setDisabled("appearance");
this.state.settings.apply(
"appearance",
appearance[1],
appearance[0],
);
this.setRevision("appearance", appearance[0]);
}
const theme = tryRead("theme");
if (theme) {
this.state.setDisabled("theme");
this.state.settings.apply("theme", theme[1], theme[0]);
this.setRevision("theme", theme[0]);
}
const notifications = tryRead("notifications");
if (notifications) {
this.state.setDisabled("notifications");
this.state.notifications.apply(
"notifications",
notifications[1],
notifications[0],
);
this.setRevision("notifications", notifications[0]);
}
});
} }
async pull(client: Client) { async pull(client: Client) {
const data = await client.syncFetchSettings( const data = await client.syncFetchSettings(
SYNC_KEYS.filter(this.isEnabled), SYNC_KEYS.filter(this.isEnabled),
); );
this.apply(data);
} }
} }

View file

@ -27,7 +27,6 @@ export default class SSecurity {
} }
@computed isTrustedOrigin(origin: string) { @computed isTrustedOrigin(origin: string) {
console.log(this.settings.get("security:trustedOrigins"), origin);
return this.settings.get("security:trustedOrigins")?.includes(origin); return this.settings.get("security:trustedOrigins")?.includes(origin);
} }
} }

View file

@ -1,6 +1,6 @@
import { Wrench } from "@styled-icons/boxicons-solid"; import { Wrench } from "@styled-icons/boxicons-solid";
import { useContext, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import PaintCounter from "../../lib/PaintCounter"; import PaintCounter from "../../lib/PaintCounter";
import { TextReact } from "../../lib/i18n"; import { TextReact } from "../../lib/i18n";
@ -16,10 +16,14 @@ export default function Developer() {
const userPermission = client.user!.permission; const userPermission = client.user!.permission;
const [ping, setPing] = useState<undefined | number>(client.websocket.ping); const [ping, setPing] = useState<undefined | number>(client.websocket.ping);
setInterval( useEffect(() => {
() => setPing(client.websocket.ping), const timer = setInterval(
client.options.heartbeat * 1e3, () => setPing(client.websocket.ping),
); client.options.heartbeat * 1e3,
);
return () => clearInterval(timer);
}, []);
return ( return (
<div> <div>

View file

@ -1,3 +1,5 @@
import { observer } from "mobx-react-lite";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
@ -11,7 +13,7 @@ import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
export function Notifications() { export const Notifications = observer(() => {
const client = useContext(AppContext); const client = useContext(AppContext);
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const settings = useApplicationState().settings; const settings = useApplicationState().settings;
@ -118,4 +120,4 @@ export function Notifications() {
))} ))}
</div> </div>
); );
} });

View file

@ -2974,6 +2974,11 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
json-stringify-deterministic@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-stringify-deterministic/-/json-stringify-deterministic-1.0.2.tgz#fe72b5f49cc39c5f7c5fcb17d2fb06fca092e727"
integrity sha512-u6lgTmpDXjVJChV2pOcW7bQOOXrGZAHKzfOfJXQ4ktFrpdotKgaf1crqktYrVOe/Ul0Qrm/CMjWNGS+ErpngCg==
json5@^2.1.2: json5@^2.1.2:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"