Merge branch 'mobx'
2
.gitignore
vendored
|
@ -10,3 +10,5 @@ dist-ssr
|
||||||
public/assets
|
public/assets
|
||||||
public/assets_*
|
public/assets_*
|
||||||
!public/assets_default
|
!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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" background="#191919">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|
||||||
|
<!--App Title-->
|
||||||
<title>Revolt</title>
|
<title>Revolt</title>
|
||||||
<meta name="apple-mobile-web-app-title" content="Revolt" />
|
<meta name="apple-mobile-web-app-title" content="Revolt" />
|
||||||
|
|
||||||
|
<!--App Scaling-->
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||||
|
@ -74,9 +77,4 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
<style>
|
|
||||||
html {
|
|
||||||
background-color: #191919;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</html>
|
</html>
|
||||||
|
|
25
package.json
|
@ -5,7 +5,7 @@
|
||||||
"pull": "node scripts/setup_assets.js",
|
"pull": "node scripts/setup_assets.js",
|
||||||
"build": "rimraf build && node scripts/setup_assets.js --check && vite build",
|
"build": "rimraf build && node scripts/setup_assets.js --check && vite build",
|
||||||
"preview": "vite preview",
|
"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}'",
|
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"start": "sirv dist --cors --single --host",
|
"start": "sirv dist --cors --single --host",
|
||||||
|
@ -37,6 +37,24 @@
|
||||||
{
|
{
|
||||||
"varsIgnorePattern": "^_"
|
"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",
|
"eslint-config-preact": "^1.1.4",
|
||||||
"eventemitter3": "^4.0.7",
|
"eventemitter3": "^4.0.7",
|
||||||
"highlight.js": "^11.0.1",
|
"highlight.js": "^11.0.1",
|
||||||
|
"json-stringify-deterministic": "^1.0.2",
|
||||||
"localforage": "^1.9.0",
|
"localforage": "^1.9.0",
|
||||||
"lodash.defaultsdeep": "^4.6.1",
|
"lodash.defaultsdeep": "^4.6.1",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
|
@ -118,14 +137,12 @@
|
||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-hook-form": "6.3.0",
|
"react-hook-form": "6.3.0",
|
||||||
"react-overlapping-panels": "1.2.2",
|
"react-overlapping-panels": "1.2.2",
|
||||||
"react-redux": "^7.2.4",
|
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scroll": "^1.8.2",
|
"react-scroll": "^1.8.2",
|
||||||
"react-virtualized-auto-sizer": "^1.0.5",
|
"react-virtualized-auto-sizer": "^1.0.5",
|
||||||
"react-virtuoso": "^1.10.4",
|
"react-virtuoso": "^1.10.4",
|
||||||
"redux": "^4.1.0",
|
|
||||||
"revolt-api": "0.5.3-alpha.10",
|
"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",
|
"rimraf": "^3.0.2",
|
||||||
"sass": "^1.35.1",
|
"sass": "^1.35.1",
|
||||||
"shade-blend-color": "^1.0.0",
|
"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 { Text } from "preact-i18n";
|
||||||
import { useState } from "preact/hooks";
|
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 Button from "../ui/Button";
|
||||||
import Checkbox from "../ui/Checkbox";
|
import Checkbox from "../ui/Checkbox";
|
||||||
|
@ -49,9 +50,7 @@ type Props = {
|
||||||
|
|
||||||
export default observer((props: Props) => {
|
export default observer((props: Props) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [consent, setConsent] = useState(
|
const layout = useApplicationState().layout;
|
||||||
getState().sectionToggle["nsfw"] ?? false,
|
|
||||||
);
|
|
||||||
const [ageGate, setAgeGate] = useState(false);
|
const [ageGate, setAgeGate] = useState(false);
|
||||||
|
|
||||||
if (ageGate || !props.gated) {
|
if (ageGate || !props.gated) {
|
||||||
|
@ -81,26 +80,19 @@ export default observer((props: Props) => {
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={consent}
|
checked={layout.getSectionState(SECTION_NSFW, false)}
|
||||||
onChange={(v) => {
|
onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}>
|
||||||
setConsent(v);
|
|
||||||
if (v) {
|
|
||||||
dispatch({
|
|
||||||
type: "SECTION_TOGGLE_SET",
|
|
||||||
id: "nsfw",
|
|
||||||
state: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch({ type: "SECTION_TOGGLE_UNSET", id: "nsfw" });
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Text id="app.main.channel.nsfw.confirm" />
|
<Text id="app.main.channel.nsfw.confirm" />
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<Button contrast onClick={() => history.goBack()}>
|
<Button contrast onClick={() => history.goBack()}>
|
||||||
<Text id="app.special.modals.actions.back" />
|
<Text id="app.special.modals.actions.back" />
|
||||||
</Button>
|
</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`} />
|
<Text id={`app.main.channel.nsfw.${props.type}.confirm`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { ChevronDown } from "@styled-icons/boxicons-regular";
|
import { ChevronDown } from "@styled-icons/boxicons-regular";
|
||||||
|
|
||||||
import { State, store } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { Action } from "../../redux/reducers";
|
|
||||||
|
|
||||||
import Details from "../ui/Details";
|
import Details from "../ui/Details";
|
||||||
|
|
||||||
|
@ -25,27 +24,14 @@ export default function CollapsibleSection({
|
||||||
children,
|
children,
|
||||||
...detailsProps
|
...detailsProps
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const state: State = store.getState();
|
const layout = useApplicationState().layout;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Details
|
<Details
|
||||||
open={state.sectionToggle[id] ?? defaultValue}
|
open={layout.getSectionState(id, defaultValue)}
|
||||||
onToggle={(e) => setState(e.currentTarget.open)}
|
onToggle={(e) =>
|
||||||
|
layout.setSectionState(id, e.currentTarget.open, defaultValue)
|
||||||
|
}
|
||||||
{...detailsProps}>
|
{...detailsProps}>
|
||||||
<summary>
|
<summary>
|
||||||
<div class="padding">
|
<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;
|
const REVISION = 3;
|
||||||
|
|
||||||
export function setEmojiPack(pack: EmojiPacks) {
|
export function setGlobalEmojiPack(pack: EmojiPack) {
|
||||||
EMOJI_PACK = pack;
|
EMOJI_PACK = pack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,21 @@
|
||||||
import { dispatch } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
|
|
||||||
import { Language, Languages } from "../../context/Locale";
|
import { Language, Languages } from "../../context/Locale";
|
||||||
|
|
||||||
import ComboBox from "../ui/ComboBox";
|
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 (
|
return (
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value={props.locale}
|
value={locale.getLanguage()}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
dispatch({
|
locale.setLanguage(e.currentTarget.value as Language)
|
||||||
type: "SET_LOCALE",
|
|
||||||
locale: e.currentTarget.value as Language,
|
|
||||||
})
|
|
||||||
}>
|
}>
|
||||||
{Object.keys(Languages).map((x) => {
|
{Object.keys(Languages).map((x) => {
|
||||||
const l = Languages[x as keyof typeof Languages];
|
const l = Languages[x as keyof typeof Languages];
|
||||||
|
@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) {
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(LocaleSelector, (state) => {
|
|
||||||
return {
|
|
||||||
locale: state.locale,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { Download, CloudDownload } from "@styled-icons/boxicons-regular";
|
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 { internalSubscribe } from "../../lib/eventEmitter";
|
||||||
|
|
||||||
import { ThemeContext } from "../../context/Theme";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
|
||||||
import IconButton from "../ui/IconButton";
|
import IconButton from "../ui/IconButton";
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ export default function UpdateIndicator({ style }: Props) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useApplicationState().settings.theme;
|
||||||
|
|
||||||
if (style === "titlebar") {
|
if (style === "titlebar") {
|
||||||
return (
|
return (
|
||||||
|
@ -36,7 +36,10 @@ export default function UpdateIndicator({ style }: Props) {
|
||||||
content="A new update is available!"
|
content="A new update is available!"
|
||||||
placement="bottom">
|
placement="bottom">
|
||||||
<div onClick={() => updateSW(true)}>
|
<div onClick={() => updateSW(true)}>
|
||||||
<CloudDownload size={22} color={theme.success} />
|
<CloudDownload
|
||||||
|
size={22}
|
||||||
|
color={theme.getVariable("success")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,7 +50,7 @@ export default function UpdateIndicator({ style }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton onClick={() => updateSW(true)}>
|
<IconButton onClick={() => updateSW(true)}>
|
||||||
<Download size={22} color={theme.success} />
|
<Download size={22} color={theme.getVariable("success")} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useState } from "preact/hooks";
|
||||||
|
|
||||||
import { internalEmit } from "../../../lib/eventEmitter";
|
import { internalEmit } from "../../../lib/eventEmitter";
|
||||||
|
|
||||||
import { QueuedMessage } from "../../../redux/reducers/queue";
|
import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
|
@ -20,10 +20,9 @@ import {
|
||||||
SMOOTH_SCROLL_ON_RECEIVE,
|
SMOOTH_SCROLL_ON_RECEIVE,
|
||||||
} from "../../../lib/renderer/Singleton";
|
} from "../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
import { dispatch, getState } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { Reply } from "../../../redux/reducers/queue";
|
import { Reply } from "../../../mobx/stores/MessageQueue";
|
||||||
|
|
||||||
import { SoundContext } from "../../../context/Settings";
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
import {
|
import {
|
||||||
FileUploader,
|
FileUploader,
|
||||||
|
@ -112,17 +111,16 @@ const Action = styled.div`
|
||||||
const RE_SED = new RegExp("^s/([^])*/([^])*$");
|
const RE_SED = new RegExp("^s/([^])*/([^])*$");
|
||||||
|
|
||||||
// ! FIXME: add to app config and load from app config
|
// ! 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) => {
|
export default observer(({ channel }: Props) => {
|
||||||
const [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
|
const state = useApplicationState();
|
||||||
|
|
||||||
const [uploadState, setUploadState] = useState<UploadState>({
|
const [uploadState, setUploadState] = useState<UploadState>({
|
||||||
type: "none",
|
type: "none",
|
||||||
});
|
});
|
||||||
const [typing, setTyping] = useState<boolean | number>(false);
|
const [typing, setTyping] = useState<boolean | number>(false);
|
||||||
const [replies, setReplies] = useState<Reply[]>([]);
|
const [replies, setReplies] = useState<Reply[]>([]);
|
||||||
const playSound = useContext(SoundContext);
|
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const translate = useTranslation();
|
const translate = useTranslation();
|
||||||
|
@ -148,27 +146,18 @@ export default observer(({ channel }: Props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push message content to draft.
|
||||||
const setMessage = useCallback(
|
const setMessage = useCallback(
|
||||||
(content?: string) => {
|
(content?: string) => state.draft.set(channel._id, content),
|
||||||
setDraft(content ?? "");
|
[state.draft, channel._id],
|
||||||
|
|
||||||
if (content) {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_DRAFT",
|
|
||||||
channel: channel._id,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch({
|
|
||||||
type: "CLEAR_DRAFT",
|
|
||||||
channel: channel._id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[channel._id],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param content
|
||||||
|
* @param action
|
||||||
|
*/
|
||||||
function append(content: string, action: "quote" | "mention") {
|
function append(content: string, action: "quote" | "mention") {
|
||||||
const text =
|
const text =
|
||||||
action === "quote"
|
action === "quote"
|
||||||
|
@ -178,10 +167,10 @@ export default observer(({ channel }: Props) => {
|
||||||
.join("\n")}\n\n`
|
.join("\n")}\n\n`
|
||||||
: `${content} `;
|
: `${content} `;
|
||||||
|
|
||||||
if (!draft || draft.length === 0) {
|
if (!state.draft.has(channel._id)) {
|
||||||
setMessage(text);
|
setMessage(text);
|
||||||
} else {
|
} else {
|
||||||
setMessage(`${draft}\n${text}`);
|
setMessage(`${state.draft.get(channel._id)}\n${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,13 +179,16 @@ export default observer(({ channel }: Props) => {
|
||||||
"append",
|
"append",
|
||||||
append as (...args: unknown[]) => void,
|
append as (...args: unknown[]) => void,
|
||||||
);
|
);
|
||||||
}, [draft, setMessage]);
|
}, [state.draft, channel._id, setMessage]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger send message.
|
||||||
|
*/
|
||||||
async function send() {
|
async function send() {
|
||||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const content = draft?.trim() ?? "";
|
const content = state.draft.get(channel._id)?.trim() ?? "";
|
||||||
if (uploadState.type === "attached") return sendFile(content);
|
if (uploadState.type === "attached") return sendFile(content);
|
||||||
if (content.length === 0) return;
|
if (content.length === 0) return;
|
||||||
|
|
||||||
|
@ -247,20 +239,15 @@ export default observer(({ channel }: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
playSound("outbound");
|
state.settings.sounds.playSound("outbound");
|
||||||
|
|
||||||
dispatch({
|
state.queue.add(nonce, channel._id, {
|
||||||
type: "QUEUE_ADD",
|
_id: nonce,
|
||||||
nonce,
|
|
||||||
channel: channel._id,
|
channel: channel._id,
|
||||||
message: {
|
author: client.user!._id,
|
||||||
_id: nonce,
|
|
||||||
channel: channel._id,
|
|
||||||
author: client.user!._id,
|
|
||||||
|
|
||||||
content,
|
content,
|
||||||
replies,
|
replies,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
|
defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
|
||||||
|
@ -272,15 +259,16 @@ export default observer(({ channel }: Props) => {
|
||||||
replies,
|
replies,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dispatch({
|
state.queue.fail(nonce, takeError(error));
|
||||||
type: "QUEUE_FAIL",
|
|
||||||
error: takeError(error),
|
|
||||||
nonce,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param content
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
async function sendFile(content: string) {
|
async function sendFile(content: string) {
|
||||||
if (uploadState.type !== "attached") return;
|
if (uploadState.type !== "attached") return;
|
||||||
const attachments: string[] = [];
|
const attachments: string[] = [];
|
||||||
|
@ -360,7 +348,7 @@ export default observer(({ channel }: Props) => {
|
||||||
|
|
||||||
setMessage();
|
setMessage();
|
||||||
setReplies([]);
|
setReplies([]);
|
||||||
playSound("outbound");
|
state.settings.sounds.playSound("outbound");
|
||||||
|
|
||||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||||
setUploadState({
|
setUploadState({
|
||||||
|
@ -372,6 +360,10 @@ export default observer(({ channel }: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
function startTyping() {
|
function startTyping() {
|
||||||
if (typeof typing === "number" && +new Date() < typing) return;
|
if (typeof typing === "number" && +new Date() < typing) return;
|
||||||
|
|
||||||
|
@ -385,6 +377,10 @@ export default observer(({ channel }: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param force
|
||||||
|
*/
|
||||||
function stopTyping(force?: boolean) {
|
function stopTyping(force?: boolean) {
|
||||||
if (force || typing) {
|
if (force || typing) {
|
||||||
const ws = client.websocket;
|
const ws = client.websocket;
|
||||||
|
@ -503,7 +499,7 @@ export default observer(({ channel }: Props) => {
|
||||||
id="message"
|
id="message"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
value={draft ?? ""}
|
value={state.draft.get(channel._id) ?? ""}
|
||||||
padding="var(--message-box-padding)"
|
padding="var(--message-box-padding)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.ctrlKey && e.key === "Enter") {
|
if (e.ctrlKey && e.key === "Enter") {
|
||||||
|
@ -515,7 +511,7 @@ export default observer(({ channel }: Props) => {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.key === "ArrowUp" &&
|
e.key === "ArrowUp" &&
|
||||||
(!draft || draft.length === 0)
|
!state.draft.has(channel._id)
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
internalEmit("MessageRenderer", "edit_last");
|
internalEmit("MessageRenderer", "edit_last");
|
||||||
|
|
|
@ -10,8 +10,9 @@ import { StateUpdater, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import { internalSubscribe } from "../../../../lib/eventEmitter";
|
import { internalSubscribe } from "../../../../lib/eventEmitter";
|
||||||
|
|
||||||
import { dispatch, getState } from "../../../../redux";
|
import { useApplicationState } from "../../../../mobx/State";
|
||||||
import { Reply } from "../../../../redux/reducers/queue";
|
import { SECTION_MENTION } from "../../../../mobx/stores/Layout";
|
||||||
|
import { Reply } from "../../../../mobx/stores/MessageQueue";
|
||||||
|
|
||||||
import IconButton from "../../../ui/IconButton";
|
import IconButton from "../../../ui/IconButton";
|
||||||
|
|
||||||
|
@ -81,6 +82,7 @@ const Base = styled.div`
|
||||||
const MAX_REPLIES = 5;
|
const MAX_REPLIES = 5;
|
||||||
export default observer(({ channel, replies, setReplies }: Props) => {
|
export default observer(({ channel, replies, setReplies }: Props) => {
|
||||||
const client = channel.client;
|
const client = channel.client;
|
||||||
|
const layout = useApplicationState().layout;
|
||||||
|
|
||||||
// Event listener for adding new messages to reply bar.
|
// Event listener for adding new messages to reply bar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -99,7 +101,7 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||||
mention:
|
mention:
|
||||||
message.author_id === client.user!._id
|
message.author_id === client.user!._id
|
||||||
? false
|
? false
|
||||||
: getState().sectionToggle.mention ?? false,
|
: layout.getSectionState("SECTION_MENTION", false),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -181,11 +183,11 @@ export default observer(({ channel, replies, setReplies }: Props) => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch({
|
layout.setSectionState(
|
||||||
type: "SECTION_TOGGLE_SET",
|
SECTION_MENTION,
|
||||||
id: "mention",
|
|
||||||
state,
|
state,
|
||||||
});
|
false,
|
||||||
|
);
|
||||||
}}>
|
}}>
|
||||||
<span class="toggle">
|
<span class="toggle">
|
||||||
<At size={15} />
|
<At size={15} />
|
||||||
|
|
|
@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import { defer } from "../../../../lib/defer";
|
import { defer } from "../../../../lib/defer";
|
||||||
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { dispatch } from "../../../../redux";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AppContext,
|
AppContext,
|
||||||
ClientStatus,
|
ClientStatus,
|
||||||
|
@ -44,8 +42,7 @@ const EmbedInviteBase = styled.div`
|
||||||
> button {
|
> button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
`
|
`}
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const EmbedInviteDetails = styled.div`
|
const EmbedInviteDetails = styled.div`
|
||||||
|
@ -55,8 +52,7 @@ const EmbedInviteDetails = styled.div`
|
||||||
isTouchscreenDevice &&
|
isTouchscreenDevice &&
|
||||||
css`
|
css`
|
||||||
width: calc(100% - 55px);
|
width: calc(100% - 55px);
|
||||||
`
|
`}
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const EmbedInviteName = styled.div`
|
const EmbedInviteName = styled.div`
|
||||||
|
@ -74,11 +70,10 @@ type Props = {
|
||||||
code: string;
|
code: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmbedInvite(props: Props) {
|
export function EmbedInvite({ code }: Props) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
const code = props.code;
|
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
const [joinError, setJoinError] = useState<string | undefined>(undefined);
|
const [joinError, setJoinError] = useState<string | undefined>(undefined);
|
||||||
|
@ -124,7 +119,8 @@ export function EmbedInvite(props: Props) {
|
||||||
<EmbedInviteDetails>
|
<EmbedInviteDetails>
|
||||||
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
|
<EmbedInviteName>{invite.server_name}</EmbedInviteName>
|
||||||
<EmbedInviteMemberCount>
|
<EmbedInviteMemberCount>
|
||||||
{invite.member_count.toLocaleString()} {invite.member_count === 1 ? "member" : "members"}
|
{invite.member_count.toLocaleString()}{" "}
|
||||||
|
{invite.member_count === 1 ? "member" : "members"}
|
||||||
</EmbedInviteMemberCount>
|
</EmbedInviteMemberCount>
|
||||||
</EmbedInviteDetails>
|
</EmbedInviteDetails>
|
||||||
{processing ? (
|
{processing ? (
|
||||||
|
@ -151,10 +147,9 @@ export function EmbedInvite(props: Props) {
|
||||||
|
|
||||||
defer(() => {
|
defer(() => {
|
||||||
if (server) {
|
if (server) {
|
||||||
dispatch({
|
client.unreads!.markMultipleRead(
|
||||||
type: "UNREADS_MARK_MULTIPLE_READ",
|
server.channel_ids,
|
||||||
channels: server.channel_ids,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
history.push(
|
history.push(
|
||||||
`/server/${server._id}/channel/${invite.channel_id}`,
|
`/server/${server._id}/channel/${invite.channel_id}`,
|
||||||
|
@ -172,7 +167,9 @@ export function EmbedInvite(props: Props) {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{client.servers.get(invite.server_id) ? "Joined" : "Join"}
|
{client.servers.get(invite.server_id)
|
||||||
|
? "Joined"
|
||||||
|
: "Join"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</EmbedInviteBase>
|
</EmbedInviteBase>
|
||||||
|
|
|
@ -5,12 +5,10 @@ import { useParams } from "react-router-dom";
|
||||||
import { Masquerade } from "revolt-api/types/Channels";
|
import { Masquerade } from "revolt-api/types/Channels";
|
||||||
import { Presence } from "revolt-api/types/Users";
|
import { Presence } from "revolt-api/types/Users";
|
||||||
import { User } from "revolt.js/dist/maps/Users";
|
import { User } from "revolt.js/dist/maps/Users";
|
||||||
import { Nullable } from "revolt.js/dist/util/null";
|
|
||||||
import styled, { css } from "styled-components";
|
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 { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import fallback from "../assets/user.png";
|
import fallback from "../assets/user.png";
|
||||||
|
@ -26,15 +24,15 @@ interface Props extends IconBaseProps<User> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStatusColour(user?: User) {
|
export function useStatusColour(user?: User) {
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useApplicationState().settings.theme;
|
||||||
|
|
||||||
return user?.online && user?.status?.presence !== Presence.Invisible
|
return user?.online && user?.status?.presence !== Presence.Invisible
|
||||||
? user?.status?.presence === Presence.Idle
|
? user?.status?.presence === Presence.Idle
|
||||||
? theme["status-away"]
|
? theme.getVariable("status-away")
|
||||||
: user?.status?.presence === Presence.Busy
|
: user?.status?.presence === Presence.Busy
|
||||||
? theme["status-busy"]
|
? theme.getVariable("status-busy")
|
||||||
: theme["status-online"]
|
: theme.getVariable("status-online")
|
||||||
: theme["status-invisible"];
|
: theme.getVariable("status-invisible");
|
||||||
}
|
}
|
||||||
|
|
||||||
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
|
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
|
||||||
|
|
|
@ -5,8 +5,7 @@ import styled, { css } from "styled-components";
|
||||||
|
|
||||||
import ConditionalLink from "../../lib/ConditionalLink";
|
import ConditionalLink from "../../lib/ConditionalLink";
|
||||||
|
|
||||||
import { connectState } from "../../redux/connector";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { LastOpened } from "../../redux/reducers/last_opened";
|
|
||||||
|
|
||||||
import { useClient } from "../../context/revoltjs/RevoltClient";
|
import { useClient } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>`
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
export default observer(() => {
|
||||||
lastOpened: LastOpened;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BottomNavigation = observer(({ lastOpened }: Props) => {
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const layout = useApplicationState().layout;
|
||||||
const user = client.users.get(client.user!._id);
|
const user = client.users.get(client.user!._id);
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const path = useLocation().pathname;
|
const path = useLocation().pathname;
|
||||||
|
|
||||||
const channel_id = lastOpened["home"];
|
|
||||||
|
|
||||||
const friendsActive = path.startsWith("/friends");
|
const friendsActive = path.startsWith("/friends");
|
||||||
const settingsActive = path.startsWith("/settings");
|
const settingsActive = path.startsWith("/settings");
|
||||||
const homeActive = !(friendsActive || settingsActive);
|
const homeActive = !(friendsActive || settingsActive);
|
||||||
|
@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
|
||||||
if (settingsActive) {
|
if (settingsActive) {
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
history.goBack();
|
history.goBack();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel_id) {
|
history.push(layout.getLastHomePath());
|
||||||
history.push(`/channel/${channel_id}`);
|
|
||||||
} else {
|
|
||||||
history.push("/");
|
|
||||||
}
|
|
||||||
}}>
|
}}>
|
||||||
<Message size={24} />
|
<Message size={24} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => {
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connectState(BottomNavigation, (state) => {
|
|
||||||
return {
|
|
||||||
lastOpened: state.lastOpened,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { Route, Switch } from "react-router";
|
import { Route, Switch } from "react-router";
|
||||||
|
|
||||||
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
import { SIDEBAR_CHANNELS } from "../../mobx/stores/Layout";
|
||||||
|
|
||||||
import SidebarBase from "./SidebarBase";
|
import SidebarBase from "./SidebarBase";
|
||||||
import HomeSidebar from "./left/HomeSidebar";
|
import HomeSidebar from "./left/HomeSidebar";
|
||||||
import ServerListSidebar from "./left/ServerListSidebar";
|
import ServerListSidebar from "./left/ServerListSidebar";
|
||||||
import ServerSidebar from "./left/ServerSidebar";
|
import ServerSidebar from "./left/ServerSidebar";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { State } from "../../redux";
|
|
||||||
|
|
||||||
export default function LeftSidebar() {
|
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 (
|
return (
|
||||||
<SidebarBase>
|
<SidebarBase>
|
||||||
|
|
|
@ -117,7 +117,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-muted="true"] {
|
&[data-muted="true"] {
|
||||||
color: var(--tertiary-foreground);
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-alert="true"],
|
&[data-alert="true"],
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Notepad,
|
Notepad,
|
||||||
} from "@styled-icons/boxicons-solid";
|
} from "@styled-icons/boxicons-solid";
|
||||||
import { observer } from "mobx-react-lite";
|
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 { RelationshipStatus } from "revolt-api/types/Users";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
@ -15,54 +15,38 @@ import ConditionalLink from "../../../lib/ConditionalLink";
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import Category from "../../ui/Category";
|
import Category from "../../ui/Category";
|
||||||
import placeholderSVG from "../items/placeholder.svg";
|
import placeholderSVG from "../items/placeholder.svg";
|
||||||
import { mapChannelWithUnread, useUnreads } from "./common";
|
|
||||||
|
|
||||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||||
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
|
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
|
||||||
import ConnectionStatus from "../items/ConnectionStatus";
|
import ConnectionStatus from "../items/ConnectionStatus";
|
||||||
|
|
||||||
type Props = {
|
export default observer(() => {
|
||||||
unreads: Unreads;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HomeSidebar = observer((props: Props) => {
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { channel } = useParams<{ channel: string }>();
|
const state = useApplicationState();
|
||||||
|
const { channel: currentChannel } = useParams<{ channel: string }>();
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
const channels = [...client.channels.values()]
|
const channels = [...client.channels.values()].filter(
|
||||||
.filter(
|
(x) => x.channel_type === "DirectMessage" || x.channel_type === "Group",
|
||||||
(x) =>
|
);
|
||||||
x.channel_type === "DirectMessage" ||
|
|
||||||
x.channel_type === "Group",
|
|
||||||
)
|
|
||||||
.map((x) => mapChannelWithUnread(x, props.unreads));
|
|
||||||
|
|
||||||
const obj = client.channels.get(channel);
|
const obj = client.channels.get(currentChannel);
|
||||||
if (channel && !obj) return <Redirect to="/" />;
|
|
||||||
if (obj) useUnreads({ ...props, channel: obj });
|
|
||||||
|
|
||||||
useEffect(() => {
|
// ! FIXME: move this globally
|
||||||
if (!channel) return;
|
// Track what page the user was last on (in home page).
|
||||||
|
useEffect(() => state.layout.setLastHomePath(pathname), [pathname]);
|
||||||
|
|
||||||
dispatch({
|
channels.sort((b, a) =>
|
||||||
type: "LAST_OPENED_SET",
|
a.last_message_id_or_past.localeCompare(b.last_message_id_or_past),
|
||||||
parent: "home",
|
);
|
||||||
child: channel,
|
|
||||||
});
|
|
||||||
}, [channel]);
|
|
||||||
|
|
||||||
channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericSidebarBase mobilePadding>
|
<GenericSidebarBase mobilePadding>
|
||||||
|
@ -132,31 +116,37 @@ const HomeSidebar = observer((props: Props) => {
|
||||||
{channels.length === 0 && (
|
{channels.length === 0 && (
|
||||||
<img src={placeholderSVG} loading="eager" />
|
<img src={placeholderSVG} loading="eager" />
|
||||||
)}
|
)}
|
||||||
{channels.map((x) => {
|
{channels.map((channel) => {
|
||||||
let user;
|
let user;
|
||||||
if (x.channel.channel_type === "DirectMessage") {
|
if (channel.channel_type === "DirectMessage") {
|
||||||
if (!x.channel.active) return null;
|
if (!channel.active) return null;
|
||||||
user = x.channel.recipient;
|
user = channel.recipient;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) return null;
|
||||||
console.warn(
|
|
||||||
`Skipped DM ${x.channel._id} because user was missing.`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUnread = channel.isUnread(state.notifications);
|
||||||
|
const mentionCount = channel.getMentions(
|
||||||
|
state.notifications,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionalLink
|
<ConditionalLink
|
||||||
key={x.channel._id}
|
key={channel._id}
|
||||||
active={x.channel._id === channel}
|
active={channel._id === currentChannel}
|
||||||
to={`/channel/${x.channel._id}`}>
|
to={`/channel/${channel._id}`}>
|
||||||
<ChannelButton
|
<ChannelButton
|
||||||
user={user}
|
user={user}
|
||||||
channel={x.channel}
|
channel={channel}
|
||||||
alert={x.unread}
|
alert={
|
||||||
alertCount={x.alertCount}
|
mentionCount > 0
|
||||||
active={x.channel._id === channel}
|
? "mention"
|
||||||
|
: isUnread
|
||||||
|
? "unread"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
alertCount={mentionCount}
|
||||||
|
active={channel._id === currentChannel}
|
||||||
/>
|
/>
|
||||||
</ConditionalLink>
|
</ConditionalLink>
|
||||||
);
|
);
|
||||||
|
@ -166,13 +156,3 @@ const HomeSidebar = observer((props: Props) => {
|
||||||
</GenericSidebarBase>
|
</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 PaintCounter from "../../../lib/PaintCounter";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { connectState } from "../../../redux/connector";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { LastOpened } from "../../../redux/reducers/last_opened";
|
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
@ -25,7 +23,6 @@ import UserHover from "../../common/user/UserHover";
|
||||||
import UserIcon from "../../common/user/UserIcon";
|
import UserIcon from "../../common/user/UserIcon";
|
||||||
import IconButton from "../../ui/IconButton";
|
import IconButton from "../../ui/IconButton";
|
||||||
import LineDivider from "../../ui/LineDivider";
|
import LineDivider from "../../ui/LineDivider";
|
||||||
import { mapChannelWithUnread } from "./common";
|
|
||||||
|
|
||||||
import { Children } from "../../../types/Preact";
|
import { Children } from "../../../types/Preact";
|
||||||
|
|
||||||
|
@ -195,46 +192,14 @@ function Swoosh() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
export default observer(() => {
|
||||||
unreads: Unreads;
|
|
||||||
lastOpened: LastOpened;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const state = useApplicationState();
|
||||||
|
|
||||||
const { server: server_id } = useParams<{ server?: string }>();
|
const { server: server_id } = useParams<{ server?: string }>();
|
||||||
const server = server_id ? client.servers.get(server_id) : undefined;
|
const server = server_id ? client.servers.get(server_id) : undefined;
|
||||||
const activeServers = [...client.servers.values()];
|
const servers = [...client.servers.values()];
|
||||||
const channels = [...client.channels.values()].map((x) =>
|
const channels = [...client.channels.values()];
|
||||||
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 history = useHistory();
|
const history = useHistory();
|
||||||
const path = useLocation().pathname;
|
const path = useLocation().pathname;
|
||||||
|
@ -242,16 +207,16 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
||||||
|
|
||||||
let homeUnread: "mention" | "unread" | undefined;
|
let homeUnread: "mention" | "unread" | undefined;
|
||||||
let alertCount = 0;
|
let alertCount = 0;
|
||||||
for (const x of channels) {
|
for (const channel of channels) {
|
||||||
if (x.channel?.channel_type === "Group" && x.unread) {
|
if (channel?.channel_type === "Group" && channel.unread) {
|
||||||
homeUnread = "unread";
|
homeUnread = "unread";
|
||||||
alertCount += x.alertCount ?? 0;
|
alertCount += channel.mentions.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
x.channel?.channel_type === "DirectMessage" &&
|
channel.channel_type === "DirectMessage" &&
|
||||||
x.channel.active &&
|
channel.active &&
|
||||||
x.unread
|
channel.unread
|
||||||
) {
|
) {
|
||||||
alertCount++;
|
alertCount++;
|
||||||
}
|
}
|
||||||
|
@ -270,7 +235,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
||||||
<ServerList>
|
<ServerList>
|
||||||
<ConditionalLink
|
<ConditionalLink
|
||||||
active={homeActive}
|
active={homeActive}
|
||||||
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
|
to={state.layout.getLastHomePath()}>
|
||||||
<ServerEntry home active={homeActive}>
|
<ServerEntry home active={homeActive}>
|
||||||
<Swoosh />
|
<Swoosh />
|
||||||
<div
|
<div
|
||||||
|
@ -278,13 +243,13 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
homeActive && history.push("/settings")
|
homeActive && history.push("/settings")
|
||||||
}>
|
}>
|
||||||
<UserHover user={client.user}>
|
<UserHover user={client.user ?? undefined}>
|
||||||
<Icon
|
<Icon
|
||||||
size={42}
|
size={42}
|
||||||
unread={homeUnread}
|
unread={homeUnread}
|
||||||
count={alertCount}>
|
count={alertCount}>
|
||||||
<UserIcon
|
<UserIcon
|
||||||
target={client.user}
|
target={client.user ?? undefined}
|
||||||
size={32}
|
size={32}
|
||||||
status
|
status
|
||||||
hover
|
hover
|
||||||
|
@ -295,35 +260,40 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
||||||
</ServerEntry>
|
</ServerEntry>
|
||||||
</ConditionalLink>
|
</ConditionalLink>
|
||||||
<LineDivider />
|
<LineDivider />
|
||||||
{servers.map((entry) => {
|
{servers.map((server) => {
|
||||||
const active = entry.server._id === server?._id;
|
const active = server._id === server_id;
|
||||||
const id = lastOpened[entry.server._id];
|
|
||||||
|
const isUnread = server.isUnread(state.notifications);
|
||||||
|
const mentionCount = server.getMentions(
|
||||||
|
state.notifications,
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionalLink
|
<ConditionalLink
|
||||||
key={entry.server._id}
|
key={server._id}
|
||||||
active={active}
|
active={active}
|
||||||
to={`/server/${entry.server._id}${
|
to={state.layout.getServerPath(server._id)}>
|
||||||
id ? `/channel/${id}` : ""
|
|
||||||
}`}>
|
|
||||||
<ServerEntry
|
<ServerEntry
|
||||||
active={active}
|
active={active}
|
||||||
onContextMenu={attachContextMenu("Menu", {
|
onContextMenu={attachContextMenu("Menu", {
|
||||||
server: entry.server._id,
|
server: server._id,
|
||||||
unread: entry.unread,
|
unread: isUnread,
|
||||||
})}>
|
})}>
|
||||||
<Swoosh />
|
<Swoosh />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={entry.server.name}
|
content={server.name}
|
||||||
placement="right">
|
placement="right">
|
||||||
<Icon
|
<Icon
|
||||||
size={42}
|
size={42}
|
||||||
unread={entry.unread}
|
unread={
|
||||||
count={entry.alertCount}>
|
mentionCount > 0
|
||||||
<ServerIcon
|
? "mention"
|
||||||
size={32}
|
: isUnread
|
||||||
target={entry.server}
|
? "unread"
|
||||||
/>
|
: undefined
|
||||||
|
}
|
||||||
|
count={mentionCount}>
|
||||||
|
<ServerIcon size={32} target={server} />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ServerEntry>
|
</ServerEntry>
|
||||||
|
@ -357,10 +327,3 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => {
|
||||||
</ServersBase>
|
</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 { internalEmit } from "../../../lib/eventEmitter";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import { Notifications } from "../../../redux/reducers/notifications";
|
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
|
|
||||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import CollapsibleSection from "../../common/CollapsibleSection";
|
import CollapsibleSection from "../../common/CollapsibleSection";
|
||||||
import ServerHeader from "../../common/ServerHeader";
|
import ServerHeader from "../../common/ServerHeader";
|
||||||
import Category from "../../ui/Category";
|
import Category from "../../ui/Category";
|
||||||
import { mapChannelWithUnread, useUnreads } from "./common";
|
|
||||||
|
|
||||||
import { ChannelButton } from "../items/ButtonItem";
|
import { ChannelButton } from "../items/ButtonItem";
|
||||||
import ConnectionStatus from "../items/ConnectionStatus";
|
import ConnectionStatus from "../items/ConnectionStatus";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
unreads: Unreads;
|
|
||||||
notifications: Notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerBase = styled.div`
|
const ServerBase = styled.div`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 232px;
|
width: 232px;
|
||||||
|
@ -57,8 +48,9 @@ const ServerList = styled.div`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ServerSidebar = observer((props: Props) => {
|
export default observer(() => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const state = useApplicationState();
|
||||||
const { server: server_id, channel: channel_id } =
|
const { server: server_id, channel: channel_id } =
|
||||||
useParams<{ server: string; channel?: string }>();
|
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_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(() => {
|
useEffect(() => {
|
||||||
if (!channel_id) return;
|
if (!channel_id) return;
|
||||||
|
if (!server_id) return;
|
||||||
|
|
||||||
dispatch({
|
state.layout.setLastOpened(server_id, channel_id);
|
||||||
type: "LAST_OPENED_SET",
|
|
||||||
parent: server_id!,
|
|
||||||
child: channel_id!,
|
|
||||||
});
|
|
||||||
}, [channel_id, server_id]);
|
}, [channel_id, server_id]);
|
||||||
|
|
||||||
const uncategorised = new Set(server.channel_ids);
|
const uncategorised = new Set(server.channel_ids);
|
||||||
|
@ -96,7 +85,8 @@ const ServerSidebar = observer((props: Props) => {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
const active = channel?._id === entry._id;
|
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 (
|
return (
|
||||||
<ConditionalLink
|
<ConditionalLink
|
||||||
|
@ -117,10 +107,15 @@ const ServerSidebar = observer((props: Props) => {
|
||||||
<ChannelButton
|
<ChannelButton
|
||||||
channel={entry}
|
channel={entry}
|
||||||
active={active}
|
active={active}
|
||||||
// ! FIXME: pull it out directly
|
alert={
|
||||||
alert={mapChannelWithUnread(entry, props.unreads).unread}
|
mentionCount.length > 0
|
||||||
|
? "mention"
|
||||||
|
: isUnread
|
||||||
|
? "unread"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
compact
|
compact
|
||||||
muted={muted}
|
muted={state.notifications.isMuted(entry)}
|
||||||
/>
|
/>
|
||||||
</ConditionalLink>
|
</ConditionalLink>
|
||||||
);
|
);
|
||||||
|
@ -163,10 +158,3 @@ const ServerSidebar = observer((props: Props) => {
|
||||||
</ServerBase>
|
</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 { RefObject } from "preact";
|
||||||
import { useRef } from "preact/hooks";
|
import { useRef } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useDebounceCallback } from "../../lib/debounce";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
@ -115,6 +117,11 @@ const Rows = styled.div`
|
||||||
|
|
||||||
export default function ColourSwatches({ value, onChange }: Props) {
|
export default function ColourSwatches({ value, onChange }: Props) {
|
||||||
const ref = useRef<HTMLInputElement>() as RefObject<HTMLInputElement>;
|
const ref = useRef<HTMLInputElement>() as RefObject<HTMLInputElement>;
|
||||||
|
const setValue = useDebounceCallback(
|
||||||
|
(value) => onChange(value as string),
|
||||||
|
[onChange],
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwatchesBase>
|
<SwatchesBase>
|
||||||
|
@ -122,7 +129,7 @@ export default function ColourSwatches({ value, onChange }: Props) {
|
||||||
type="color"
|
type="color"
|
||||||
value={value}
|
value={value}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onChange={(ev) => onChange(ev.currentTarget.value)}
|
onChange={(ev) => setValue(ev.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Swatch
|
<Swatch
|
||||||
colour={value}
|
colour={value}
|
||||||
|
|
|
@ -167,7 +167,7 @@ export default function Modal(props: Props) {
|
||||||
isModalClosing = animateClose;
|
isModalClosing = animateClose;
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
setAnimateClose(true);
|
setAnimateClose(true);
|
||||||
setTimeout(() => props.onClose?.(), 2e2);
|
setTimeout(() => props.onClose!(), 2e2);
|
||||||
}, [setAnimateClose, props]);
|
}, [setAnimateClose, props]);
|
||||||
|
|
||||||
useEffect(() => internalSubscribe("Modal", "close", onClose), [onClose]);
|
useEffect(() => internalSubscribe("Modal", "close", onClose), [onClose]);
|
||||||
|
|
|
@ -7,9 +7,9 @@ interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
description?: Children;
|
description?: Children;
|
||||||
|
|
||||||
checked: boolean;
|
checked?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onSelect: () => void;
|
onSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseProps {
|
interface BaseProps {
|
||||||
|
@ -87,9 +87,10 @@ const RadioDescription = styled.span<BaseProps>`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Radio(props: Props) {
|
export default function Radio(props: Props) {
|
||||||
|
const selected = props.checked ?? false;
|
||||||
return (
|
return (
|
||||||
<RadioBase
|
<RadioBase
|
||||||
selected={props.checked}
|
selected={selected}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!props.disabled && props.onSelect && props.onSelect()
|
!props.disabled && props.onSelect && props.onSelect()
|
||||||
|
@ -101,7 +102,7 @@ export default function Radio(props: Props) {
|
||||||
<span>
|
<span>
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<RadioDescription selected={props.checked}>
|
<RadioDescription selected={selected}>
|
||||||
{props.description}
|
{props.description}
|
||||||
</RadioDescription>
|
</RadioDescription>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar";
|
||||||
import format from "dayjs/plugin/localizedFormat";
|
import format from "dayjs/plugin/localizedFormat";
|
||||||
import update from "dayjs/plugin/updateLocale";
|
import update from "dayjs/plugin/updateLocale";
|
||||||
import defaultsDeep from "lodash.defaultsdeep";
|
import defaultsDeep from "lodash.defaultsdeep";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import { IntlProvider } from "preact-i18n";
|
import { IntlProvider } from "preact-i18n";
|
||||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { connectState } from "../redux/connector";
|
import { useApplicationState } from "../mobx/State";
|
||||||
|
|
||||||
import definition from "../../external/lang/en.json";
|
import definition from "../../external/lang/en.json";
|
||||||
|
|
||||||
|
@ -204,7 +205,6 @@ export const Languages: { [key in Language]: LanguageEntry } = {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
locale: Language;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dictionary {
|
export interface Dictionary {
|
||||||
|
@ -222,59 +222,14 @@ export interface Dictionary {
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Locale({ children, locale }: Props) {
|
export default observer(({ children }: Props) => {
|
||||||
const [defns, setDefinition] = useState<Dictionary>(
|
const locale = useApplicationState().locale;
|
||||||
|
const [definitions, setDefinition] = useState<Dictionary>(
|
||||||
definition as Dictionary,
|
definition as Dictionary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load relevant language information, fallback to English if invalid.
|
const lang = locale.getLanguage();
|
||||||
const lang = Languages[locale] ?? Languages.en;
|
const source = Languages[lang];
|
||||||
|
|
||||||
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 loadLanguage = useCallback(
|
const loadLanguage = useCallback(
|
||||||
(locale: string) => {
|
(locale: string) => {
|
||||||
|
@ -288,13 +243,13 @@ function Locale({ children, locale }: Props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
import(`../../external/lang/${lang.i18n}.json`).then(
|
import(`../../external/lang/${source.i18n}.json`).then(
|
||||||
async (lang_file) => {
|
async (lang_file) => {
|
||||||
// Transform the definitions data.
|
// Transform the definitions data.
|
||||||
const defn = transformLanguage(lang_file.default);
|
const defn = transformLanguage(lang_file.default);
|
||||||
|
|
||||||
// Determine and load dayjs locales.
|
// Determine and load dayjs locales.
|
||||||
const target = lang.dayjs ?? lang.i18n;
|
const target = source.dayjs ?? source.i18n;
|
||||||
const dayjs_locale = await import(
|
const dayjs_locale = await import(
|
||||||
`../../node_modules/dayjs/esm/locale/${target}.js`
|
`../../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(() => {
|
useEffect(() => {
|
||||||
// Apply RTL language format.
|
// Apply RTL language format.
|
||||||
document.body.style.direction = lang.rtl ? "rtl" : "";
|
document.body.style.direction = source.rtl ? "rtl" : "";
|
||||||
}, [lang.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 { Helmet } from "react-helmet";
|
||||||
import { createGlobalStyle } from "styled-components";
|
import { createGlobalStyle } from "styled-components";
|
||||||
|
|
||||||
import { createContext } from "preact";
|
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import { connectState } from "../redux/connector";
|
import { useApplicationState } from "../mobx/State";
|
||||||
|
|
||||||
import { Children } from "../types/Preact";
|
|
||||||
import { fetchManifest, fetchTheme } from "../pages/settings/panes/ThemeShop";
|
|
||||||
import { getState } from "../redux";
|
|
||||||
|
|
||||||
export type Variables =
|
export type Variables =
|
||||||
| "accent"
|
| "accent"
|
||||||
|
@ -57,6 +53,7 @@ export type Fonts =
|
||||||
| "Raleway"
|
| "Raleway"
|
||||||
| "Ubuntu"
|
| "Ubuntu"
|
||||||
| "Comic Neue";
|
| "Comic Neue";
|
||||||
|
|
||||||
export type MonospaceFonts =
|
export type MonospaceFonts =
|
||||||
| "Fira Code"
|
| "Fira Code"
|
||||||
| "Roboto Mono"
|
| "Roboto Mono"
|
||||||
|
@ -65,9 +62,11 @@ export type MonospaceFonts =
|
||||||
| "Ubuntu Mono"
|
| "Ubuntu Mono"
|
||||||
| "JetBrains Mono";
|
| "JetBrains Mono";
|
||||||
|
|
||||||
export type Theme = {
|
export type Overrides = {
|
||||||
[variable in Variables]: string;
|
[variable in Variables]: string;
|
||||||
} & {
|
};
|
||||||
|
|
||||||
|
export type Theme = Overrides & {
|
||||||
light?: boolean;
|
light?: boolean;
|
||||||
font?: Fonts;
|
font?: Fonts;
|
||||||
css?: string;
|
css?: string;
|
||||||
|
@ -227,7 +226,6 @@ export const DEFAULT_MONO_FONT = "Fira Code";
|
||||||
// Generated from https://gitlab.insrt.uk/revolt/community/themes
|
// Generated from https://gitlab.insrt.uk/revolt/community/themes
|
||||||
export const PRESETS: Record<string, Theme> = {
|
export const PRESETS: Record<string, Theme> = {
|
||||||
light: {
|
light: {
|
||||||
light: true,
|
|
||||||
accent: "#FD6671",
|
accent: "#FD6671",
|
||||||
background: "#F6F6F6",
|
background: "#F6F6F6",
|
||||||
foreground: "#000000",
|
foreground: "#000000",
|
||||||
|
@ -254,7 +252,6 @@ export const PRESETS: Record<string, Theme> = {
|
||||||
"status-invisible": "#A5A5A5",
|
"status-invisible": "#A5A5A5",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
light: false,
|
|
||||||
accent: "#FD6671",
|
accent: "#FD6671",
|
||||||
background: "#191919",
|
background: "#191919",
|
||||||
foreground: "#F6F6F6",
|
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 keys = Object.keys(PRESETS.dark);
|
||||||
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
|
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
|
||||||
:root {
|
:root {
|
||||||
|
@ -315,39 +290,32 @@ export const generateVariables = (theme: Theme) => {
|
||||||
return (Object.keys(theme) as Variables[]).map((key) => {
|
return (Object.keys(theme) as Variables[]).map((key) => {
|
||||||
if (!keys.includes(key)) return;
|
if (!keys.includes(key)) return;
|
||||||
return `--${key}: ${theme[key]};`;
|
return `--${key}: ${theme[key]};`;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Load the default default them and apply extras later
|
export default observer(() => {
|
||||||
export const ThemeContext = createContext<Theme>(PRESETS["dark"]);
|
const settings = useApplicationState().settings;
|
||||||
|
const theme = settings.theme;
|
||||||
interface Props {
|
|
||||||
children: Children;
|
|
||||||
options?: ThemeOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Theme({ children, options }: Props) {
|
|
||||||
const theme: Theme = {
|
|
||||||
...getBaseTheme(options?.base ?? 'dark'),
|
|
||||||
...options?.custom,
|
|
||||||
};
|
|
||||||
|
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const font = theme.font ?? DEFAULT_FONT;
|
const font = theme.getFont() ?? DEFAULT_FONT;
|
||||||
root.setProperty("--font", `"${font}"`);
|
root.setProperty("--font", `"${font}"`);
|
||||||
FONTS[font].load();
|
FONTS[font].load();
|
||||||
}, [root, theme.font]);
|
}, [root, theme.getFont()]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const font = theme.monospaceFont ?? DEFAULT_MONO_FONT;
|
const font = theme.getMonospaceFont() ?? DEFAULT_MONO_FONT;
|
||||||
root.setProperty("--monospace-font", `"${font}"`);
|
root.setProperty("--monospace-font", `"${font}"`);
|
||||||
MONOSPACE_FONTS[font].load();
|
MONOSPACE_FONTS[font].load();
|
||||||
}, [root, theme.monospaceFont]);
|
}, [root, theme.getMonospaceFont()]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
root.setProperty("--ligatures", options?.ligatures ? "normal" : "none");
|
root.setProperty(
|
||||||
}, [root, options?.ligatures]);
|
"--ligatures",
|
||||||
|
settings.get("appearance:ligatures") ? "normal" : "none",
|
||||||
|
);
|
||||||
|
}, [root, settings.get("appearance:ligatures")]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resize = () =>
|
const resize = () =>
|
||||||
|
@ -358,22 +326,14 @@ function Theme({ children, options }: Props) {
|
||||||
return () => window.removeEventListener("resize", resize);
|
return () => window.removeEventListener("resize", resize);
|
||||||
}, [root]);
|
}, [root]);
|
||||||
|
|
||||||
|
const variables = theme.getVariables();
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={theme}>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<meta name="theme-color" content={theme["background"]} />
|
<meta name="theme-color" content={variables["background"]} />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<GlobalTheme theme={theme} />
|
<GlobalTheme theme={variables} />
|
||||||
{theme.css && (
|
<style dangerouslySetInnerHTML={{ __html: theme.getCSS() ?? "" }} />
|
||||||
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
</>
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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 { 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 { Children } from "../types/Preact";
|
||||||
import Locale from "./Locale";
|
import Locale from "./Locale";
|
||||||
import Settings from "./Settings";
|
|
||||||
import Theme from "./Theme";
|
import Theme from "./Theme";
|
||||||
import Intermediate from "./intermediate/Intermediate";
|
import Intermediate from "./intermediate/Intermediate";
|
||||||
import Client from "./revoltjs/RevoltClient";
|
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 }) {
|
export default function Context({ children }: { children: Children }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hydrateState().then(() => setReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!ready) return <Preloader type="spinner" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router basename={import.meta.env.BASE_URL}>
|
<Router basename={import.meta.env.BASE_URL}>
|
||||||
<State>
|
<Locale>
|
||||||
<Theme>
|
<Intermediate>
|
||||||
<Settings>
|
<Client>{children}</Client>
|
||||||
<Locale>
|
</Intermediate>
|
||||||
<Intermediate>
|
</Locale>
|
||||||
<Client>{children}</Client>
|
<Theme />
|
||||||
</Intermediate>
|
|
||||||
</Locale>
|
|
||||||
</Settings>
|
|
||||||
</Theme>
|
|
||||||
</State>
|
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { internalSubscribe } from "../../lib/eventEmitter";
|
import { internalSubscribe } from "../../lib/eventEmitter";
|
||||||
import { determineLink } from "../../lib/links";
|
import { determineLink } from "../../lib/links";
|
||||||
|
|
||||||
import { getState } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
|
||||||
import { Action } from "../../components/ui/Modal";
|
import { Action } from "../../components/ui/Modal";
|
||||||
|
|
||||||
|
@ -132,6 +132,7 @@ interface Props {
|
||||||
|
|
||||||
export default function Intermediate(props: Props) {
|
export default function Intermediate(props: Props) {
|
||||||
const [screen, openScreen] = useState<Screen>({ id: "none" });
|
const [screen, openScreen] = useState<Screen>({ id: "none" });
|
||||||
|
const settings = useApplicationState().settings;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
|
@ -154,10 +155,11 @@ export default function Intermediate(props: Props) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "external": {
|
case "external": {
|
||||||
const { trustedLinks } = getState();
|
|
||||||
if (
|
if (
|
||||||
!trusted &&
|
!trusted &&
|
||||||
!trustedLinks.domains?.includes(link.url.hostname)
|
!settings.security.isTrustedOrigin(
|
||||||
|
link.url.hostname,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
openScreen({
|
openScreen({
|
||||||
id: "external_link_prompt",
|
id: "external_link_prompt",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ interface Props {
|
||||||
|
|
||||||
export function ExternalLinkModal({ onClose, link }: Props) {
|
export function ExternalLinkModal({ onClose, link }: Props) {
|
||||||
const { openLink } = useIntermediate();
|
const { openLink } = useIntermediate();
|
||||||
|
const settings = useApplicationState().settings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
@ -39,13 +40,10 @@ export function ExternalLinkModal({ onClose, link }: Props) {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(link);
|
const url = new URL(link);
|
||||||
dispatch({
|
settings.security.addTrustedOrigin(url.hostname);
|
||||||
type: "TRUSTED_LINKS_ADD_DOMAIN",
|
|
||||||
domain: url.hostname,
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
openLink(link);
|
openLink(link, true);
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
plain: true,
|
plain: true,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Redirect } from "react-router-dom";
|
import { Redirect } from "react-router-dom";
|
||||||
|
|
||||||
import { useContext } from "preact/hooks";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
|
||||||
import { Children } from "../../types/Preact";
|
import { Children } from "../../types/Preact";
|
||||||
import { OperationsContext } from "./RevoltClient";
|
import { useClient } from "./RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
auth?: boolean;
|
auth?: boolean;
|
||||||
|
@ -11,11 +11,13 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CheckAuth = (props: 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" />;
|
return <Redirect to="/login" />;
|
||||||
} else if (!props.auth && operations.ready()) {
|
} else if (!props.auth && ready) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,22 +8,10 @@ import { useCallback, useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import { useTranslation } from "../../lib/i18n";
|
import { useTranslation } from "../../lib/i18n";
|
||||||
|
|
||||||
import { connectState } from "../../redux/connector";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import {
|
|
||||||
getNotificationState,
|
|
||||||
Notifications,
|
|
||||||
shouldNotify,
|
|
||||||
} from "../../redux/reducers/notifications";
|
|
||||||
import { NotificationOptions } from "../../redux/reducers/settings";
|
|
||||||
|
|
||||||
import { SoundContext } from "../Settings";
|
|
||||||
import { AppContext } from "./RevoltClient";
|
import { AppContext } from "./RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
options?: NotificationOptions;
|
|
||||||
notifs: Notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications: { [key: string]: Notification } = {};
|
const notifications: { [key: string]: Notification } = {};
|
||||||
|
|
||||||
async function createNotification(
|
async function createNotification(
|
||||||
|
@ -38,9 +26,11 @@ async function createNotification(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Notifier({ options, notifs }: Props) {
|
function Notifier() {
|
||||||
const translate = useTranslation();
|
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 client = useContext(AppContext);
|
||||||
const { guild: guild_id, channel: channel_id } = useParams<{
|
const { guild: guild_id, channel: channel_id } = useParams<{
|
||||||
|
@ -48,19 +38,13 @@ function Notifier({ options, notifs }: Props) {
|
||||||
channel: string;
|
channel: string;
|
||||||
}>();
|
}>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const playSound = useContext(SoundContext);
|
|
||||||
|
|
||||||
const message = useCallback(
|
const message = useCallback(
|
||||||
async (msg: Message) => {
|
async (msg: Message) => {
|
||||||
if (msg.author_id === client.user!._id) return;
|
|
||||||
if (msg.channel_id === channel_id && document.hasFocus()) return;
|
if (msg.channel_id === channel_id && document.hasFocus()) return;
|
||||||
if (client.user!.status?.presence === Presence.Busy) return;
|
if (!notifs.shouldNotify(msg)) return;
|
||||||
if (msg.author?.relationship === RelationshipStatus.Blocked) return;
|
|
||||||
|
|
||||||
const notifState = getNotificationState(notifs, msg.channel!);
|
state.settings.sounds.playSound("message");
|
||||||
if (!shouldNotify(notifState, msg, client.user!._id)) return;
|
|
||||||
|
|
||||||
playSound("message");
|
|
||||||
if (!showNotification) return;
|
if (!showNotification) return;
|
||||||
|
|
||||||
const effectiveName = msg.masquerade?.name ?? msg.author?.username;
|
const effectiveName = msg.masquerade?.name ?? msg.author?.username;
|
||||||
|
@ -220,7 +204,7 @@ function Notifier({ options, notifs }: Props) {
|
||||||
channel_id,
|
channel_id,
|
||||||
client,
|
client,
|
||||||
notifs,
|
notifs,
|
||||||
playSound,
|
state,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -268,7 +252,7 @@ function Notifier({ options, notifs }: Props) {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
playSound,
|
state,
|
||||||
guild_id,
|
guild_id,
|
||||||
channel_id,
|
channel_id,
|
||||||
showNotification,
|
showNotification,
|
||||||
|
@ -296,28 +280,17 @@ function Notifier({ options, notifs }: Props) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotifierComponent = connectState(
|
|
||||||
Notifier,
|
|
||||||
(state) => {
|
|
||||||
return {
|
|
||||||
options: state.settings.notification,
|
|
||||||
notifs: state.notifications,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function NotificationsComponent() {
|
export default function NotificationsComponent() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/server/:server/channel/:channel">
|
<Route path="/server/:server/channel/:channel">
|
||||||
<NotifierComponent />
|
<Notifier />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/channel/:channel">
|
<Route path="/channel/:channel">
|
||||||
<NotifierComponent />
|
<Notifier />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<NotifierComponent />
|
<Notifier />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* 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 { Client } from "revolt.js";
|
||||||
import { Route } from "revolt.js/dist/api/routes";
|
|
||||||
|
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import { AuthState } from "../../redux/reducers/auth";
|
|
||||||
|
|
||||||
import Preloader from "../../components/ui/Preloader";
|
import Preloader from "../../components/ui/Preloader";
|
||||||
|
|
||||||
import { Children } from "../../types/Preact";
|
import { Children } from "../../types/Preact";
|
||||||
import { useIntermediate } from "../intermediate/Intermediate";
|
import { useIntermediate } from "../intermediate/Intermediate";
|
||||||
import { registerEvents, setReconnectDisallowed } from "./events";
|
import { registerEvents } from "./events";
|
||||||
import { takeError } from "./util";
|
import { takeError } from "./util";
|
||||||
|
|
||||||
export enum ClientStatus {
|
export enum ClientStatus {
|
||||||
INIT,
|
|
||||||
LOADING,
|
|
||||||
READY,
|
READY,
|
||||||
|
LOADING,
|
||||||
OFFLINE,
|
OFFLINE,
|
||||||
DISCONNECTED,
|
DISCONNECTED,
|
||||||
CONNECTING,
|
CONNECTING,
|
||||||
|
@ -29,179 +25,75 @@ export enum ClientStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientOperations {
|
export interface ClientOperations {
|
||||||
login: (
|
|
||||||
data: Route<"POST", "/auth/session/login">["data"],
|
|
||||||
) => Promise<void>;
|
|
||||||
logout: (shouldRequest?: boolean) => 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 AppContext = createContext<Client>(null!);
|
||||||
export const StatusContext = createContext<ClientStatus>(null!);
|
export const StatusContext = createContext<ClientStatus>(null!);
|
||||||
export const OperationsContext = createContext<ClientOperations>(null!);
|
export const LogOutContext = createContext(() => {});
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
auth: AuthState;
|
|
||||||
children: Children;
|
children: Children;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Context({ auth, children }: Props) {
|
export default observer(({ children }: Props) => {
|
||||||
|
const state = useApplicationState();
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const [status, setStatus] = useState(ClientStatus.INIT);
|
const [client, setClient] = useState<Client>(null!);
|
||||||
const [client, setClient] = useState<Client>(
|
const [status, setStatus] = useState(ClientStatus.LOADING);
|
||||||
undefined as unknown as Client,
|
const [loaded, setLoaded] = useState(false);
|
||||||
);
|
|
||||||
|
function logout() {
|
||||||
|
setLoaded(false);
|
||||||
|
client.logout(false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (navigator.onLine) {
|
||||||
const client = new Client({
|
new Client().req("GET", "/").then(state.config.set);
|
||||||
autoReconnect: false,
|
}
|
||||||
apiURL: import.meta.env.VITE_API_URL,
|
|
||||||
debug: import.meta.env.DEV,
|
|
||||||
});
|
|
||||||
|
|
||||||
setClient(client);
|
|
||||||
setStatus(ClientStatus.LOADING);
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (state.auth.isLoggedIn()) {
|
||||||
if (auth.active) {
|
setLoaded(false);
|
||||||
dispatch({ type: "QUEUE_FAIL_ALL" });
|
const client = state.config.createClient();
|
||||||
|
setClient(client);
|
||||||
|
|
||||||
const active = auth.accounts[auth.active];
|
client
|
||||||
client.user = client.users.get(active.session.user_id);
|
.useExistingSession(state.auth.getSession()!)
|
||||||
if (!navigator.onLine) {
|
.catch((err) => {
|
||||||
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);
|
|
||||||
const error = takeError(err);
|
const error = takeError(err);
|
||||||
if (error === "Forbidden" || error === "Unauthorized") {
|
if (error === "Forbidden" || error === "Unauthorized") {
|
||||||
operations.logout(true);
|
client.logout(true);
|
||||||
openScreen({ id: "signed_out" });
|
openScreen({ id: "signed_out" });
|
||||||
} else {
|
} else {
|
||||||
|
setStatus(ClientStatus.DISCONNECTED);
|
||||||
openScreen({ id: "error", error });
|
openScreen({ id: "error", error });
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} else {
|
.finally(() => setLoaded(true));
|
||||||
try {
|
} else {
|
||||||
await client.fetchConfiguration();
|
setStatus(ClientStatus.READY);
|
||||||
} catch (err) {
|
setLoaded(true);
|
||||||
console.error("Failed to connect to API server.");
|
}
|
||||||
}
|
}, [state.auth.getSession()]);
|
||||||
|
|
||||||
setStatus(ClientStatus.READY);
|
useEffect(() => registerEvents(state.auth, setStatus, client), [client]);
|
||||||
}
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (status === ClientStatus.LOADING) {
|
if (!loaded || status === ClientStatus.LOADING) {
|
||||||
return <Preloader type="spinner" />;
|
return <Preloader type="spinner" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={client}>
|
<AppContext.Provider value={client}>
|
||||||
<StatusContext.Provider value={status}>
|
<StatusContext.Provider value={status}>
|
||||||
<OperationsContext.Provider value={operations}>
|
<LogOutContext.Provider value={logout}>
|
||||||
{children}
|
{children}
|
||||||
</OperationsContext.Provider>
|
</LogOutContext.Provider>
|
||||||
</StatusContext.Provider>
|
</StatusContext.Provider>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export default connectState<{ children: Children }>(Context, (state) => {
|
|
||||||
return {
|
|
||||||
auth: state.auth,
|
|
||||||
sync: state.sync,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useClient = () => useContext(AppContext);
|
export const useClient = () => useContext(AppContext);
|
||||||
|
|
|
@ -5,45 +5,35 @@ import { Message } from "revolt.js/dist/maps/Messages";
|
||||||
|
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import { QueuedMessage } from "../../redux/reducers/queue";
|
import { setGlobalEmojiPack } from "../../components/common/Emoji";
|
||||||
|
|
||||||
import { AppContext } from "./RevoltClient";
|
import { AppContext } from "./RevoltClient";
|
||||||
|
|
||||||
type Props = {
|
export default function StateMonitor() {
|
||||||
messages: QueuedMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function StateMonitor(props: Props) {
|
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
const state = useApplicationState();
|
||||||
useEffect(() => {
|
|
||||||
dispatch({
|
|
||||||
type: "QUEUE_DROP_ALL",
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function add(msg: Message) {
|
function add(msg: Message) {
|
||||||
if (!msg.nonce) return;
|
if (!msg.nonce) return;
|
||||||
if (!props.messages.find((x) => x.id === msg.nonce)) return;
|
if (
|
||||||
|
!state.queue.get(msg.channel_id).find((x) => x.id === msg.nonce)
|
||||||
dispatch({
|
)
|
||||||
type: "QUEUE_REMOVE",
|
return;
|
||||||
nonce: msg.nonce,
|
state.queue.remove(msg.nonce);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addListener("message", add);
|
client.addListener("message", add);
|
||||||
return () => client.removeListener("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;
|
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.
|
* 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 { 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 { useApplicationState } from "../../mobx/State";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import { Notifications } from "../../redux/reducers/notifications";
|
|
||||||
import { Settings } from "../../redux/reducers/settings";
|
|
||||||
import {
|
|
||||||
DEFAULT_ENABLED_SYNC,
|
|
||||||
SyncData,
|
|
||||||
SyncKeys,
|
|
||||||
SyncOptions,
|
|
||||||
} from "../../redux/reducers/sync";
|
|
||||||
|
|
||||||
import { Language } from "../Locale";
|
import { useClient } from "./RevoltClient";
|
||||||
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
|
|
||||||
|
|
||||||
type Props = {
|
export default function SyncManager() {
|
||||||
settings: Settings;
|
const client = useClient();
|
||||||
locale: Language;
|
const state = useApplicationState();
|
||||||
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);
|
|
||||||
|
|
||||||
|
// Sync settings from Revolt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === ClientStatus.ONLINE) {
|
state.sync.pull(client);
|
||||||
client
|
}, [client]);
|
||||||
.syncFetchSettings(
|
|
||||||
DEFAULT_ENABLED_SYNC.filter(
|
|
||||||
(x) => !props.sync?.disabled?.includes(x),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
dispatch({
|
|
||||||
type: "SYNC_UPDATE",
|
|
||||||
update: mapSync(data),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
client
|
// Keep data synced.
|
||||||
.syncFetchUnreads()
|
useEffect(() => state.registerListeners(client), [client]);
|
||||||
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
|
|
||||||
}
|
|
||||||
}, [client, props.sync?.disabled, status]);
|
|
||||||
|
|
||||||
const syncChange = useCallback(
|
|
||||||
(key: SyncKeys, data: unknown) => {
|
|
||||||
const timestamp = +new Date();
|
|
||||||
dispatch({
|
|
||||||
type: "SYNC_SET_REVISION",
|
|
||||||
key,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.syncSetSettings(
|
|
||||||
{
|
|
||||||
[key]: data as string,
|
|
||||||
},
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[client],
|
|
||||||
);
|
|
||||||
|
|
||||||
const disabled = useMemo(
|
|
||||||
() => props.sync.disabled ?? [],
|
|
||||||
[props.sync.disabled],
|
|
||||||
);
|
|
||||||
for (const [key, object] of [
|
|
||||||
["appearance", props.settings.appearance],
|
|
||||||
["theme", props.settings.theme],
|
|
||||||
["locale", props.locale],
|
|
||||||
["notifications", props.notifications],
|
|
||||||
] as [SyncKeys, unknown][]) {
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled.indexOf(key) === -1) {
|
|
||||||
if (typeof lastValues[key] !== "undefined") {
|
|
||||||
if (!isEqual(lastValues[key], object)) {
|
|
||||||
syncChange(key, object);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastValues[key] = object;
|
|
||||||
}, [key, syncChange, disabled, object]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Take data updates from Revolt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onPacket(packet: ClientboundNotification) {
|
function onPacket(packet: ClientboundNotification) {
|
||||||
if (packet.type === "UserSettingsUpdate") {
|
if (packet.type === "UserSettingsUpdate") {
|
||||||
const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
|
state.sync.apply(packet.update);
|
||||||
mapSync(packet.update, props.sync.revision);
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "SYNC_UPDATE",
|
|
||||||
update,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addListener("packet", onPacket);
|
client.addListener("packet", onPacket);
|
||||||
return () => client.removeListener("packet", onPacket);
|
return () => client.removeListener("packet", onPacket);
|
||||||
}, [client, disabled, props.sync]);
|
}, [client]);
|
||||||
|
|
||||||
return null;
|
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 { Client } from "revolt.js/dist";
|
||||||
import { Message } from "revolt.js/dist/maps/Messages";
|
|
||||||
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
|
||||||
|
|
||||||
import { StateUpdater } from "preact/hooks";
|
import { StateUpdater } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
import Auth from "../../mobx/stores/Auth";
|
||||||
|
|
||||||
import { ClientOperations, ClientStatus } from "./RevoltClient";
|
import { ClientStatus } from "./RevoltClient";
|
||||||
|
|
||||||
export let preventReconnect = false;
|
export let preventReconnect = false;
|
||||||
let preventUntil = 0;
|
let preventUntil = 0;
|
||||||
|
@ -16,10 +14,12 @@ export function setReconnectDisallowed(allowed: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerEvents(
|
export function registerEvents(
|
||||||
{ operations }: { operations: ClientOperations },
|
auth: Auth,
|
||||||
setStatus: StateUpdater<ClientStatus>,
|
setStatus: StateUpdater<ClientStatus>,
|
||||||
client: Client,
|
client: Client,
|
||||||
) {
|
) {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
function attemptReconnect() {
|
function attemptReconnect() {
|
||||||
if (preventReconnect) return;
|
if (preventReconnect) return;
|
||||||
function reconnect() {
|
function reconnect() {
|
||||||
|
@ -36,40 +36,19 @@ export function registerEvents(
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let listeners: Record<string, (...args: any[]) => void> = {
|
let listeners: Record<string, (...args: any[]) => void> = {
|
||||||
connecting: () =>
|
connecting: () => setStatus(ClientStatus.CONNECTING),
|
||||||
operations.ready() && setStatus(ClientStatus.CONNECTING),
|
|
||||||
|
|
||||||
dropped: () => {
|
dropped: () => {
|
||||||
if (operations.ready()) {
|
setStatus(ClientStatus.DISCONNECTED);
|
||||||
setStatus(ClientStatus.DISCONNECTED);
|
attemptReconnect();
|
||||||
attemptReconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
packet: (packet: ClientboundNotification) => {
|
|
||||||
switch (packet.type) {
|
|
||||||
case "ChannelAck": {
|
|
||||||
dispatch({
|
|
||||||
type: "UNREADS_MARK_READ",
|
|
||||||
channel: packet.id,
|
|
||||||
message: packet.message_id,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
message: (message: Message) => {
|
|
||||||
if (message.mention_ids?.includes(client.user!._id)) {
|
|
||||||
dispatch({
|
|
||||||
type: "UNREADS_MENTION",
|
|
||||||
channel: message.channel_id,
|
|
||||||
message: message._id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ready: () => setStatus(ClientStatus.ONLINE),
|
ready: () => setStatus(ClientStatus.ONLINE),
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
auth.logout();
|
||||||
|
setStatus(ClientStatus.READY);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
@ -89,19 +68,15 @@ export function registerEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
const online = () => {
|
const online = () => {
|
||||||
if (operations.ready()) {
|
setStatus(ClientStatus.RECONNECTING);
|
||||||
setStatus(ClientStatus.RECONNECTING);
|
setReconnectDisallowed(false);
|
||||||
setReconnectDisallowed(false);
|
attemptReconnect();
|
||||||
attemptReconnect();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const offline = () => {
|
const offline = () => {
|
||||||
if (operations.ready()) {
|
setReconnectDisallowed(true);
|
||||||
setReconnectDisallowed(true);
|
client.websocket.disconnect();
|
||||||
client.websocket.disconnect();
|
setStatus(ClientStatus.OFFLINE);
|
||||||
setStatus(ClientStatus.OFFLINE);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("online", online);
|
window.addEventListener("online", online);
|
||||||
|
|
|
@ -32,14 +32,9 @@ import {
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../redux";
|
import { useApplicationState } from "../mobx/State";
|
||||||
import { connectState } from "../redux/connector";
|
import { QueuedMessage } from "../mobx/stores/MessageQueue";
|
||||||
import {
|
import { NotificationState } from "../mobx/stores/NotificationOptions";
|
||||||
getNotificationState,
|
|
||||||
Notifications,
|
|
||||||
NotificationState,
|
|
||||||
} from "../redux/reducers/notifications";
|
|
||||||
import { QueuedMessage } from "../redux/reducers/queue";
|
|
||||||
|
|
||||||
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
|
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
|
||||||
import {
|
import {
|
||||||
|
@ -48,6 +43,7 @@ import {
|
||||||
StatusContext,
|
StatusContext,
|
||||||
} from "../context/revoltjs/RevoltClient";
|
} from "../context/revoltjs/RevoltClient";
|
||||||
import { takeError } from "../context/revoltjs/util";
|
import { takeError } from "../context/revoltjs/util";
|
||||||
|
import CMNotifications from "./contextmenu/CMNotifications";
|
||||||
|
|
||||||
import Tooltip from "../components/common/Tooltip";
|
import Tooltip from "../components/common/Tooltip";
|
||||||
import UserStatus from "../components/common/user/UserStatus";
|
import UserStatus from "../components/common/user/UserStatus";
|
||||||
|
@ -105,41 +101,42 @@ type Action =
|
||||||
| { action: "create_channel"; target: Server }
|
| { action: "create_channel"; target: Server }
|
||||||
| { action: "create_category"; target: Server }
|
| { action: "create_category"; target: Server }
|
||||||
| {
|
| {
|
||||||
action: "create_invite";
|
action: "create_invite";
|
||||||
target: Channel;
|
target: Channel;
|
||||||
}
|
}
|
||||||
| { action: "leave_group"; target: Channel }
|
| { action: "leave_group"; target: Channel }
|
||||||
| {
|
| {
|
||||||
action: "delete_channel";
|
action: "delete_channel";
|
||||||
target: Channel;
|
target: Channel;
|
||||||
}
|
}
|
||||||
| { action: "close_dm"; target: Channel }
|
| { action: "close_dm"; target: Channel }
|
||||||
| { action: "leave_server"; target: Server }
|
| { action: "leave_server"; target: Server }
|
||||||
| { action: "delete_server"; target: Server }
|
| { action: "delete_server"; target: Server }
|
||||||
| { action: "edit_identity"; 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_settings" }
|
||||||
| { action: "open_channel_settings"; id: string }
|
| { action: "open_channel_settings"; id: string }
|
||||||
| { action: "open_server_settings"; id: string }
|
| { action: "open_server_settings"; id: string }
|
||||||
| { action: "open_server_channel_settings"; server: string; id: string }
|
| { action: "open_server_channel_settings"; server: string; id: string }
|
||||||
| {
|
| {
|
||||||
action: "set_notification_state";
|
action: "set_notification_state";
|
||||||
key: string;
|
key: string;
|
||||||
state?: NotificationState;
|
state?: NotificationState;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
|
||||||
notifications: Notifications;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ! FIXME: I dare someone to re-write this
|
// ! FIXME: I dare someone to re-write this
|
||||||
// Tip: This should just be split into separate context menus per logical area.
|
// 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 { openScreen, writeClipboard } = useIntermediate();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const userId = client.user!._id;
|
const userId = client.user!._id;
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
const isOnline = status === ClientStatus.ONLINE;
|
const isOnline = status === ClientStatus.ONLINE;
|
||||||
|
const state = useApplicationState();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
function contextClick(data?: Action) {
|
function contextClick(data?: Action) {
|
||||||
|
@ -171,21 +168,19 @@ function ContextMenus(props: Props) {
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
dispatch({
|
client.unreads!.markRead(
|
||||||
type: "UNREADS_MARK_READ",
|
data.channel._id,
|
||||||
channel: data.channel._id,
|
data.channel.last_message_id!,
|
||||||
message: data.channel.last_message_id!,
|
true,
|
||||||
});
|
true,
|
||||||
|
);
|
||||||
data.channel.ack(undefined, true);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "mark_server_as_read":
|
case "mark_server_as_read":
|
||||||
{
|
{
|
||||||
dispatch({
|
client.unreads!.markMultipleRead(
|
||||||
type: "UNREADS_MARK_MULTIPLE_READ",
|
data.server.channel_ids,
|
||||||
channels: data.server.channel_ids,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
data.server.ack();
|
data.server.ack();
|
||||||
}
|
}
|
||||||
|
@ -195,11 +190,7 @@ function ContextMenus(props: Props) {
|
||||||
{
|
{
|
||||||
const nonce = data.message.id;
|
const nonce = data.message.id;
|
||||||
const fail = (error: string) =>
|
const fail = (error: string) =>
|
||||||
dispatch({
|
state.queue.fail(nonce, error);
|
||||||
type: "QUEUE_FAIL",
|
|
||||||
nonce,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.channels
|
client.channels
|
||||||
.get(data.message.channel)!
|
.get(data.message.channel)!
|
||||||
|
@ -210,19 +201,13 @@ function ContextMenus(props: Props) {
|
||||||
})
|
})
|
||||||
.catch(fail);
|
.catch(fail);
|
||||||
|
|
||||||
dispatch({
|
state.queue.start(nonce);
|
||||||
type: "QUEUE_START",
|
|
||||||
nonce,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "cancel_message":
|
case "cancel_message":
|
||||||
{
|
{
|
||||||
dispatch({
|
state.queue.remove(data.message.id);
|
||||||
type: "QUEUE_REMOVE",
|
|
||||||
nonce: data.message.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -427,6 +412,7 @@ function ContextMenus(props: Props) {
|
||||||
case "open_notification_options": {
|
case "open_notification_options": {
|
||||||
openContextMenu("NotificationOptions", {
|
openContextMenu("NotificationOptions", {
|
||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
|
server: data.server,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -445,16 +431,6 @@ function ContextMenus(props: Props) {
|
||||||
case "open_server_settings":
|
case "open_server_settings":
|
||||||
history.push(`/server/${data.id}/settings`);
|
history.push(`/server/${data.id}/settings`);
|
||||||
break;
|
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) => {
|
})().catch((err) => {
|
||||||
openScreen({ id: "error", error: takeError(err) });
|
openScreen({ id: "error", error: takeError(err) });
|
||||||
|
@ -488,8 +464,9 @@ function ContextMenus(props: Props) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<MenuItem data={action} disabled={disabled}>
|
<MenuItem data={action} disabled={disabled}>
|
||||||
<Text
|
<Text
|
||||||
id={`app.context_menu.${locale ?? action.action
|
id={`app.context_menu.${
|
||||||
}`}
|
locale ?? action.action
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
{tip && <div className="tip">{tip}</div>}
|
{tip && <div className="tip">{tip}</div>}
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
|
@ -545,8 +522,8 @@ function ContextMenus(props: Props) {
|
||||||
const user = uid ? client.users.get(uid) : undefined;
|
const user = uid ? client.users.get(uid) : undefined;
|
||||||
const serverChannel =
|
const serverChannel =
|
||||||
targetChannel &&
|
targetChannel &&
|
||||||
(targetChannel.channel_type === "TextChannel" ||
|
(targetChannel.channel_type === "TextChannel" ||
|
||||||
targetChannel.channel_type === "VoiceChannel")
|
targetChannel.channel_type === "VoiceChannel")
|
||||||
? targetChannel
|
? targetChannel
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
@ -558,8 +535,8 @@ function ContextMenus(props: Props) {
|
||||||
(server
|
(server
|
||||||
? server.permission
|
? server.permission
|
||||||
: serverChannel
|
: serverChannel
|
||||||
? serverChannel.server?.permission
|
? serverChannel.server?.permission
|
||||||
: 0) || 0;
|
: 0) || 0;
|
||||||
const userPermissions = (user ? user.permission : 0) || 0;
|
const userPermissions = (user ? user.permission : 0) || 0;
|
||||||
|
|
||||||
if (unread) {
|
if (unread) {
|
||||||
|
@ -705,7 +682,8 @@ function ContextMenus(props: Props) {
|
||||||
if (message && !queued) {
|
if (message && !queued) {
|
||||||
const sendPermission =
|
const sendPermission =
|
||||||
message.channel &&
|
message.channel &&
|
||||||
message.channel.permission & ChannelPermission.SendMessage
|
message.channel.permission &
|
||||||
|
ChannelPermission.SendMessage;
|
||||||
|
|
||||||
if (sendPermission) {
|
if (sendPermission) {
|
||||||
generateAction({
|
generateAction({
|
||||||
|
@ -741,7 +719,7 @@ function ContextMenus(props: Props) {
|
||||||
if (
|
if (
|
||||||
message.author_id === userId ||
|
message.author_id === userId ||
|
||||||
channelPermissions &
|
channelPermissions &
|
||||||
ChannelPermission.ManageMessages
|
ChannelPermission.ManageMessages
|
||||||
) {
|
) {
|
||||||
generateAction({
|
generateAction({
|
||||||
action: "delete_message",
|
action: "delete_message",
|
||||||
|
@ -765,8 +743,8 @@ function ContextMenus(props: Props) {
|
||||||
type === "Image"
|
type === "Image"
|
||||||
? "open_image"
|
? "open_image"
|
||||||
: type === "Video"
|
: type === "Video"
|
||||||
? "open_video"
|
? "open_video"
|
||||||
: "open_file",
|
: "open_file",
|
||||||
);
|
);
|
||||||
|
|
||||||
generateAction(
|
generateAction(
|
||||||
|
@ -777,8 +755,8 @@ function ContextMenus(props: Props) {
|
||||||
type === "Image"
|
type === "Image"
|
||||||
? "save_image"
|
? "save_image"
|
||||||
: type === "Video"
|
: type === "Video"
|
||||||
? "save_video"
|
? "save_video"
|
||||||
: "save_file",
|
: "save_file",
|
||||||
);
|
);
|
||||||
|
|
||||||
generateAction(
|
generateAction(
|
||||||
|
@ -919,6 +897,16 @@ function ContextMenus(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sid && server) {
|
if (sid && server) {
|
||||||
|
generateAction(
|
||||||
|
{
|
||||||
|
action: "open_notification_options",
|
||||||
|
server,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
<ChevronRight size={24} />,
|
||||||
|
);
|
||||||
|
|
||||||
if (server.channels[0] !== undefined)
|
if (server.channels[0] !== undefined)
|
||||||
generateAction(
|
generateAction(
|
||||||
{
|
{
|
||||||
|
@ -930,9 +918,9 @@ function ContextMenus(props: Props) {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
serverPermissions &
|
serverPermissions &
|
||||||
ServerPermission.ChangeNickname ||
|
ServerPermission.ChangeNickname ||
|
||||||
serverPermissions &
|
serverPermissions &
|
||||||
ServerPermission.ChangeAvatar
|
ServerPermission.ChangeAvatar
|
||||||
)
|
)
|
||||||
generateAction(
|
generateAction(
|
||||||
{ action: "edit_identity", target: server },
|
{ action: "edit_identity", target: server },
|
||||||
|
@ -976,10 +964,10 @@ function ContextMenus(props: Props) {
|
||||||
sid
|
sid
|
||||||
? "copy_sid"
|
? "copy_sid"
|
||||||
: cid
|
: cid
|
||||||
? "copy_cid"
|
? "copy_cid"
|
||||||
: message
|
: message
|
||||||
? "copy_mid"
|
? "copy_mid"
|
||||||
: "copy_uid",
|
: "copy_uid",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1083,76 +1071,7 @@ function ContextMenus(props: Props) {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</ContextMenuWithData>
|
</ContextMenuWithData>
|
||||||
<ContextMenuWithData
|
<CMNotifications />
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)));
|
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 { observer } from "mobx-react-lite";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
|
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
import { useEffect } from "preact/hooks";
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
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 { 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 AgeGate from "../../components/common/AgeGate";
|
||||||
import MessageBox from "../../components/common/messaging/MessageBox";
|
import MessageBox from "../../components/common/messaging/MessageBox";
|
||||||
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
|
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
|
||||||
|
@ -84,17 +84,26 @@ export function Channel({ id }: { id: string }) {
|
||||||
return <TextChannel channel={channel} />;
|
return <TextChannel channel={channel} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
|
|
||||||
const CHANNELS_SIDEBAR_KEY = "sidebar_channels";
|
|
||||||
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
|
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
|
||||||
const [showMembers, setMembers] = useState(
|
const layout = useApplicationState().layout;
|
||||||
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
|
|
||||||
);
|
// Mark channel as read.
|
||||||
const [showChannels, setChannels] = useState(
|
useEffect(() => {
|
||||||
getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true,
|
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 (
|
return (
|
||||||
<AgeGate
|
<AgeGate
|
||||||
type="channel"
|
type="channel"
|
||||||
|
@ -106,54 +115,19 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
|
||||||
channel.nsfw
|
channel.nsfw
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
<ChannelHeader
|
<ChannelHeader channel={channel} />
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ChannelMain>
|
<ChannelMain>
|
||||||
<ChannelContent>
|
<ChannelContent>
|
||||||
<VoiceHeader id={id} />
|
<VoiceHeader id={channel._id} />
|
||||||
<MessageArea channel={channel} />
|
<MessageArea channel={channel} />
|
||||||
<TypingIndicator channel={channel} />
|
<TypingIndicator channel={channel} />
|
||||||
<JumpToBottom channel={channel} />
|
<JumpToBottom channel={channel} />
|
||||||
<MessageBox channel={channel} />
|
<MessageBox channel={channel} />
|
||||||
</ChannelContent>
|
</ChannelContent>
|
||||||
{!isTouchscreenDevice && showMembers && <RightSidebar />}
|
{!isTouchscreenDevice &&
|
||||||
|
layout.getSectionState(SIDEBAR_MEMBERS, true) && (
|
||||||
|
<RightSidebar />
|
||||||
|
)}
|
||||||
</ChannelMain>
|
</ChannelMain>
|
||||||
</AgeGate>
|
</AgeGate>
|
||||||
);
|
);
|
||||||
|
@ -173,13 +147,19 @@ function ChannelPlaceholder() {
|
||||||
<PlaceholderBase>
|
<PlaceholderBase>
|
||||||
<Header placement="primary">
|
<Header placement="primary">
|
||||||
<Hash size={24} />
|
<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>
|
</Header>
|
||||||
|
|
||||||
<div className="placeholder">
|
<div className="placeholder">
|
||||||
<Ghost width={80} />
|
<Ghost width={80} />
|
||||||
<div className="primary"><Text id="app.main.channel.errors.title" /></div>
|
<div className="primary">
|
||||||
<div className="secondary"><Text id="app.main.channel.errors.nochannels" /></div>
|
<Text id="app.main.channel.errors.title" />
|
||||||
|
</div>
|
||||||
|
<div className="secondary">
|
||||||
|
<Text id="app.main.channel.errors.nochannels" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PlaceholderBase>
|
</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 { Notepad, Group } from "@styled-icons/boxicons-solid";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Channel } from "revolt.js/dist/maps/Channels";
|
import { Channel } from "revolt.js/dist/maps/Channels";
|
||||||
|
@ -7,6 +7,9 @@ import styled, { css } from "styled-components";
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
|
||||||
|
|
||||||
import { useIntermediate } from "../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../context/intermediate/Intermediate";
|
||||||
import { getChannelName } from "../../context/revoltjs/util";
|
import { getChannelName } from "../../context/revoltjs/util";
|
||||||
|
|
||||||
|
@ -65,7 +68,7 @@ const Info = styled.div`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const IconContainer = styled.div`
|
const IconConainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -84,84 +87,81 @@ const IconContainer = styled.div`
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(
|
export default observer(({ channel }: ChannelHeaderProps) => {
|
||||||
({ channel, toggleSidebar, toggleChannelSidebar }: ChannelHeaderProps) => {
|
const { openScreen } = useIntermediate();
|
||||||
const { openScreen } = useIntermediate();
|
const layout = useApplicationState().layout;
|
||||||
|
|
||||||
const name = getChannelName(channel);
|
const name = getChannelName(channel);
|
||||||
let icon, recipient: User | undefined;
|
let icon, recipient: User | undefined;
|
||||||
switch (channel.channel_type) {
|
switch (channel.channel_type) {
|
||||||
case "SavedMessages":
|
case "SavedMessages":
|
||||||
icon = <Notepad size={24} />;
|
icon = <Notepad size={24} />;
|
||||||
break;
|
break;
|
||||||
case "DirectMessage":
|
case "DirectMessage":
|
||||||
icon = <At size={24} />;
|
icon = <At size={24} />;
|
||||||
recipient = channel.recipient;
|
recipient = channel.recipient;
|
||||||
break;
|
break;
|
||||||
case "Group":
|
case "Group":
|
||||||
icon = <Group size={24} />;
|
icon = <Group size={24} />;
|
||||||
break;
|
break;
|
||||||
case "TextChannel":
|
case "TextChannel":
|
||||||
icon = <Hash size={24} />;
|
icon = <Hash size={24} />;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header placement="primary">
|
<Header placement="primary">
|
||||||
<HamburgerAction />
|
<HamburgerAction />
|
||||||
<IconContainer onClick={toggleChannelSidebar}>
|
<IconConainer
|
||||||
{/*isTouchscreenDevice && <ChevronLeft size={18} /> FIXME: requires mobx merge */}
|
onClick={() =>
|
||||||
{icon}
|
layout.toggleSectionState(SIDEBAR_MEMBERS, true)
|
||||||
</IconContainer>
|
}>
|
||||||
<Info>
|
{/*isTouchscreenDevice && <ChevronLeft size={18} /> FIXME: requires mobx merge */}
|
||||||
<span className="name">{name}</span>
|
{icon}
|
||||||
{isTouchscreenDevice &&
|
</IconConainer>
|
||||||
channel.channel_type === "DirectMessage" && (
|
<Info>
|
||||||
<>
|
<span className="name">{name}</span>
|
||||||
<div className="divider" />
|
{isTouchscreenDevice &&
|
||||||
<span className="desc">
|
channel.channel_type === "DirectMessage" && (
|
||||||
<div
|
<>
|
||||||
className="status"
|
<div className="divider" />
|
||||||
style={{
|
<span className="desc">
|
||||||
backgroundColor:
|
<div
|
||||||
useStatusColour(recipient),
|
className="status"
|
||||||
}}
|
style={{
|
||||||
/>
|
backgroundColor:
|
||||||
<UserStatus user={recipient} />
|
useStatusColour(recipient),
|
||||||
</span>
|
}}
|
||||||
</>
|
/>
|
||||||
)}
|
<UserStatus user={recipient} />
|
||||||
{!isTouchscreenDevice &&
|
</span>
|
||||||
(channel.channel_type === "Group" ||
|
</>
|
||||||
channel.channel_type === "TextChannel") &&
|
)}
|
||||||
channel.description && (
|
{!isTouchscreenDevice &&
|
||||||
<>
|
(channel.channel_type === "Group" ||
|
||||||
<div className="divider" />
|
channel.channel_type === "TextChannel") &&
|
||||||
<span
|
channel.description && (
|
||||||
className="desc"
|
<>
|
||||||
onClick={() =>
|
<div className="divider" />
|
||||||
openScreen({
|
<span
|
||||||
id: "channel_info",
|
className="desc"
|
||||||
channel,
|
onClick={() =>
|
||||||
})
|
openScreen({
|
||||||
}>
|
id: "channel_info",
|
||||||
<Markdown
|
channel,
|
||||||
content={
|
})
|
||||||
channel.description.split(
|
}>
|
||||||
"\n",
|
<Markdown
|
||||||
)[0] ?? ""
|
content={
|
||||||
}
|
channel.description.split("\n")[0] ?? ""
|
||||||
disallowBigEmoji
|
}
|
||||||
/>
|
disallowBigEmoji
|
||||||
</span>
|
/>
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
</Info>
|
)}
|
||||||
<HeaderActions
|
</Info>
|
||||||
channel={channel}
|
<HeaderActions channel={channel} />
|
||||||
toggleSidebar={toggleSidebar}
|
</Header>
|
||||||
/>
|
);
|
||||||
</Header>
|
});
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -14,6 +14,9 @@ import { internalEmit } from "../../../lib/eventEmitter";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState";
|
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 { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
|
||||||
import UpdateIndicator from "../../../components/common/UpdateIndicator";
|
import UpdateIndicator from "../../../components/common/UpdateIndicator";
|
||||||
|
@ -21,10 +24,8 @@ import IconButton from "../../../components/ui/IconButton";
|
||||||
|
|
||||||
import { ChannelHeaderProps } from "../ChannelHeader";
|
import { ChannelHeaderProps } from "../ChannelHeader";
|
||||||
|
|
||||||
export default function HeaderActions({
|
export default function HeaderActions({ channel }: ChannelHeaderProps) {
|
||||||
channel,
|
const layout = useApplicationState().layout;
|
||||||
toggleSidebar,
|
|
||||||
}: ChannelHeaderProps) {
|
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@ export default function HeaderActions({
|
||||||
if (isTouchscreenDevice) {
|
if (isTouchscreenDevice) {
|
||||||
openRightSidebar();
|
openRightSidebar();
|
||||||
} else {
|
} else {
|
||||||
toggleSidebar?.();
|
layout.toggleSectionState(SIDEBAR_MEMBERS, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,14 +10,12 @@ import styled from "styled-components";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { memo } from "preact/compat";
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
|
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
|
||||||
import { ChannelRenderer } from "../../../lib/renderer/Singleton";
|
import { ChannelRenderer } from "../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
import { connectState } from "../../../redux/connector";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { QueuedMessage } from "../../../redux/reducers/queue";
|
|
||||||
|
|
||||||
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
|
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
|
||||||
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
import { useClient } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
@ -33,7 +31,6 @@ import MessageEditor from "./MessageEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
highlight?: string;
|
highlight?: string;
|
||||||
queue: QueuedMessage[];
|
|
||||||
renderer: ChannelRenderer;
|
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 client = useClient();
|
||||||
const userId = client.user!._id;
|
const userId = client.user!._id;
|
||||||
|
const queue = useApplicationState().queue;
|
||||||
|
|
||||||
const [editing, setEditing] = useState<string | undefined>(undefined);
|
const [editing, setEditing] = useState<string | undefined>(undefined);
|
||||||
const stopEditing = () => {
|
const stopEditing = () => {
|
||||||
|
@ -192,8 +190,7 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
|
||||||
|
|
||||||
const nonces = renderer.messages.map((x) => x.nonce);
|
const nonces = renderer.messages.map((x) => x.nonce);
|
||||||
if (renderer.atBottom) {
|
if (renderer.atBottom) {
|
||||||
for (const msg of queue) {
|
for (const msg of queue.get(renderer.channel._id)) {
|
||||||
if (msg.channel !== renderer.channel._id) continue;
|
|
||||||
if (nonces.includes(msg.id)) continue;
|
if (nonces.includes(msg.id)) continue;
|
||||||
|
|
||||||
if (previous) {
|
if (previous) {
|
||||||
|
@ -237,11 +234,3 @@ const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
|
||||||
|
|
||||||
return <>{render}</>;
|
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 { Wrench } from "@styled-icons/boxicons-solid";
|
||||||
|
|
||||||
import { useContext, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import PaintCounter from "../../lib/PaintCounter";
|
import PaintCounter from "../../lib/PaintCounter";
|
||||||
import { TextReact } from "../../lib/i18n";
|
import { TextReact } from "../../lib/i18n";
|
||||||
|
@ -16,10 +16,14 @@ export default function Developer() {
|
||||||
const userPermission = client.user!.permission;
|
const userPermission = client.user!.permission;
|
||||||
const [ping, setPing] = useState<undefined | number>(client.websocket.ping);
|
const [ping, setPing] = useState<undefined | number>(client.websocket.ping);
|
||||||
|
|
||||||
setInterval(
|
useEffect(() => {
|
||||||
() => setPing(client.websocket.ping),
|
const timer = setInterval(
|
||||||
client.options.heartbeat * 1e3,
|
() => setPing(client.websocket.ping),
|
||||||
);
|
client.options.heartbeat * 1e3,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -14,22 +14,20 @@ import styled, { css } from "styled-components";
|
||||||
import styles from "./Home.module.scss";
|
import styles from "./Home.module.scss";
|
||||||
import "./snow.scss";
|
import "./snow.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useMemo, useState } from "preact/hooks";
|
import { useContext, useMemo } from "preact/hooks";
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
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 { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import wideSVG from "../../../public/assets/wide.svg";
|
import wideSVG from "../../../public/assets/wide.svg";
|
||||||
import Emoji from "../../components/common/Emoji";
|
|
||||||
import Tooltip from "../../components/common/Tooltip";
|
import Tooltip from "../../components/common/Tooltip";
|
||||||
import Header from "../../components/ui/Header";
|
import Header from "../../components/ui/Header";
|
||||||
import CategoryButton from "../../components/ui/fluent/CategoryButton";
|
import CategoryButton from "../../components/ui/fluent/CategoryButton";
|
||||||
|
|
||||||
const CHANNELS_SIDEBAR_KEY = "sidebar_channels";
|
|
||||||
|
|
||||||
const IconConainer = styled.div`
|
const IconConainer = styled.div`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
|
@ -57,29 +55,14 @@ const Overlay = styled.div`
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const [showChannels, setChannels] = useState(
|
const layout = useApplicationState().layout;
|
||||||
getState().sectionToggle[CHANNELS_SIDEBAR_KEY] ?? true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleChannelSidebar = () => {
|
const toggleChannelSidebar = () => {
|
||||||
if (isTouchscreenDevice) {
|
if (isTouchscreenDevice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setChannels(!showChannels);
|
layout.toggleSectionState(SIDEBAR_CHANNELS, true);
|
||||||
|
|
||||||
if (showChannels) {
|
|
||||||
dispatch({
|
|
||||||
type: "SECTION_TOGGLE_SET",
|
|
||||||
id: CHANNELS_SIDEBAR_KEY,
|
|
||||||
state: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch({
|
|
||||||
type: "SECTION_TOGGLE_UNSET",
|
|
||||||
id: CHANNELS_SIDEBAR_KEY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const snowflakes = useMemo(() => {
|
const snowflakes = useMemo(() => {
|
||||||
|
|
|
@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import { defer } from "../../lib/defer";
|
import { defer } from "../../lib/defer";
|
||||||
import { TextReact } from "../../lib/i18n";
|
import { TextReact } from "../../lib/i18n";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
|
||||||
|
|
||||||
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
|
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
|
||||||
import {
|
import {
|
||||||
AppContext,
|
AppContext,
|
||||||
|
@ -168,11 +166,9 @@ export default function Invite() {
|
||||||
|
|
||||||
defer(() => {
|
defer(() => {
|
||||||
if (server) {
|
if (server) {
|
||||||
dispatch({
|
client.unreads!.markMultipleRead(
|
||||||
type: "UNREADS_MARK_MULTIPLE_READ",
|
server.channel_ids,
|
||||||
channels:
|
);
|
||||||
server.channel_ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
history.push(
|
history.push(
|
||||||
`/server/${server._id}/channel/${invite.channel_id}`,
|
`/server/${server._id}/channel/${invite.channel_id}`,
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { Route, Switch } from "react-router-dom";
|
import { Route, Switch } from "react-router-dom";
|
||||||
import { LIBRARY_VERSION } from "revolt.js";
|
import { LIBRARY_VERSION } from "revolt.js";
|
||||||
|
|
||||||
import styles from "./Login.module.scss";
|
import styles from "./Login.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
|
|
||||||
import { ThemeContext } from "../../context/Theme";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
|
||||||
|
|
||||||
import LocaleSelector from "../../components/common/LocaleSelector";
|
import LocaleSelector from "../../components/common/LocaleSelector";
|
||||||
|
import background from "./background.jpg";
|
||||||
|
|
||||||
import { Titlebar } from "../../components/native/Titlebar";
|
import { Titlebar } from "../../components/native/Titlebar";
|
||||||
import { APP_VERSION } from "../../version";
|
import { APP_VERSION } from "../../version";
|
||||||
import background from "./background.jpg";
|
|
||||||
import { FormCreate } from "./forms/FormCreate";
|
import { FormCreate } from "./forms/FormCreate";
|
||||||
import { FormLogin } from "./forms/FormLogin";
|
import { FormLogin } from "./forms/FormLogin";
|
||||||
import { FormReset, FormSendReset } from "./forms/FormReset";
|
import { FormReset, FormSendReset } from "./forms/FormReset";
|
||||||
import { FormResend, FormVerify } from "./forms/FormVerify";
|
import { FormResend, FormVerify } from "./forms/FormVerify";
|
||||||
|
|
||||||
export default function Login() {
|
export default observer(() => {
|
||||||
const theme = useContext(ThemeContext);
|
const state = useApplicationState();
|
||||||
const client = useContext(AppContext);
|
const theme = state.settings.theme;
|
||||||
|
const configuration = state.config.get();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -30,13 +30,15 @@ export default function Login() {
|
||||||
)}
|
)}
|
||||||
<div className={styles.login}>
|
<div className={styles.login}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<meta name="theme-color" content={theme.background} />
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content={theme.getVariable("background")}
|
||||||
|
/>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.attribution}>
|
<div className={styles.attribution}>
|
||||||
<span>
|
<span>
|
||||||
API:{" "}
|
API: <code>{configuration?.revolt ?? "???"}</code>{" "}
|
||||||
<code>{client.configuration?.revolt ?? "???"}</code>{" "}
|
|
||||||
· revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
|
· revolt.js: <code>{LIBRARY_VERSION}</code>{" "}
|
||||||
· App: <code>{APP_VERSION}</code>
|
· App: <code>{APP_VERSION}</code>
|
||||||
</span>
|
</span>
|
||||||
|
@ -80,4 +82,4 @@ export default function Login() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import styles from "../Login.module.scss";
|
import styles from "../Login.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
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";
|
import Preloader from "../../../components/ui/Preloader";
|
||||||
|
|
||||||
|
@ -13,22 +14,22 @@ export interface CaptchaProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CaptchaBlock(props: CaptchaProps) {
|
export const CaptchaBlock = observer((props: CaptchaProps) => {
|
||||||
const client = useContext(AppContext);
|
const configuration = useApplicationState().config.get();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client.configuration?.features.captcha.enabled) {
|
if (!configuration?.features.captcha.enabled) {
|
||||||
props.onSuccess();
|
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 <Preloader type="spinner" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<HCaptcha
|
<HCaptcha
|
||||||
sitekey={client.configuration.features.captcha.key}
|
sitekey={configuration.features.captcha.key}
|
||||||
onVerify={(token) => props.onSuccess(token)}
|
onVerify={(token) => props.onSuccess(token)}
|
||||||
/>
|
/>
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
|
@ -38,4 +39,4 @@ export function CaptchaBlock(props: CaptchaProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
@ -6,6 +6,8 @@ import styles from "../Login.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useState } from "preact/hooks";
|
import { useContext, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
import { takeError } from "../../../context/revoltjs/util";
|
import { takeError } from "../../../context/revoltjs/util";
|
||||||
|
|
||||||
|
@ -44,7 +46,7 @@ interface FormInputs {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Form({ page, callback }: Props) {
|
export function Form({ page, callback }: Props) {
|
||||||
const client = useContext(AppContext);
|
const configuration = useApplicationState().config.get();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState<string | undefined>(undefined);
|
const [success, setSuccess] = useState<string | undefined>(undefined);
|
||||||
|
@ -80,10 +82,7 @@ export function Form({ page, callback }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (configuration?.features.captcha.enabled && page !== "reset") {
|
||||||
client.configuration?.features.captcha.enabled &&
|
|
||||||
page !== "reset"
|
|
||||||
) {
|
|
||||||
setCaptcha({
|
setCaptcha({
|
||||||
onSuccess: async (captcha) => {
|
onSuccess: async (captcha) => {
|
||||||
setCaptcha(undefined);
|
setCaptcha(undefined);
|
||||||
|
@ -111,7 +110,7 @@ export function Form({ page, callback }: Props) {
|
||||||
if (typeof success !== "undefined") {
|
if (typeof success !== "undefined") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.success}>
|
<div className={styles.success}>
|
||||||
{client.configuration?.features.email ? (
|
{configuration?.features.email ? (
|
||||||
<>
|
<>
|
||||||
<Envelope size={72} />
|
<Envelope size={72} />
|
||||||
<h2>
|
<h2>
|
||||||
|
@ -172,15 +171,14 @@ export function Form({ page, callback }: Props) {
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{client.configuration?.features.invite_only &&
|
{configuration?.features.invite_only && page === "create" && (
|
||||||
page === "create" && (
|
<FormField
|
||||||
<FormField
|
type="invite"
|
||||||
type="invite"
|
register={register}
|
||||||
register={register}
|
showOverline
|
||||||
showOverline
|
error={errors.invite?.message}
|
||||||
error={errors.invite?.message}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Overline type="error" error={error}>
|
<Overline type="error" error={error}>
|
||||||
<Text id={`login.error.${page}`} />
|
<Text id={`login.error.${page}`} />
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { useContext } from "preact/hooks";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
|
||||||
|
|
||||||
import { Form } from "./Form";
|
import { Form } from "./Form";
|
||||||
|
|
||||||
export function FormCreate() {
|
export function FormCreate() {
|
||||||
const client = useContext(AppContext);
|
const config = useApplicationState().config;
|
||||||
|
const client = config.createClient();
|
||||||
return <Form page="create" callback={(data) => client.register(data)} />;
|
return <Form page="create" callback={(data) => client.register(data)} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { detect } from "detect-browser";
|
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";
|
import { Form } from "./Form";
|
||||||
|
|
||||||
export function FormLogin() {
|
export function FormLogin() {
|
||||||
const { login } = useContext(OperationsContext);
|
const auth = useApplicationState().auth;
|
||||||
const history = useHistory();
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
@ -34,8 +35,41 @@ export function FormLogin() {
|
||||||
friendly_name = "Unknown Device";
|
friendly_name = "Unknown Device";
|
||||||
}
|
}
|
||||||
|
|
||||||
await login({ ...data, friendly_name });
|
// ! FIXME: temporary login flow code
|
||||||
history.push("/");
|
// 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 { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import { Form } from "./Form";
|
import { Form } from "./Form";
|
||||||
|
|
||||||
export function FormSendReset() {
|
export function FormSendReset() {
|
||||||
const client = useContext(AppContext);
|
const config = useApplicationState().config;
|
||||||
|
const client = config.createClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { useHistory, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
import { takeError } from "../../../context/revoltjs/util";
|
import { takeError } from "../../../context/revoltjs/util";
|
||||||
|
|
||||||
|
@ -11,7 +13,8 @@ import Preloader from "../../../components/ui/Preloader";
|
||||||
import { Form } from "./Form";
|
import { Form } from "./Form";
|
||||||
|
|
||||||
export function FormResend() {
|
export function FormResend() {
|
||||||
const client = useContext(AppContext);
|
const config = useApplicationState().config;
|
||||||
|
const client = config.createClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
|
|
@ -32,7 +32,6 @@ function mapMailProvider(email?: string): [string, string] | undefined {
|
||||||
case "outlook.com.br":
|
case "outlook.com.br":
|
||||||
case "outlook.cl":
|
case "outlook.cl":
|
||||||
case "outlook.cz":
|
case "outlook.cz":
|
||||||
case "outlook.dk":
|
|
||||||
case "outlook.com.gr":
|
case "outlook.com.gr":
|
||||||
case "outlook.co.il":
|
case "outlook.co.il":
|
||||||
case "outlook.in":
|
case "outlook.in":
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { ThemeContext } from "../../context/Theme";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
|
||||||
import Category from "../../components/ui/Category";
|
import Category from "../../components/ui/Category";
|
||||||
import Header from "../../components/ui/Header";
|
import Header from "../../components/ui/Header";
|
||||||
|
@ -55,7 +55,7 @@ export function GenericSettings({
|
||||||
indexHeader,
|
indexHeader,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useApplicationState().settings.theme;
|
||||||
const { page } = useParams<{ page: string }>();
|
const { page } = useParams<{ page: string }>();
|
||||||
|
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
|
@ -96,8 +96,8 @@ export function GenericSettings({
|
||||||
name="theme-color"
|
name="theme-color"
|
||||||
content={
|
content={
|
||||||
isTouchscreenDevice
|
isTouchscreenDevice
|
||||||
? theme["background"]
|
? theme.getVariable("background")
|
||||||
: theme["secondary-background"]
|
: theme.getVariable("secondary-background")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Store,
|
Store,
|
||||||
Bot,
|
Bot,
|
||||||
} from "@styled-icons/boxicons-solid";
|
} from "@styled-icons/boxicons-solid";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import { Route, Switch, useHistory } from "react-router-dom";
|
import { Route, Switch, useHistory } from "react-router-dom";
|
||||||
import { LIBRARY_VERSION } from "revolt.js";
|
import { LIBRARY_VERSION } from "revolt.js";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
@ -26,13 +27,10 @@ import styles from "./Settings.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { isExperimentEnabled } from "../../redux/reducers/experiments";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
|
||||||
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
|
import RequiresOnline from "../../context/revoltjs/RequiresOnline";
|
||||||
import {
|
import { AppContext, LogOutContext } from "../../context/revoltjs/RevoltClient";
|
||||||
AppContext,
|
|
||||||
OperationsContext,
|
|
||||||
} from "../../context/revoltjs/RevoltClient";
|
|
||||||
|
|
||||||
import UserIcon from "../../components/common/user/UserIcon";
|
import UserIcon from "../../components/common/user/UserIcon";
|
||||||
import LineDivider from "../../components/ui/LineDivider";
|
import LineDivider from "../../components/ui/LineDivider";
|
||||||
|
@ -64,10 +62,11 @@ const IndexHeader = styled.div`
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Settings() {
|
export default observer(() => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const operations = useContext(OperationsContext);
|
const logout = useContext(LogOutContext);
|
||||||
|
const experiments = useApplicationState().experiments;
|
||||||
|
|
||||||
function switchPage(to?: string) {
|
function switchPage(to?: string) {
|
||||||
if (to) {
|
if (to) {
|
||||||
|
@ -138,14 +137,14 @@ export default function Settings() {
|
||||||
title: <Text id="app.settings.pages.experiments.title" />,
|
title: <Text id="app.settings.pages.experiments.title" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
divider: !isExperimentEnabled("theme_shop"),
|
divider: !experiments.isEnabled("theme_shop"),
|
||||||
category: "revolt",
|
category: "revolt",
|
||||||
id: "bots",
|
id: "bots",
|
||||||
icon: <Bot size={20} />,
|
icon: <Bot size={20} />,
|
||||||
title: <Text id="app.settings.pages.bots.title" />,
|
title: <Text id="app.settings.pages.bots.title" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hidden: !isExperimentEnabled("theme_shop"),
|
hidden: !experiments.isEnabled("theme_shop"),
|
||||||
divider: true,
|
divider: true,
|
||||||
id: "theme_shop",
|
id: "theme_shop",
|
||||||
icon: <Store size={20} />,
|
icon: <Store size={20} />,
|
||||||
|
@ -191,7 +190,7 @@ export default function Settings() {
|
||||||
<Route path="/settings/bots">
|
<Route path="/settings/bots">
|
||||||
<MyBots />
|
<MyBots />
|
||||||
</Route>
|
</Route>
|
||||||
{isExperimentEnabled("theme_shop") && (
|
{experiments.isEnabled("theme_shop") && (
|
||||||
<Route path="/settings/theme_shop">
|
<Route path="/settings/theme_shop">
|
||||||
<ThemeShop />
|
<ThemeShop />
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -229,7 +228,7 @@ export default function Settings() {
|
||||||
</a>
|
</a>
|
||||||
<LineDivider />
|
<LineDivider />
|
||||||
<ButtonItem
|
<ButtonItem
|
||||||
onClick={() => operations.logout()}
|
onClick={logout}
|
||||||
className={styles.logOut}
|
className={styles.logOut}
|
||||||
compact>
|
compact>
|
||||||
<LogOut size={20} />
|
<LogOut size={20} />
|
||||||
|
@ -277,4 +276,4 @@ export default function Settings() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
@ -1,528 +1,53 @@
|
||||||
import {
|
import { observer } from "mobx-react-lite";
|
||||||
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 styles from "./Panes.module.scss";
|
import styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
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 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 {
|
import {
|
||||||
settings: Settings;
|
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 const Appearance = observer(() => {
|
||||||
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";
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.appearance}>
|
<div className={styles.appearance}>
|
||||||
<h3>
|
<ThemeBaseSelectorShim />
|
||||||
<Text id="app.settings.pages.appearance.theme" />
|
<ThemeShopShim />
|
||||||
</h3>
|
<ThemeAccentShim />
|
||||||
<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>
|
|
||||||
|
|
||||||
{isExperimentEnabled("theme_shop") && (
|
<DisplayFontShim />
|
||||||
<Link
|
<DisplayLigaturesShim />
|
||||||
to="/settings/theme_shop"
|
<DisplayEmojiShim />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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
|
<CollapsibleSection
|
||||||
defaultValue={false}
|
defaultValue={false}
|
||||||
id="settings_overrides"
|
id="settings_overrides"
|
||||||
summary={<Text id="app.settings.pages.appearance.overrides" />}>
|
summary={<Text id="app.settings.pages.appearance.overrides" />}>
|
||||||
<div className={styles.actions}>
|
<ThemeTools />
|
||||||
<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>
|
|
||||||
<h3>App</h3>
|
<h3>App</h3>
|
||||||
<div className={styles.overrides}>
|
<ThemeOverrides />
|
||||||
{(
|
|
||||||
[
|
|
||||||
"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>
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
id="settings_advanced_appearance"
|
id="settings_advanced_appearance"
|
||||||
defaultValue={false}
|
defaultValue={false}
|
||||||
summary={<Text id="app.settings.pages.appearance.advanced" />}>
|
summary={<Text id="app.settings.pages.appearance.advanced" />}>
|
||||||
<h3>
|
<DisplayMonospaceFontShim />
|
||||||
<Text id="app.settings.pages.appearance.mono_font" />
|
<ThemeCustomCSSShim />
|
||||||
</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)}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export const Appearance = connectState(Component, (state) => {
|
|
||||||
return {
|
|
||||||
settings: state.settings,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getContrastingColour(hex: string, fallback: string): string {
|
// <DisplayCompactShim />
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { TextReact } from "../../../lib/i18n";
|
||||||
import { stopPropagation } from "../../../lib/stopPropagation";
|
import { stopPropagation } from "../../../lib/stopPropagation";
|
||||||
import { voiceState } from "../../../lib/vortex/VoiceState";
|
import { voiceState } from "../../../lib/vortex/VoiceState";
|
||||||
|
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
|
|
||||||
import Button from "../../../components/ui/Button";
|
import Button from "../../../components/ui/Button";
|
||||||
import ComboBox from "../../../components/ui/ComboBox";
|
import ComboBox from "../../../components/ui/ComboBox";
|
||||||
import Overline from "../../../components/ui/Overline";
|
import Overline from "../../../components/ui/Overline";
|
||||||
|
@ -20,7 +18,9 @@ import opusSVG from "../assets/opus_logo.svg";
|
||||||
|
|
||||||
const constraints = { audio: true };
|
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>(
|
const [mediaStream, setMediaStream] = useState<MediaStream | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
@ -244,7 +244,3 @@ function changeAudioDevice(deviceId: string, deviceType: string) {
|
||||||
window.localStorage.setItem("audioOutputDevice", deviceId);
|
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 styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import {
|
import {
|
||||||
AVAILABLE_EXPERIMENTS,
|
AVAILABLE_EXPERIMENTS,
|
||||||
ExperimentOptions,
|
|
||||||
EXPERIMENTS,
|
EXPERIMENTS,
|
||||||
isExperimentEnabled,
|
} from "../../../mobx/stores/Experiments";
|
||||||
} from "../../../redux/reducers/experiments";
|
|
||||||
|
|
||||||
import Checkbox from "../../../components/ui/Checkbox";
|
import Checkbox from "../../../components/ui/Checkbox";
|
||||||
|
|
||||||
interface Props {
|
export const ExperimentsPage = observer(() => {
|
||||||
options?: ExperimentOptions;
|
const experiments = useApplicationState().experiments;
|
||||||
}
|
|
||||||
|
|
||||||
export function Component(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.experiments}>
|
<div className={styles.experiments}>
|
||||||
<h3>
|
<h3>
|
||||||
|
@ -25,15 +22,8 @@ export function Component(props: Props) {
|
||||||
{AVAILABLE_EXPERIMENTS.map((key) => (
|
{AVAILABLE_EXPERIMENTS.map((key) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={key}
|
key={key}
|
||||||
checked={isExperimentEnabled(key, props.options)}
|
checked={experiments.isEnabled(key)}
|
||||||
onChange={(enabled) =>
|
onChange={(enabled) => experiments.setEnabled(key, enabled)}
|
||||||
dispatch({
|
|
||||||
type: enabled
|
|
||||||
? "EXPERIMENTS_ENABLE"
|
|
||||||
: "EXPERIMENTS_DISABLE",
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
description={EXPERIMENTS[key].description}>
|
description={EXPERIMENTS[key].description}>
|
||||||
{EXPERIMENTS[key].title}
|
{EXPERIMENTS[key].title}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
@ -45,10 +35,4 @@ export function Component(props: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
import { useMemo } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Language,
|
Language,
|
||||||
|
@ -17,26 +19,25 @@ import enchantingTableWEBP from "../assets/enchanting_table.webp";
|
||||||
import tamilFlagPNG from "../assets/tamil_nadu_flag.png";
|
import tamilFlagPNG from "../assets/tamil_nadu_flag.png";
|
||||||
import tokiponaSVG from "../assets/toki_pona.svg";
|
import tokiponaSVG from "../assets/toki_pona.svg";
|
||||||
|
|
||||||
type Props = {
|
type Key = [Language, LanguageEntry];
|
||||||
locale: Language;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={x}
|
key={x}
|
||||||
className={styles.entry}
|
className={styles.entry}
|
||||||
checked={locale === x}
|
checked={selected}
|
||||||
onChange={(v) => {
|
onChange={onSelect}>
|
||||||
if (v) {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_LOCALE",
|
|
||||||
locale: x as Language,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div className={styles.flag}>
|
<div className={styles.flag}>
|
||||||
{lang.i18n === "ta" ? (
|
{lang.i18n === "ta" ? (
|
||||||
<img
|
<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) => [
|
* Component providing the language selection menu.
|
||||||
x,
|
*/
|
||||||
Langs[x as keyof typeof Langs],
|
export const Languages = observer(() => {
|
||||||
]) as Key[];
|
const locale = useApplicationState().locale;
|
||||||
|
const language = locale.getLanguage();
|
||||||
|
|
||||||
// Get the user's system language. Check for exact
|
// Generate languages array.
|
||||||
// matches first, otherwise check for partial matches
|
const languages = useMemo(() => {
|
||||||
const preferredLanguage =
|
const languages = Object.keys(Langs).map((x) => [
|
||||||
navigator.languages.filter((lang) =>
|
x,
|
||||||
languages.find((l) => l[0].replace(/_/g, "-") == lang),
|
Langs[x as keyof typeof Langs],
|
||||||
)?.[0] ||
|
]) as Key[];
|
||||||
navigator.languages
|
|
||||||
?.map((x) => x.split("-")[0])
|
|
||||||
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
|
|
||||||
?.split("-")[0];
|
|
||||||
|
|
||||||
if (preferredLanguage) {
|
// Get the user's system language. Check for exact
|
||||||
// This moves the user's system language to the top of the language list
|
// matches first, otherwise check for partial matches
|
||||||
const prefLangKey = languages.find(
|
const preferredLanguage =
|
||||||
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
|
navigator.languages.filter((lang) =>
|
||||||
);
|
languages.find((l) => l[0].replace(/_/g, "-") == lang),
|
||||||
if (prefLangKey) {
|
)?.[0] ||
|
||||||
languages.splice(
|
navigator.languages
|
||||||
0,
|
?.map((x) => x.split("-")[0])
|
||||||
0,
|
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
|
||||||
languages.splice(languages.indexOf(prefLangKey), 1)[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 (
|
return (
|
||||||
<div className={styles.languages}>
|
<div className={styles.languages}>
|
||||||
|
@ -98,11 +121,7 @@ export function Component(props: Props) {
|
||||||
<Text id="app.settings.pages.language.select" />
|
<Text id="app.settings.pages.language.select" />
|
||||||
</h3>
|
</h3>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}
|
||||||
.filter(([, lang]) => !lang.cat)
|
|
||||||
.map(([x, lang]) => (
|
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.language.const" />
|
<Text id="app.settings.pages.language.const" />
|
||||||
|
@ -110,9 +129,7 @@ export function Component(props: Props) {
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages
|
||||||
.filter(([, lang]) => lang.cat === "const")
|
.filter(([, lang]) => lang.cat === "const")
|
||||||
.map(([x, lang]) => (
|
.map(EntryFactory)}
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.language.other" />
|
<Text id="app.settings.pages.language.other" />
|
||||||
|
@ -120,9 +137,7 @@ export function Component(props: Props) {
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages
|
||||||
.filter(([, lang]) => lang.cat === "alt")
|
.filter(([, lang]) => lang.cat === "alt")
|
||||||
.map(([x, lang]) => (
|
.map(EntryFactory)}
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<Tip>
|
<Tip>
|
||||||
<span>
|
<span>
|
||||||
|
@ -137,10 +152,4 @@ export function Component(props: Props) {
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</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 styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
@ -6,28 +6,17 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { urlBase64ToUint8Array } from "../../../lib/conversion";
|
import { urlBase64ToUint8Array } from "../../../lib/conversion";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import {
|
|
||||||
DEFAULT_SOUNDS,
|
|
||||||
NotificationOptions,
|
|
||||||
SoundOptions,
|
|
||||||
} from "../../../redux/reducers/settings";
|
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import Checkbox from "../../../components/ui/Checkbox";
|
import Checkbox from "../../../components/ui/Checkbox";
|
||||||
|
|
||||||
import { SOUNDS_ARRAY } from "../../../assets/sounds/Audio";
|
export const Notifications = observer(() => {
|
||||||
|
|
||||||
interface Props {
|
|
||||||
options?: NotificationOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component({ options }: Props) {
|
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
|
const settings = useApplicationState().settings;
|
||||||
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
|
const [pushEnabled, setPushEnabled] = useState<undefined | boolean>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
@ -42,10 +31,6 @@ export function Component({ options }: Props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enabledSounds: SoundOptions = defaultsDeep(
|
|
||||||
options?.sounds ?? {},
|
|
||||||
DEFAULT_SOUNDS,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.notifications}>
|
<div className={styles.notifications}>
|
||||||
<h3>
|
<h3>
|
||||||
|
@ -53,7 +38,7 @@ export function Component({ options }: Props) {
|
||||||
</h3>
|
</h3>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!("Notification" in window)}
|
disabled={!("Notification" in window)}
|
||||||
checked={options?.desktopEnabled ?? false}
|
checked={settings.get("notifications:desktop", false)!}
|
||||||
description={
|
description={
|
||||||
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
|
<Text id="app.settings.pages.notifications.descriptions.enable_desktop" />
|
||||||
}
|
}
|
||||||
|
@ -61,6 +46,7 @@ export function Component({ options }: Props) {
|
||||||
if (desktopEnabled) {
|
if (desktopEnabled) {
|
||||||
const permission =
|
const permission =
|
||||||
await Notification.requestPermission();
|
await Notification.requestPermission();
|
||||||
|
|
||||||
if (permission !== "granted") {
|
if (permission !== "granted") {
|
||||||
return openScreen({
|
return openScreen({
|
||||||
id: "error",
|
id: "error",
|
||||||
|
@ -69,10 +55,7 @@ export function Component({ options }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
settings.set("notifications:desktop", desktopEnabled);
|
||||||
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
|
|
||||||
options: { desktopEnabled },
|
|
||||||
});
|
|
||||||
}}>
|
}}>
|
||||||
<Text id="app.settings.pages.notifications.enable_desktop" />
|
<Text id="app.settings.pages.notifications.enable_desktop" />
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
@ -125,32 +108,16 @@ export function Component({ options }: Props) {
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.notifications.sounds" />
|
<Text id="app.settings.pages.notifications.sounds" />
|
||||||
</h3>
|
</h3>
|
||||||
{SOUNDS_ARRAY.map((key) => (
|
{settings.sounds.getState().map(({ id, enabled }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={key}
|
key={id}
|
||||||
checked={!!enabledSounds[key]}
|
checked={enabled}
|
||||||
onChange={(enabled) =>
|
onChange={(enabled) =>
|
||||||
dispatch({
|
settings.sounds.setEnabled(id, enabled)
|
||||||
type: "SETTINGS_SET_NOTIFICATION_OPTIONS",
|
|
||||||
options: {
|
|
||||||
sounds: {
|
|
||||||
...options?.sounds,
|
|
||||||
[key]: enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}>
|
}>
|
||||||
<Text
|
<Text id={`app.settings.pages.notifications.sound.${id}`} />
|
||||||
id={`app.settings.pages.notifications.sound.${key}`}
|
|
||||||
/>
|
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export const Notifications = connectState(Component, (state) => {
|
|
||||||
return {
|
|
||||||
options: state.settings.notification,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -461,97 +461,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.sessions {
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import styles from "./Panes.module.scss";
|
import styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import { dispatch } from "../../../redux";
|
import { useApplicationState } from "../../../mobx/State";
|
||||||
import { connectState } from "../../../redux/connector";
|
import { SyncKeys } from "../../../mobx/stores/Sync";
|
||||||
import { SyncKeys, SyncOptions } from "../../../redux/reducers/sync";
|
|
||||||
|
|
||||||
import Checkbox from "../../../components/ui/Checkbox";
|
import Checkbox from "../../../components/ui/Checkbox";
|
||||||
|
|
||||||
interface Props {
|
export const Sync = observer(() => {
|
||||||
options?: SyncOptions;
|
const sync = useApplicationState().sync;
|
||||||
}
|
|
||||||
|
|
||||||
export function Component(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.notifications}>
|
<div className={styles.notifications}>
|
||||||
{/*<h3>
|
{/*<h3>
|
||||||
|
@ -31,22 +30,13 @@ export function Component(props: Props) {
|
||||||
).map(([key, title]) => (
|
).map(([key, title]) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={key}
|
key={key}
|
||||||
checked={
|
checked={sync.isEnabled(key)}
|
||||||
(props.options?.disabled ?? []).indexOf(key) === -1
|
|
||||||
}
|
|
||||||
description={
|
description={
|
||||||
<Text
|
<Text
|
||||||
id={`app.settings.pages.sync.descriptions.${key}`}
|
id={`app.settings.pages.sync.descriptions.${key}`}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onChange={(enabled) =>
|
onChange={() => sync.toggle(key)}>
|
||||||
dispatch({
|
|
||||||
type: enabled
|
|
||||||
? "SYNC_ENABLE_KEY"
|
|
||||||
: "SYNC_DISABLE_KEY",
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
}>
|
|
||||||
<Text id={`app.settings.pages.${title}`} />
|
<Text id={`app.settings.pages.${title}`} />
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
|
@ -55,10 +45,4 @@ export function Component(props: Props) {
|
||||||
</h5>*/}
|
</h5>*/}
|
||||||
</div>
|
</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 styled from "styled-components";
|
||||||
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
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 Tip from "../../../components/ui/Tip";
|
||||||
import previewPath from "../assets/preview.svg";
|
import previewPath from "../assets/preview.svg";
|
||||||
|
|
||||||
|
@ -258,6 +250,8 @@ export function ThemeShop() {
|
||||||
>(null);
|
>(null);
|
||||||
const [themeData, setThemeData] = useState<Record<string, Theme>>({});
|
const [themeData, setThemeData] = useState<Record<string, Theme>>({});
|
||||||
|
|
||||||
|
const themes = useApplicationState().settings.theme;
|
||||||
|
|
||||||
async function fetchThemeList() {
|
async function fetchThemeList() {
|
||||||
const manifest = await fetchManifest();
|
const manifest = await fetchManifest();
|
||||||
setThemeList(
|
setThemeList(
|
||||||
|
@ -352,21 +346,9 @@ export function ThemeShop() {
|
||||||
data-loaded={Reflect.has(themeData, slug)}>
|
data-loaded={Reflect.has(themeData, slug)}>
|
||||||
<button
|
<button
|
||||||
class="preview"
|
class="preview"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
dispatch({
|
themes.hydrate(themeData[slug], true)
|
||||||
type: "THEMES_SET_THEME",
|
}>
|
||||||
theme: {
|
|
||||||
slug,
|
|
||||||
meta: theme,
|
|
||||||
theme: themeData[slug],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "SETTINGS_SET_THEME",
|
|
||||||
theme: { base: slug },
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<div class="previewBox">
|
<div class="previewBox">
|
||||||
<div class="hover">Use theme</div>
|
<div class="hover">Use theme</div>
|
||||||
<ThemePreview
|
<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;
|
|
||||||
}
|
|