feat(mobx): refactor and remove (react-)redux

This commit is contained in:
Paul 2021-12-23 21:43:11 +00:00
parent 6e1bcab92b
commit cc0e45526c
55 changed files with 249 additions and 1522 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ dist-ssr
public/assets
public/assets_*
!public/assets_default
.vscode/vscode-chrome-debug-userdatadir

17
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://local.revolt.chat:3000",
"webRoot": "${workspaceFolder}",
"runtimeExecutable": "/usr/bin/chromium",
"userDataDir": "${workspaceFolder}/.vscode/vscode-chrome-debug-userdatadir"
}
]
}

View file

@ -136,14 +136,12 @@
"react-helmet": "^6.1.0",
"react-hook-form": "6.3.0",
"react-overlapping-panels": "1.2.2",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-scroll": "^1.8.2",
"react-virtualized-auto-sizer": "^1.0.5",
"react-virtuoso": "^1.10.4",
"redux": "^4.1.0",
"revolt-api": "0.5.3-alpha.10",
"revolt.js": "5.2.0-patch.0",
"revolt.js": "5.2.1-patch.1",
"rimraf": "^3.0.2",
"sass": "^1.35.1",
"shade-blend-color": "^1.0.0",

View file

@ -6,7 +6,8 @@ import styled from "styled-components";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { dispatch, getState } from "../../redux";
import { useApplicationState } from "../../mobx/State";
import { SECTION_NSFW } from "../../mobx/stores/Layout";
import Button from "../ui/Button";
import Checkbox from "../ui/Checkbox";
@ -49,9 +50,7 @@ type Props = {
export default observer((props: Props) => {
const history = useHistory();
const [consent, setConsent] = useState(
getState().sectionToggle["nsfw"] ?? false,
);
const layout = useApplicationState().layout;
const [ageGate, setAgeGate] = useState(false);
if (ageGate || !props.gated) {
@ -81,26 +80,19 @@ export default observer((props: Props) => {
</span>
<Checkbox
checked={consent}
onChange={(v) => {
setConsent(v);
if (v) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: "nsfw",
state: true,
});
} else {
dispatch({ type: "SECTION_TOGGLE_UNSET", id: "nsfw" });
}
}}>
checked={layout.getSectionState(SECTION_NSFW, false)}
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}>
<Text id="app.main.channel.nsfw.confirm" />
</Checkbox>
<div className="actions">
<Button contrast onClick={() => history.goBack()}>
<Text id="app.special.modals.actions.back" />
</Button>
<Button contrast onClick={() => consent && setAgeGate(true)}>
<Button
contrast
onClick={() =>
layout.getSectionState(SECTION_NSFW) && setAgeGate(true)
}>
<Text id={`app.main.channel.nsfw.${props.type}.confirm`} />
</Button>
</div>

View file

@ -7,7 +7,7 @@ import { useState } from "preact/hooks";
import { internalEmit } from "../../../lib/eventEmitter";
import { QueuedMessage } from "../../../redux/reducers/queue";
import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useClient } from "../../../context/revoltjs/RevoltClient";

View file

@ -21,7 +21,7 @@ import {
} from "../../../lib/renderer/Singleton";
import { useApplicationState } from "../../../mobx/State";
import { Reply } from "../../../redux/reducers/queue";
import { Reply } from "../../../mobx/stores/MessageQueue";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
@ -111,7 +111,7 @@ const Action = styled.div`
const RE_SED = new RegExp("^s/([^])*/([^])*$");
// ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 4;
export const CAN_UPLOAD_AT_ONCE = 5;
export default observer(({ channel }: Props) => {
const state = useApplicationState();

View file

@ -10,8 +10,9 @@ import { StateUpdater, useEffect } from "preact/hooks";
import { internalSubscribe } from "../../../../lib/eventEmitter";
import { dispatch, getState } from "../../../../redux";
import { Reply } from "../../../../redux/reducers/queue";
import { useApplicationState } from "../../../../mobx/State";
import { SECTION_MENTION } from "../../../../mobx/stores/Layout";
import { Reply } from "../../../../mobx/stores/MessageQueue";
import IconButton from "../../../ui/IconButton";
@ -81,6 +82,7 @@ const Base = styled.div`
const MAX_REPLIES = 5;
export default observer(({ channel, replies, setReplies }: Props) => {
const client = channel.client;
const layout = useApplicationState().layout;
// Event listener for adding new messages to reply bar.
useEffect(() => {
@ -99,7 +101,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
mention:
message.author_id === client.user!._id
? false
: getState().sectionToggle.mention ?? false,
: layout.getSectionState("SECTION_MENTION", false),
},
]);
});
@ -181,11 +183,11 @@ export default observer(({ channel, replies, setReplies }: Props) => {
}),
);
dispatch({
type: "SECTION_TOGGLE_SET",
id: "mention",
layout.setSectionState(
SECTION_MENTION,
state,
});
false,
);
}}>
<span class="toggle">
<At size={15} />

View file

@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { defer } from "../../../../lib/defer";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { dispatch } from "../../../../redux";
import {
AppContext,
ClientStatus,
@ -44,8 +42,7 @@ const EmbedInviteBase = styled.div`
> button {
width: 100%;
}
`
}
`}
`;
const EmbedInviteDetails = styled.div`
@ -55,8 +52,7 @@ const EmbedInviteDetails = styled.div`
isTouchscreenDevice &&
css`
width: calc(100% - 55px);
`
}
`}
`;
const EmbedInviteName = styled.div`
@ -74,11 +70,10 @@ type Props = {
code: string;
};
export function EmbedInvite(props: Props) {
export function EmbedInvite({ code }: Props) {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const code = props.code;
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [joinError, setJoinError] = useState<string | undefined>(undefined);
@ -124,7 +119,8 @@ export function EmbedInvite(props: Props) {
<EmbedInviteDetails>
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
<EmbedInviteMemberCount>
{invite.member_count.toLocaleString()} {invite.member_count === 1 ? "member" : "members"}
{invite.member_count.toLocaleString()}{" "}
{invite.member_count === 1 ? "member" : "members"}
</EmbedInviteMemberCount>
</EmbedInviteDetails>
{processing ? (
@ -151,10 +147,9 @@ export function EmbedInvite(props: Props) {
defer(() => {
if (server) {
dispatch({
type: "UNREADS_MARK_MULTIPLE_READ",
channels: server.channel_ids,
});
client.unreads!.markMultipleRead(
server.channel_ids,
);
history.push(
`/server/${server._id}/channel/${invite.channel_id}`,
@ -172,7 +167,9 @@ export function EmbedInvite(props: Props) {
setProcessing(false);
}
}}>
{client.servers.get(invite.server_id) ? "Joined" : "Join"}
{client.servers.get(invite.server_id)
? "Joined"
: "Join"}
</Button>
)}
</EmbedInviteBase>

View file

@ -1,14 +1,16 @@
import { Route, Switch } from "react-router";
import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_CHANNELS } from "../../mobx/stores/Layout";
import SidebarBase from "./SidebarBase";
import HomeSidebar from "./left/HomeSidebar";
import ServerListSidebar from "./left/ServerListSidebar";
import ServerSidebar from "./left/ServerSidebar";
import { useSelector } from "react-redux";
import { State } from "../../redux";
export default function LeftSidebar() {
const isOpen = useSelector((state: State) => state.sectionToggle['sidebar_channels'] ?? true)
const layout = useApplicationState().layout;
const isOpen = layout.getSectionState(SIDEBAR_CHANNELS, true);
return (
<SidebarBase>

View file

@ -11,7 +11,6 @@ import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State";
import { connectState } from "../../../redux/connector";
import { useClient } from "../../../context/revoltjs/RevoltClient";

View file

@ -1,79 +0,0 @@
import { reaction } from "mobx";
import { Channel } from "revolt.js/dist/maps/Channels";
import { useLayoutEffect } from "preact/hooks";
import { dispatch } from "../../../redux";
import { Unreads } from "../../../redux/reducers/unreads";
type UnreadProps = {
channel: Channel;
unreads: Unreads;
};
export function useUnreads({ channel, unreads }: UnreadProps) {
// const firstLoad = useRef(true);
useLayoutEffect(() => {
function checkUnread(target: Channel) {
if (!target) return;
if (target._id !== channel._id) return;
if (
target.channel_type === "SavedMessages" ||
target.channel_type === "VoiceChannel"
)
return;
const unread = unreads[channel._id]?.last_id;
if (target.last_message_id) {
if (
!unread ||
(unread && target.last_message_id.localeCompare(unread) > 0)
) {
dispatch({
type: "UNREADS_MARK_READ",
channel: channel._id,
message: target.last_message_id,
});
channel.ack(target.last_message_id);
}
}
}
checkUnread(channel);
return reaction(
() => channel.last_message,
() => checkUnread(channel),
);
}, [channel, unreads]);
}
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
const last_message_id = channel.last_message_id;
let unread: "mention" | "unread" | undefined;
let alertCount: undefined | number;
if (last_message_id && unreads) {
const u = unreads[channel._id];
if (u) {
if (u.mentions && u.mentions.length > 0) {
alertCount = u.mentions.length;
unread = "mention";
} else if (
u.last_id &&
(last_message_id as string).localeCompare(u.last_id) > 0
) {
unread = "unread";
}
} else {
unread = "unread";
}
}
return {
channel,
timestamp: last_message_id ?? channel._id,
unread,
alertCount,
};
}

View file

@ -167,7 +167,7 @@ export default function Modal(props: Props) {
isModalClosing = animateClose;
const onClose = useCallback(() => {
setAnimateClose(true);
setTimeout(() => props.onClose?.(), 2e2);
setTimeout(() => props.onClose!(), 2e2);
}, [setAnimateClose, props]);
useEffect(() => internalSubscribe("Modal", "close", onClose), [onClose]);

View file

@ -5,7 +5,6 @@ import { createGlobalStyle } from "styled-components";
import { useEffect } from "preact/hooks";
import { useApplicationState } from "../mobx/State";
import { getState } from "../redux";
export type Variables =
| "accent"
@ -280,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 {

View file

@ -1,7 +1,5 @@
import { BrowserRouter as Router } from "react-router-dom";
import State from "../redux/State";
import { Children } from "../types/Preact";
import Locale from "./Locale";
import Theme from "./Theme";
@ -15,14 +13,12 @@ import Client from "./revoltjs/RevoltClient";
export default function Context({ children }: { children: Children }) {
return (
<Router basename={import.meta.env.BASE_URL}>
<State>
<Locale>
<Intermediate>
<Client>{children}</Client>
</Intermediate>
</Locale>
<Theme />
</State>
<Locale>
<Intermediate>
<Client>{children}</Client>
</Intermediate>
</Locale>
<Theme />
</Router>
);
}

View file

@ -1,7 +1,6 @@
import { Text } from "preact-i18n";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux";
import Modal from "../../../components/ui/Modal";

View file

@ -6,31 +6,28 @@ import { Message } from "revolt.js/dist/maps/Messages";
import { useContext, useEffect } from "preact/hooks";
import { useApplicationState } from "../../mobx/State";
import { connectState } from "../../redux/connector";
import { QueuedMessage } from "../../redux/reducers/queue";
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);
const state = useApplicationState();
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
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(() => {
@ -40,9 +37,3 @@ function StateMonitor(props: Props) {
return null;
}
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
};
});

View file

@ -1,25 +1,6 @@
/**
* 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 { 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 { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
/*type Props = {
settings: Settings;

View file

@ -5,7 +5,6 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"
import { StateUpdater } from "preact/hooks";
import Auth from "../../mobx/stores/Auth";
import { dispatch } from "../../redux";
import { ClientStatus } from "./RevoltClient";
@ -46,29 +45,6 @@ export function registerEvents(
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,
});
}
},
ready: () => setStatus(ClientStatus.ONLINE),
logout: () => {

View file

@ -33,14 +33,8 @@ 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 {
getNotificationState,
Notifications,
NotificationState,
} from "../redux/reducers/notifications";
import { QueuedMessage } from "../redux/reducers/queue";
import { QueuedMessage } from "../mobx/stores/MessageQueue";
import { NotificationState } from "../mobx/stores/NotificationOptions";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import {
@ -174,21 +168,19 @@ export default function ContextMenus() {
)
return;
dispatch({
type: "UNREADS_MARK_READ",
channel: data.channel._id,
message: data.channel.last_message_id!,
});
data.channel.ack(undefined, true);
client.unreads!.markRead(
data.channel._id,
data.channel.last_message_id!,
true,
true,
);
}
break;
case "mark_server_as_read":
{
dispatch({
type: "UNREADS_MARK_MULTIPLE_READ",
channels: data.server.channel_ids,
});
client.unreads!.markMultipleRead(
data.server.channel_ids,
);
data.server.ack();
}
@ -439,16 +431,6 @@ export default function ContextMenus() {
case "open_server_settings":
history.push(`/server/${data.id}/settings`);
break;
case "set_notification_state": {
const { key, state } = data;
if (state) {
dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else {
dispatch({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
}
})().catch((err) => {
openScreen({ id: "error", error: takeError(err) });

View file

@ -14,6 +14,7 @@ import MessageQueue from "./stores/MessageQueue";
import NotificationOptions from "./stores/NotificationOptions";
import ServerConfig from "./stores/ServerConfig";
import Settings from "./stores/Settings";
import Sync from "./stores/Sync";
/**
* Handles global application state.
@ -28,6 +29,7 @@ export default class State {
notifications: NotificationOptions;
queue: MessageQueue;
settings: Settings;
sync: Sync;
private persistent: [string, Persistent<unknown>][] = [];
@ -44,6 +46,7 @@ export default class State {
this.notifications = new NotificationOptions();
this.queue = new MessageQueue();
this.settings = new Settings();
this.sync = new Sync();
makeAutoObservable(this);
this.registerListeners = this.registerListeners.bind(this);
@ -116,14 +119,25 @@ export default class State {
}
}
const StateContext = createContext<State>(null!);
export const StateContextProvider = StateContext.Provider;
var state: State;
/**
* Get the application state
* @returns Application state
*/
export function useApplicationState() {
return useContext(StateContext);
if (!state) state = new State();
return state;
}
/**
*
* Redux hydration:
* localForage.getItem("state").then((s) => {
if (s !== null) {
dispatch({ type: "__INIT", state: s as State });
}
state.hydrate().then(() => setLoaded(true));
});
*/

View file

@ -1,25 +0,0 @@
split settings per account(?)
multiple accounts need to be supported
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 (v1_prefix)
need to document these data structures
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

View file

@ -1,4 +1,4 @@
import { AuthState } from "../../redux/reducers/auth";
import { Session } from "revolt-api/types/Auth";
import { Language } from "../../context/Locale";
import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme";
@ -7,6 +7,7 @@ import { Data as DataAuth } from "../stores/Auth";
import { Data as DataLocaleOptions } from "../stores/LocaleOptions";
import { Data as DataNotificationOptions } from "../stores/NotificationOptions";
import { ISettings } from "../stores/Settings";
import { Data as DataSync } from "../stores/Sync";
export type LegacyTheme = Overrides & {
light?: boolean;
@ -39,7 +40,29 @@ export interface LegacySyncData {
notifications?: LegacyNotifications;
}
function legacyMigrateAuth(auth: AuthState): DataAuth {
export type LegacySyncKeys =
| "theme"
| "appearance"
| "locale"
| "notifications";
export interface LegacySyncOptions {
disabled?: LegacySyncKeys[];
revision?: {
[key: string]: number;
};
}
export interface LegacyAuthState {
accounts: {
[key: string]: {
session: Session;
};
};
active?: string;
}
function legacyMigrateAuth(auth: LegacyAuthState): DataAuth {
return {
current: auth.active,
sessions: auth.accounts,
@ -82,3 +105,12 @@ function legacyMigrateNotification(
channel,
};
}
function legacyMigrateSync(sync: LegacySyncOptions): DataSync {
return {
disabled: sync.disabled ?? [],
revision: {
...sync.revision,
},
};
}

View file

@ -12,6 +12,11 @@ export interface Data {
openSections?: Record<string, boolean>;
}
export const SIDEBAR_MEMBERS = "sidebar_members";
export const SIDEBAR_CHANNELS = "sidebar_channels";
export const SECTION_MENTION = "mention";
export const SECTION_NSFW = "nsfw";
/**
* Keeps track of the last open channels, tabs, etc.
* Handles providing good UX experience on navigating
@ -165,4 +170,13 @@ export default class Layout implements Store, Persistent<Data> {
this.openSections.set(id, value);
}
}
/**
* Toggle state of a section.
* @param id Section ID
* @param def Default state value
*/
@action toggleSectionState(id: string, def?: boolean) {
this.setSectionState(id, !this.getSectionState(id, def));
}
}

View file

@ -78,10 +78,14 @@ export default class Settings implements Store, Persistent<ISettings> {
/**
* Get a settings key.
* @param key Colon-divided key
* @param defaultValue Default value if not present
* @returns Value at key
*/
@computed get<T extends keyof ISettings>(key: T) {
return this.data.get(key) as ISettings[T] | undefined;
@computed get<T extends keyof ISettings>(
key: T,
defaultValue?: ISettings[T],
) {
return (this.data.get(key) as ISettings[T] | undefined) ?? defaultValue;
}
@action remove<T extends keyof ISettings>(key: T) {

View file

@ -23,6 +23,9 @@ export const SYNC_KEYS: SyncKeys[] = [
export interface Data {
disabled: SyncKeys[];
revision: {
[key: string]: number;
};
}
/**
@ -30,12 +33,14 @@ export interface Data {
*/
export default class Sync implements Store, Persistent<Data> {
private disabled: ObservableSet<SyncKeys>;
private revision: ObservableMap<SyncKeys, number>;
/**
* Construct new Sync store.
*/
constructor() {
this.disabled = new ObservableSet();
this.revision = new ObservableMap();
makeAutoObservable(this);
this.isEnabled = this.isEnabled.bind(this);
}
@ -47,6 +52,7 @@ export default class Sync implements Store, Persistent<Data> {
toJSON() {
return {
enabled: [...this.disabled],
revision: mapToRecord(this.revision),
};
}
@ -58,6 +64,22 @@ export default class Sync implements Store, Persistent<Data> {
}
}
@action enable(key: SyncKeys) {
this.disabled.delete(key);
}
@action disable(key: SyncKeys) {
this.disabled.add(key);
}
@action toggle(key: SyncKeys) {
if (this.isEnabled(key)) {
this.disable(key);
} else {
this.enable(key);
}
}
@computed isEnabled(key: SyncKeys) {
return !this.disabled.has(key);
}

View file

@ -7,11 +7,12 @@ import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { useEffect } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { dispatch, getState } from "../../redux";
import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
import { useClient } from "../../context/revoltjs/RevoltClient";
@ -83,15 +84,8 @@ export function Channel({ id }: { id: string }) {
return <TextChannel channel={channel} />;
}
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
const CHANNELS_SIDEBAR_KEY = "sidebar_channels";
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
);
const [showChannels, setChannels] = useState(
getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true,
);
const layout = useApplicationState().layout;
// Mark channel as read.
useEffect(() => {
@ -121,45 +115,7 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
channel.nsfw
)
}>
<ChannelHeader
channel={channel}
toggleSidebar={() => {
setMembers(!showMembers);
if (showMembers) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: MEMBERS_SIDEBAR_KEY,
state: false,
});
} else {
dispatch({
type: "SECTION_TOGGLE_UNSET",
id: MEMBERS_SIDEBAR_KEY,
});
}
}}
toggleChannelSidebar={() => {
if (isTouchscreenDevice) {
return;
}
setChannels(!showChannels);
if (showChannels) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: CHANNELS_SIDEBAR_KEY,
state: false,
});
} else {
dispatch({
type: "SECTION_TOGGLE_UNSET",
id: CHANNELS_SIDEBAR_KEY,
});
}
}}
/>
<ChannelHeader channel={channel} />
<ChannelMain>
<ChannelContent>
<VoiceHeader id={channel._id} />
@ -168,7 +124,10 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
<JumpToBottom channel={channel} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && <RightSidebar />}
{!isTouchscreenDevice &&
layout.getSectionState(SIDEBAR_MEMBERS, true) && (
<RightSidebar />
)}
</ChannelMain>
</AgeGate>
);

View file

@ -1,4 +1,4 @@
import { At, Hash, Menu } from "@styled-icons/boxicons-regular";
import { At, Hash } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
@ -7,6 +7,9 @@ import styled, { css } from "styled-components";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { getChannelName } from "../../context/revoltjs/util";
@ -69,14 +72,16 @@ const IconConainer = styled.div`
cursor: pointer;
color: var(--secondary-foreground);
${!isTouchscreenDevice && css`
${!isTouchscreenDevice &&
css`
&:hover {
color: var(--foreground);
}
`}
`
`;
export default observer(({ channel, toggleSidebar, toggleChannelSidebar }: ChannelHeaderProps) => {
export default observer(({ channel }: ChannelHeaderProps) => {
const layout = useApplicationState().layout;
const { openScreen } = useIntermediate();
const name = getChannelName(channel);
@ -100,7 +105,12 @@ export default observer(({ channel, toggleSidebar, toggleChannelSidebar }: Chann
return (
<Header placement="primary">
<HamburgerAction />
<IconConainer onClick={toggleChannelSidebar}>{icon}</IconConainer>
<IconConainer
onClick={() =>
layout.toggleSectionState(SIDEBAR_MEMBERS, true)
}>
{icon}
</IconConainer>
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
@ -143,7 +153,7 @@ export default observer(({ channel, toggleSidebar, toggleChannelSidebar }: Chann
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
<HeaderActions channel={channel} />
</Header>
);
});

View file

@ -14,6 +14,9 @@ import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState";
import { useApplicationState } from "../../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../../mobx/stores/Layout";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import UpdateIndicator from "../../../components/common/UpdateIndicator";
@ -21,10 +24,8 @@ import IconButton from "../../../components/ui/IconButton";
import { ChannelHeaderProps } from "../ChannelHeader";
export default function HeaderActions({
channel,
toggleSidebar,
}: ChannelHeaderProps) {
export default function HeaderActions({ channel }: ChannelHeaderProps) {
const layout = useApplicationState().layout;
const { openScreen } = useIntermediate();
const history = useHistory();
@ -40,7 +41,7 @@ export default function HeaderActions({
if (isTouchscreenDevice) {
openRightSidebar();
} else {
toggleSidebar?.();
layout.toggleSectionState(SIDEBAR_MEMBERS, true);
}
}

View file

@ -10,15 +10,12 @@ import styled from "styled-components";
import { decodeTime } from "ulid";
import { Text } from "preact-i18n";
import { memo } from "preact/compat";
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";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { useClient } from "../../../context/revoltjs/RevoltClient";

View file

@ -4,11 +4,12 @@ import styled, { css } from "styled-components";
import styles from "./Home.module.scss";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { dispatch, getState } from "../../redux";
import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_CHANNELS } from "../../mobx/stores/Layout";
import { AppContext } from "../../context/revoltjs/RevoltClient";
@ -18,8 +19,6 @@ import Tooltip from "../../components/common/Tooltip";
import Header from "../../components/ui/Header";
import CategoryButton from "../../components/ui/fluent/CategoryButton";
const CHANNELS_SIDEBAR_KEY = "sidebar_channels";
const IconConainer = styled.div`
cursor: pointer;
color: var(--secondary-foreground);
@ -34,29 +33,14 @@ const IconConainer = styled.div`
export default function Home() {
const client = useContext(AppContext);
const [showChannels, setChannels] = useState(
getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true,
);
const layout = useApplicationState().layout;
const toggleChannelSidebar = () => {
if (isTouchscreenDevice) {
return;
}
setChannels(!showChannels);
if (showChannels) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: CHANNELS_SIDEBAR_KEY,
state: false,
});
} else {
dispatch({
type: "SECTION_TOGGLE_UNSET",
id: CHANNELS_SIDEBAR_KEY,
});
}
layout.toggleSectionState(SIDEBAR_CHANNELS, true);
};
return (

View file

@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { defer } from "../../lib/defer";
import { TextReact } from "../../lib/i18n";
import { dispatch } from "../../redux";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import {
AppContext,
@ -168,11 +166,9 @@ export default function Invite() {
defer(() => {
if (server) {
dispatch({
type: "UNREADS_MARK_MULTIPLE_READ",
channels:
server.channel_ids,
});
client.unreads!.markMultipleRead(
server.channel_ids,
);
history.push(
`/server/${server._id}/channel/${invite.channel_id}`,

View file

@ -6,8 +6,6 @@ import { TextReact } from "../../../lib/i18n";
import { stopPropagation } from "../../../lib/stopPropagation";
import { voiceState } from "../../../lib/vortex/VoiceState";
import { connectState } from "../../../redux/connector";
import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox";
import Overline from "../../../components/ui/Overline";
@ -17,7 +15,7 @@ const constraints = { audio: true };
// TODO: do not rewrite this code until voice is rewritten!
export function Component() {
export function Audio() {
const [mediaStream, setMediaStream] = useState<MediaStream | undefined>(
undefined,
);
@ -163,7 +161,3 @@ function changeAudioDevice(deviceId: string, deviceType: string) {
window.localStorage.setItem("audioOutputDevice", deviceId);
}
}
export const Audio = connectState(Component, () => {
return;
});

View file

@ -4,12 +4,7 @@ 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";
import {
Language,

View file

@ -5,23 +5,16 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { urlBase64ToUint8Array } from "../../../lib/conversion";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { NotificationOptions } from "../../../redux/reducers/settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Checkbox from "../../../components/ui/Checkbox";
interface Props {
options?: NotificationOptions;
}
export function Component({ options }: Props) {
export function Notifications() {
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const sounds = useApplicationState().settings.sounds;
const settings = useApplicationState().settings;
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
undefined,
);
@ -43,7 +36,7 @@ export function Component({ options }: Props) {
</h3>
<Checkbox
disabled={!("Notification" in window)}
checked={options?.desktopEnabled ?? false}
checked={settings.get("notifications:desktop", false)!}
description={
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
}
@ -51,6 +44,7 @@ export function Component({ options }: Props) {
if (desktopEnabled) {
const permission =
await Notification.requestPermission();
if (permission !== "granted") {
return openScreen({
id: "error",
@ -59,10 +53,7 @@ export function Component({ options }: Props) {
}
}
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: { desktopEnabled },
});
settings.set("notifications:desktop", desktopEnabled);
}}>
<Text id="app.settings.pages.notifications.enable_desktop" />
</Checkbox>
@ -115,20 +106,16 @@ export function Component({ options }: Props) {
<h3>
<Text id="app.settings.pages.notifications.sounds" />
</h3>
{sounds.getState().map(({ id, enabled }) => (
{settings.sounds.getState().map(({ id, enabled }) => (
<Checkbox
key={id}
checked={enabled}
onChange={(enabled) => sounds.setEnabled(id, enabled)}>
onChange={(enabled) =>
settings.sounds.setEnabled(id, enabled)
}>
<Text id={`app.settings.pages.notifications.sound.${id}`} />
</Checkbox>
))}
</div>
);
}
export const Notifications = connectState(Component, (state) => {
return {
options: state.settings.notification,
};
});

View file

@ -1,17 +1,16 @@
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 { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
import { useApplicationState } from "../../../mobx/State";
import { SyncKeys } from "../../../mobx/stores/Sync";
import Checkbox from "../../../components/ui/Checkbox";
interface Props {
options?: SyncOptions;
}
export const Sync = observer(() => {
const sync = useApplicationState().sync;
export function Component(props: Props) {
return (
<div className={styles.notifications}>
<h3>
@ -27,31 +26,16 @@ export function Component(props: Props) {
).map(([key, title]) => (
<Checkbox
key={key}
checked={
(props.options?.disabled ?? []).indexOf(key) === -1
}
checked={sync.isEnabled(key)}
description={
<Text
id={`app.settings.pages.sync.descriptions.${key}`}
/>
}
onChange={(enabled) =>
dispatch({
type: enabled
? "SYNC_ENABLE_KEY"
: "SYNC_DISABLE_KEY",
key,
})
}>
onChange={() => sync.toggle(key)}>
<Text id={`app.settings.pages.${title}`} />
</Checkbox>
))}
</div>
);
}
export const Sync = connectState(Component, (state) => {
return {
options: state.sync,
};
});

View file

@ -3,9 +3,8 @@ import styled from "styled-components";
import { useEffect, useState } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { dispatch } from "../../../redux";
import { Theme, generateVariables, ThemeOptions } from "../../../context/Theme";
import { Theme, generateVariables } from "../../../context/Theme";
import Tip from "../../../components/ui/Tip";
import previewPath from "../assets/preview.svg";

View file

@ -1,49 +0,0 @@
import localForage from "localforage";
import { Provider } from "react-redux";
import { useEffect, useRef, useState } from "preact/hooks";
import MobXState, { StateContextProvider } from "../mobx/State";
import { dispatch, State, store } from ".";
import { Children } from "../types/Preact";
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 { current: state } = useRef(new MobXState());
// Globally expose the application state.
useEffect(() => {
(window as unknown as Record<string, unknown>).state = state;
}, [state]);
useEffect(() => {
localForage.getItem("state").then((s) => {
if (s !== null) {
dispatch({ type: "__INIT", state: s as State });
}
state.hydrate().then(() => setLoaded(true));
});
}, []);
if (!loaded) return null;
useEffect(state.registerListeners);
return (
<Provider store={store}>
<StateContextProvider value={state}>
{props.children}
</StateContextProvider>
</Provider>
);
}

View file

@ -1,16 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { connect, ConnectedComponent } from "react-redux";
import { h } from "preact";
import { memo } from "preact/compat";
import { State } from ".";
export function connectState<T>(
component: (props: any) => h.JSX.Element | null,
mapKeys: (state: State, props: T) => any,
memoize?: boolean,
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
const c = connect(mapKeys)(component);
return memoize ? memo(c) : c;
}

View file

@ -1,94 +0,0 @@
import localForage from "localforage";
import { createStore } from "redux";
import { RevoltConfiguration } from "revolt-api/types/Core";
import { Language } from "../context/Locale";
import rootReducer, { Action } from "./reducers";
import { AuthState } from "./reducers/auth";
import { Drafts } from "./reducers/drafts";
import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened";
import { Notifications } from "./reducers/notifications";
import { QueuedMessage } from "./reducers/queue";
import { SectionToggle } from "./reducers/section_toggle";
import { Settings } from "./reducers/settings";
import { SyncOptions } from "./reducers/sync";
import { Themes } from "./reducers/themes";
import { TrustedLinks } from "./reducers/trusted_links";
import { Unreads } from "./reducers/unreads";
export type State = {
config: RevoltConfiguration;
locale: Language;
auth: AuthState;
settings: Settings;
unreads: Unreads;
queue: QueuedMessage[];
drafts: Drafts;
sync: SyncOptions;
experiments: ExperimentOptions;
lastOpened: LastOpened;
notifications: Notifications;
sectionToggle: SectionToggle;
trustedLinks: TrustedLinks;
themes: Themes;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const store = createStore((state: any, action: any) => {
if (import.meta.env.DEV) {
console.debug("State Update:", action);
}
if (action.type === "__INIT") {
return action.state;
}
return rootReducer(state, action);
});
// Save state using localForage.
store.subscribe(() => {
const {
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
trustedLinks,
themes,
} = store.getState() as State;
localForage.setItem("state", {
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
trustedLinks,
themes,
});
});
export function dispatch(action: Action) {
store.dispatch(action);
}
export function getState(): State {
return store.getState();
}

View file

@ -1,49 +0,0 @@
import { Session } from "revolt-api/types/Auth";
export interface AuthState {
accounts: {
[key: string]: {
session: Session;
};
};
active?: string;
}
export type AuthAction =
| { type: undefined }
| {
type: "LOGIN";
session: Session;
}
| {
type: "LOGOUT";
user_id?: string;
};
export function auth(
state = { accounts: {} } as AuthState,
action: AuthAction,
): AuthState {
switch (action.type) {
case "LOGIN":
return {
accounts: {
...state.accounts,
[action.session.user_id]: {
session: action.session,
},
},
active: action.session.user_id,
};
case "LOGOUT": {
const accounts = Object.assign({}, state.accounts);
action.user_id && delete accounts[action.user_id];
return {
accounts,
};
}
default:
return state;
}
}

View file

@ -1,35 +0,0 @@
export type Drafts = { [key: string]: string };
export type DraftAction =
| { type: undefined }
| {
type: "SET_DRAFT";
channel: string;
content: string;
}
| {
type: "CLEAR_DRAFT";
channel: string;
}
| {
type: "RESET";
};
export function drafts(state: Drafts = {}, action: DraftAction): Drafts {
switch (action.type) {
case "SET_DRAFT":
return {
...state,
[action.channel]: action.content,
};
case "CLEAR_DRAFT": {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [action.channel]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,67 +0,0 @@
import { getState } from "..";
export type Experiments = "search" | "theme_shop";
export const AVAILABLE_EXPERIMENTS: Experiments[] = ["theme_shop"];
export const EXPERIMENTS: {
[key in Experiments]: { 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.",
},
};
export interface ExperimentOptions {
enabled?: Experiments[];
}
export type ExperimentsAction =
| { type: undefined }
| {
type: "EXPERIMENTS_ENABLE";
key: Experiments;
}
| {
type: "EXPERIMENTS_DISABLE";
key: Experiments;
};
export function experiments(
state = {} as ExperimentOptions,
action: ExperimentsAction,
): ExperimentOptions {
switch (action.type) {
case "EXPERIMENTS_ENABLE":
return {
...state,
enabled: [
...(state.enabled ?? [])
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x))
.filter((v) => v !== action.key),
action.key,
],
};
case "EXPERIMENTS_DISABLE":
return {
...state,
enabled: state.enabled
?.filter((v) => v !== action.key)
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x)),
};
default:
return state;
}
}
export function isExperimentEnabled(
name: Experiments,
experiments: ExperimentOptions = getState().experiments,
) {
return experiments.enabled?.includes(name) ?? false;
}

View file

@ -1,51 +0,0 @@
import { combineReducers } from "redux";
import { State } from "..";
import { auth, AuthAction } from "./auth";
import { drafts, DraftAction } from "./drafts";
import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened";
import { locale, LocaleAction } from "./locale";
import { notifications, NotificationsAction } from "./notifications";
import { queue, QueueAction } from "./queue";
import { sectionToggle, SectionToggleAction } from "./section_toggle";
import { config, ConfigAction } from "./server_config";
import { settings, SettingsAction } from "./settings";
import { sync, SyncAction } from "./sync";
import { themes, ThemesAction } from "./themes";
import { trustedLinks, TrustedLinksAction } from "./trusted_links";
import { unreads, UnreadsAction } from "./unreads";
export default combineReducers({
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
trustedLinks,
themes,
});
export type Action =
| ConfigAction
| LocaleAction
| AuthAction
| SettingsAction
| UnreadsAction
| QueueAction
| DraftAction
| SyncAction
| ExperimentsAction
| LastOpenedAction
| NotificationsAction
| SectionToggleAction
| TrustedLinksAction
| ThemesAction
| { type: "__INIT"; state: State };

View file

@ -1,32 +0,0 @@
export interface LastOpened {
[key: string]: string;
}
export type LastOpenedAction =
| { type: undefined }
| {
type: "LAST_OPENED_SET";
parent: string;
child: string;
}
| {
type: "RESET";
};
export function lastOpened(
state = {} as LastOpened,
action: LastOpenedAction,
): LastOpened {
switch (action.type) {
case "LAST_OPENED_SET": {
return {
...state,
[action.parent]: action.child,
};
}
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,56 +0,0 @@
import { Language, Languages } from "../../context/Locale";
import type { SyncUpdateAction } from "./sync";
export type LocaleAction =
| { type: undefined }
| {
type: "SET_LOCALE";
locale: Language;
}
| SyncUpdateAction;
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;
}
export function locale(state = findLanguage(), action: LocaleAction): Language {
switch (action.type) {
case "SET_LOCALE":
return action.locale;
case "SYNC_UPDATE":
return (action.update.locale?.[1] ?? state) as Language;
default:
return state;
}
}

View file

@ -1,83 +0,0 @@
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import type { SyncUpdateAction } from "./sync";
export type NotificationState = "all" | "mention" | "none" | "muted";
export type Notifications = {
[key: string]: NotificationState;
};
export const DEFAULT_STATES: {
[key in Channel["channel_type"]]: NotificationState;
} = {
SavedMessages: "all",
DirectMessage: "all",
Group: "all",
TextChannel: "mention",
VoiceChannel: "mention",
};
export function getNotificationState(
notifications: Notifications,
channel: Channel,
) {
return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type];
}
export function shouldNotify(
state: NotificationState,
message: Message,
user_id: string,
) {
switch (state) {
case "muted":
case "none":
return false;
case "mention": {
if (!message.mention_ids?.includes(user_id)) return false;
}
}
return true;
}
export type NotificationsAction =
| { type: undefined }
| {
type: "NOTIFICATIONS_SET";
key: string;
state: NotificationState;
}
| {
type: "NOTIFICATIONS_REMOVE";
key: string;
}
| SyncUpdateAction
| {
type: "RESET";
};
export function notifications(
state = {} as Notifications,
action: NotificationsAction,
): Notifications {
switch (action.type) {
case "NOTIFICATIONS_SET":
return {
...state,
[action.key]: action.state,
};
case "NOTIFICATIONS_REMOVE": {
const { [action.key]: _, ...newState } = state;
return newState;
}
case "SYNC_UPDATE":
return action.update.notifications?.[1] ?? state;
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,115 +0,0 @@
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;
}
export type QueueAction =
| { type: undefined }
| {
type: "QUEUE_ADD";
nonce: string;
channel: string;
message: QueuedMessageData;
}
| {
type: "QUEUE_FAIL";
nonce: string;
error: string;
}
| {
type: "QUEUE_START";
nonce: string;
}
| {
type: "QUEUE_REMOVE";
nonce: string;
}
| {
type: "QUEUE_DROP_ALL";
}
| {
type: "QUEUE_FAIL_ALL";
}
| {
type: "RESET";
};
export function queue(
state: QueuedMessage[] = [],
action: QueueAction,
): QueuedMessage[] {
switch (action.type) {
case "QUEUE_ADD": {
return [
...state.filter((x) => x.id !== action.nonce),
{
id: action.nonce,
data: action.message,
channel: action.channel,
status: QueueStatus.SENDING,
},
];
}
case "QUEUE_FAIL": {
const entry = state.find(
(x) => x.id === action.nonce,
) as QueuedMessage;
return [
...state.filter((x) => x.id !== action.nonce),
{
...entry,
status: QueueStatus.ERRORED,
error: action.error,
},
];
}
case "QUEUE_START": {
const entry = state.find(
(x) => x.id === action.nonce,
) as QueuedMessage;
return [
...state.filter((x) => x.id !== action.nonce),
{
...entry,
status: QueueStatus.SENDING,
},
];
}
case "QUEUE_REMOVE":
return state.filter((x) => x.id !== action.nonce);
case "QUEUE_FAIL_ALL":
return state.map((x) => {
return {
...x,
status: QueueStatus.ERRORED,
};
});
case "QUEUE_DROP_ALL":
case "RESET":
return [];
default:
return state;
}
}

View file

@ -1,40 +0,0 @@
export interface SectionToggle {
[key: string]: boolean;
}
export type SectionToggleAction =
| { type: undefined }
| {
type: "SECTION_TOGGLE_SET";
id: string;
state: boolean;
}
| {
type: "SECTION_TOGGLE_UNSET";
id: string;
}
| {
type: "RESET";
};
export function sectionToggle(
state = {} as SectionToggle,
action: SectionToggleAction,
): SectionToggle {
switch (action.type) {
case "SECTION_TOGGLE_SET": {
return {
...state,
[action.id]: action.state,
};
}
case "SECTION_TOGGLE_UNSET": {
const { [action.id]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,20 +0,0 @@
import type { RevoltConfiguration } from "revolt-api/types/Core";
export type ConfigAction =
| { type: undefined }
| {
type: "SET_CONFIG";
config: RevoltConfiguration;
};
export function config(
state = {} as RevoltConfiguration,
action: ConfigAction,
): RevoltConfiguration {
switch (action.type) {
case "SET_CONFIG":
return action.config;
default:
return state;
}
}

View file

@ -1,112 +0,0 @@
import type { Theme, ThemeOptions } from "../../context/Theme";
import { setGlobalEmojiPack } from "../../components/common/Emoji";
import type { SyncUpdateAction } from "./sync";
type Sounds = "message" | "outbound" | "call_join" | "call_leave";
export type SoundOptions = {
[key in Sounds]?: boolean;
};
export const DEFAULT_SOUNDS: SoundOptions = {
message: true,
outbound: false,
call_join: true,
call_leave: true,
};
export interface NotificationOptions {
desktopEnabled?: boolean;
sounds?: SoundOptions;
}
export type EmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji";
export interface AppearanceOptions {
emojiPack?: EmojiPacks;
}
export interface Settings {
theme?: ThemeOptions;
appearance?: AppearanceOptions;
notification?: NotificationOptions;
}
export type SettingsAction =
| { type: undefined }
| {
type: "SETTINGS_SET_THEME";
theme: ThemeOptions;
}
| {
type: "SETTINGS_SET_THEME_OVERRIDE";
custom?: Partial<Theme>;
}
| {
type: "SETTINGS_SET_NOTIFICATION_OPTIONS";
options: NotificationOptions;
}
| {
type: "SETTINGS_SET_APPEARANCE";
options: Partial<AppearanceOptions>;
}
| SyncUpdateAction
| {
type: "RESET";
};
export function settings(
state = {} as Settings,
action: SettingsAction,
): Settings {
// setGlobalEmojiPack(state.appearance?.emojiPack ?? "mutant");
switch (action.type) {
case "SETTINGS_SET_THEME":
return {
...state,
theme: {
...state.theme,
...action.theme,
},
};
case "SETTINGS_SET_THEME_OVERRIDE":
return {
...state,
theme: {
...state.theme,
custom: {
...state.theme?.custom,
...action.custom,
},
},
};
case "SETTINGS_SET_NOTIFICATION_OPTIONS":
return {
...state,
notification: {
...state.notification,
...action.options,
},
};
case "SETTINGS_SET_APPEARANCE":
return {
...state,
appearance: {
...state.appearance,
...action.options,
},
};
case "SYNC_UPDATE":
return {
...state,
appearance: action.update.appearance?.[1] ?? state.appearance,
theme: action.update.theme?.[1] ?? state.theme,
};
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,94 +0,0 @@
import type { Language } from "../../context/Locale";
import type { ThemeOptions } from "../../context/Theme";
import type { Notifications } from "./notifications";
import type { AppearanceOptions } from "./settings";
export type SyncKeys = "theme" | "appearance" | "locale" | "notifications";
export interface SyncData {
locale?: Language;
theme?: ThemeOptions;
appearance?: AppearanceOptions;
notifications?: Notifications;
}
export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [
"theme",
"appearance",
"locale",
"notifications",
];
export interface SyncOptions {
disabled?: SyncKeys[];
revision?: {
[key: string]: number;
};
}
export type SyncUpdateAction = {
type: "SYNC_UPDATE";
update: { [key in SyncKeys]?: [number, SyncData[key]] };
};
export type SyncAction =
| { type: undefined }
| {
type: "SYNC_ENABLE_KEY";
key: SyncKeys;
}
| {
type: "SYNC_DISABLE_KEY";
key: SyncKeys;
}
| {
type: "SYNC_SET_REVISION";
key: SyncKeys;
timestamp: number;
}
| SyncUpdateAction;
export function sync(
state = {} as SyncOptions,
action: SyncAction,
): SyncOptions {
switch (action.type) {
case "SYNC_DISABLE_KEY":
return {
...state,
disabled: [
...(state.disabled ?? []).filter((v) => v !== action.key),
action.key,
],
};
case "SYNC_ENABLE_KEY":
return {
...state,
disabled: state.disabled?.filter((v) => v !== action.key),
};
case "SYNC_SET_REVISION":
return {
...state,
revision: {
...state.revision,
[action.key]: action.timestamp,
},
};
case "SYNC_UPDATE": {
const revision = { ...state.revision };
for (const key of Object.keys(action.update)) {
const value = action.update[key as SyncKeys];
if (value) {
revision[key] = value[0];
}
}
return {
...state,
revision,
};
}
default:
return state;
}
}

View file

@ -1,33 +0,0 @@
import { Theme } from "../../context/Theme";
import { ThemeMetadata } from "../../pages/settings/panes/ThemeShop";
export interface StoredTheme {
slug: string;
meta: ThemeMetadata;
theme: Theme;
}
export type Themes = Record<string, StoredTheme>;
export type ThemesAction =
| { type: undefined }
| { type: "THEMES_SET_THEME"; theme: StoredTheme }
| { type: "THEMES_REMOVE_THEME"; slug: string }
| { type: "RESET" };
export function themes(state: Themes = {}, action: ThemesAction) {
switch (action.type) {
case "THEMES_SET_THEME":
return {
...state,
[action.theme.slug]: action.theme,
};
case "THEMES_REMOVE_THEME":
return { ...state, [action.slug]: null };
case "RESET":
return {};
default:
return state;
}
}

View file

@ -1,37 +0,0 @@
export interface TrustedLinks {
domains?: string[];
}
export type TrustedLinksAction =
| { type: undefined }
| {
type: "TRUSTED_LINKS_ADD_DOMAIN";
domain: string;
}
| {
type: "TRUSTED_LINKS_REMOVE_DOMAIN";
domain: string;
};
export function trustedLinks(
state = {} as TrustedLinks,
action: TrustedLinksAction,
): TrustedLinks {
switch (action.type) {
case "TRUSTED_LINKS_ADD_DOMAIN":
return {
...state,
domains: [
...(state.domains ?? []).filter((v) => v !== action.domain),
action.domain,
],
};
case "TRUSTED_LINKS_REMOVE_DOMAIN":
return {
...state,
domains: state.domains?.filter((v) => v !== action.domain),
};
default:
return state;
}
}

View file

@ -1,77 +0,0 @@
import type { ChannelUnread } from "revolt-api/types/Sync";
import { ulid } from "ulid";
export interface Unreads {
[key: string]: Partial<Omit<ChannelUnread, "_id">>;
}
export type UnreadsAction =
| { type: undefined }
| {
type: "UNREADS_MARK_READ";
channel: string;
message: string;
}
| {
type: "UNREADS_MARK_MULTIPLE_READ";
channels: string[];
}
| {
type: "UNREADS_SET";
unreads: ChannelUnread[];
}
| {
type: "UNREADS_MENTION";
channel: string;
message: string;
}
| {
type: "RESET";
};
export function unreads(state = {} as Unreads, action: UnreadsAction): Unreads {
switch (action.type) {
case "UNREADS_MARK_READ":
return {
...state,
[action.channel]: {
last_id: action.message,
},
};
case "UNREADS_MARK_MULTIPLE_READ": {
const newState = { ...state };
const last_id = ulid();
for (const channel of action.channels) {
newState[channel] = {
last_id,
};
}
return newState;
}
case "UNREADS_SET": {
const obj: Unreads = {};
for (const entry of action.unreads) {
const { _id, ...v } = entry;
obj[_id.channel] = v;
}
return obj;
}
case "UNREADS_MENTION": {
const obj = state[action.channel];
return {
...state,
[action.channel]: {
...obj,
mentions: [...(obj?.mentions ?? []), action.message],
},
};
}
case "RESET":
return {};
default:
return state;
}
}

View file

@ -2463,10 +2463,10 @@ events@^3.3.0:
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
exponential-backoff@^3.1.0:
"exponential-backoff@npm:@insertish/exponential-backoff":
version "3.1.0"
resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.0.tgz#9409c7e579131f8bd4b32d7d8094a911040f2e68"
integrity sha512-oBuz5SYz5zzyuHINoe9ooePwSu0xApKWgeNzok4hZ5YKXFh9zrQBEM15CXqoZkJJPuI2ArvqjPQd8UKJA753XA==
resolved "https://registry.yarnpkg.com/@insertish/exponential-backoff/-/exponential-backoff-3.1.0.tgz#1d2e4c215fa8647779cfeab74ecb54a5c36835e6"
integrity sha512-8Jab9OfjheI84T04QjUwXceSO1DMGy8goDqVdnuoffC2fg23zBnikLJkrRHiT/ao4c08v4R2mU7+/DXMWmROng==
fake-mediastreamtrack@^1.1.6:
version "1.1.6"
@ -2912,10 +2912,10 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
isomorphic-ws@^4.0.1:
"isomorphic-ws@npm:@insertish/isomorphic-ws":
version "4.0.1"
resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc"
integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==
resolved "https://registry.yarnpkg.com/@insertish/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#5bcd6f73b93efa9ccdb6abf887ae808d40827169"
integrity sha512-kFD/p8T4Hkqr992QrdkbW/cQ/W/q2d9MPCobwzBv2PwTKLkCD9RaYDy6m17qRnSLQQ5PU0kHCG8kaOwAqzj1vQ==
javascript-natural-sort@0.7.1:
version "0.7.1"
@ -3576,18 +3576,6 @@ react-redux@^7.2.0:
prop-types "^15.7.2"
react-is "^16.13.1"
react-redux@^7.2.4:
version "7.2.4"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225"
integrity sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==
dependencies:
"@babel/runtime" "^7.12.1"
"@types/react-redux" "^7.1.16"
hoist-non-react-statics "^3.3.2"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^16.13.1"
react-router-dom@^5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.1.tgz#34af8b551a4ce17487d3f80e651b91651978dff6"
@ -3651,7 +3639,7 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
redux@^4.0.0, redux@^4.0.4, redux@^4.1.0:
redux@^4.0.0, redux@^4.0.4:
version "4.1.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.1.tgz#76f1c439bb42043f985fbd9bf21990e60bd67f47"
integrity sha512-hZQZdDEM25UY2P493kPYuKqviVwZ58lEmGQNeQ+gXa+U0gYPUBf7NKYazbe3m+bs/DzM/ahN12DbF+NG8i0CWw==
@ -3770,15 +3758,15 @@ 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.2.0-patch.0:
version "5.2.0-patch.0"
resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.2.0-patch.0.tgz#af6afc402399e5394b50b2e7d1573ff490fd3906"
integrity sha512-PnHKqRpEvrBFm1xtLA/lGG5FIsp5kW4eB8sYiejjQCA1DWi7Xg6MNvyOjjha6jKftPXF8roivfZWEnM7sY1bnA==
revolt.js@5.2.1-patch.1:
version "5.2.1-patch.1"
resolved "https://registry.yarnpkg.com/revolt.js/-/revolt.js-5.2.1-patch.1.tgz#4b392d4dae12ea28f559ef89790368f53788c81d"
integrity sha512-u2vvbCWXKx+vZKqlt5izowf9XnMbWdh3GaPMzipek6l6mBYSCIlr796HoiiIO3c2T3AWqh3zav97rm8z3jOIXg==
dependencies:
axios "^0.21.4"
eventemitter3 "^4.0.7"
exponential-backoff "^3.1.0"
isomorphic-ws "^4.0.1"
exponential-backoff "npm:@insertish/exponential-backoff"
isomorphic-ws "npm:@insertish/isomorphic-ws"
lodash.defaultsdeep "^4.6.1"
lodash.flatten "^4.4.0"
lodash.isequal "^4.5.0"