Merge branch 'mobx'
2
.gitignore
vendored
|
@ -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
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
10
index.html
|
@ -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>
|
||||
|
|
25
package.json
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
|||
import { defer } from "../../../../lib/defer";
|
||||
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
|
||||
|
||||
import { dispatch } from "../../../../redux";
|
||||
|
||||
import {
|
||||
AppContext,
|
||||
ClientStatus,
|
||||
|
@ -44,8 +42,7 @@ const EmbedInviteBase = styled.div`
|
|||
> button {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmbedInviteDetails = styled.div`
|
||||
|
@ -55,8 +52,7 @@ const EmbedInviteDetails = styled.div`
|
|||
isTouchscreenDevice &&
|
||||
css`
|
||||
width: calc(100% - 55px);
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmbedInviteName = styled.div`
|
||||
|
@ -74,11 +70,10 @@ type Props = {
|
|||
code: string;
|
||||
};
|
||||
|
||||
export function EmbedInvite(props: Props) {
|
||||
export function EmbedInvite({ code }: Props) {
|
||||
const history = useHistory();
|
||||
const client = useContext(AppContext);
|
||||
const status = useContext(StatusContext);
|
||||
const code = props.code;
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [joinError, setJoinError] = useState<string | undefined>(undefined);
|
||||
|
@ -124,7 +119,8 @@ export function EmbedInvite(props: Props) {
|
|||
<EmbedInviteDetails>
|
||||
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
|
||||
<EmbedInviteMemberCount>
|
||||
{invite.member_count.toLocaleString()} {invite.member_count === 1 ? "member" : "members"}
|
||||
{invite.member_count.toLocaleString()}{" "}
|
||||
{invite.member_count === 1 ? "member" : "members"}
|
||||
</EmbedInviteMemberCount>
|
||||
</EmbedInviteDetails>
|
||||
{processing ? (
|
||||
|
@ -151,10 +147,9 @@ export function EmbedInvite(props: Props) {
|
|||
|
||||
defer(() => {
|
||||
if (server) {
|
||||
dispatch({
|
||||
type: "UNREADS_MARK_MULTIPLE_READ",
|
||||
channels: server.channel_ids,
|
||||
});
|
||||
client.unreads!.markMultipleRead(
|
||||
server.channel_ids,
|
||||
);
|
||||
|
||||
history.push(
|
||||
`/server/${server._id}/channel/${invite.channel_id}`,
|
||||
|
@ -172,7 +167,9 @@ export function EmbedInvite(props: Props) {
|
|||
setProcessing(false);
|
||||
}
|
||||
}}>
|
||||
{client.servers.get(invite.server_id) ? "Joined" : "Join"}
|
||||
{client.servers.get(invite.server_id)
|
||||
? "Joined"
|
||||
: "Join"}
|
||||
</Button>
|
||||
)}
|
||||
</EmbedInviteBase>
|
||||
|
|
|
@ -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 }>`
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -117,7 +117,7 @@
|
|||
}
|
||||
|
||||
&[data-muted="true"] {
|
||||
color: var(--tertiary-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&[data-alert="true"],
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
221
src/components/settings/AppearanceShims.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
});
|
161
src/components/settings/appearance/EmojiSelector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
84
src/components/settings/appearance/ThemeBaseSelector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
181
src/components/settings/appearance/ThemeOverrides.tsx
Normal 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";
|
||||
}
|
89
src/components/settings/appearance/ThemeTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
@ -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}
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
);
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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="/" />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
119
src/lib/contextmenu/CMNotifications.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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
|
@ -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;
|
||||
}
|
17
src/mobx/interfaces/Persistent.ts
Normal 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;
|
||||
}
|
3
src/mobx/interfaces/Store.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default interface Store {
|
||||
get id(): string;
|
||||
}
|
9
src/mobx/interfaces/Syncable.ts
Normal 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
|
@ -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
|
@ -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
|
@ -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();
|
||||
}
|
||||
}
|
117
src/mobx/stores/Experiments.ts
Normal 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
|
@ -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));
|
||||
}
|
||||
}
|
93
src/mobx/stores/LocaleOptions.ts
Normal 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;
|
||||
}
|
||||
}
|
108
src/mobx/stores/MessageQueue.ts
Normal 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);
|
||||
}
|
||||
}
|
234
src/mobx/stores/NotificationOptions.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
72
src/mobx/stores/ServerConfig.ts
Normal 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
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
107
src/mobx/stores/helpers/SAudio.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
src/mobx/stores/helpers/SSecurity.ts
Normal 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);
|
||||
}
|
||||
}
|
177
src/mobx/stores/helpers/STheme.ts
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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>{" "}
|
||||
· revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
|
||||
· App: <code>{APP_VERSION}</code>
|
||||
</span>
|
||||
|
@ -80,4 +82,4 @@ export default function Login() {
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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}`} />
|
||||
|
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|