Merge branch 'mobx'

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

2
.gitignore vendored
View file

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

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

@ -0,0 +1,16 @@
{
// 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"
}
]
}

View file

@ -1,10 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" background="#191919">
<head>
<meta charset="UTF-8" />
<!--App Title-->
<title>Revolt</title>
<meta name="apple-mobile-web-app-title" content="Revolt" />
<!--App Scaling-->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
@ -74,9 +77,4 @@
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<style>
html {
background-color: #191919;
}
</style>
</html>

View file

@ -5,7 +5,7 @@
"pull": "node scripts/setup_assets.js",
"build": "rimraf build && node scripts/setup_assets.js --check && vite build",
"preview": "vite preview",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"lint": "eslint src/**/*.{js,jsx,ts,tsx}",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit",
"start": "sirv dist --cors --single --host",
@ -37,6 +37,24 @@
{
"varsIgnorePattern": "^_"
}
],
"require-jsdoc": [
"error",
{
"require": {
"FunctionDeclaration": true,
"MethodDefinition": true,
"ClassDeclaration": true,
"ArrowFunctionExpression": false,
"FunctionExpression": false
},
"ignore": {
"MethodDefinition": [
"toJSON",
"hydrate"
]
}
}
]
}
},
@ -99,6 +117,7 @@
"eslint-config-preact": "^1.1.4",
"eventemitter3": "^4.0.7",
"highlight.js": "^11.0.1",
"json-stringify-deterministic": "^1.0.2",
"localforage": "^1.9.0",
"lodash.defaultsdeep": "^4.6.1",
"lodash.isequal": "^4.5.0",
@ -118,14 +137,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.1.0-alpha.10",
"revolt.js": "5.2.1-patch.1",
"rimraf": "^3.0.2",
"sass": "^1.35.1",
"shade-blend-color": "^1.0.0",

View file

@ -1,29 +0,0 @@
import call_join from "./call_join.mp3";
import call_leave from "./call_leave.mp3";
import message from "./message.mp3";
import outbound from "./outbound.mp3";
const SoundMap: { [key in Sounds]: string } = {
message,
outbound,
call_join,
call_leave,
};
export type Sounds = "message" | "outbound" | "call_join" | "call_leave";
export const SOUNDS_ARRAY: Sounds[] = [
"message",
"outbound",
"call_join",
"call_leave",
];
export function playSound(sound: Sounds) {
const file = SoundMap[sound];
const el = new Audio(file);
try {
el.play();
} catch (err) {
console.error("Failed to play audio file", file, err);
}
}

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

@ -1,7 +1,6 @@
import { ChevronDown } from "@styled-icons/boxicons-regular";
import { State, store } from "../../redux";
import { Action } from "../../redux/reducers";
import { useApplicationState } from "../../mobx/State";
import Details from "../ui/Details";
@ -25,27 +24,14 @@ export default function CollapsibleSection({
children,
...detailsProps
}: Props) {
const state: State = store.getState();
function setState(state: boolean) {
if (state === defaultValue) {
store.dispatch({
type: "SECTION_TOGGLE_UNSET",
id,
} as Action);
} else {
store.dispatch({
type: "SECTION_TOGGLE_SET",
id,
state,
} as Action);
}
}
const layout = useApplicationState().layout;
return (
<Details
open={state.sectionToggle[id] ?? defaultValue}
onToggle={(e) => setState(e.currentTarget.open)}
open={layout.getSectionState(id, defaultValue)}
onToggle={(e) =>
layout.setSectionState(id, e.currentTarget.open, defaultValue)
}
{...detailsProps}>
<summary>
<div class="padding">

View file

@ -1,9 +1,9 @@
import { EmojiPacks } from "../../redux/reducers/settings";
export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji";
let EMOJI_PACK = "mutant";
let EMOJI_PACK: EmojiPack = "mutant";
const REVISION = 3;
export function setEmojiPack(pack: EmojiPacks) {
export function setGlobalEmojiPack(pack: EmojiPack) {
EMOJI_PACK = pack;
}

View file

@ -1,23 +1,21 @@
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { useApplicationState } from "../../mobx/State";
import { Language, Languages } from "../../context/Locale";
import ComboBox from "../ui/ComboBox";
type Props = {
locale: string;
};
/**
* Component providing a language selector combobox.
* Note: this is not an observer but this is fine as we are just using a combobox.
*/
export default function LocaleSelector() {
const locale = useApplicationState().locale;
export function LocaleSelector(props: Props) {
return (
<ComboBox
value={props.locale}
value={locale.getLanguage()}
onChange={(e) =>
dispatch({
type: "SET_LOCALE",
locale: e.currentTarget.value as Language,
})
locale.setLanguage(e.currentTarget.value as Language)
}>
{Object.keys(Languages).map((x) => {
const l = Languages[x as keyof typeof Languages];
@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) {
</ComboBox>
);
}
export default connectState(LocaleSelector, (state) => {
return {
locale: state.locale,
};
});

View file

@ -1,11 +1,11 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { ThemeContext } from "../../context/Theme";
import { useApplicationState } from "../../mobx/State";
import IconButton from "../ui/IconButton";
@ -27,7 +27,7 @@ export default function UpdateIndicator({ style }: Props) {
});
if (!pending) return null;
const theme = useContext(ThemeContext);
const theme = useApplicationState().settings.theme;
if (style === "titlebar") {
return (
@ -36,7 +36,10 @@ export default function UpdateIndicator({ style }: Props) {
content="A new update is available!"
placement="bottom">
<div onClick={() => updateSW(true)}>
<CloudDownload size={22} color={theme.success} />
<CloudDownload
size={22}
color={theme.getVariable("success")}
/>
</div>
</Tooltip>
</div>
@ -47,7 +50,7 @@ export default function UpdateIndicator({ style }: Props) {
return (
<IconButton onClick={() => updateSW(true)}>
<Download size={22} color={theme.success} />
<Download size={22} color={theme.getVariable("success")} />
</IconButton>
);
}

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

@ -20,10 +20,9 @@ import {
SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton";
import { dispatch, getState } from "../../../redux";
import { Reply } from "../../../redux/reducers/queue";
import { useApplicationState } from "../../../mobx/State";
import { Reply } from "../../../mobx/stores/MessageQueue";
import { SoundContext } from "../../../context/Settings";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
FileUploader,
@ -112,17 +111,16 @@ 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 [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
const state = useApplicationState();
const [uploadState, setUploadState] = useState<UploadState>({
type: "none",
});
const [typing, setTyping] = useState<boolean | number>(false);
const [replies, setReplies] = useState<Reply[]>([]);
const playSound = useContext(SoundContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const translate = useTranslation();
@ -148,27 +146,18 @@ export default observer(({ channel }: Props) => {
);
}
// Push message content to draft.
const setMessage = useCallback(
(content?: string) => {
setDraft(content ?? "");
if (content) {
dispatch({
type: "SET_DRAFT",
channel: channel._id,
content,
});
} else {
dispatch({
type: "CLEAR_DRAFT",
channel: channel._id,
});
}
},
[channel._id],
(content?: string) => state.draft.set(channel._id, content),
[state.draft, channel._id],
);
useEffect(() => {
/**
*
* @param content
* @param action
*/
function append(content: string, action: "quote" | "mention") {
const text =
action === "quote"
@ -178,10 +167,10 @@ export default observer(({ channel }: Props) => {
.join("\n")}\n\n`
: `${content} `;
if (!draft || draft.length === 0) {
if (!state.draft.has(channel._id)) {
setMessage(text);
} else {
setMessage(`${draft}\n${text}`);
setMessage(`${state.draft.get(channel._id)}\n${text}`);
}
}
@ -190,13 +179,16 @@ export default observer(({ channel }: Props) => {
"append",
append as (...args: unknown[]) => void,
);
}, [draft, setMessage]);
}, [state.draft, channel._id, setMessage]);
/**
* Trigger send message.
*/
async function send() {
if (uploadState.type === "uploading" || uploadState.type === "sending")
return;
const content = draft?.trim() ?? "";
const content = state.draft.get(channel._id)?.trim() ?? "";
if (uploadState.type === "attached") return sendFile(content);
if (content.length === 0) return;
@ -247,20 +239,15 @@ export default observer(({ channel }: Props) => {
}
}
} else {
playSound("outbound");
state.settings.sounds.playSound("outbound");
dispatch({
type: "QUEUE_ADD",
nonce,
state.queue.add(nonce, channel._id, {
_id: nonce,
channel: channel._id,
message: {
_id: nonce,
channel: channel._id,
author: client.user!._id,
author: client.user!._id,
content,
replies,
},
content,
replies,
});
defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
@ -272,15 +259,16 @@ export default observer(({ channel }: Props) => {
replies,
});
} catch (error) {
dispatch({
type: "QUEUE_FAIL",
error: takeError(error),
nonce,
});
state.queue.fail(nonce, takeError(error));
}
}
}
/**
*
* @param content
* @returns
*/
async function sendFile(content: string) {
if (uploadState.type !== "attached") return;
const attachments: string[] = [];
@ -360,7 +348,7 @@ export default observer(({ channel }: Props) => {
setMessage();
setReplies([]);
playSound("outbound");
state.settings.sounds.playSound("outbound");
if (files.length > CAN_UPLOAD_AT_ONCE) {
setUploadState({
@ -372,6 +360,10 @@ export default observer(({ channel }: Props) => {
}
}
/**
*
* @returns
*/
function startTyping() {
if (typeof typing === "number" && +new Date() < typing) return;
@ -385,6 +377,10 @@ export default observer(({ channel }: Props) => {
}
}
/**
*
* @param force
*/
function stopTyping(force?: boolean) {
if (force || typing) {
const ws = client.websocket;
@ -503,7 +499,7 @@ export default observer(({ channel }: Props) => {
id="message"
maxLength={2000}
onKeyUp={onKeyUp}
value={draft ?? ""}
value={state.draft.get(channel._id) ?? ""}
padding="var(--message-box-padding)"
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") {
@ -515,7 +511,7 @@ export default observer(({ channel }: Props) => {
if (
e.key === "ArrowUp" &&
(!draft || draft.length === 0)
!state.draft.has(channel._id)
) {
e.preventDefault();
internalEmit("MessageRenderer", "edit_last");

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,
@ -33,7 +31,7 @@ const EmbedInviteBase = styled.div`
align-items: center;
padding: 0 12px;
margin-top: 2px;
${() =>
${() =>
isTouchscreenDevice &&
css`
flex-wrap: wrap;
@ -44,19 +42,17 @@ const EmbedInviteBase = styled.div`
> button {
width: 100%;
}
`
}
`}
`;
const EmbedInviteDetails = styled.div`
flex-grow: 1;
padding-left: 12px;
${() =>
${() =>
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

@ -5,12 +5,10 @@ import { useParams } from "react-router-dom";
import { Masquerade } from "revolt-api/types/Channels";
import { Presence } from "revolt-api/types/Users";
import { User } from "revolt.js/dist/maps/Users";
import { Nullable } from "revolt.js/dist/util/null";
import styled, { css } from "styled-components";
import { useContext } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { ThemeContext } from "../../../context/Theme";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png";
@ -26,15 +24,15 @@ interface Props extends IconBaseProps<User> {
}
export function useStatusColour(user?: User) {
const theme = useContext(ThemeContext);
const theme = useApplicationState().settings.theme;
return user?.online && user?.status?.presence !== Presence.Invisible
? user?.status?.presence === Presence.Idle
? theme["status-away"]
? theme.getVariable("status-away")
: user?.status?.presence === Presence.Busy
? theme["status-busy"]
: theme["status-online"]
: theme["status-invisible"];
? theme.getVariable("status-busy")
: theme.getVariable("status-online")
: theme.getVariable("status-invisible");
}
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`

View file

@ -5,8 +5,7 @@ import styled, { css } from "styled-components";
import ConditionalLink from "../../lib/ConditionalLink";
import { connectState } from "../../redux/connector";
import { LastOpened } from "../../redux/reducers/last_opened";
import { useApplicationState } from "../../mobx/State";
import { useClient } from "../../context/revoltjs/RevoltClient";
@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>`
`}
`;
interface Props {
lastOpened: LastOpened;
}
export const BottomNavigation = observer(({ lastOpened }: Props) => {
export default observer(() => {
const client = useClient();
const layout = useApplicationState().layout;
const user = client.users.get(client.user!._id);
const history = useHistory();
const path = useLocation().pathname;
const channel_id = lastOpened["home"];
const friendsActive = path.startsWith("/friends");
const settingsActive = path.startsWith("/settings");
const homeActive = !(friendsActive || settingsActive);
@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
if (settingsActive) {
if (history.length > 0) {
history.goBack();
return;
}
}
if (channel_id) {
history.push(`/channel/${channel_id}`);
} else {
history.push("/");
}
history.push(layout.getLastHomePath());
}}>
<Message size={24} />
</IconButton>
@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
</Base>
);
});
export default connectState(BottomNavigation, (state) => {
return {
lastOpened: state.lastOpened,
};
});

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

@ -117,7 +117,7 @@
}
&[data-muted="true"] {
color: var(--tertiary-foreground);
opacity: 0.4;
}
&[data-alert="true"],

View file

@ -5,7 +5,7 @@ import {
Notepad,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import { RelationshipStatus } from "revolt-api/types/Users";
import { Text } from "preact-i18n";
@ -15,54 +15,38 @@ import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { Unreads } from "../../../redux/reducers/unreads";
import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Category from "../../ui/Category";
import placeholderSVG from "../items/placeholder.svg";
import { mapChannelWithUnread, useUnreads } from "./common";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
type Props = {
unreads: Unreads;
};
const HomeSidebar = observer((props: Props) => {
export default observer(() => {
const { pathname } = useLocation();
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
const state = useApplicationState();
const { channel: currentChannel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const channels = [...client.channels.values()]
.filter(
(x) =>
x.channel_type === "DirectMessage" ||
x.channel_type === "Group",
)
.map((x) => mapChannelWithUnread(x, props.unreads));
const channels = [...client.channels.values()].filter(
(x) => x.channel_type === "DirectMessage" || x.channel_type === "Group",
);
const obj = client.channels.get(channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
const obj = client.channels.get(currentChannel);
useEffect(() => {
if (!channel) return;
// ! FIXME: move this globally
// Track what page the user was last on (in home page).
useEffect(() => state.layout.setLastHomePath(pathname), [pathname]);
dispatch({
type: "LAST_OPENED_SET",
parent: "home",
child: channel,
});
}, [channel]);
channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
channels.sort((b, a) =>
a.last_message_id_or_past.localeCompare(b.last_message_id_or_past),
);
return (
<GenericSidebarBase mobilePadding>
@ -132,31 +116,37 @@ const HomeSidebar = observer((props: Props) => {
{channels.length === 0 && (
<img src={placeholderSVG} loading="eager" />
)}
{channels.map((x) => {
{channels.map((channel) => {
let user;
if (x.channel.channel_type === "DirectMessage") {
if (!x.channel.active) return null;
user = x.channel.recipient;
if (channel.channel_type === "DirectMessage") {
if (!channel.active) return null;
user = channel.recipient;
if (!user) {
console.warn(
`Skipped DM ${x.channel._id} because user was missing.`,
);
return null;
}
if (!user) return null;
}
const isUnread = channel.isUnread(state.notifications);
const mentionCount = channel.getMentions(
state.notifications,
).length;
return (
<ConditionalLink
key={x.channel._id}
active={x.channel._id === channel}
to={`/channel/${x.channel._id}`}>
key={channel._id}
active={channel._id === currentChannel}
to={`/channel/${channel._id}`}>
<ChannelButton
user={user}
channel={x.channel}
alert={x.unread}
alertCount={x.alertCount}
active={x.channel._id === channel}
channel={channel}
alert={
mentionCount > 0
? "mention"
: isUnread
? "unread"
: undefined
}
alertCount={mentionCount}
active={channel._id === currentChannel}
/>
</ConditionalLink>
);
@ -166,13 +156,3 @@ const HomeSidebar = observer((props: Props) => {
</GenericSidebarBase>
);
});
export default connectState(
HomeSidebar,
(state) => {
return {
unreads: state.unreads,
};
},
true,
);

View file

@ -12,9 +12,7 @@ import ConditionalLink from "../../../lib/ConditionalLink";
import PaintCounter from "../../../lib/PaintCounter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { connectState } from "../../../redux/connector";
import { LastOpened } from "../../../redux/reducers/last_opened";
import { Unreads } from "../../../redux/reducers/unreads";
import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useClient } from "../../../context/revoltjs/RevoltClient";
@ -25,7 +23,6 @@ import UserHover from "../../common/user/UserHover";
import UserIcon from "../../common/user/UserIcon";
import IconButton from "../../ui/IconButton";
import LineDivider from "../../ui/LineDivider";
import { mapChannelWithUnread } from "./common";
import { Children } from "../../../types/Preact";
@ -195,46 +192,14 @@ function Swoosh() {
);
}
interface Props {
unreads: Unreads;
lastOpened: LastOpened;
}
export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
export default observer(() => {
const client = useClient();
const state = useApplicationState();
const { server: server_id } = useParams<{ server?: string }>();
const server = server_id ? client.servers.get(server_id) : undefined;
const activeServers = [...client.servers.values()];
const channels = [...client.channels.values()].map((x) =>
mapChannelWithUnread(x, unreads),
);
const unreadChannels = channels
.filter((x) => x.unread)
.map((x) => x.channel?._id);
const servers = activeServers.map((server) => {
let alertCount = 0;
for (const id of server.channel_ids) {
const channel = channels.find((x) => x.channel?._id === id);
if (channel?.alertCount) {
alertCount += channel.alertCount;
}
}
return {
server,
unread: (typeof server.channel_ids.find((x) =>
unreadChannels.includes(x),
) !== "undefined"
? alertCount > 0
? "mention"
: "unread"
: undefined) as "mention" | "unread" | undefined,
alertCount,
};
});
const servers = [...client.servers.values()];
const channels = [...client.channels.values()];
const history = useHistory();
const path = useLocation().pathname;
@ -242,16 +207,16 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
let homeUnread: "mention" | "unread" | undefined;
let alertCount = 0;
for (const x of channels) {
if (x.channel?.channel_type === "Group" && x.unread) {
for (const channel of channels) {
if (channel?.channel_type === "Group" && channel.unread) {
homeUnread = "unread";
alertCount += x.alertCount ?? 0;
alertCount += channel.mentions.length;
}
if (
x.channel?.channel_type === "DirectMessage" &&
x.channel.active &&
x.unread
channel.channel_type === "DirectMessage" &&
channel.active &&
channel.unread
) {
alertCount++;
}
@ -270,7 +235,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
<ServerList>
<ConditionalLink
active={homeActive}
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
to={state.layout.getLastHomePath()}>
<ServerEntry home active={homeActive}>
<Swoosh />
<div
@ -278,13 +243,13 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
onClick={() =>
homeActive && history.push("/settings")
}>
<UserHover user={client.user}>
<UserHover user={client.user ?? undefined}>
<Icon
size={42}
unread={homeUnread}
count={alertCount}>
<UserIcon
target={client.user}
target={client.user ?? undefined}
size={32}
status
hover
@ -295,35 +260,40 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
</ServerEntry>
</ConditionalLink>
<LineDivider />
{servers.map((entry) => {
const active = entry.server._id === server?._id;
const id = lastOpened[entry.server._id];
{servers.map((server) => {
const active = server._id === server_id;
const isUnread = server.isUnread(state.notifications);
const mentionCount = server.getMentions(
state.notifications,
).length;
return (
<ConditionalLink
key={entry.server._id}
key={server._id}
active={active}
to={`/server/${entry.server._id}${
id ? `/channel/${id}` : ""
}`}>
to={state.layout.getServerPath(server._id)}>
<ServerEntry
active={active}
onContextMenu={attachContextMenu("Menu", {
server: entry.server._id,
unread: entry.unread,
server: server._id,
unread: isUnread,
})}>
<Swoosh />
<Tooltip
content={entry.server.name}
content={server.name}
placement="right">
<Icon
size={42}
unread={entry.unread}
count={entry.alertCount}>
<ServerIcon
size={32}
target={entry.server}
/>
unread={
mentionCount > 0
? "mention"
: isUnread
? "unread"
: undefined
}
count={mentionCount}>
<ServerIcon size={32} target={server} />
</Icon>
</Tooltip>
</ServerEntry>
@ -357,10 +327,3 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
</ServersBase>
);
});
export default connectState(ServerListSidebar, (state) => {
return {
unreads: state.unreads,
lastOpened: state.lastOpened,
};
});

View file

@ -10,26 +10,17 @@ import PaintCounter from "../../../lib/PaintCounter";
import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { Notifications } from "../../../redux/reducers/notifications";
import { Unreads } from "../../../redux/reducers/unreads";
import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import CollapsibleSection from "../../common/CollapsibleSection";
import ServerHeader from "../../common/ServerHeader";
import Category from "../../ui/Category";
import { mapChannelWithUnread, useUnreads } from "./common";
import { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
interface Props {
unreads: Unreads;
notifications: Notifications;
}
const ServerBase = styled.div`
height: 100%;
width: 232px;
@ -57,8 +48,9 @@ const ServerList = styled.div`
}
`;
const ServerSidebar = observer((props: Props) => {
export default observer(() => {
const client = useClient();
const state = useApplicationState();
const { server: server_id, channel: channel_id } =
useParams<{ server: string; channel?: string }>();
@ -76,16 +68,13 @@ const ServerSidebar = observer((props: Props) => {
);
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
if (channel) useUnreads({ ...props, channel });
// ! FIXME: move this globally
// Track which channel the user was last on.
useEffect(() => {
if (!channel_id) return;
if (!server_id) return;
dispatch({
type: "LAST_OPENED_SET",
parent: server_id!,
child: channel_id!,
});
state.layout.setLastOpened(server_id, channel_id);
}, [channel_id, server_id]);
const uncategorised = new Set(server.channel_ids);
@ -96,7 +85,8 @@ const ServerSidebar = observer((props: Props) => {
if (!entry) return;
const active = channel?._id === entry._id;
const muted = props.notifications[id] === "none";
const isUnread = entry.isUnread(state.notifications);
const mentionCount = entry.getMentions(state.notifications);
return (
<ConditionalLink
@ -117,10 +107,15 @@ const ServerSidebar = observer((props: Props) => {
<ChannelButton
channel={entry}
active={active}
// ! FIXME: pull it out directly
alert={mapChannelWithUnread(entry, props.unreads).unread}
alert={
mentionCount.length > 0
? "mention"
: isUnread
? "unread"
: undefined
}
compact
muted={muted}
muted={state.notifications.isMuted(entry)}
/>
</ConditionalLink>
);
@ -163,10 +158,3 @@ const ServerSidebar = observer((props: Props) => {
</ServerBase>
);
});
export default connectState(ServerSidebar, (state) => {
return {
unreads: state.unreads,
notifications: state.notifications,
};
});

View file

@ -1,79 +0,0 @@
import { reaction } from "mobx";
import { Channel } from "revolt.js/dist/maps/Channels";
import { useLayoutEffect, useRef } 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

@ -0,0 +1,221 @@
import { Store } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Link } from "react-router-dom";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import { Text } from "preact-i18n";
import TextAreaAutoSize from "../../lib/TextAreaAutoSize";
import { useApplicationState } from "../../mobx/State";
import {
Fonts,
FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
} from "../../context/Theme";
import Checkbox from "../ui/Checkbox";
import ColourSwatches from "../ui/ColourSwatches";
import ComboBox from "../ui/ComboBox";
import Radio from "../ui/Radio";
import CategoryButton from "../ui/fluent/CategoryButton";
import { EmojiSelector } from "./appearance/EmojiSelector";
import { ThemeBaseSelector } from "./appearance/ThemeBaseSelector";
/**
* Component providing a way to switch the base theme being used.
*/
export const ThemeBaseSelectorShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<ThemeBaseSelector
value={theme.isModified() ? undefined : theme.getBase()}
setValue={(base) => {
theme.setBase(base);
theme.reset();
}}
/>
);
});
/**
* Component providing a link to the theme shop.
* Only appears if experiment is enabled.
* TODO: stabilise
*/
export const ThemeShopShim = () => {
if (!useApplicationState().experiments.isEnabled("theme_shop")) return null;
return (
<Link to="/settings/theme_shop" replace>
<CategoryButton icon={<Store size={24} />} action="chevron" hover>
<Text id="app.settings.pages.theme_shop.title" />
</CategoryButton>
</Link>
);
};
/**
* Component providing a way to change current accent colour.
*/
export const ThemeAccentShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches
value={theme.getVariable("accent")}
onChange={(colour) => {
theme.setVariable("accent", colour as string);
theme.setVariable("scrollbar-thumb", pSBC(-0.2, colour));
}}
/>
</>
);
});
/**
* Component providing a way to edit custom CSS.
*/
export const ThemeCustomCSSShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={theme.getCSS() ?? ""}
onChange={(ev) => theme.setCSS(ev.currentTarget.value)}
/>
</>
);
});
/**
* Component providing a way to switch between compact and normal message view.
*/
export const DisplayCompactShim = () => {
// TODO: WIP feature
return (
<>
<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div /* className={styles.display} */>
<Radio
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
checked>
<Text id="app.settings.pages.appearance.display.default" />
</Radio>
<Radio
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
disabled>
<Text id="app.settings.pages.appearance.display.compact" />
</Radio>
</div>
</>
);
};
/**
* Component providing a way to change primary text font.
*/
export const DisplayFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.getFont()}
onChange={(e) => theme.setFont(e.currentTarget.value as Fonts)}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
</>
);
});
/**
* Component providing a way to change secondary, monospace text font.
*/
export const DisplayMonospaceFontShim = observer(() => {
const theme = useApplicationState().settings.theme;
return (
<>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.getMonospaceFont()}
onChange={(e) =>
theme.setMonospaceFont(
e.currentTarget.value as MonospaceFonts,
)
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[key as keyof typeof MONOSPACE_FONTS]
.name
}
</option>
))}
</ComboBox>
</>
);
});
/**
* Component providing a way to toggle font ligatures.
*/
export const DisplayLigaturesShim = observer(() => {
const settings = useApplicationState().settings;
if (settings.theme.getFont() !== "Inter") return null;
return (
<p>
<Checkbox
checked={settings.get("appearance:ligatures") ?? false}
onChange={(v) => settings.set("appearance:ligatures", v)}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
</p>
);
});
/**
* Component providing a way to change emoji pack.
*/
export const DisplayEmojiShim = observer(() => {
const settings = useApplicationState().settings;
return (
<EmojiSelector
value={settings.get("appearance:emoji")}
setValue={(v) => settings.set("appearance:emoji", v)}
/>
);
});

View file

@ -0,0 +1,161 @@
import styled from "styled-components";
import { Text } from "preact-i18n";
import { EmojiPack } from "../../common/Emoji";
import mutantSVG from "./mutant_emoji.svg";
import notoSVG from "./noto_emoji.svg";
import openmojiSVG from "./openmoji_emoji.svg";
import twemojiSVG from "./twemoji_emoji.svg";
const Container = styled.div`
gap: 12px;
display: flex;
flex-direction: column;
.row {
gap: 12px;
display: flex;
> div {
flex: 1;
display: flex;
flex-direction: column;
}
}
.button {
padding: 2rem 1.2rem;
display: grid;
place-items: center;
cursor: pointer;
transition: border 0.3s;
background: var(--hover);
border: 3px solid transparent;
border-radius: var(--border-radius);
img {
max-width: 100%;
}
&[data-active="true"] {
cursor: default;
background: var(--secondary-background);
border: 3px solid var(--accent);
&:hover {
border: 3px solid var(--accent);
}
}
&:hover {
background: var(--secondary-background);
border: 3px solid var(--tertiary-background);
}
}
h4 {
text-transform: unset;
a {
opacity: 0.7;
color: var(--accent);
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
@media only screen and (max-width: 800px) {
a {
display: block;
}
}
}
`;
interface Props {
value?: EmojiPack;
setValue: (pack: EmojiPack) => void;
}
export function EmojiSelector({ value, setValue }: Props) {
return (
<>
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<Container>
<div class="row">
<div>
<div
class="button"
onClick={() => setValue("mutant")}
data-active={!value || value === "mutant"}>
<img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank"
rel="noreferrer">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
class="button"
onClick={() => setValue("twemoji")}
data-active={value === "twemoji"}>
<img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Twemoji</h4>
</div>
</div>
<div class="row">
<div>
<div
class="button"
onClick={() => setValue("openmoji")}
data-active={value === "openmoji"}>
<img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
class="button"
onClick={() => setValue("noto")}
data-active={value === "noto"}>
<img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</Container>
</>
);
}

View file

@ -0,0 +1,84 @@
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useApplicationState } from "../../../mobx/State";
import darkSVG from "./dark.svg";
import lightSVG from "./light.svg";
const List = styled.div`
gap: 8px;
display: flex;
width: 100%;
> div {
min-width: 0;
display: flex;
flex-direction: column;
}
img {
cursor: pointer;
border-radius: var(--border-radius);
transition: border 0.3s;
border: 3px solid transparent;
width: 100%;
&[data-active="true"] {
cursor: default;
border: 3px solid var(--accent);
&:hover {
border: 3px solid var(--accent);
}
}
&:hover {
border: 3px solid var(--tertiary-background);
}
}
`;
interface Props {
value?: "light" | "dark";
setValue: (base: "light" | "dark") => void;
}
export function ThemeBaseSelector({ value, setValue }: Props) {
return (
<>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<List>
<div>
<img
loading="eager"
src={lightSVG}
draggable={false}
data-active={value === "light"}
onClick={() => setValue("light")}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div>
<img
loading="eager"
src={darkSVG}
draggable={false}
data-active={value === "dark"}
onClick={() => setValue("dark")}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</List>
</>
);
}

View file

@ -0,0 +1,181 @@
import { Pencil } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { useDebounceCallback } from "../../../lib/debounce";
import { useApplicationState } from "../../../mobx/State";
import { Variables } from "../../../context/Theme";
import InputBox from "../../ui/InputBox";
const Container = styled.div`
row-gap: 8px;
display: grid;
column-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-bottom: 20px;
.entry {
padding: 12px;
margin-top: 8px;
border: 1px solid black;
border-radius: var(--border-radius);
span {
flex: 1;
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 8px;
text-transform: capitalize;
background: inherit;
background-clip: text;
-webkit-background-clip: text;
}
.override {
gap: 8px;
display: flex;
.picker {
width: 38px;
height: 38px;
display: grid;
cursor: pointer;
place-items: center;
border-radius: var(--border-radius);
background: var(--primary-background);
}
input[type="text"] {
width: 0;
min-width: 0;
flex-grow: 1;
}
}
.input {
width: 0;
height: 0;
position: relative;
input {
opacity: 0;
border: none;
display: block;
cursor: pointer;
position: relative;
top: 48px;
}
}
}
`;
export default observer(() => {
const theme = useApplicationState().settings.theme;
const setVariable = useDebounceCallback(
(data) => {
const { key, value } = data as { key: Variables; value: string };
theme.setVariable(key, value);
},
[theme],
100,
);
return (
<Container>
{(
[
"accent",
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((key) => (
<div
class="entry"
key={key}
style={{ backgroundColor: theme.getVariable(key) }}>
<div class="input">
<input
type="color"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
<span
style={{
color: getContrastingColour(
theme.getVariable(key),
theme.getVariable("primary-background"),
),
}}>
{key}
</span>
<div class="override">
<div
class="picker"
onClick={(e) =>
e.currentTarget.parentElement?.parentElement
?.querySelector("input")
?.click()
}>
<Pencil size={24} />
</div>
<InputBox
type="text"
class="text"
value={theme.getVariable(key)}
onChange={(el) =>
setVariable({
key,
value: el.currentTarget.value,
})
}
/>
</div>
</div>
))}
</Container>
);
});
function getContrastingColour(hex: string, fallback: string): string {
hex = hex.replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const cc = (r * 299 + g * 587 + b * 114) / 1000;
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(cc))
return getContrastingColour(fallback, "#fffff");
return cc >= 175 ? "black" : "white";
}

View file

@ -0,0 +1,89 @@
import { Import, Reset } from "@styled-icons/boxicons-regular";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import Tooltip from "../../common/Tooltip";
import Button from "../../ui/Button";
const Actions = styled.div`
gap: 8px;
display: flex;
margin: 18px 0 8px 0;
.code {
cursor: pointer;
display: flex;
align-items: center;
font-size: 0.875rem;
min-width: 0;
flex-grow: 1;
padding: 8px;
font-family: var(--monospace-font);
border-radius: var(--border-radius);
background: var(--secondary-background);
> div {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
`;
export default function ThemeTools() {
const { writeClipboard, openScreen } = useIntermediate();
const theme = useApplicationState().settings.theme;
return (
<Actions>
<Tooltip
content={
<Text id="app.settings.pages.appearance.reset_overrides" />
}>
<Button contrast iconbutton onClick={theme.reset}>
<Reset size={22} />
</Button>
</Tooltip>
<div
class="code"
onClick={() => writeClipboard(JSON.stringify(theme))}>
<Tooltip content={<Text id="app.special.copy" />}>
{" "}
{JSON.stringify(theme)}
</Tooltip>
</div>
<Tooltip
content={<Text id="app.settings.pages.appearance.import" />}>
<Button
contrast
iconbutton
onClick={async () => {
try {
const text = await navigator.clipboard.readText();
theme.hydrate(JSON.parse(text));
} catch (err) {
openScreen({
id: "_input",
question: (
<Text id="app.settings.pages.appearance.import_theme" />
),
field: (
<Text id="app.settings.pages.appearance.theme_data" />
),
callback: async (text) =>
theme.hydrate(JSON.parse(text)),
});
}
}}>
<Import size={22} />
</Button>
</Tooltip>
</Actions>
);
}

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -5,6 +5,8 @@ import styled, { css } from "styled-components";
import { RefObject } from "preact";
import { useRef } from "preact/hooks";
import { useDebounceCallback } from "../../lib/debounce";
interface Props {
value: string;
onChange: (value: string) => void;
@ -115,6 +117,11 @@ const Rows = styled.div`
export default function ColourSwatches({ value, onChange }: Props) {
const ref = useRef<HTMLInputElement>() as RefObject<HTMLInputElement>;
const setValue = useDebounceCallback(
(value) => onChange(value as string),
[onChange],
100,
);
return (
<SwatchesBase>
@ -122,7 +129,7 @@ export default function ColourSwatches({ value, onChange }: Props) {
type="color"
value={value}
ref={ref}
onChange={(ev) => onChange(ev.currentTarget.value)}
onChange={(ev) => setValue(ev.currentTarget.value)}
/>
<Swatch
colour={value}

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

@ -7,9 +7,9 @@ interface Props {
children: Children;
description?: Children;
checked: boolean;
checked?: boolean;
disabled?: boolean;
onSelect: () => void;
onSelect?: () => void;
}
interface BaseProps {
@ -87,9 +87,10 @@ const RadioDescription = styled.span<BaseProps>`
`;
export default function Radio(props: Props) {
const selected = props.checked ?? false;
return (
<RadioBase
selected={props.checked}
selected={selected}
disabled={props.disabled}
onClick={() =>
!props.disabled && props.onSelect && props.onSelect()
@ -101,7 +102,7 @@ export default function Radio(props: Props) {
<span>
<span>{props.children}</span>
{props.description && (
<RadioDescription selected={props.checked}>
<RadioDescription selected={selected}>
{props.description}
</RadioDescription>
)}

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);

View file

@ -32,14 +32,9 @@ import {
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { dispatch } from "../redux";
import { connectState } from "../redux/connector";
import {
getNotificationState,
Notifications,
NotificationState,
} from "../redux/reducers/notifications";
import { QueuedMessage } from "../redux/reducers/queue";
import { useApplicationState } from "../mobx/State";
import { QueuedMessage } from "../mobx/stores/MessageQueue";
import { NotificationState } from "../mobx/stores/NotificationOptions";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import {
@ -48,6 +43,7 @@ import {
StatusContext,
} from "../context/revoltjs/RevoltClient";
import { takeError } from "../context/revoltjs/util";
import CMNotifications from "./contextmenu/CMNotifications";
import Tooltip from "../components/common/Tooltip";
import UserStatus from "../components/common/user/UserStatus";
@ -105,41 +101,42 @@ type Action =
| { action: "create_channel"; target: Server }
| { action: "create_category"; target: Server }
| {
action: "create_invite";
target: Channel;
}
action: "create_invite";
target: Channel;
}
| { action: "leave_group"; target: Channel }
| {
action: "delete_channel";
target: Channel;
}
action: "delete_channel";
target: Channel;
}
| { action: "close_dm"; target: Channel }
| { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server }
| { action: "edit_identity"; target: Server }
| { action: "open_notification_options"; channel: Channel }
| {
action: "open_notification_options";
channel?: Channel;
server?: Server;
}
| { action: "open_settings" }
| { action: "open_channel_settings"; id: string }
| { action: "open_server_settings"; id: string }
| { action: "open_server_channel_settings"; server: string; id: string }
| {
action: "set_notification_state";
key: string;
state?: NotificationState;
};
type Props = {
notifications: Notifications;
};
action: "set_notification_state";
key: string;
state?: NotificationState;
};
// ! FIXME: I dare someone to re-write this
// Tip: This should just be split into separate context menus per logical area.
function ContextMenus(props: Props) {
export default function ContextMenus() {
const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext);
const userId = client.user!._id;
const status = useContext(StatusContext);
const isOnline = status === ClientStatus.ONLINE;
const state = useApplicationState();
const history = useHistory();
function contextClick(data?: Action) {
@ -171,21 +168,19 @@ function ContextMenus(props: Props) {
)
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();
}
@ -195,11 +190,7 @@ function ContextMenus(props: Props) {
{
const nonce = data.message.id;
const fail = (error: string) =>
dispatch({
type: "QUEUE_FAIL",
nonce,
error,
});
state.queue.fail(nonce, error);
client.channels
.get(data.message.channel)!
@ -210,19 +201,13 @@ function ContextMenus(props: Props) {
})
.catch(fail);
dispatch({
type: "QUEUE_START",
nonce,
});
state.queue.start(nonce);
}
break;
case "cancel_message":
{
dispatch({
type: "QUEUE_REMOVE",
nonce: data.message.id,
});
state.queue.remove(data.message.id);
}
break;
@ -427,6 +412,7 @@ function ContextMenus(props: Props) {
case "open_notification_options": {
openContextMenu("NotificationOptions", {
channel: data.channel,
server: data.server,
});
break;
}
@ -445,16 +431,6 @@ function ContextMenus(props: Props) {
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) });
@ -488,8 +464,9 @@ function ContextMenus(props: Props) {
elements.push(
<MenuItem data={action} disabled={disabled}>
<Text
id={`app.context_menu.${locale ?? action.action
}`}
id={`app.context_menu.${
locale ?? action.action
}`}
/>
{tip && <div className="tip">{tip}</div>}
</MenuItem>,
@ -545,8 +522,8 @@ function ContextMenus(props: Props) {
const user = uid ? client.users.get(uid) : undefined;
const serverChannel =
targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
? targetChannel
: undefined;
@ -558,8 +535,8 @@ function ContextMenus(props: Props) {
(server
? server.permission
: serverChannel
? serverChannel.server?.permission
: 0) || 0;
? serverChannel.server?.permission
: 0) || 0;
const userPermissions = (user ? user.permission : 0) || 0;
if (unread) {
@ -705,7 +682,8 @@ function ContextMenus(props: Props) {
if (message && !queued) {
const sendPermission =
message.channel &&
message.channel.permission & ChannelPermission.SendMessage
message.channel.permission &
ChannelPermission.SendMessage;
if (sendPermission) {
generateAction({
@ -741,7 +719,7 @@ function ContextMenus(props: Props) {
if (
message.author_id === userId ||
channelPermissions &
ChannelPermission.ManageMessages
ChannelPermission.ManageMessages
) {
generateAction({
action: "delete_message",
@ -765,8 +743,8 @@ function ContextMenus(props: Props) {
type === "Image"
? "open_image"
: type === "Video"
? "open_video"
: "open_file",
? "open_video"
: "open_file",
);
generateAction(
@ -777,8 +755,8 @@ function ContextMenus(props: Props) {
type === "Image"
? "save_image"
: type === "Video"
? "save_video"
: "save_file",
? "save_video"
: "save_file",
);
generateAction(
@ -919,6 +897,16 @@ function ContextMenus(props: Props) {
}
if (sid && server) {
generateAction(
{
action: "open_notification_options",
server,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
if (server.channels[0] !== undefined)
generateAction(
{
@ -930,9 +918,9 @@ function ContextMenus(props: Props) {
if (
serverPermissions &
ServerPermission.ChangeNickname ||
ServerPermission.ChangeNickname ||
serverPermissions &
ServerPermission.ChangeAvatar
ServerPermission.ChangeAvatar
)
generateAction(
{ action: "edit_identity", target: server },
@ -976,10 +964,10 @@ function ContextMenus(props: Props) {
sid
? "copy_sid"
: cid
? "copy_cid"
: message
? "copy_mid"
: "copy_uid",
? "copy_cid"
: message
? "copy_mid"
: "copy_uid",
);
}
@ -1083,76 +1071,7 @@ function ContextMenus(props: Props) {
);
}}
</ContextMenuWithData>
<ContextMenuWithData
id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(
props.notifications,
channel,
);
const elements: Children[] = [
<MenuItem
key="notif"
data={{
action: "set_notification_state",
key: channel._id,
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>,
];
function generate(key: string, icon: Children) {
elements.push(
<MenuItem
key={key}
data={{
action: "set_notification_state",
key: channel._id,
state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
);
}
generate("all", <Bell size={24} />);
generate("mention", <At size={24} />);
generate("muted", <BellOff size={24} />);
generate("none", <Block size={24} />);
return elements;
}}
</ContextMenuWithData>
<CMNotifications />
</>
);
}
export default connectState(ContextMenus, (state) => {
return {
notifications: state.notifications,
};
});

View file

@ -0,0 +1,119 @@
import {
At,
Bell,
BellOff,
Check,
CheckSquare,
Block,
Square,
LeftArrowAlt,
} from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Server } from "revolt.js/dist/maps/Servers";
import { ContextMenuWithData, MenuItem } from "preact-context-menu";
import { Text } from "preact-i18n";
import { useApplicationState } from "../../mobx/State";
import { NotificationState } from "../../mobx/stores/NotificationOptions";
import LineDivider from "../../components/ui/LineDivider";
import { Children } from "../../types/Preact";
interface Action {
key: string;
type: "channel" | "server";
state?: NotificationState;
}
/**
* Provides a context menu for controlling notification options.
*/
export default observer(() => {
const notifications = useApplicationState().notifications;
const contextClick = (data?: Action) =>
data &&
(data.type === "channel"
? notifications.setChannelState(data.key, data.state)
: notifications.setServerState(data.key, data.state));
return (
<ContextMenuWithData id="NotificationOptions" onClose={contextClick}>
{({ channel, server }: { channel?: Channel; server?: Server }) => {
// Find the computed and actual state values for channel / server.
const state = channel
? notifications.getChannelState(channel._id)
: notifications.computeForServer(server!._id);
const actual = channel
? notifications.computeForChannel(channel)
: undefined;
// If we're editing channel, show a default option too.
const elements: Children[] = channel
? [
<MenuItem
key="notif"
data={{
key: channel._id,
type: "channel",
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>,
<LineDivider />,
]
: [];
/**
* Generate a new entry we can select.
* @param key Notification state
* @param icon Icon for this state
*/
function generate(key: string, icon: Children) {
elements.push(
<MenuItem
key={key}
data={{
key: channel ? channel._id : server!._id,
type: channel ? "channel" : "server",
state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
);
}
generate("all", <Bell size={24} />);
generate("mention", <At size={24} />);
generate("none", <BellOff size={24} />);
generate("muted", <Block size={24} />);
return elements;
}}
</ContextMenuWithData>
);
});

View file

@ -7,3 +7,11 @@ export function urlBase64ToUint8Array(base64String: string) {
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
export function mapToRecord<K extends symbol | string | number, V>(
map: Map<K, V>,
) {
let record = {} as Record<K, V>;
map.forEach((v, k) => (record[k] = v));
return record;
}

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

@ -0,0 +1,236 @@
// @ts-expect-error No typings.
import stringify from "json-stringify-deterministic";
import localforage from "localforage";
import { makeAutoObservable, reaction } from "mobx";
import { Client } from "revolt.js";
import { legacyMigrateForwards, LegacyState } from "./legacy/redux";
import Persistent from "./interfaces/Persistent";
import Syncable from "./interfaces/Syncable";
import Auth from "./stores/Auth";
import Draft from "./stores/Draft";
import Experiments from "./stores/Experiments";
import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions";
import MessageQueue from "./stores/MessageQueue";
import NotificationOptions from "./stores/NotificationOptions";
import ServerConfig from "./stores/ServerConfig";
import Settings from "./stores/Settings";
import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync";
export const MIGRATIONS = {
REDUX: 1640305719826,
};
/**
* Handles global application state.
*/
export default class State {
auth: Auth;
draft: Draft;
locale: LocaleOptions;
experiments: Experiments;
layout: Layout;
config: ServerConfig;
notifications: NotificationOptions;
queue: MessageQueue;
settings: Settings;
sync: Sync;
private persistent: [string, Persistent<unknown>][] = [];
private disabled: Set<string> = new Set();
/**
* Construct new State.
*/
constructor() {
this.auth = new Auth();
this.draft = new Draft();
this.locale = new LocaleOptions();
this.experiments = new Experiments();
this.layout = new Layout();
this.config = new ServerConfig();
this.notifications = new NotificationOptions();
this.queue = new MessageQueue();
this.settings = new Settings();
this.sync = new Sync(this);
makeAutoObservable(this);
this.register();
this.setDisabled = this.setDisabled.bind(this);
}
/**
* Categorise and register stores referenced on this object.
*/
private register() {
for (const key of Object.keys(this)) {
const obj = (
this as unknown as Record<string, Record<string, unknown>>
)[key];
// Check if this is an object.
if (typeof obj === "object") {
// Check if this is a Store.
if (typeof obj.id === "string") {
const id = obj.id;
// Check if this is a Persistent<T>
if (
typeof obj.hydrate === "function" &&
typeof obj.toJSON === "function"
) {
this.persistent.push([
id,
obj as unknown as Persistent<unknown>,
]);
}
}
}
}
}
/**
* Temporarily ignore updates to a key.
* @param key Key to ignore
*/
setDisabled(key: string) {
this.disabled.add(key);
}
/**
* Save to disk.
*/
async save() {
for (const [id, store] of this.persistent) {
await localforage.setItem(id, store.toJSON());
}
}
/**
* Register reaction listeners for persistent data stores.
* @returns Function to dispose of listeners
*/
registerListeners(client: Client) {
const listeners = this.persistent.map(([id, store]) => {
return reaction(
() => stringify(store.toJSON()),
async (value) => {
try {
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) {
console.error("Failed to serialise!");
console.error(err);
console.error(value);
}
},
);
});
return () => listeners.forEach((x) => x());
}
/**
* Load data stores from local storage.
*/
async hydrate() {
// Migrate legacy Redux store.
let legacy = await localforage.getItem("state");
if (legacy) {
if (typeof legacy === "string") {
legacy = JSON.parse(legacy);
}
legacyMigrateForwards(legacy as Partial<LegacyState>, this);
await localforage.removeItem("state");
await this.save();
return;
}
// Load MobX store.
const sync = (await localforage.getItem("sync")) as DataSync;
if (sync) {
const { revision } = sync;
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]);
}
}
}
}
}
var state: State;
export async function hydrateState() {
state = new State();
await state.hydrate();
}
/**
* Get the application state
* @returns Application state
*/
export function useApplicationState() {
return state;
}

View file

@ -0,0 +1,17 @@
import Store from "./Store";
/**
* A data store which is persistent and should cache its data locally.
*/
export default interface Persistent<T> extends Store {
/**
* Serialise this data store.
*/
toJSON(): unknown;
/**
* Hydrate this data store using given data.
* @param data Given data
*/
hydrate(data: T, revision: number): void;
}

View file

@ -0,0 +1,3 @@
export default interface Store {
get id(): string;
}

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

193
src/mobx/legacy/redux.ts Normal file
View file

@ -0,0 +1,193 @@
import { runInAction } from "mobx";
import { Session } from "revolt-api/types/Auth";
import { Language } from "../../context/Locale";
import {
Fonts,
MonospaceFonts,
Overrides,
ThemeOptions,
} from "../../context/Theme";
import State from "../State";
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;
font?: Fonts;
css?: string;
monospaceFont?: MonospaceFonts;
};
export interface LegacyThemeOptions {
base?: string;
ligatures?: boolean;
custom?: Partial<LegacyTheme>;
}
export type LegacyEmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji";
export interface LegacyAppearanceOptions {
emojiPack?: LegacyEmojiPacks;
}
export type LegacyNotificationState = "all" | "mention" | "none" | "muted";
export type LegacyNotifications = {
[key: string]: LegacyNotificationState;
};
export interface LegacySyncData {
locale?: Language;
theme?: LegacyThemeOptions;
appearance?: LegacyAppearanceOptions;
notifications?: LegacyNotifications;
}
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;
}
export interface LegacySettings {
theme?: LegacyThemeOptions;
appearance?: LegacyAppearanceOptions;
}
export function legacyMigrateAuth(auth: LegacyAuthState): DataAuth {
return {
current: auth.active,
sessions: auth.accounts,
};
}
export function legacyMigrateLocale(lang: Language): DataLocaleOptions {
return {
lang,
};
}
export function legacyMigrateTheme(
theme: LegacyThemeOptions,
): Partial<ISettings> {
const { light, font, css, monospaceFont, ...variables } =
theme.custom ?? {};
return {
"appearance:ligatures": theme.ligatures,
"appearance:theme:base": theme.base === "light" ? "light" : "dark",
"appearance:theme:light": light,
"appearance:theme:font": font,
"appearance:theme:monoFont": monospaceFont,
"appearance:theme:css": css,
"appearance:theme:overrides": variables,
};
}
export function legacyMigrateAppearance(
appearance: LegacyAppearanceOptions,
): Partial<ISettings> {
return {
"appearance:emoji": appearance.emojiPack,
};
}
/**
* Remove trolling from an object
* @param inp Object to remove trolling from
* @returns Object without trolling
*/
function detroll(inp: object): ISettings {
const obj: object = {};
Object.keys(inp)
.filter((x) => typeof (inp as any)[x] !== "undefined")
.map((x) => ((obj as any)[x] = (inp as any)[x]));
return obj as unknown as ISettings;
}
export function legacyMigrateNotification(
channel: LegacyNotifications,
): DataNotificationOptions {
return {
channel,
};
}
export function legacyMigrateSync(sync: LegacySyncOptions): DataSync {
return {
disabled: sync.disabled ?? [],
revision: {
...sync.revision,
},
};
}
export type LegacyState = {
locale: Language;
auth: LegacyAuthState;
settings: LegacySettings;
sync: LegacySyncOptions;
notifications: LegacyNotifications;
};
export function legacyMigrateForwards(
data: Partial<LegacyState>,
target: State,
) {
runInAction(() => {
if ("sync" in data) {
target.sync.hydrate(legacyMigrateSync(data.sync!));
}
if ("locale" in data) {
target.locale.hydrate(legacyMigrateLocale(data.locale!));
}
if ("auth" in data) {
target.auth.hydrate(legacyMigrateAuth(data.auth!));
}
if ("settings" in data) {
if (data!.settings!.theme) {
target.settings.hydrate(
detroll(legacyMigrateTheme(data.settings!.theme!)),
);
}
if (data!.settings!.appearance) {
target.settings.hydrate(
detroll(
legacyMigrateAppearance(data.settings!.appearance!),
),
);
}
}
if ("notifications" in data) {
target.notifications.hydrate(
legacyMigrateNotification(data.notifications!),
);
}
});
}

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

@ -0,0 +1,125 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Session } from "revolt-api/types/Auth";
import { Nullable } from "revolt.js/dist/util/null";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
interface Account {
session: Session;
}
export interface Data {
sessions: Record<string, Account>;
current?: string;
}
/**
* Handles account authentication, managing multiple
* accounts and their sessions.
*/
export default class Auth implements Store, Persistent<Data> {
private sessions: ObservableMap<string, Account>;
private current: Nullable<string>;
/**
* Construct new Auth store.
*/
constructor() {
this.sessions = new ObservableMap();
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);
}
get id() {
return "auth";
}
@action toJSON() {
return {
sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))),
current: this.current ?? undefined,
};
}
@action hydrate(data: Data) {
if (Array.isArray(data.sessions)) {
data.sessions.forEach(([key, value]) =>
this.sessions.set(key, value),
);
} else if (
typeof data.sessions === "object" &&
data.sessions !== null
) {
let v = data.sessions;
Object.keys(data.sessions).forEach((id) =>
this.sessions.set(id, v[id]),
);
}
if (data.current && this.sessions.has(data.current)) {
this.current = data.current;
}
}
/**
* Add a new session to the auth manager.
* @param session Session
*/
@action setSession(session: Session) {
this.sessions.set(session.user_id, { session });
this.current = session.user_id;
}
/**
* Remove existing session by user ID.
* @param user_id User ID tied to session
*/
@action removeSession(user_id: string) {
if (user_id == this.current) {
this.current = null;
}
this.sessions.delete(user_id);
}
/**
* Remove current session.
*/
@action logout() {
this.current && this.removeSession(this.current);
}
/**
* Get current session.
* @returns Current session
*/
@computed getSession() {
if (!this.current) return;
return this.sessions.get(this.current)!.session;
}
/**
* Check whether we are currently logged in.
* @returns Whether we are logged in
*/
@computed isLoggedIn() {
return this.current !== null;
}
}

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

@ -0,0 +1,85 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
export interface Data {
drafts: Record<string, string>;
}
/**
* Handles storing draft (currently being written) messages.
*/
export default class Draft implements Store, Persistent<Data> {
private drafts: ObservableMap<string, string>;
/**
* Construct new Draft store.
*/
constructor() {
this.drafts = new ObservableMap();
makeAutoObservable(this);
}
get id() {
return "draft";
}
toJSON() {
return {
drafts: mapToRecord(this.drafts),
};
}
@action hydrate(data: Data) {
Object.keys(data.drafts).forEach((key) =>
this.drafts.set(key, data.drafts[key]),
);
}
/**
* Get draft for a channel.
* @param channel Channel ID
*/
@computed get(channel: string) {
return this.drafts.get(channel);
}
/**
* Check whether a channel has a draft.
* @param channel Channel ID
*/
@computed has(channel: string) {
return this.drafts.has(channel) && this.drafts.get(channel)!.length > 0;
}
/**
* Set draft for a channel.
* @param channel Channel ID
* @param content Draft content
*/
@action set(channel: string, content?: string) {
if (typeof content === "undefined") {
return this.clear(channel);
}
this.drafts.set(channel, content);
}
/**
* Clear draft from a channel.
* @param channel Channel ID
*/
@action clear(channel: string) {
this.drafts.delete(channel);
}
/**
* Reset and clear all drafts.
*/
@action reset() {
this.drafts.clear();
}
}

View file

@ -0,0 +1,117 @@
import {
action,
autorun,
computed,
makeAutoObservable,
ObservableSet,
} from "mobx";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
/**
* Union type of available experiments.
*/
export type Experiment = "dummy" | "theme_shop";
/**
* Currently active experiments.
*/
export const AVAILABLE_EXPERIMENTS: Experiment[] = ["dummy", "theme_shop"];
/**
* Definitions for experiments listed by {@link Experiment}.
*/
export const EXPERIMENTS: {
[key in Experiment]: { title: string; description: string };
} = {
dummy: {
title: "Dummy Experiment",
description: "This is a dummy experiment.",
},
theme_shop: {
title: "Theme Shop",
description: "Allows you to access and set user submitted themes.",
},
};
export interface Data {
enabled?: Experiment[];
}
/**
* Handles enabling and disabling client experiments.
*/
export default class Experiments implements Store, Persistent<Data> {
private enabled: ObservableSet<Experiment>;
/**
* Construct new Experiments store.
*/
constructor() {
this.enabled = new ObservableSet();
makeAutoObservable(this);
}
get id() {
return "experiments";
}
toJSON() {
return {
enabled: [...this.enabled],
};
}
@action hydrate(data: Data) {
if (data.enabled) {
for (const experiment of data.enabled) {
this.enabled.add(experiment as Experiment);
}
}
}
/**
* Check if an experiment is enabled.
* @param experiment Experiment
*/
@computed isEnabled(experiment: Experiment) {
return this.enabled.has(experiment);
}
/**
* Enable an experiment.
* @param experiment Experiment
*/
@action enable(experiment: Experiment) {
this.enabled.add(experiment);
}
/**
* Disable an experiment.
* @param experiment Experiment
*/
@action disable(experiment: Experiment) {
this.enabled.delete(experiment);
}
/**
* Set the state of an experiment.
* @param key Experiment
* @param enabled Whether this experiment is enabled.
*/
@computed setEnabled(key: Experiment, enabled: boolean): void {
if (enabled) {
this.enable(key);
} else {
this.disable(key);
}
}
/**
* Reset and disable all experiments.
*/
@action reset() {
this.enabled.clear();
}
}

182
src/mobx/stores/Layout.ts Normal file
View file

@ -0,0 +1,182 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
export interface Data {
lastSection?: "home" | "server";
lastHomePath?: string;
lastOpened?: Record<string, string>;
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
* back and forth between different parts of the app.
*/
export default class Layout implements Store, Persistent<Data> {
/**
* The last 'major section' that the user had open.
* This is either the home tab or a channel ID (for a server channel).
*/
private lastSection: "home" | string;
/**
* The last path the user had open in the home tab.
*/
private lastHomePath: string;
/**
* Map of last channels viewed in servers.
*/
private lastOpened: ObservableMap<string, string>;
/**
* Map of section IDs to their current state.
*/
private openSections: ObservableMap<string, boolean>;
/**
* Construct new Layout store.
*/
constructor() {
this.lastSection = "home";
this.lastHomePath = "/";
this.lastOpened = new ObservableMap();
this.openSections = new ObservableMap();
makeAutoObservable(this);
}
get id() {
return "layout";
}
toJSON() {
return {
lastSection: this.lastSection,
lastHomePath: this.lastHomePath,
lastOpened: mapToRecord(this.lastOpened),
openSections: mapToRecord(this.openSections),
};
}
@action hydrate(data: Data) {
if (data.lastSection) {
this.lastSection = data.lastSection;
}
if (data.lastHomePath) {
this.lastHomePath = data.lastHomePath;
}
if (data.lastOpened) {
Object.keys(data.lastOpened).forEach((key) =>
this.lastOpened.set(key, data.lastOpened![key]),
);
}
if (data.openSections) {
Object.keys(data.openSections).forEach((key) =>
this.openSections.set(key, data.openSections![key]),
);
}
}
/**
* Get the last 'major section' the user had open.
* @returns Last open section
*/
@computed getLastSection() {
return this.lastSection;
}
/**
* Get last opened channel in a server.
* @param server Server ID
*/
@computed getLastOpened(server: string) {
return this.lastOpened.get(server);
}
/**
* Get the path to a server (as seen on sidebar).
* @param server Server ID
* @returns Pathname
*/
@computed getServerPath(server: string) {
let path = `/server/${server}`;
if (this.lastOpened.has(server)) {
path += `/channel/${this.getLastOpened(server)}`;
}
return path;
}
/**
* Set last opened channel in a server.
* @param server Server ID
* @param channel Channel ID
*/
@action setLastOpened(server: string, channel: string) {
this.lastOpened.set(server, channel);
this.lastSection = "server";
}
/**
* Get the last path the user had open in the home tab.
* @returns Last home path
*/
@computed getLastHomePath() {
return this.lastHomePath;
}
/**
* Set the current path open in the home tab.
* @param path Pathname
*/
@action setLastHomePath(path: string) {
this.lastHomePath = path;
this.lastSection = "home";
}
/**
*
* @param id Section ID
* @returns Whether the section is open
* @param def Default state value
*/
@computed getSectionState(id: string, def?: boolean) {
return this.openSections.get(id) ?? def ?? false;
}
/**
* Set the state of a section.
* @param id Section ID
* @param value New state value
* @param def Default state value
*/
@action setSectionState(id: string, value: boolean, def?: boolean) {
if (value === def) {
this.openSections.delete(id);
} else {
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

@ -0,0 +1,93 @@
import { action, computed, makeAutoObservable } from "mobx";
import { Language, Languages } from "../../context/Locale";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
export interface Data {
lang: Language;
}
/**
* Detect the browser language or match given language.
* @param lang Language to find
* @returns Matched Language
*/
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;
}
/**
* Keeps track of user's language settings.
*/
export default class LocaleOptions implements Store, Persistent<Data> {
private lang: Language;
/**
* Construct new LocaleOptions store.
*/
constructor() {
this.lang = findLanguage();
makeAutoObservable(this);
}
get id() {
return "locale";
}
toJSON() {
return {
lang: this.lang,
};
}
@action hydrate(data: Data) {
this.setLanguage(data.lang);
}
/**
* Get current language.
*/
@computed getLanguage() {
return this.lang;
}
/**
* Set current language.
*/
@action setLanguage(language: Language) {
if (typeof Languages[language] === "undefined") return;
this.lang = language;
}
}

View file

@ -0,0 +1,108 @@
import {
action,
computed,
IObservableArray,
makeAutoObservable,
observable,
} from "mobx";
import Store from "../interfaces/Store";
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;
}
/**
* Handles waiting for messages to send and send failure.
*/
export default class MessageQueue implements Store {
private messages: IObservableArray<QueuedMessage>;
/**
* Construct new MessageQueue store.
*/
constructor() {
this.messages = observable.array([]);
makeAutoObservable(this);
}
get id() {
return "queue";
}
/**
* Add a message to the queue.
* @param id Nonce value
* @param channel Channel ID
* @param data Message data
*/
@action add(id: string, channel: string, data: QueuedMessageData) {
this.messages.push({
id,
channel,
data,
status: QueueStatus.SENDING,
});
}
/**
* Fail a queued message.
* @param id Nonce value
* @param error Error string
*/
@action fail(id: string, error: string) {
const entry = this.messages.find((x) => x.id === id)!;
entry.status = QueueStatus.ERRORED;
entry.error = error;
}
/**
* Mark a queued message as sending.
* @param id Nonce value
*/
@action start(id: string) {
const entry = this.messages.find((x) => x.id === id)!;
entry.status = QueueStatus.SENDING;
}
/**
* Remove a queued message.
* @param id Nonce value
*/
@action remove(id: string) {
const entry = this.messages.find((x) => x.id === id)!;
this.messages.remove(entry);
}
/**
* Get all queued messages for a channel.
* @param channel Channel ID
* @returns Array of queued messages
*/
@computed get(channel: string) {
return this.messages.filter((x) => x.channel === channel);
}
}

View file

@ -0,0 +1,234 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { mapToRecord } from "../../lib/conversion";
import {
legacyMigrateNotification,
LegacyNotifications,
} from "../legacy/redux";
import { MIGRATIONS } from "../State";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable";
/**
* Possible notification states.
* TODO: make "muted" gray out the channel
* TODO: add server defaults
*/
export type NotificationState = "all" | "mention" | "none" | "muted";
/**
* Default notification states for various types of channels.
*/
export const DEFAULT_STATES: {
[key in Channel["channel_type"]]: NotificationState;
} = {
SavedMessages: "all",
DirectMessage: "all",
Group: "all",
TextChannel: undefined!,
VoiceChannel: undefined!,
};
/**
* Default state for servers.
*/
export const DEFAULT_SERVER_STATE: NotificationState = "mention";
export interface Data {
server?: Record<string, NotificationState>;
channel?: Record<string, NotificationState>;
}
/**
* Manages the user's notification preferences.
*/
export default class NotificationOptions
implements Store, Persistent<Data>, Syncable
{
private server: ObservableMap<string, NotificationState>;
private channel: ObservableMap<string, NotificationState>;
/**
* Construct new Experiments store.
*/
constructor() {
this.server = new ObservableMap();
this.channel = new ObservableMap();
makeAutoObservable(this);
}
get id() {
return "notifications";
}
toJSON() {
return {
server: mapToRecord(this.server),
channel: mapToRecord(this.channel),
};
}
@action hydrate(data: Data) {
if (data.server) {
Object.keys(data.server).forEach((key) =>
this.server.set(key, data.server![key]),
);
}
if (data.channel) {
Object.keys(data.channel).forEach((key) =>
this.channel.set(key, data.channel![key]),
);
}
}
/**
* Compute the actual notification state for a given Channel.
* @param channel Channel
* @returns Notification state
*/
computeForChannel(channel: Channel) {
if (this.channel.has(channel._id)) {
return this.channel.get(channel._id);
}
if (channel.server_id) {
return this.computeForServer(channel.server_id);
}
return DEFAULT_STATES[channel.channel_type];
}
/**
* Check whether an incoming message should notify the user.
* @param message Message
* @returns Whether it should notify the user
*/
shouldNotify(message: Message) {
// Make sure the author is not blocked.
if (message.author?.relationship === RelationshipStatus.Blocked) {
return false;
}
// Check if the message was sent by us.
const user = message.client.user!;
if (message.author_id === user._id) {
return false;
}
// Check whether we are busy.
if (user.status?.presence === Presence.Busy) {
return false;
}
switch (this.computeForChannel(message.channel!)) {
case "muted":
case "none":
// Ignore if muted.
return false;
case "mention":
// Ignore if it doesn't mention us.
if (!message.mention_ids?.includes(user._id)) return false;
}
return true;
}
/**
* Compute the notification state for a given server.
* @param server_id Server ID
* @returns Notification state
*/
computeForServer(server_id: string) {
if (this.server.has(server_id)) {
return this.server.get(server_id);
}
return DEFAULT_SERVER_STATE;
}
/**
* Get the notification state of a channel.
* @param channel_id Channel ID
* @returns Notification state
*/
getChannelState(channel_id: string) {
return this.channel.get(channel_id);
}
/**
* Set the notification state of a channel.
* @param channel_id Channel ID
* @param state Notification state
*/
setChannelState(channel_id: string, state?: NotificationState) {
if (state) {
this.channel.set(channel_id, state);
} else {
this.channel.delete(channel_id);
}
}
/**
* Get the notification state of a server.
* @param server_id Server ID
* @returns Notification state
*/
getServerState(server_id: string) {
return this.server.get(server_id);
}
/**
* Set the notification state of a server.
* @param server_id Server ID
* @param state Notification state
*/
setServerState(server_id: string, state?: NotificationState) {
if (state) {
this.server.set(server_id, state);
} else {
this.server.delete(server_id);
}
}
/**
* Check whether a Channel or Server is muted.
* @param target Channel or Server
* @returns Whether this object is muted
*/
isMuted(target?: Channel | Server) {
var value: NotificationState | undefined;
if (target instanceof Channel) {
value = this.computeForChannel(target);
} else if (target instanceof Server) {
value = this.computeForServer(target._id);
}
if (value === "muted") {
return true;
}
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

@ -0,0 +1,72 @@
import { action, computed, makeAutoObservable } from "mobx";
import { RevoltConfiguration } from "revolt-api/types/Core";
import { Client } from "revolt.js";
import { Nullable } from "revolt.js/dist/util/null";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
/**
* Stores server configuration data.
*/
export default class ServerConfig
implements Store, Persistent<RevoltConfiguration>
{
private config: Nullable<RevoltConfiguration>;
/**
* Construct new ServerConfig store.
*/
constructor() {
this.config = null;
makeAutoObservable(this);
this.set = this.set.bind(this);
}
get id() {
return "server_conf";
}
toJSON() {
return JSON.parse(JSON.stringify(this.config));
}
@action hydrate(data: RevoltConfiguration) {
this.config = data;
}
/**
* Create a new Revolt client.
* @returns Revolt client
*/
createClient() {
const client = new Client({
unreads: true,
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
});
if (this.config !== null) {
client.configuration = this.config;
}
return client;
}
/**
* Get server configuration.
* @returns Server configuration
*/
@computed get() {
return this.config;
}
/**
* Set server configuration.
* @param config Server configuration
*/
@action set(config: RevoltConfiguration) {
this.config = config;
}
}

180
src/mobx/stores/Settings.ts Normal file
View file

@ -0,0 +1,180 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { mapToRecord } from "../../lib/conversion";
import {
LegacyAppearanceOptions,
legacyMigrateAppearance,
legacyMigrateTheme,
LegacyTheme,
LegacyThemeOptions,
} from "../legacy/redux";
import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme";
import { EmojiPack } from "../../components/common/Emoji";
import { MIGRATIONS } from "../State";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable";
import SAudio, { SoundOptions } from "./helpers/SAudio";
import SSecurity from "./helpers/SSecurity";
import STheme from "./helpers/STheme";
export interface ISettings {
"notifications:desktop": boolean;
"notifications:sounds": SoundOptions;
"appearance:emoji": EmojiPack;
"appearance:ligatures": boolean;
"appearance:theme:base": "dark" | "light";
"appearance:theme:overrides": Partial<Overrides>;
"appearance:theme:light": boolean;
"appearance:theme:font": Fonts;
"appearance:theme:monoFont": MonospaceFonts;
"appearance:theme:css": string;
"security:trustedOrigins": string[];
}
/**
* Manages user settings.
*/
export default class Settings
implements Store, Persistent<ISettings>, Syncable
{
private data: ObservableMap<string, unknown>;
theme: STheme;
sounds: SAudio;
security: SSecurity;
/**
* Construct new Settings store.
*/
constructor() {
this.data = new ObservableMap();
makeAutoObservable(this);
this.theme = new STheme(this);
this.sounds = new SAudio(this);
this.security = new SSecurity(this);
}
get id() {
return "settings";
}
toJSON() {
return JSON.parse(JSON.stringify(mapToRecord(this.data)));
}
@action hydrate(data: ISettings) {
Object.keys(data).forEach(
(key) =>
(data as any)[key] && this.data.set(key, (data as any)[key]),
);
}
/**
* Set a settings key.
* @param key Colon-divided key
* @param value Value
*/
@action set<T extends keyof ISettings>(key: T, value: ISettings[T]) {
this.data.set(key, value);
}
/**
* 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,
defaultValue?: ISettings[T],
) {
return (this.data.get(key) as ISettings[T] | undefined) ?? defaultValue;
}
@action remove<T extends keyof ISettings>(key: T) {
this.data.delete(key);
}
/**
* Set a value in settings without type-checking.
* @param key Colon-divided key
* @param value Value
*/
@action setUnchecked(key: string, value: unknown) {
this.data.set(key, value);
}
/**
* Get a settings key with unknown type.
* @param key Colon-divided key
* @returns Value at key
*/
@computed getUnchecked(key: string) {
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;
}
}

165
src/mobx/stores/Sync.ts Normal file
View file

@ -0,0 +1,165 @@
import {
action,
computed,
makeAutoObservable,
ObservableMap,
ObservableSet,
runInAction,
} from "mobx";
import { Client } from "revolt.js";
import { UserSettings } from "revolt.js/node_modules/revolt-api/types/Sync";
import { mapToRecord } from "../../lib/conversion";
import State from "../State";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
export type SyncKeys = "theme" | "appearance" | "locale" | "notifications";
export const SYNC_KEYS: SyncKeys[] = [
"theme",
"appearance",
"locale",
"notifications",
];
export interface Data {
disabled: SyncKeys[];
revision: {
[key: string]: number;
};
}
/**
* Handles syncing settings data.
*/
export default class Sync implements Store, Persistent<Data> {
private state: State;
private disabled: ObservableSet<SyncKeys>;
private revision: ObservableMap<string, number>;
/**
* Construct new Sync store.
*/
constructor(state: State) {
this.state = state;
this.disabled = new ObservableSet();
this.revision = new ObservableMap();
makeAutoObservable(this);
this.isEnabled = this.isEnabled.bind(this);
}
get id() {
return "sync";
}
toJSON() {
return {
enabled: [...this.disabled],
revision: mapToRecord(this.revision),
};
}
@action hydrate(data: Data) {
if (data.disabled) {
for (const key of data.disabled) {
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) {
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) && 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) {
const data = await client.syncFetchSettings(
SYNC_KEYS.filter(this.isEnabled),
);
this.apply(data);
}
}

View file

@ -0,0 +1,107 @@
import { makeAutoObservable, computed, action } from "mobx";
import call_join from "../../../assets/sounds/call_join.mp3";
import call_leave from "../../../assets/sounds/call_leave.mp3";
import message from "../../../assets/sounds/message.mp3";
import outbound from "../../../assets/sounds/outbound.mp3";
import Settings from "../Settings";
export type Sounds = "message" | "outbound" | "call_join" | "call_leave";
export interface Sound {
enabled: boolean;
path: string;
}
export type SoundOptions = {
[key in Sounds]?: Partial<Sound>;
};
export const DefaultSoundPack: { [key in Sounds]: string } = {
message,
outbound,
call_join,
call_leave,
};
export const ALL_SOUNDS: Sounds[] = [
"message",
"outbound",
"call_join",
"call_leave",
];
export const DEFAULT_SOUNDS: Sounds[] = ["message", "call_join", "call_leave"];
/**
* Helper class for reading and writing themes.
*/
export default class SAudio {
private settings: Settings;
private cache: Map<string, HTMLAudioElement>;
/**
* Construct a new sound helper.
* @param settings Settings parent class
*/
constructor(settings: Settings) {
this.settings = settings;
makeAutoObservable(this);
this.cache = new Map();
// Asynchronously load Audio files into cache.
setTimeout(() => this.loadCache(), 0);
}
@action setEnabled(sound: Sounds, enabled: boolean) {
const obj = this.settings.get("notifications:sounds");
this.settings.set("notifications:sounds", {
...obj,
[sound]: {
...obj?.[sound],
enabled,
},
});
}
@computed getSound(sound: Sounds, options?: SoundOptions): Sound {
return {
path: DefaultSoundPack[sound],
enabled: DEFAULT_SOUNDS.includes(sound),
...(options ?? this.settings.get("notifications:sounds"))?.[sound],
};
}
@computed getState(): ({ id: Sounds } & Sound)[] {
const options = this.settings.get("notifications:sounds");
return ALL_SOUNDS.map((id) => {
return { id, ...this.getSound(id, options) };
});
}
getAudio(path: string) {
if (this.cache.has(path)) {
return this.cache.get(path)!;
} else {
const el = new Audio(path);
this.cache.set(path, el);
return el;
}
}
loadCache() {
this.getState().map(({ path }) => this.getAudio(path));
}
playSound(sound: Sounds) {
const definition = this.getSound(sound);
if (definition.enabled) {
const audio = this.getAudio(definition.path);
try {
audio.play();
} catch (err) {
console.error("Hit error while playing", sound + ":", err);
}
}
}
}

View file

@ -0,0 +1,32 @@
import { makeAutoObservable, computed, action } from "mobx";
import Settings from "../Settings";
/**
* Helper class for changing security options.
*/
export default class SSecurity {
private settings: Settings;
/**
* Construct a new security helper.
* @param settings Settings parent class
*/
constructor(settings: Settings) {
this.settings = settings;
makeAutoObservable(this);
}
@action addTrustedOrigin(origin: string) {
this.settings.set("security:trustedOrigins", [
...(this.settings.get("security:trustedOrigins") ?? []).filter(
(x) => x !== origin,
),
origin,
]);
}
@computed isTrustedOrigin(origin: string) {
return this.settings.get("security:trustedOrigins")?.includes(origin);
}
}

View file

@ -0,0 +1,177 @@
import { makeAutoObservable, computed, action } from "mobx";
import {
Theme,
PRESETS,
Variables,
DEFAULT_FONT,
DEFAULT_MONO_FONT,
Fonts,
MonospaceFonts,
} from "../../../context/Theme";
import Settings from "../Settings";
/**
* Helper class for reading and writing themes.
*/
export default class STheme {
private settings: Settings;
/**
* Construct a new theme helper.
* @param settings Settings parent class
*/
constructor(settings: Settings) {
this.settings = settings;
makeAutoObservable(this);
this.setBase = this.setBase.bind(this);
this.reset = this.reset.bind(this);
}
@computed toJSON() {
return JSON.parse(
JSON.stringify({
...this.getVariables(),
css: this.getCSS(),
font: this.getFont(),
monospaceFont: this.getMonospaceFont(),
}),
);
}
@action hydrate(data: Partial<Theme>, resetCSS = false) {
if (resetCSS) this.setCSS();
for (const key of Object.keys(data)) {
const value = data[key as keyof Theme] as string;
switch (key) {
case "css": {
this.setCSS(value);
break;
}
case "font": {
this.setFont(value as Fonts);
break;
}
case "monospaceFont": {
this.setMonospaceFont(value as MonospaceFonts);
break;
}
default:
this.setVariable(key as Variables, value);
}
}
}
/**
* Get the base theme used for this theme.
* @returns Id of base theme
*/
@computed getBase() {
return this.settings.get("appearance:theme:base") ?? "dark";
}
/**
* Get whether the theme is light.
* @returns True if the theme is light
*/
@computed isLight() {
return (
this.settings.get("appearance:theme:light") ??
this.getBase() === "light"
);
}
/**
* Get the current theme's CSS variables.
* @returns Record of CSS variables
*/
@computed getVariables(): Theme {
return {
...PRESETS[this.getBase()],
...this.settings.get("appearance:theme:overrides"),
light: this.isLight(),
};
}
@action setVariable(key: Variables, value: string) {
this.settings.set("appearance:theme:overrides", {
...this.settings.get("appearance:theme:overrides"),
[key]: value,
});
}
/**
* Get a specific value of a variable by its key.
* @param key Variable
* @returns Value of variable
*/
@computed getVariable(key: Variables) {
return (this.settings.get("appearance:theme:overrides")?.[key] ??
PRESETS[this.getBase()]?.[key])!;
}
@action setFont(font: Fonts) {
this.settings.set("appearance:theme:font", font);
}
/**
* Get the current applied font.
* @returns Current font
*/
@computed getFont() {
return this.settings.get("appearance:theme:font") ?? DEFAULT_FONT;
}
@action setMonospaceFont(font: MonospaceFonts) {
this.settings.set("appearance:theme:monoFont", font);
}
/**
* Get the current applied monospace font.
* @returns Current monospace font
*/
@computed getMonospaceFont() {
return (
this.settings.get("appearance:theme:monoFont") ?? DEFAULT_MONO_FONT
);
}
@action setCSS(value?: string) {
if (value && value.length > 0) {
this.settings.set("appearance:theme:css", value);
} else {
this.settings.remove("appearance:theme:css");
}
}
/**
* Get the currently applied CSS snippet.
* @returns CSS string
*/
@computed getCSS() {
return this.settings.get("appearance:theme:css");
}
@computed isModified() {
return (
Object.keys(this.settings.get("appearance:theme:overrides") ?? {})
.length > 0
);
}
@action setBase(base?: "light" | "dark") {
if (base) {
this.settings.set("appearance:theme:base", base);
} else {
this.settings.remove("appearance:theme:base");
}
}
@action reset() {
this.settings.remove("appearance:theme:overrides");
this.settings.remove("appearance:theme:css");
}
}

View file

@ -1,21 +1,21 @@
import { Hash } from "@styled-icons/boxicons-regular";
import { Ghost } from "@styled-icons/boxicons-solid";
import { reaction } from "mobx";
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { 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";
import { Hash } from "@styled-icons/boxicons-regular";
import { Ghost } from "@styled-icons/boxicons-solid";
import AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
@ -52,19 +52,19 @@ const PlaceholderBase = styled.div`
justify-content: center;
text-align: center;
margin: auto;
.primary {
color: var(--secondary-foreground);
font-weight: 700;
font-size: 22px;
margin: 0 0 5px 0;
}
.secondary {
color: var(--tertiary-foreground);
font-weight: 400;
}
svg {
margin: 2em auto;
fill-opacity: 0.8;
@ -84,17 +84,26 @@ 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(() => {
const checkUnread = () =>
channel.unread &&
channel.client.unreads!.markRead(
channel._id,
channel.last_message_id!,
true,
);
checkUnread();
return reaction(
() => channel.last_message_id,
() => checkUnread(),
);
}, [channel]);
const id = channel._id;
return (
<AgeGate
type="channel"
@ -106,54 +115,19 @@ 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={id} />
<VoiceHeader id={channel._id} />
<MessageArea channel={channel} />
<TypingIndicator channel={channel} />
<JumpToBottom channel={channel} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && <RightSidebar />}
{!isTouchscreenDevice &&
layout.getSectionState(SIDEBAR_MEMBERS, true) && (
<RightSidebar />
)}
</ChannelMain>
</AgeGate>
);
@ -173,13 +147,19 @@ function ChannelPlaceholder() {
<PlaceholderBase>
<Header placement="primary">
<Hash size={24} />
<span className="name"><Text id="app.main.channel.errors.nochannel" /></span>
<span className="name">
<Text id="app.main.channel.errors.nochannel" />
</span>
</Header>
<div className="placeholder">
<Ghost width={80} />
<div className="primary"><Text id="app.main.channel.errors.title" /></div>
<div className="secondary"><Text id="app.main.channel.errors.nochannels" /></div>
<div className="primary">
<Text id="app.main.channel.errors.title" />
</div>
<div className="secondary">
<Text id="app.main.channel.errors.nochannels" />
</div>
</div>
</PlaceholderBase>
);

View file

@ -1,4 +1,4 @@
import { At, Hash, Menu, ChevronLeft } 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";
@ -65,7 +68,7 @@ const Info = styled.div`
}
`;
const IconContainer = styled.div`
const IconConainer = styled.div`
display: flex;
align-items: center;
cursor: pointer;
@ -84,84 +87,81 @@ const IconContainer = styled.div`
`}
`;
export default observer(
({ channel, toggleSidebar, toggleChannelSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
export default observer(({ channel }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const layout = useApplicationState().layout;
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
recipient = channel.recipient;
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
recipient = channel.recipient;
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
return (
<Header placement="primary">
<HamburgerAction />
<IconContainer onClick={toggleChannelSidebar}>
{/*isTouchscreenDevice && <ChevronLeft size={18} /> FIXME: requires mobx merge */}
{icon}
</IconContainer>
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel,
})
}>
<Markdown
content={
channel.description.split(
"\n",
)[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions
channel={channel}
toggleSidebar={toggleSidebar}
/>
</Header>
);
},
);
return (
<Header placement="primary">
<HamburgerAction />
<IconConainer
onClick={() =>
layout.toggleSectionState(SIDEBAR_MEMBERS, true)
}>
{/*isTouchscreenDevice && <ChevronLeft size={18} /> FIXME: requires mobx merge */}
{icon}
</IconConainer>
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<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,14 +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 { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue";
import { useApplicationState } from "../../../mobx/State";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { useClient } from "../../../context/revoltjs/RevoltClient";
@ -33,7 +31,6 @@ import MessageEditor from "./MessageEditor";
interface Props {
highlight?: string;
queue: QueuedMessage[];
renderer: ChannelRenderer;
}
@ -48,9 +45,10 @@ const BlockedMessage = styled.div`
}
`;
const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
export default observer(({ renderer, highlight }: Props) => {
const client = useClient();
const userId = client.user!._id;
const queue = useApplicationState().queue;
const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => {
@ -192,8 +190,7 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
const nonces = renderer.messages.map((x) => x.nonce);
if (renderer.atBottom) {
for (const msg of queue) {
if (msg.channel !== renderer.channel._id) continue;
for (const msg of queue.get(renderer.channel._id)) {
if (nonces.includes(msg.id)) continue;
if (previous) {
@ -237,11 +234,3 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
return <>{render}</>;
});
export default memo(
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
);

View file

@ -1,6 +1,6 @@
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 { TextReact } from "../../lib/i18n";
@ -16,10 +16,14 @@ export default function Developer() {
const userPermission = client.user!.permission;
const [ping, setPing] = useState<undefined | number>(client.websocket.ping);
setInterval(
() => setPing(client.websocket.ping),
client.options.heartbeat * 1e3,
);
useEffect(() => {
const timer = setInterval(
() => setPing(client.websocket.ping),
client.options.heartbeat * 1e3,
);
return () => clearInterval(timer);
}, []);
return (
<div>

View file

@ -14,22 +14,20 @@ import styled, { css } from "styled-components";
import styles from "./Home.module.scss";
import "./snow.scss";
import { Text } from "preact-i18n";
import { useContext, useMemo, useState } from "preact/hooks";
import { useContext, useMemo } 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";
import wideSVG from "../../../public/assets/wide.svg";
import Emoji from "../../components/common/Emoji";
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);
@ -57,29 +55,14 @@ const Overlay = 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);
};
const snowflakes = useMemo(() => {

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

@ -1,27 +1,27 @@
import { observer } from "mobx-react-lite";
import { Helmet } from "react-helmet";
import { Route, Switch } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js";
import styles from "./Login.module.scss";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { ThemeContext } from "../../context/Theme";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { useApplicationState } from "../../mobx/State";
import LocaleSelector from "../../components/common/LocaleSelector";
import background from "./background.jpg";
import { Titlebar } from "../../components/native/Titlebar";
import { APP_VERSION } from "../../version";
import background from "./background.jpg";
import { FormCreate } from "./forms/FormCreate";
import { FormLogin } from "./forms/FormLogin";
import { FormReset, FormSendReset } from "./forms/FormReset";
import { FormResend, FormVerify } from "./forms/FormVerify";
export default function Login() {
const theme = useContext(ThemeContext);
const client = useContext(AppContext);
export default observer(() => {
const state = useApplicationState();
const theme = state.settings.theme;
const configuration = state.config.get();
return (
<>
@ -30,13 +30,15 @@ export default function Login() {
)}
<div className={styles.login}>
<Helmet>
<meta name="theme-color" content={theme.background} />
<meta
name="theme-color"
content={theme.getVariable("background")}
/>
</Helmet>
<div className={styles.content}>
<div className={styles.attribution}>
<span>
API:{" "}
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
API: <code>{configuration?.revolt ?? "???"}</code>{" "}
&middot; revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
&middot; App: <code>{APP_VERSION}</code>
</span>
@ -80,4 +82,4 @@ export default function Login() {
</div>
</>
);
}
});

View file

@ -1,10 +1,11 @@
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { observer } from "mobx-react-lite";
import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks";
import { useEffect } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useApplicationState } from "../../../mobx/State";
import Preloader from "../../../components/ui/Preloader";
@ -13,22 +14,22 @@ export interface CaptchaProps {
onCancel: () => void;
}
export function CaptchaBlock(props: CaptchaProps) {
const client = useContext(AppContext);
export const CaptchaBlock = observer((props: CaptchaProps) => {
const configuration = useApplicationState().config.get();
useEffect(() => {
if (!client.configuration?.features.captcha.enabled) {
if (!configuration?.features.captcha.enabled) {
props.onSuccess();
}
}, [client.configuration?.features.captcha.enabled, props]);
}, [configuration?.features.captcha.enabled, props]);
if (!client.configuration?.features.captcha.enabled)
if (!configuration?.features.captcha.enabled)
return <Preloader type="spinner" />;
return (
<div>
<HCaptcha
sitekey={client.configuration.features.captcha.key}
sitekey={configuration.features.captcha.key}
onVerify={(token) => props.onSuccess(token)}
/>
<div className={styles.footer}>
@ -38,4 +39,4 @@ export function CaptchaBlock(props: CaptchaProps) {
</div>
</div>
);
}
});

View file

@ -6,6 +6,8 @@ import styles from "../Login.module.scss";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
@ -44,7 +46,7 @@ interface FormInputs {
}
export function Form({ page, callback }: Props) {
const client = useContext(AppContext);
const configuration = useApplicationState().config.get();
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | undefined>(undefined);
@ -80,10 +82,7 @@ export function Form({ page, callback }: Props) {
}
try {
if (
client.configuration?.features.captcha.enabled &&
page !== "reset"
) {
if (configuration?.features.captcha.enabled && page !== "reset") {
setCaptcha({
onSuccess: async (captcha) => {
setCaptcha(undefined);
@ -111,7 +110,7 @@ export function Form({ page, callback }: Props) {
if (typeof success !== "undefined") {
return (
<div className={styles.success}>
{client.configuration?.features.email ? (
{configuration?.features.email ? (
<>
<Envelope size={72} />
<h2>
@ -172,15 +171,14 @@ export function Form({ page, callback }: Props) {
error={errors.password?.message}
/>
)}
{client.configuration?.features.invite_only &&
page === "create" && (
<FormField
type="invite"
register={register}
showOverline
error={errors.invite?.message}
/>
)}
{configuration?.features.invite_only && page === "create" && (
<FormField
type="invite"
register={register}
showOverline
error={errors.invite?.message}
/>
)}
{error && (
<Overline type="error" error={error}>
<Text id={`login.error.${page}`} />

View file

@ -1,10 +1,9 @@
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useApplicationState } from "../../../mobx/State";
import { Form } from "./Form";
export function FormCreate() {
const client = useContext(AppContext);
const config = useApplicationState().config;
const client = config.createClient();
return <Form page="create" callback={(data) => client.register(data)} />;
}

View file

@ -1,15 +1,16 @@
import { detect } from "detect-browser";
import { useHistory } from "react-router-dom";
import { Session } from "revolt-api/types/Auth";
import { Client } from "revolt.js";
import { useContext } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { OperationsContext } from "../../../context/revoltjs/RevoltClient";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { Form } from "./Form";
export function FormLogin() {
const { login } = useContext(OperationsContext);
const history = useHistory();
const auth = useApplicationState().auth;
const { openScreen } = useIntermediate();
return (
<Form
@ -34,8 +35,41 @@ export function FormLogin() {
friendly_name = "Unknown Device";
}
await login({ ...data, friendly_name });
history.push("/");
// ! FIXME: temporary login flow code
// This should be replaced in the future.
const client = new Client();
await client.fetchConfiguration();
const session = (await client.req(
"POST",
"/auth/session/login",
{ ...data, friendly_name },
)) as unknown as Session;
client.session = session;
(client as any).Axios.defaults.headers = {
"x-session-token": session?.token,
};
async function login() {
auth.setSession(session);
}
const { onboarding } = await client.req(
"GET",
"/onboard/hello",
);
if (onboarding) {
openScreen({
id: "onboarding",
callback: async (username: string) =>
client
.completeOnboarding({ username }, false)
.then(login),
});
} else {
login();
}
}}
/>
);

View file

@ -2,12 +2,15 @@ import { useHistory, useParams } from "react-router-dom";
import { useContext } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { Form } from "./Form";
export function FormSendReset() {
const client = useContext(AppContext);
const config = useApplicationState().config;
const client = config.createClient();
return (
<Form

View file

@ -2,6 +2,8 @@ import { useHistory, useParams } from "react-router-dom";
import { useContext, useEffect, useState } from "preact/hooks";
import { useApplicationState } from "../../../mobx/State";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
@ -11,7 +13,8 @@ import Preloader from "../../../components/ui/Preloader";
import { Form } from "./Form";
export function FormResend() {
const client = useContext(AppContext);
const config = useApplicationState().config;
const client = config.createClient();
return (
<Form

View file

@ -32,7 +32,6 @@ function mapMailProvider(email?: string): [string, string] | undefined {
case "outlook.com.br":
case "outlook.cl":
case "outlook.cz":
case "outlook.dk":
case "outlook.com.gr":
case "outlook.co.il":
case "outlook.in":

View file

@ -15,7 +15,7 @@ import {
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { ThemeContext } from "../../context/Theme";
import { useApplicationState } from "../../mobx/State";
import Category from "../../components/ui/Category";
import Header from "../../components/ui/Header";
@ -55,7 +55,7 @@ export function GenericSettings({
indexHeader,
}: Props) {
const history = useHistory();
const theme = useContext(ThemeContext);
const theme = useApplicationState().settings.theme;
const { page } = useParams<{ page: string }>();
const [closing, setClosing] = useState(false);
@ -96,8 +96,8 @@ export function GenericSettings({
name="theme-color"
content={
isTouchscreenDevice
? theme["background"]
: theme["secondary-background"]
? theme.getVariable("background")
: theme.getVariable("secondary-background")
}
/>
</Helmet>

View file

@ -18,6 +18,7 @@ import {
Store,
Bot,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Route, Switch, useHistory } from "react-router-dom";
import { LIBRARY_VERSION } from "revolt.js";
import styled from "styled-components";
@ -26,13 +27,10 @@ import styles from "./Settings.module.scss";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { isExperimentEnabled } from "../../redux/reducers/experiments";
import { useApplicationState } from "../../mobx/State";
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
import {
AppContext,
OperationsContext,
} from "../../context/revoltjs/RevoltClient";
import { AppContext, LogOutContext } from "../../context/revoltjs/RevoltClient";
import UserIcon from "../../components/common/user/UserIcon";
import LineDivider from "../../components/ui/LineDivider";
@ -64,10 +62,11 @@ const IndexHeader = styled.div`
gap: 10px;
`;
export default function Settings() {
export default observer(() => {
const history = useHistory();
const client = useContext(AppContext);
const operations = useContext(OperationsContext);
const logout = useContext(LogOutContext);
const experiments = useApplicationState().experiments;
function switchPage(to?: string) {
if (to) {
@ -138,14 +137,14 @@ export default function Settings() {
title: <Text id="app.settings.pages.experiments.title" />,
},
{
divider: !isExperimentEnabled("theme_shop"),
divider: !experiments.isEnabled("theme_shop"),
category: "revolt",
id: "bots",
icon: <Bot size={20} />,
title: <Text id="app.settings.pages.bots.title" />,
},
{
hidden: !isExperimentEnabled("theme_shop"),
hidden: !experiments.isEnabled("theme_shop"),
divider: true,
id: "theme_shop",
icon: <Store size={20} />,
@ -191,7 +190,7 @@ export default function Settings() {
<Route path="/settings/bots">
<MyBots />
</Route>
{isExperimentEnabled("theme_shop") && (
{experiments.isEnabled("theme_shop") && (
<Route path="/settings/theme_shop">
<ThemeShop />
</Route>
@ -229,7 +228,7 @@ export default function Settings() {
</a>
<LineDivider />
<ButtonItem
onClick={() => operations.logout()}
onClick={logout}
className={styles.logOut}
compact>
<LogOut size={20} />
@ -277,4 +276,4 @@ export default function Settings() {
}
/>
);
}
});

View file

@ -1,528 +1,53 @@
import {
Reset,
Import,
FontFamily,
CodeAlt,
} from "@styled-icons/boxicons-regular";
import {
Pencil,
Store,
Palette,
HappyBeaming,
QuoteLeft,
} from "@styled-icons/boxicons-solid";
import { Link } from "react-router-dom";
// @ts-expect-error shade-blend-color does not have typings.
import pSBC from "shade-blend-color";
import { observer } from "mobx-react-lite";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { debounce } from "../../../lib/debounce";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { isExperimentEnabled } from "../../../redux/reducers/experiments";
import { EmojiPacks, Settings } from "../../../redux/reducers/settings";
import {
DEFAULT_FONT,
DEFAULT_MONO_FONT,
Fonts,
FONTS,
FONT_KEYS,
MonospaceFonts,
MONOSPACE_FONTS,
MONOSPACE_FONT_KEYS,
Theme,
ThemeContext,
ThemeOptions,
} from "../../../context/Theme";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import CollapsibleSection from "../../../components/common/CollapsibleSection";
import Tooltip from "../../../components/common/Tooltip";
import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox";
import ColourSwatches from "../../../components/ui/ColourSwatches";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
import darkSVG from "../assets/dark.svg";
import lightSVG from "../assets/light.svg";
import mutantSVG from "../assets/mutant_emoji.svg";
import notoSVG from "../assets/noto_emoji.svg";
import openmojiSVG from "../assets/openmoji_emoji.svg";
import twemojiSVG from "../assets/twemoji_emoji.svg";
interface Props {
settings: Settings;
}
import {
ThemeBaseSelectorShim,
ThemeShopShim,
ThemeAccentShim,
DisplayFontShim,
DisplayMonospaceFontShim,
DisplayLigaturesShim,
DisplayEmojiShim,
ThemeCustomCSSShim,
} from "../../../components/settings/AppearanceShims";
import ThemeOverrides from "../../../components/settings/appearance/ThemeOverrides";
import ThemeTools from "../../../components/settings/appearance/ThemeTools";
// ! FIXME: code needs to be rewritten to fix jittering
export function Component(props: Props) {
const theme = useContext(ThemeContext);
const { writeClipboard, openScreen } = useIntermediate();
function setTheme(theme: ThemeOptions) {
dispatch({
type: "SETTINGS_SET_THEME",
theme,
});
}
const pushOverride = useCallback((custom: Partial<Theme>) => {
dispatch({
type: "SETTINGS_SET_THEME_OVERRIDE",
custom,
});
}, []);
function setAccent(accent: string) {
setOverride({
accent,
"scrollbar-thumb": pSBC(-0.2, accent),
});
}
const emojiPack = props.settings.appearance?.emojiPack ?? "mutant";
function setEmojiPack(emojiPack: EmojiPacks) {
dispatch({
type: "SETTINGS_SET_APPEARANCE",
options: {
emojiPack,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const setOverride = useCallback(
debounce(pushOverride as (...args: unknown[]) => void, 200),
[pushOverride],
) as (custom: Partial<Theme>) => void;
const [css, setCSS] = useState(props.settings.theme?.custom?.css ?? "");
useEffect(() => setOverride({ css }), [setOverride, css]);
const selected = props.settings.theme?.base ?? "dark";
export const Appearance = observer(() => {
return (
<div className={styles.appearance}>
<h3>
<Text id="app.settings.pages.appearance.theme" />
</h3>
<div className={styles.themes}>
<div className={styles.theme}>
<img
loading="eager"
src={lightSVG}
draggable={false}
data-active={selected === "light"}
onClick={() =>
selected !== "light" && setTheme({ base: "light" })
}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.light" />
</h4>
</div>
<div className={styles.theme}>
<img
loading="eager"
src={darkSVG}
draggable={false}
data-active={selected === "dark"}
onClick={() =>
selected !== "dark" && setTheme({ base: "dark" })
}
onContextMenu={(e) => e.preventDefault()}
/>
<h4>
<Text id="app.settings.pages.appearance.color.dark" />
</h4>
</div>
</div>
<ThemeBaseSelectorShim />
<ThemeShopShim />
<ThemeAccentShim />
{isExperimentEnabled("theme_shop") && (
<Link
to="/settings/theme_shop"
replace
className={styles.focus}>
<CategoryButton
icon={<Store size={24} />}
action="chevron"
description={"Browse themes made by the community"}
hover>
<Text id="app.settings.pages.theme_shop.title" />
</CategoryButton>
</Link>
)}
<DisplayFontShim />
<DisplayLigaturesShim />
<DisplayEmojiShim />
<h3>
<Text id="app.settings.pages.appearance.accent_selector" />
</h3>
<ColourSwatches value={theme.accent} onChange={setAccent} />
{/* TOFIX: Chane this checkbox to turn off the seasonal home page animations*/}
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
"Displays effects in the home tab during holiday seasons."
}>
Seasonal theme
</Checkbox>
{/*<h3>
<Text id="app.settings.pages.appearance.message_display" />
</h3>
<div className={styles.display}>
<Radio
description={
<Text id="app.settings.pages.appearance.display.default_description" />
}
checked
>
<Text id="app.settings.pages.appearance.display.default" />
</Radio>
<Radio
description={
<Text id="app.settings.pages.appearance.display.compact_description" />
}
disabled
>
<Text id="app.settings.pages.appearance.display.compact" />
</Radio>
</div>*/}
<hr />
{/*<CategoryButton
icon={<Palette size={24} />}
description={"Customize the look of your app using themes."}
action="chevron">
Themes
</CategoryButton>
<CategoryButton
icon={<FontFamily size={24} />}
description={"Change the font and size used in the app."}
action="chevron">
{`Font & text size`}
</CategoryButton>
<CategoryButton
icon={<QuoteLeft size={24} />}
description={"Change the look of your messages."}
action="chevron">
Message Display
</CategoryButton>
<CategoryButton
icon={<HappyBeaming size={24} />}
description={"Personalize your client with an emoji pack."}
action="chevron">
Emoji Packs
</CategoryButton>
<h3>Advanced</h3>
<CategoryButton
icon={<CodeAlt size={24} />}
description={"Customize the client CSS to your heart's content"}
action="chevron">
Custom CSS
</CategoryButton>*/}
<h3>
<Text id="app.settings.pages.appearance.font" />
</h3>
<ComboBox
value={theme.font ?? DEFAULT_FONT}
onChange={(e) =>
pushOverride({ font: e.currentTarget.value as Fonts })
}>
{FONT_KEYS.map((key) => (
<option value={key} key={key}>
{FONTS[key as keyof typeof FONTS].name}
</option>
))}
</ComboBox>
{/* TOFIX: Only show when a font with ligature support is selected, i.e.: Inter.*/}
<Checkbox
checked={props.settings.theme?.ligatures === true}
onChange={() =>
setTheme({
ligatures: !props.settings.theme?.ligatures,
})
}
description={
<Text id="app.settings.pages.appearance.ligatures_desc" />
}>
<Text id="app.settings.pages.appearance.ligatures" />
</Checkbox>
<hr />
<h3>
<Text id="app.settings.pages.appearance.emoji_pack" />
</h3>
<div className={styles.emojiPack}>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("mutant")}
data-active={emojiPack === "mutant"}>
<img
loading="eager"
src={mutantSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>
Mutant Remix{" "}
<a
href="https://mutant.revolt.chat"
target="_blank"
rel="noreferrer">
(by Revolt)
</a>
</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("twemoji")}
data-active={emojiPack === "twemoji"}>
<img
loading="eager"
src={twemojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Twemoji</h4>
</div>
</div>
<div className={styles.row}>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("openmoji")}
data-active={emojiPack === "openmoji"}>
<img
loading="eager"
src={openmojiSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Openmoji</h4>
</div>
<div>
<div
className={styles.button}
onClick={() => setEmojiPack("noto")}
data-active={emojiPack === "noto"}>
<img
loading="eager"
src={notoSVG}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
<h4>Noto Emoji</h4>
</div>
</div>
</div>
<hr />
<CollapsibleSection
defaultValue={false}
id="settings_overrides"
summary={<Text id="app.settings.pages.appearance.overrides" />}>
<div className={styles.actions}>
<Tooltip
content={
<Text id="app.settings.pages.appearance.reset_overrides" />
}>
<Button
contrast
iconbutton
onClick={() => setTheme({ custom: {} })}>
<Reset size={22} />
</Button>
</Tooltip>
<div
className={styles.code}
onClick={() => writeClipboard(JSON.stringify(theme))}>
<Tooltip content={<Text id="app.special.copy" />}>
{" "}
{/*TOFIX: Try to put the tooltip above the .code div without messing up the css challenge */}
{JSON.stringify(theme)}
</Tooltip>
</div>
<Tooltip
content={
<Text id="app.settings.pages.appearance.import" />
}>
<Button
contrast
iconbutton
onClick={async () => {
try {
const text =
await navigator.clipboard.readText();
setOverride(JSON.parse(text));
} catch (err) {
openScreen({
id: "_input",
question: (
<Text id="app.settings.pages.appearance.import_theme" />
),
field: (
<Text id="app.settings.pages.appearance.theme_data" />
),
callback: async (string) =>
setOverride(JSON.parse(string)),
});
}
}}>
<Import size={22} />
</Button>
</Tooltip>
</div>
<ThemeTools />
<h3>App</h3>
<div className={styles.overrides}>
{(
[
"accent",
"background",
"foreground",
"primary-background",
"primary-header",
"secondary-background",
"secondary-foreground",
"secondary-header",
"tertiary-background",
"tertiary-foreground",
"block",
"message-box",
"mention",
"scrollbar-thumb",
"scrollbar-track",
"status-online",
"status-away",
"status-busy",
"status-streaming",
"status-invisible",
"success",
"warning",
"error",
"hover",
] as const
).map((x) => (
<div
className={styles.entry}
key={x}
style={{ backgroundColor: theme[x] }}>
<div className={styles.input}>
<input
type="color"
value={theme[x]}
onChange={(v) =>
setOverride({
[x]: v.currentTarget.value,
})
}
/>
</div>
<span
style={`color: ${getContrastingColour(
theme[x],
theme["primary-background"],
)}`}>
{x}
</span>
<div className={styles.override}>
<div
className={styles.picker}
onClick={(e) =>
e.currentTarget.parentElement?.parentElement
?.querySelector("input")
?.click()
}>
<Pencil size={24} />
</div>
<InputBox
type="text"
className={styles.text}
value={theme[x]}
onChange={(y) =>
setOverride({
[x]: y.currentTarget.value,
})
}
/>
</div>
</div>
))}
</div>
<ThemeOverrides />
</CollapsibleSection>
<CollapsibleSection
id="settings_advanced_appearance"
defaultValue={false}
summary={<Text id="app.settings.pages.appearance.advanced" />}>
<h3>
<Text id="app.settings.pages.appearance.mono_font" />
</h3>
<ComboBox
value={theme.monospaceFont ?? DEFAULT_MONO_FONT}
onChange={(e) =>
pushOverride({
monospaceFont: e.currentTarget
.value as MonospaceFonts,
})
}>
{MONOSPACE_FONT_KEYS.map((key) => (
<option value={key} key={key}>
{
MONOSPACE_FONTS[
key as keyof typeof MONOSPACE_FONTS
].name
}
</option>
))}
</ComboBox>
<h3>
<Text id="app.settings.pages.appearance.custom_css" />
</h3>
<TextAreaAutoSize
maxRows={20}
minHeight={480}
code
value={css}
onChange={(ev) => setCSS(ev.currentTarget.value)}
/>
<DisplayMonospaceFontShim />
<ThemeCustomCSSShim />
</CollapsibleSection>
</div>
);
}
export const Appearance = connectState(Component, (state) => {
return {
settings: state.settings,
};
});
function getContrastingColour(hex: string, fallback: string): string {
hex = hex.replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const cc = (r * 299 + g * 587 + b * 114) / 1000;
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(cc))
return getContrastingColour(fallback, "#fffff");
return cc >= 175 ? "black" : "white";
}
// <DisplayCompactShim />

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";
@ -20,7 +18,9 @@ import opusSVG from "../assets/opus_logo.svg";
const constraints = { audio: true };
export function Component() {
// TODO: do not rewrite this code until voice is rewritten!
export function Audio() {
const [mediaStream, setMediaStream] = useState<MediaStream | undefined>(
undefined,
);
@ -244,7 +244,3 @@ function changeAudioDevice(deviceId: string, deviceType: string) {
window.localStorage.setItem("audioOutputDevice", deviceId);
}
}
export const Audio = connectState(Component, () => {
return;
});

View file

@ -1,22 +1,19 @@
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 { useApplicationState } from "../../../mobx/State";
import {
AVAILABLE_EXPERIMENTS,
ExperimentOptions,
EXPERIMENTS,
isExperimentEnabled,
} from "../../../redux/reducers/experiments";
} from "../../../mobx/stores/Experiments";
import Checkbox from "../../../components/ui/Checkbox";
interface Props {
options?: ExperimentOptions;
}
export const ExperimentsPage = observer(() => {
const experiments = useApplicationState().experiments;
export function Component(props: Props) {
return (
<div className={styles.experiments}>
<h3>
@ -25,15 +22,8 @@ export function Component(props: Props) {
{AVAILABLE_EXPERIMENTS.map((key) => (
<Checkbox
key={key}
checked={isExperimentEnabled(key, props.options)}
onChange={(enabled) =>
dispatch({
type: enabled
? "EXPERIMENTS_ENABLE"
: "EXPERIMENTS_DISABLE",
key,
})
}
checked={experiments.isEnabled(key)}
onChange={(enabled) => experiments.setEnabled(key, enabled)}
description={EXPERIMENTS[key].description}>
{EXPERIMENTS[key].title}
</Checkbox>
@ -45,10 +35,4 @@ export function Component(props: Props) {
)}
</div>
);
}
export const ExperimentsPage = connectState(Component, (state) => {
return {
options: state.experiments,
};
});

View file

@ -1,8 +1,10 @@
import { observer } from "mobx-react-lite";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useMemo } from "preact/hooks";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import { useApplicationState } from "../../../mobx/State";
import {
Language,
@ -17,26 +19,25 @@ import enchantingTableWEBP from "../assets/enchanting_table.webp";
import tamilFlagPNG from "../assets/tamil_nadu_flag.png";
import tokiponaSVG from "../assets/toki_pona.svg";
type Props = {
locale: Language;
};
type Key = [Language, LanguageEntry];
type Key = [string, LanguageEntry];
interface Props {
entry: Key;
selected: boolean;
onSelect: () => void;
}
function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
/**
* Component providing individual language entries.
* @param param0 Entry data
*/
function Entry({ entry: [x, lang], selected, onSelect }: Props) {
return (
<Checkbox
key={x}
className={styles.entry}
checked={locale === x}
onChange={(v) => {
if (v) {
dispatch({
type: "SET_LOCALE",
locale: x as Language,
});
}
}}>
checked={selected}
onChange={onSelect}>
<div className={styles.flag}>
{lang.i18n === "ta" ? (
<img
@ -61,36 +62,58 @@ function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
);
}
export function Component(props: Props) {
const languages = Object.keys(Langs).map((x) => [
x,
Langs[x as keyof typeof Langs],
]) as Key[];
/**
* Component providing the language selection menu.
*/
export const Languages = observer(() => {
const locale = useApplicationState().locale;
const language = locale.getLanguage();
// Get the user's system language. Check for exact
// matches first, otherwise check for partial matches
const preferredLanguage =
navigator.languages.filter((lang) =>
languages.find((l) => l[0].replace(/_/g, "-") == lang),
)?.[0] ||
navigator.languages
?.map((x) => x.split("-")[0])
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
?.split("-")[0];
// Generate languages array.
const languages = useMemo(() => {
const languages = Object.keys(Langs).map((x) => [
x,
Langs[x as keyof typeof Langs],
]) as Key[];
if (preferredLanguage) {
// This moves the user's system language to the top of the language list
const prefLangKey = languages.find(
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
);
if (prefLangKey) {
languages.splice(
0,
0,
languages.splice(languages.indexOf(prefLangKey), 1)[0],
// Get the user's system language. Check for exact
// matches first, otherwise check for partial matches
const preferredLanguage =
navigator.languages.filter((lang) =>
languages.find((l) => l[0].replace(/_/g, "-") == lang),
)?.[0] ||
navigator.languages
?.map((x) => x.split("-")[0])
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
?.split("-")[0];
if (preferredLanguage) {
// This moves the user's system language to the top of the language list
const prefLangKey = languages.find(
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
);
if (prefLangKey) {
languages.splice(
0,
0,
languages.splice(languages.indexOf(prefLangKey), 1)[0],
);
}
}
}
return languages;
}, []);
// Creates entries with given key.
const EntryFactory = ([x, lang]: Key) => (
<Entry
key={x}
entry={[x, lang]}
selected={language === x}
onSelect={() => locale.setLanguage(x)}
/>
);
return (
<div className={styles.languages}>
@ -98,11 +121,7 @@ export function Component(props: Props) {
<Text id="app.settings.pages.language.select" />
</h3>
<div className={styles.list}>
{languages
.filter(([, lang]) => !lang.cat)
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
{languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}
</div>
<h3>
<Text id="app.settings.pages.language.const" />
@ -110,9 +129,7 @@ export function Component(props: Props) {
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.cat === "const")
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
.map(EntryFactory)}
</div>
<h3>
<Text id="app.settings.pages.language.other" />
@ -120,9 +137,7 @@ export function Component(props: Props) {
<div className={styles.list}>
{languages
.filter(([, lang]) => lang.cat === "alt")
.map(([x, lang]) => (
<Entry key={x} entry={[x, lang]} {...props} />
))}
.map(EntryFactory)}
</div>
<Tip>
<span>
@ -137,10 +152,4 @@ export function Component(props: Props) {
</Tip>
</div>
);
}
export const Languages = connectState(Component, (state) => {
return {
locale: state.locale,
};
});

View file

@ -1,4 +1,4 @@
import defaultsDeep from "lodash.defaultsdeep";
import { observer } from "mobx-react-lite";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
@ -6,28 +6,17 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { urlBase64ToUint8Array } from "../../../lib/conversion";
import { dispatch } from "../../../redux";
import { connectState } from "../../../redux/connector";
import {
DEFAULT_SOUNDS,
NotificationOptions,
SoundOptions,
} from "../../../redux/reducers/settings";
import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import Checkbox from "../../../components/ui/Checkbox";
import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio";
interface Props {
options?: NotificationOptions;
}
export function Component({ options }: Props) {
export const Notifications = observer(() => {
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const settings = useApplicationState().settings;
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
undefined,
);
@ -42,10 +31,6 @@ export function Component({ options }: Props) {
});
}, []);
const enabledSounds: SoundOptions = defaultsDeep(
options?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (
<div className={styles.notifications}>
<h3>
@ -53,7 +38,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" />
}
@ -61,6 +46,7 @@ export function Component({ options }: Props) {
if (desktopEnabled) {
const permission =
await Notification.requestPermission();
if (permission !== "granted") {
return openScreen({
id: "error",
@ -69,10 +55,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>
@ -125,32 +108,16 @@ export function Component({ options }: Props) {
<h3>
<Text id="app.settings.pages.notifications.sounds" />
</h3>
{SOUNDS_ARRAY.map((key) => (
{settings.sounds.getState().map(({ id, enabled }) => (
<Checkbox
key={key}
checked={!!enabledSounds[key]}
key={id}
checked={enabled}
onChange={(enabled) =>
dispatch({
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
options: {
sounds: {
...options?.sounds,
[key]: enabled,
},
},
})
settings.sounds.setEnabled(id, enabled)
}>
<Text
id={`app.settings.pages.notifications.sound.${key}`}
/>
<Text id={`app.settings.pages.notifications.sound.${id}`} />
</Checkbox>
))}
</div>
);
}
export const Notifications = connectState(Component, (state) => {
return {
options: state.settings.notification,
};
});

View file

@ -461,97 +461,6 @@
display: flex;
flex-direction: column;
}
.actions {
gap: 8px;
display: flex;
margin: 18px 0 8px 0;
.code {
cursor: pointer;
display: flex;
align-items: center;
font-size: 0.875rem;
min-width: 0;
flex-grow: 1;
padding: 8px;
font-family: var(--monospace-font);
border-radius: var(--border-radius);
background: var(--secondary-background);
> div {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.overrides {
row-gap: 8px;
display: grid;
column-gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
margin-bottom: 20px;
.entry {
padding: 12px;
margin-top: 8px;
border: 1px solid black;
border-radius: var(--border-radius);
span {
flex: 1;
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 8px;
text-transform: capitalize;
background: inherit;
background-clip: text;
-webkit-background-clip: text;
}
.override {
gap: 8px;
display: flex;
.picker {
width: 38px;
height: 38px;
display: grid;
cursor: pointer;
place-items: center;
border-radius: var(--border-radius);
background: var(--primary-background);
}
input[type="text"] {
width: 0;
min-width: 0;
flex-grow: 1;
}
}
.input {
width: 0;
height: 0;
position: relative;
input {
opacity: 0;
border: none;
display: block;
cursor: pointer;
position: relative;
top: 48px;
}
}
}
}
}
.sessions {

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>
@ -31,22 +30,13 @@ 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>
))}
@ -55,10 +45,4 @@ export function Component(props: Props) {
</h5>*/}
</div>
);
}
export const Sync = connectState(Component, (state) => {
return {
options: state.sync,
};
});

View file

@ -1,19 +1,11 @@
import { Plus, Check } from "@styled-icons/boxicons-regular";
import {
Star,
BarChartAlt2,
Brush,
Bookmark,
} from "@styled-icons/boxicons-solid";
import styled from "styled-components";
import { useEffect, useState } from "preact/hooks";
import { dispatch } from "../../../redux";
import { useApplicationState } from "../../../mobx/State";
import { Theme, generateVariables, ThemeOptions } from "../../../context/Theme";
import { Theme, generateVariables } from "../../../context/Theme";
import InputBox from "../../../components/ui/InputBox";
import Tip from "../../../components/ui/Tip";
import previewPath from "../assets/preview.svg";
@ -258,6 +250,8 @@ export function ThemeShop() {
>(null);
const [themeData, setThemeData] = useState<Record<string, Theme>>({});
const themes = useApplicationState().settings.theme;
async function fetchThemeList() {
const manifest = await fetchManifest();
setThemeList(
@ -352,21 +346,9 @@ export function ThemeShop() {
data-loaded={Reflect.has(themeData, slug)}>
<button
class="preview"
onClick={() => {
dispatch({
type: "THEMES_SET_THEME",
theme: {
slug,
meta: theme,
theme: themeData[slug],
},
});
dispatch({
type: "SETTINGS_SET_THEME",
theme: { base: slug },
});
}}>
onClick={() =>
themes.hydrate(themeData[slug], true)
}>
<div class="previewBox">
<div class="hover">Use theme</div>
<ThemePreview

View file

@ -1,28 +0,0 @@
import localForage from "localforage";
import { Provider } from "react-redux";
import { useEffect, useState } from "preact/hooks";
import { dispatch, State, store } from ".";
import { Children } from "../types/Preact";
interface Props {
children: Children;
}
export default function StateLoader(props: Props) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
localForage.getItem("state").then((state) => {
if (state !== null) {
dispatch({ type: "__INIT", state: state as State });
}
setLoaded(true);
});
}, []);
if (!loaded) return null;
return <Provider store={store}>{props.children}</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;
}

Some files were not shown because too many files have changed in this diff Show more