diff --git a/.gitignore b/.gitignore index 321920d0..bb4a98c2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ dist-ssr public/assets public/assets_* !public/assets_default + +.vscode/vscode-chrome-debug-userdatadir diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..62e9edab --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/index.html b/index.html index 26041f77..ec592bdd 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,13 @@ - + + + Revolt + - diff --git a/package.json b/package.json index 852ff3cf..8f2fc590 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "pull": "node scripts/setup_assets.js", "build": "rimraf build && node scripts/setup_assets.js --check && vite build", "preview": "vite preview", - "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", + "lint": "eslint src/**/*.{js,jsx,ts,tsx}", "fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", "typecheck": "tsc --noEmit", "start": "sirv dist --cors --single --host", @@ -37,6 +37,24 @@ { "varsIgnorePattern": "^_" } + ], + "require-jsdoc": [ + "error", + { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true, + "ArrowFunctionExpression": false, + "FunctionExpression": false + }, + "ignore": { + "MethodDefinition": [ + "toJSON", + "hydrate" + ] + } + } ] } }, @@ -99,6 +117,7 @@ "eslint-config-preact": "^1.1.4", "eventemitter3": "^4.0.7", "highlight.js": "^11.0.1", + "json-stringify-deterministic": "^1.0.2", "localforage": "^1.9.0", "lodash.defaultsdeep": "^4.6.1", "lodash.isequal": "^4.5.0", @@ -118,14 +137,12 @@ "react-helmet": "^6.1.0", "react-hook-form": "6.3.0", "react-overlapping-panels": "1.2.2", - "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", "react-scroll": "^1.8.2", "react-virtualized-auto-sizer": "^1.0.5", "react-virtuoso": "^1.10.4", - "redux": "^4.1.0", "revolt-api": "0.5.3-alpha.10", - "revolt.js": "^5.1.0-alpha.10", + "revolt.js": "5.2.1-patch.1", "rimraf": "^3.0.2", "sass": "^1.35.1", "shade-blend-color": "^1.0.0", diff --git a/src/assets/sounds/Audio.ts b/src/assets/sounds/Audio.ts deleted file mode 100644 index 5d2cf91a..00000000 --- a/src/assets/sounds/Audio.ts +++ /dev/null @@ -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); - } -} diff --git a/src/components/common/AgeGate.tsx b/src/components/common/AgeGate.tsx index 03071212..11c2b56c 100644 --- a/src/components/common/AgeGate.tsx +++ b/src/components/common/AgeGate.tsx @@ -6,7 +6,8 @@ import styled from "styled-components"; import { Text } from "preact-i18n"; import { useState } from "preact/hooks"; -import { dispatch, getState } from "../../redux"; +import { useApplicationState } from "../../mobx/State"; +import { SECTION_NSFW } from "../../mobx/stores/Layout"; import Button from "../ui/Button"; import Checkbox from "../ui/Checkbox"; @@ -49,9 +50,7 @@ type Props = { export default observer((props: Props) => { const history = useHistory(); - const [consent, setConsent] = useState( - getState().sectionToggle["nsfw"] ?? false, - ); + const layout = useApplicationState().layout; const [ageGate, setAgeGate] = useState(false); if (ageGate || !props.gated) { @@ -81,26 +80,19 @@ export default observer((props: Props) => { { - setConsent(v); - if (v) { - dispatch({ - type: "SECTION_TOGGLE_SET", - id: "nsfw", - state: true, - }); - } else { - dispatch({ type: "SECTION_TOGGLE_UNSET", id: "nsfw" }); - } - }}> + checked={layout.getSectionState(SECTION_NSFW, false)} + onChange={() => layout.toggleSectionState(SECTION_NSFW, false)}>
-
diff --git a/src/components/common/CollapsibleSection.tsx b/src/components/common/CollapsibleSection.tsx index ac2d9809..cea03b06 100644 --- a/src/components/common/CollapsibleSection.tsx +++ b/src/components/common/CollapsibleSection.tsx @@ -1,7 +1,6 @@ import { ChevronDown } from "@styled-icons/boxicons-regular"; -import { State, store } from "../../redux"; -import { Action } from "../../redux/reducers"; +import { useApplicationState } from "../../mobx/State"; import Details from "../ui/Details"; @@ -25,27 +24,14 @@ export default function CollapsibleSection({ children, ...detailsProps }: Props) { - const state: State = store.getState(); - - function setState(state: boolean) { - if (state === defaultValue) { - store.dispatch({ - type: "SECTION_TOGGLE_UNSET", - id, - } as Action); - } else { - store.dispatch({ - type: "SECTION_TOGGLE_SET", - id, - state, - } as Action); - } - } + const layout = useApplicationState().layout; return (
setState(e.currentTarget.open)} + open={layout.getSectionState(id, defaultValue)} + onToggle={(e) => + layout.setSectionState(id, e.currentTarget.open, defaultValue) + } {...detailsProps}>
diff --git a/src/components/common/Emoji.tsx b/src/components/common/Emoji.tsx index 05d68f72..31d11df8 100644 --- a/src/components/common/Emoji.tsx +++ b/src/components/common/Emoji.tsx @@ -1,9 +1,9 @@ -import { EmojiPacks } from "../../redux/reducers/settings"; +export type EmojiPack = "mutant" | "twemoji" | "noto" | "openmoji"; -let EMOJI_PACK = "mutant"; +let EMOJI_PACK: EmojiPack = "mutant"; const REVISION = 3; -export function setEmojiPack(pack: EmojiPacks) { +export function setGlobalEmojiPack(pack: EmojiPack) { EMOJI_PACK = pack; } diff --git a/src/components/common/LocaleSelector.tsx b/src/components/common/LocaleSelector.tsx index 8099f510..5fdaef24 100644 --- a/src/components/common/LocaleSelector.tsx +++ b/src/components/common/LocaleSelector.tsx @@ -1,23 +1,21 @@ -import { dispatch } from "../../redux"; -import { connectState } from "../../redux/connector"; +import { useApplicationState } from "../../mobx/State"; import { Language, Languages } from "../../context/Locale"; import ComboBox from "../ui/ComboBox"; -type Props = { - locale: string; -}; +/** + * Component providing a language selector combobox. + * Note: this is not an observer but this is fine as we are just using a combobox. + */ +export default function LocaleSelector() { + const locale = useApplicationState().locale; -export function LocaleSelector(props: Props) { return ( - dispatch({ - type: "SET_LOCALE", - locale: e.currentTarget.value as Language, - }) + locale.setLanguage(e.currentTarget.value as Language) }> {Object.keys(Languages).map((x) => { const l = Languages[x as keyof typeof Languages]; @@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) { ); } - -export default connectState(LocaleSelector, (state) => { - return { - locale: state.locale, - }; -}); diff --git a/src/components/common/UpdateIndicator.tsx b/src/components/common/UpdateIndicator.tsx index e4898b4e..cbd003dc 100644 --- a/src/components/common/UpdateIndicator.tsx +++ b/src/components/common/UpdateIndicator.tsx @@ -1,11 +1,11 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { Download, CloudDownload } from "@styled-icons/boxicons-regular"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { internalSubscribe } from "../../lib/eventEmitter"; -import { ThemeContext } from "../../context/Theme"; +import { useApplicationState } from "../../mobx/State"; import IconButton from "../ui/IconButton"; @@ -27,7 +27,7 @@ export default function UpdateIndicator({ style }: Props) { }); if (!pending) return null; - const theme = useContext(ThemeContext); + const theme = useApplicationState().settings.theme; if (style === "titlebar") { return ( @@ -36,7 +36,10 @@ export default function UpdateIndicator({ style }: Props) { content="A new update is available!" placement="bottom">
updateSW(true)}> - +
@@ -47,7 +50,7 @@ export default function UpdateIndicator({ style }: Props) { return ( updateSW(true)}> - + ); } diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 5eae1ed9..1765fb5f 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -7,7 +7,7 @@ import { useState } from "preact/hooks"; import { internalEmit } from "../../../lib/eventEmitter"; -import { QueuedMessage } from "../../../redux/reducers/queue"; +import { QueuedMessage } from "../../../mobx/stores/MessageQueue"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useClient } from "../../../context/revoltjs/RevoltClient"; diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 409f2439..330db243 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -20,10 +20,9 @@ import { SMOOTH_SCROLL_ON_RECEIVE, } from "../../../lib/renderer/Singleton"; -import { dispatch, getState } from "../../../redux"; -import { Reply } from "../../../redux/reducers/queue"; +import { useApplicationState } from "../../../mobx/State"; +import { Reply } from "../../../mobx/stores/MessageQueue"; -import { SoundContext } from "../../../context/Settings"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { FileUploader, @@ -112,17 +111,16 @@ const Action = styled.div` const RE_SED = new RegExp("^s/([^])*/([^])*$"); // ! FIXME: add to app config and load from app config -export const CAN_UPLOAD_AT_ONCE = 4; +export const CAN_UPLOAD_AT_ONCE = 5; export default observer(({ channel }: Props) => { - const [draft, setDraft] = useState(getState().drafts[channel._id] ?? ""); + const state = useApplicationState(); const [uploadState, setUploadState] = useState({ type: "none", }); const [typing, setTyping] = useState(false); const [replies, setReplies] = useState([]); - const playSound = useContext(SoundContext); const { openScreen } = useIntermediate(); const client = useContext(AppContext); const translate = useTranslation(); @@ -148,27 +146,18 @@ export default observer(({ channel }: Props) => { ); } + // Push message content to draft. const setMessage = useCallback( - (content?: string) => { - setDraft(content ?? ""); - - if (content) { - dispatch({ - type: "SET_DRAFT", - channel: channel._id, - content, - }); - } else { - dispatch({ - type: "CLEAR_DRAFT", - channel: channel._id, - }); - } - }, - [channel._id], + (content?: string) => state.draft.set(channel._id, content), + [state.draft, channel._id], ); useEffect(() => { + /** + * + * @param content + * @param action + */ function append(content: string, action: "quote" | "mention") { const text = action === "quote" @@ -178,10 +167,10 @@ export default observer(({ channel }: Props) => { .join("\n")}\n\n` : `${content} `; - if (!draft || draft.length === 0) { + if (!state.draft.has(channel._id)) { setMessage(text); } else { - setMessage(`${draft}\n${text}`); + setMessage(`${state.draft.get(channel._id)}\n${text}`); } } @@ -190,13 +179,16 @@ export default observer(({ channel }: Props) => { "append", append as (...args: unknown[]) => void, ); - }, [draft, setMessage]); + }, [state.draft, channel._id, setMessage]); + /** + * Trigger send message. + */ async function send() { if (uploadState.type === "uploading" || uploadState.type === "sending") return; - const content = draft?.trim() ?? ""; + const content = state.draft.get(channel._id)?.trim() ?? ""; if (uploadState.type === "attached") return sendFile(content); if (content.length === 0) return; @@ -247,20 +239,15 @@ export default observer(({ channel }: Props) => { } } } else { - playSound("outbound"); + state.settings.sounds.playSound("outbound"); - dispatch({ - type: "QUEUE_ADD", - nonce, + state.queue.add(nonce, channel._id, { + _id: nonce, channel: channel._id, - message: { - _id: nonce, - channel: channel._id, - author: client.user!._id, + author: client.user!._id, - content, - replies, - }, + content, + replies, }); defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE)); @@ -272,15 +259,16 @@ export default observer(({ channel }: Props) => { replies, }); } catch (error) { - dispatch({ - type: "QUEUE_FAIL", - error: takeError(error), - nonce, - }); + state.queue.fail(nonce, takeError(error)); } } } + /** + * + * @param content + * @returns + */ async function sendFile(content: string) { if (uploadState.type !== "attached") return; const attachments: string[] = []; @@ -360,7 +348,7 @@ export default observer(({ channel }: Props) => { setMessage(); setReplies([]); - playSound("outbound"); + state.settings.sounds.playSound("outbound"); if (files.length > CAN_UPLOAD_AT_ONCE) { setUploadState({ @@ -372,6 +360,10 @@ export default observer(({ channel }: Props) => { } } + /** + * + * @returns + */ function startTyping() { if (typeof typing === "number" && +new Date() < typing) return; @@ -385,6 +377,10 @@ export default observer(({ channel }: Props) => { } } + /** + * + * @param force + */ function stopTyping(force?: boolean) { if (force || typing) { const ws = client.websocket; @@ -503,7 +499,7 @@ export default observer(({ channel }: Props) => { id="message" maxLength={2000} onKeyUp={onKeyUp} - value={draft ?? ""} + value={state.draft.get(channel._id) ?? ""} padding="var(--message-box-padding)" onKeyDown={(e) => { if (e.ctrlKey && e.key === "Enter") { @@ -515,7 +511,7 @@ export default observer(({ channel }: Props) => { if ( e.key === "ArrowUp" && - (!draft || draft.length === 0) + !state.draft.has(channel._id) ) { e.preventDefault(); internalEmit("MessageRenderer", "edit_last"); diff --git a/src/components/common/messaging/bars/ReplyBar.tsx b/src/components/common/messaging/bars/ReplyBar.tsx index a2d10452..0e853e25 100644 --- a/src/components/common/messaging/bars/ReplyBar.tsx +++ b/src/components/common/messaging/bars/ReplyBar.tsx @@ -10,8 +10,9 @@ import { StateUpdater, useEffect } from "preact/hooks"; import { internalSubscribe } from "../../../../lib/eventEmitter"; -import { dispatch, getState } from "../../../../redux"; -import { Reply } from "../../../../redux/reducers/queue"; +import { useApplicationState } from "../../../../mobx/State"; +import { SECTION_MENTION } from "../../../../mobx/stores/Layout"; +import { Reply } from "../../../../mobx/stores/MessageQueue"; import IconButton from "../../../ui/IconButton"; @@ -81,6 +82,7 @@ const Base = styled.div` const MAX_REPLIES = 5; export default observer(({ channel, replies, setReplies }: Props) => { const client = channel.client; + const layout = useApplicationState().layout; // Event listener for adding new messages to reply bar. useEffect(() => { @@ -99,7 +101,7 @@ export default observer(({ channel, replies, setReplies }: Props) => { mention: message.author_id === client.user!._id ? false - : getState().sectionToggle.mention ?? false, + : layout.getSectionState("SECTION_MENTION", false), }, ]); }); @@ -181,11 +183,11 @@ export default observer(({ channel, replies, setReplies }: Props) => { }), ); - dispatch({ - type: "SECTION_TOGGLE_SET", - id: "mention", + layout.setSectionState( + SECTION_MENTION, state, - }); + false, + ); }}> diff --git a/src/components/common/messaging/embed/EmbedInvite.tsx b/src/components/common/messaging/embed/EmbedInvite.tsx index 830c5695..165a6f7b 100644 --- a/src/components/common/messaging/embed/EmbedInvite.tsx +++ b/src/components/common/messaging/embed/EmbedInvite.tsx @@ -10,8 +10,6 @@ import { useContext, useEffect, useState } from "preact/hooks"; import { defer } from "../../../../lib/defer"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; -import { dispatch } from "../../../../redux"; - import { AppContext, ClientStatus, @@ -33,7 +31,7 @@ const EmbedInviteBase = styled.div` align-items: center; padding: 0 12px; margin-top: 2px; - ${() => + ${() => isTouchscreenDevice && css` flex-wrap: wrap; @@ -44,19 +42,17 @@ const EmbedInviteBase = styled.div` > button { width: 100%; } - ` - } + `} `; const EmbedInviteDetails = styled.div` flex-grow: 1; padding-left: 12px; - ${() => + ${() => isTouchscreenDevice && css` width: calc(100% - 55px); - ` - } + `} `; const EmbedInviteName = styled.div` @@ -74,11 +70,10 @@ type Props = { code: string; }; -export function EmbedInvite(props: Props) { +export function EmbedInvite({ code }: Props) { const history = useHistory(); const client = useContext(AppContext); const status = useContext(StatusContext); - const code = props.code; const [processing, setProcessing] = useState(false); const [error, setError] = useState(undefined); const [joinError, setJoinError] = useState(undefined); @@ -124,7 +119,8 @@ export function EmbedInvite(props: Props) { {invite.server_name} - {invite.member_count.toLocaleString()} {invite.member_count === 1 ? "member" : "members"} + {invite.member_count.toLocaleString()}{" "} + {invite.member_count === 1 ? "member" : "members"} {processing ? ( @@ -151,10 +147,9 @@ export function EmbedInvite(props: Props) { defer(() => { if (server) { - dispatch({ - type: "UNREADS_MARK_MULTIPLE_READ", - channels: server.channel_ids, - }); + client.unreads!.markMultipleRead( + server.channel_ids, + ); history.push( `/server/${server._id}/channel/${invite.channel_id}`, @@ -172,7 +167,9 @@ export function EmbedInvite(props: Props) { setProcessing(false); } }}> - {client.servers.get(invite.server_id) ? "Joined" : "Join"} + {client.servers.get(invite.server_id) + ? "Joined" + : "Join"} )} diff --git a/src/components/common/user/UserIcon.tsx b/src/components/common/user/UserIcon.tsx index 13a4911e..595a6760 100644 --- a/src/components/common/user/UserIcon.tsx +++ b/src/components/common/user/UserIcon.tsx @@ -5,12 +5,10 @@ import { useParams } from "react-router-dom"; import { Masquerade } from "revolt-api/types/Channels"; import { Presence } from "revolt-api/types/Users"; import { User } from "revolt.js/dist/maps/Users"; -import { Nullable } from "revolt.js/dist/util/null"; import styled, { css } from "styled-components"; -import { useContext } from "preact/hooks"; +import { useApplicationState } from "../../../mobx/State"; -import { ThemeContext } from "../../../context/Theme"; import { useClient } from "../../../context/revoltjs/RevoltClient"; import fallback from "../assets/user.png"; @@ -26,15 +24,15 @@ interface Props extends IconBaseProps { } export function useStatusColour(user?: User) { - const theme = useContext(ThemeContext); + const theme = useApplicationState().settings.theme; return user?.online && user?.status?.presence !== Presence.Invisible ? user?.status?.presence === Presence.Idle - ? theme["status-away"] + ? theme.getVariable("status-away") : user?.status?.presence === Presence.Busy - ? theme["status-busy"] - : theme["status-online"] - : theme["status-invisible"]; + ? theme.getVariable("status-busy") + : theme.getVariable("status-online") + : theme.getVariable("status-invisible"); } const VoiceIndicator = styled.div<{ status: VoiceStatus }>` diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 16ec584d..48d684a3 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -5,8 +5,7 @@ import styled, { css } from "styled-components"; import ConditionalLink from "../../lib/ConditionalLink"; -import { connectState } from "../../redux/connector"; -import { LastOpened } from "../../redux/reducers/last_opened"; +import { useApplicationState } from "../../mobx/State"; import { useClient } from "../../context/revoltjs/RevoltClient"; @@ -47,19 +46,14 @@ const Button = styled.a<{ active: boolean }>` `} `; -interface Props { - lastOpened: LastOpened; -} - -export const BottomNavigation = observer(({ lastOpened }: Props) => { +export default observer(() => { const client = useClient(); + const layout = useApplicationState().layout; const user = client.users.get(client.user!._id); const history = useHistory(); const path = useLocation().pathname; - const channel_id = lastOpened["home"]; - const friendsActive = path.startsWith("/friends"); const settingsActive = path.startsWith("/settings"); const homeActive = !(friendsActive || settingsActive); @@ -73,14 +67,11 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { if (settingsActive) { if (history.length > 0) { history.goBack(); + return; } } - if (channel_id) { - history.push(`/channel/${channel_id}`); - } else { - history.push("/"); - } + history.push(layout.getLastHomePath()); }}> @@ -117,9 +108,3 @@ export const BottomNavigation = observer(({ lastOpened }: Props) => { ); }); - -export default connectState(BottomNavigation, (state) => { - return { - lastOpened: state.lastOpened, - }; -}); diff --git a/src/components/navigation/LeftSidebar.tsx b/src/components/navigation/LeftSidebar.tsx index d9787a2e..309cb360 100644 --- a/src/components/navigation/LeftSidebar.tsx +++ b/src/components/navigation/LeftSidebar.tsx @@ -1,14 +1,16 @@ import { Route, Switch } from "react-router"; +import { useApplicationState } from "../../mobx/State"; +import { SIDEBAR_CHANNELS } from "../../mobx/stores/Layout"; + import SidebarBase from "./SidebarBase"; import HomeSidebar from "./left/HomeSidebar"; import ServerListSidebar from "./left/ServerListSidebar"; import ServerSidebar from "./left/ServerSidebar"; -import { useSelector } from "react-redux"; -import { State } from "../../redux"; export default function LeftSidebar() { - const isOpen = useSelector((state: State) => state.sectionToggle['sidebar_channels'] ?? true) + const layout = useApplicationState().layout; + const isOpen = layout.getSectionState(SIDEBAR_CHANNELS, true); return ( diff --git a/src/components/navigation/items/Item.module.scss b/src/components/navigation/items/Item.module.scss index a0148f7c..42bed90f 100644 --- a/src/components/navigation/items/Item.module.scss +++ b/src/components/navigation/items/Item.module.scss @@ -117,7 +117,7 @@ } &[data-muted="true"] { - color: var(--tertiary-foreground); + opacity: 0.4; } &[data-alert="true"], diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 8c66fc7c..d378fd75 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -5,7 +5,7 @@ import { Notepad, } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; -import { Link, Redirect, useLocation, useParams } from "react-router-dom"; +import { Link, useLocation, useParams } from "react-router-dom"; import { RelationshipStatus } from "revolt-api/types/Users"; import { Text } from "preact-i18n"; @@ -15,54 +15,38 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { dispatch } from "../../../redux"; -import { connectState } from "../../../redux/connector"; -import { Unreads } from "../../../redux/reducers/unreads"; +import { useApplicationState } from "../../../mobx/State"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { AppContext } from "../../../context/revoltjs/RevoltClient"; import Category from "../../ui/Category"; import placeholderSVG from "../items/placeholder.svg"; -import { mapChannelWithUnread, useUnreads } from "./common"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import ButtonItem, { ChannelButton } from "../items/ButtonItem"; import ConnectionStatus from "../items/ConnectionStatus"; -type Props = { - unreads: Unreads; -}; - -const HomeSidebar = observer((props: Props) => { +export default observer(() => { const { pathname } = useLocation(); const client = useContext(AppContext); - const { channel } = useParams<{ channel: string }>(); + const state = useApplicationState(); + const { channel: currentChannel } = useParams<{ channel: string }>(); const { openScreen } = useIntermediate(); - const channels = [...client.channels.values()] - .filter( - (x) => - x.channel_type === "DirectMessage" || - x.channel_type === "Group", - ) - .map((x) => mapChannelWithUnread(x, props.unreads)); + const channels = [...client.channels.values()].filter( + (x) => x.channel_type === "DirectMessage" || x.channel_type === "Group", + ); - const obj = client.channels.get(channel); - if (channel && !obj) return ; - if (obj) useUnreads({ ...props, channel: obj }); + const obj = client.channels.get(currentChannel); - useEffect(() => { - if (!channel) return; + // ! FIXME: move this globally + // Track what page the user was last on (in home page). + useEffect(() => state.layout.setLastHomePath(pathname), [pathname]); - dispatch({ - type: "LAST_OPENED_SET", - parent: "home", - child: channel, - }); - }, [channel]); - - channels.sort((b, a) => a.timestamp.localeCompare(b.timestamp)); + channels.sort((b, a) => + a.last_message_id_or_past.localeCompare(b.last_message_id_or_past), + ); return ( @@ -132,31 +116,37 @@ const HomeSidebar = observer((props: Props) => { {channels.length === 0 && ( )} - {channels.map((x) => { + {channels.map((channel) => { let user; - if (x.channel.channel_type === "DirectMessage") { - if (!x.channel.active) return null; - user = x.channel.recipient; + if (channel.channel_type === "DirectMessage") { + if (!channel.active) return null; + user = channel.recipient; - if (!user) { - console.warn( - `Skipped DM ${x.channel._id} because user was missing.`, - ); - return null; - } + if (!user) return null; } + const isUnread = channel.isUnread(state.notifications); + const mentionCount = channel.getMentions( + state.notifications, + ).length; + return ( + key={channel._id} + active={channel._id === currentChannel} + to={`/channel/${channel._id}`}> 0 + ? "mention" + : isUnread + ? "unread" + : undefined + } + alertCount={mentionCount} + active={channel._id === currentChannel} /> ); @@ -166,13 +156,3 @@ const HomeSidebar = observer((props: Props) => { ); }); - -export default connectState( - HomeSidebar, - (state) => { - return { - unreads: state.unreads, - }; - }, - true, -); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 36470250..40689779 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -12,9 +12,7 @@ import ConditionalLink from "../../../lib/ConditionalLink"; import PaintCounter from "../../../lib/PaintCounter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { connectState } from "../../../redux/connector"; -import { LastOpened } from "../../../redux/reducers/last_opened"; -import { Unreads } from "../../../redux/reducers/unreads"; +import { useApplicationState } from "../../../mobx/State"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useClient } from "../../../context/revoltjs/RevoltClient"; @@ -25,7 +23,6 @@ import UserHover from "../../common/user/UserHover"; import UserIcon from "../../common/user/UserIcon"; import IconButton from "../../ui/IconButton"; import LineDivider from "../../ui/LineDivider"; -import { mapChannelWithUnread } from "./common"; import { Children } from "../../../types/Preact"; @@ -195,46 +192,14 @@ function Swoosh() { ); } -interface Props { - unreads: Unreads; - lastOpened: LastOpened; -} - -export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { +export default observer(() => { const client = useClient(); + const state = useApplicationState(); const { server: server_id } = useParams<{ server?: string }>(); const server = server_id ? client.servers.get(server_id) : undefined; - const activeServers = [...client.servers.values()]; - const channels = [...client.channels.values()].map((x) => - mapChannelWithUnread(x, unreads), - ); - - const unreadChannels = channels - .filter((x) => x.unread) - .map((x) => x.channel?._id); - - const servers = activeServers.map((server) => { - let alertCount = 0; - for (const id of server.channel_ids) { - const channel = channels.find((x) => x.channel?._id === id); - if (channel?.alertCount) { - alertCount += channel.alertCount; - } - } - - return { - server, - unread: (typeof server.channel_ids.find((x) => - unreadChannels.includes(x), - ) !== "undefined" - ? alertCount > 0 - ? "mention" - : "unread" - : undefined) as "mention" | "unread" | undefined, - alertCount, - }; - }); + const servers = [...client.servers.values()]; + const channels = [...client.channels.values()]; const history = useHistory(); const path = useLocation().pathname; @@ -242,16 +207,16 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { let homeUnread: "mention" | "unread" | undefined; let alertCount = 0; - for (const x of channels) { - if (x.channel?.channel_type === "Group" && x.unread) { + for (const channel of channels) { + if (channel?.channel_type === "Group" && channel.unread) { homeUnread = "unread"; - alertCount += x.alertCount ?? 0; + alertCount += channel.mentions.length; } if ( - x.channel?.channel_type === "DirectMessage" && - x.channel.active && - x.unread + channel.channel_type === "DirectMessage" && + channel.active && + channel.unread ) { alertCount++; } @@ -270,7 +235,7 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { + to={state.layout.getLastHomePath()}>
{ onClick={() => homeActive && history.push("/settings") }> - + { - {servers.map((entry) => { - const active = entry.server._id === server?._id; - const id = lastOpened[entry.server._id]; + {servers.map((server) => { + const active = server._id === server_id; + + const isUnread = server.isUnread(state.notifications); + const mentionCount = server.getMentions( + state.notifications, + ).length; return ( + to={state.layout.getServerPath(server._id)}> - + unread={ + mentionCount > 0 + ? "mention" + : isUnread + ? "unread" + : undefined + } + count={mentionCount}> + @@ -357,10 +327,3 @@ export const ServerListSidebar = observer(({ unreads, lastOpened }: Props) => { ); }); - -export default connectState(ServerListSidebar, (state) => { - return { - unreads: state.unreads, - lastOpened: state.lastOpened, - }; -}); diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 5cceba82..6481caf8 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -10,26 +10,17 @@ import PaintCounter from "../../../lib/PaintCounter"; import { internalEmit } from "../../../lib/eventEmitter"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { dispatch } from "../../../redux"; -import { connectState } from "../../../redux/connector"; -import { Notifications } from "../../../redux/reducers/notifications"; -import { Unreads } from "../../../redux/reducers/unreads"; +import { useApplicationState } from "../../../mobx/State"; import { useClient } from "../../../context/revoltjs/RevoltClient"; import CollapsibleSection from "../../common/CollapsibleSection"; import ServerHeader from "../../common/ServerHeader"; import Category from "../../ui/Category"; -import { mapChannelWithUnread, useUnreads } from "./common"; import { ChannelButton } from "../items/ButtonItem"; import ConnectionStatus from "../items/ConnectionStatus"; -interface Props { - unreads: Unreads; - notifications: Notifications; -} - const ServerBase = styled.div` height: 100%; width: 232px; @@ -57,8 +48,9 @@ const ServerList = styled.div` } `; -const ServerSidebar = observer((props: Props) => { +export default observer(() => { const client = useClient(); + const state = useApplicationState(); const { server: server_id, channel: channel_id } = useParams<{ server: string; channel?: string }>(); @@ -76,16 +68,13 @@ const ServerSidebar = observer((props: Props) => { ); if (channel_id && !channel) return ; - if (channel) useUnreads({ ...props, channel }); - + // ! FIXME: move this globally + // Track which channel the user was last on. useEffect(() => { if (!channel_id) return; + if (!server_id) return; - dispatch({ - type: "LAST_OPENED_SET", - parent: server_id!, - child: channel_id!, - }); + state.layout.setLastOpened(server_id, channel_id); }, [channel_id, server_id]); const uncategorised = new Set(server.channel_ids); @@ -96,7 +85,8 @@ const ServerSidebar = observer((props: Props) => { if (!entry) return; const active = channel?._id === entry._id; - const muted = props.notifications[id] === "none"; + const isUnread = entry.isUnread(state.notifications); + const mentionCount = entry.getMentions(state.notifications); return ( { 0 + ? "mention" + : isUnread + ? "unread" + : undefined + } compact - muted={muted} + muted={state.notifications.isMuted(entry)} /> ); @@ -163,10 +158,3 @@ const ServerSidebar = observer((props: Props) => { ); }); - -export default connectState(ServerSidebar, (state) => { - return { - unreads: state.unreads, - notifications: state.notifications, - }; -}); diff --git a/src/components/navigation/left/common.ts b/src/components/navigation/left/common.ts deleted file mode 100644 index 774934f4..00000000 --- a/src/components/navigation/left/common.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/components/settings/AppearanceShims.tsx b/src/components/settings/AppearanceShims.tsx new file mode 100644 index 00000000..e465462e --- /dev/null +++ b/src/components/settings/AppearanceShims.tsx @@ -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 ( + { + 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 ( + + } action="chevron" hover> + + + + ); +}; + +/** + * Component providing a way to change current accent colour. + */ +export const ThemeAccentShim = observer(() => { + const theme = useApplicationState().settings.theme; + return ( + <> +

+ +

+ { + 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 ( + <> +

+ +

+ theme.setCSS(ev.currentTarget.value)} + /> + + ); +}); + +/** + * Component providing a way to switch between compact and normal message view. + */ +export const DisplayCompactShim = () => { + // TODO: WIP feature + return ( + <> +

+ +

+
+ + } + checked> + + + + } + disabled> + + +
+ + ); +}; + +/** + * Component providing a way to change primary text font. + */ +export const DisplayFontShim = observer(() => { + const theme = useApplicationState().settings.theme; + return ( + <> +

+ +

+ theme.setFont(e.currentTarget.value as Fonts)}> + {FONT_KEYS.map((key) => ( + + ))} + + + ); +}); + +/** + * Component providing a way to change secondary, monospace text font. + */ +export const DisplayMonospaceFontShim = observer(() => { + const theme = useApplicationState().settings.theme; + return ( + <> +

+ +

+ + theme.setMonospaceFont( + e.currentTarget.value as MonospaceFonts, + ) + }> + {MONOSPACE_FONT_KEYS.map((key) => ( + + ))} + + + ); +}); + +/** + * Component providing a way to toggle font ligatures. + */ +export const DisplayLigaturesShim = observer(() => { + const settings = useApplicationState().settings; + if (settings.theme.getFont() !== "Inter") return null; + + return ( +

+ settings.set("appearance:ligatures", v)} + description={ + + }> + + +

+ ); +}); + +/** + * Component providing a way to change emoji pack. + */ +export const DisplayEmojiShim = observer(() => { + const settings = useApplicationState().settings; + return ( + settings.set("appearance:emoji", v)} + /> + ); +}); diff --git a/src/components/settings/appearance/EmojiSelector.tsx b/src/components/settings/appearance/EmojiSelector.tsx new file mode 100644 index 00000000..4b54cf76 --- /dev/null +++ b/src/components/settings/appearance/EmojiSelector.tsx @@ -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 ( + <> +

+ +

+ +
+
+
setValue("mutant")} + data-active={!value || value === "mutant"}> + e.preventDefault()} + /> +
+

+ Mutant Remix{" "} + + (by Revolt) + +

+
+
+
setValue("twemoji")} + data-active={value === "twemoji"}> + e.preventDefault()} + /> +
+

Twemoji

+
+
+
+
+
setValue("openmoji")} + data-active={value === "openmoji"}> + e.preventDefault()} + /> +
+

Openmoji

+
+
+
setValue("noto")} + data-active={value === "noto"}> + e.preventDefault()} + /> +
+

Noto Emoji

+
+
+
+ + ); +} diff --git a/src/components/settings/appearance/ThemeBaseSelector.tsx b/src/components/settings/appearance/ThemeBaseSelector.tsx new file mode 100644 index 00000000..17702588 --- /dev/null +++ b/src/components/settings/appearance/ThemeBaseSelector.tsx @@ -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 ( + <> +

+ +

+ +
+ setValue("light")} + onContextMenu={(e) => e.preventDefault()} + /> +

+ +

+
+
+ setValue("dark")} + onContextMenu={(e) => e.preventDefault()} + /> +

+ +

+
+
+ + ); +} diff --git a/src/components/settings/appearance/ThemeOverrides.tsx b/src/components/settings/appearance/ThemeOverrides.tsx new file mode 100644 index 00000000..aaee68b8 --- /dev/null +++ b/src/components/settings/appearance/ThemeOverrides.tsx @@ -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 ( + + {( + [ + "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) => ( +
+
+ + setVariable({ + key, + value: el.currentTarget.value, + }) + } + /> +
+ + {key} + +
+
+ e.currentTarget.parentElement?.parentElement + ?.querySelector("input") + ?.click() + }> + +
+ + setVariable({ + key, + value: el.currentTarget.value, + }) + } + /> +
+
+ ))} +
+ ); +}); + +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"; +} diff --git a/src/components/settings/appearance/ThemeTools.tsx b/src/components/settings/appearance/ThemeTools.tsx new file mode 100644 index 00000000..2c503a20 --- /dev/null +++ b/src/components/settings/appearance/ThemeTools.tsx @@ -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 ( + + + }> + + +
writeClipboard(JSON.stringify(theme))}> + }> + {" "} + {JSON.stringify(theme)} + +
+ }> + + +
+ ); +} diff --git a/src/pages/settings/assets/dark.svg b/src/components/settings/appearance/dark.svg similarity index 100% rename from src/pages/settings/assets/dark.svg rename to src/components/settings/appearance/dark.svg diff --git a/src/pages/settings/assets/light.svg b/src/components/settings/appearance/light.svg similarity index 100% rename from src/pages/settings/assets/light.svg rename to src/components/settings/appearance/light.svg diff --git a/src/pages/settings/assets/mutant_emoji.svg b/src/components/settings/appearance/mutant_emoji.svg similarity index 100% rename from src/pages/settings/assets/mutant_emoji.svg rename to src/components/settings/appearance/mutant_emoji.svg diff --git a/src/pages/settings/assets/noto_emoji.svg b/src/components/settings/appearance/noto_emoji.svg similarity index 100% rename from src/pages/settings/assets/noto_emoji.svg rename to src/components/settings/appearance/noto_emoji.svg diff --git a/src/pages/settings/assets/openmoji_emoji.svg b/src/components/settings/appearance/openmoji_emoji.svg similarity index 100% rename from src/pages/settings/assets/openmoji_emoji.svg rename to src/components/settings/appearance/openmoji_emoji.svg diff --git a/src/pages/settings/assets/twemoji_emoji.svg b/src/components/settings/appearance/twemoji_emoji.svg similarity index 100% rename from src/pages/settings/assets/twemoji_emoji.svg rename to src/components/settings/appearance/twemoji_emoji.svg diff --git a/src/components/ui/ColourSwatches.tsx b/src/components/ui/ColourSwatches.tsx index f35523e4..4ff13e7a 100644 --- a/src/components/ui/ColourSwatches.tsx +++ b/src/components/ui/ColourSwatches.tsx @@ -5,6 +5,8 @@ import styled, { css } from "styled-components"; import { RefObject } from "preact"; import { useRef } from "preact/hooks"; +import { useDebounceCallback } from "../../lib/debounce"; + interface Props { value: string; onChange: (value: string) => void; @@ -115,6 +117,11 @@ const Rows = styled.div` export default function ColourSwatches({ value, onChange }: Props) { const ref = useRef() as RefObject; + const setValue = useDebounceCallback( + (value) => onChange(value as string), + [onChange], + 100, + ); return ( @@ -122,7 +129,7 @@ export default function ColourSwatches({ value, onChange }: Props) { type="color" value={value} ref={ref} - onChange={(ev) => onChange(ev.currentTarget.value)} + onChange={(ev) => setValue(ev.currentTarget.value)} /> { setAnimateClose(true); - setTimeout(() => props.onClose?.(), 2e2); + setTimeout(() => props.onClose!(), 2e2); }, [setAnimateClose, props]); useEffect(() => internalSubscribe("Modal", "close", onClose), [onClose]); diff --git a/src/components/ui/Radio.tsx b/src/components/ui/Radio.tsx index a9418d88..8f4dbb6c 100644 --- a/src/components/ui/Radio.tsx +++ b/src/components/ui/Radio.tsx @@ -7,9 +7,9 @@ interface Props { children: Children; description?: Children; - checked: boolean; + checked?: boolean; disabled?: boolean; - onSelect: () => void; + onSelect?: () => void; } interface BaseProps { @@ -87,9 +87,10 @@ const RadioDescription = styled.span` `; export default function Radio(props: Props) { + const selected = props.checked ?? false; return ( !props.disabled && props.onSelect && props.onSelect() @@ -101,7 +102,7 @@ export default function Radio(props: Props) { {props.children} {props.description && ( - + {props.description} )} diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index fe5504f3..1eb409ef 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar"; import format from "dayjs/plugin/localizedFormat"; import update from "dayjs/plugin/updateLocale"; import defaultsDeep from "lodash.defaultsdeep"; +import { observer } from "mobx-react-lite"; import { IntlProvider } from "preact-i18n"; import { useCallback, useEffect, useState } from "preact/hooks"; -import { connectState } from "../redux/connector"; +import { useApplicationState } from "../mobx/State"; import definition from "../../external/lang/en.json"; @@ -204,7 +205,6 @@ export const Languages: { [key in Language]: LanguageEntry } = { interface Props { children: JSX.Element | JSX.Element[]; - locale: Language; } export interface Dictionary { @@ -222,59 +222,14 @@ export interface Dictionary { | undefined; } -function Locale({ children, locale }: Props) { - const [defns, setDefinition] = useState( +export default observer(({ children }: Props) => { + const locale = useApplicationState().locale; + const [definitions, setDefinition] = useState( definition as Dictionary, ); - // Load relevant language information, fallback to English if invalid. - const lang = Languages[locale] ?? Languages.en; - - function transformLanguage(source: Dictionary) { - // Fallback untranslated strings to English (UK) - const obj = defaultsDeep(source, definition); - - // Take relevant objects out, dayjs and defaults - // should exist given we just took defaults above. - const { dayjs } = obj; - const { defaults } = dayjs; - - // Determine whether we are using 12-hour clock. - const twelvehour = defaults?.twelvehour - ? defaults.twelvehour === "yes" - : false; - - // Determine what date separator we are using. - const separator: string = defaults?.date_separator ?? "/"; - - // Determine what date format we are using. - const date: "traditional" | "simplified" | "ISO8601" = - defaults?.date_format ?? "traditional"; - - // Available date formats. - const DATE_FORMATS = { - traditional: `DD${separator}MM${separator}YYYY`, - simplified: `MM${separator}DD${separator}YYYY`, - ISO8601: "YYYY-MM-DD", - }; - - // Replace data in dayjs object, make sure to provide fallbacks. - dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional; - dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm"; - - // Replace {{time}} format string in dayjs strings with the time format. - Object.keys(dayjs) - .filter((k) => typeof dayjs[k] === "string") - .forEach( - (k) => - (dayjs[k] = dayjs[k].replace( - /{{time}}/g, - dayjs["timeFormat"], - )), - ); - - return obj; - } + const lang = locale.getLanguage(); + const source = Languages[lang]; const loadLanguage = useCallback( (locale: string) => { @@ -288,13 +243,13 @@ function Locale({ children, locale }: Props) { return; } - import(`../../external/lang/${lang.i18n}.json`).then( + import(`../../external/lang/${source.i18n}.json`).then( async (lang_file) => { // Transform the definitions data. const defn = transformLanguage(lang_file.default); // Determine and load dayjs locales. - const target = lang.dayjs ?? lang.i18n; + const target = source.dayjs ?? source.i18n; const dayjs_locale = await import( `../../node_modules/dayjs/esm/locale/${target}.js` ); @@ -312,25 +267,63 @@ function Locale({ children, locale }: Props) { }, ); }, - [lang.dayjs, lang.i18n], + [source.dayjs, source.i18n], ); - useEffect(() => loadLanguage(locale), [locale, lang, loadLanguage]); + useEffect(() => loadLanguage(lang), [lang, source, loadLanguage]); useEffect(() => { // Apply RTL language format. - document.body.style.direction = lang.rtl ? "rtl" : ""; - }, [lang.rtl]); + document.body.style.direction = source.rtl ? "rtl" : ""; + }, [source.rtl]); - return {children}; + return {children}; +}); + +/** + * 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>( - Locale, - (state) => { - return { - locale: state.locale, - }; - }, - true, -); diff --git a/src/context/Settings.tsx b/src/context/Settings.tsx deleted file mode 100644 index 78e268cc..00000000 --- a/src/context/Settings.tsx +++ /dev/null @@ -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({}); -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 ( - - - {children} - - - ); -} - -export default connectState>( - SettingsProvider, - (state) => { - return { - settings: state.settings, - }; - }, -); diff --git a/src/context/Theme.tsx b/src/context/Theme.tsx index 49b527f0..2c69ac9f 100644 --- a/src/context/Theme.tsx +++ b/src/context/Theme.tsx @@ -1,14 +1,10 @@ +import { observer } from "mobx-react-lite"; import { Helmet } from "react-helmet"; import { createGlobalStyle } from "styled-components"; -import { createContext } from "preact"; import { useEffect } from "preact/hooks"; -import { connectState } from "../redux/connector"; - -import { Children } from "../types/Preact"; -import { fetchManifest, fetchTheme } from "../pages/settings/panes/ThemeShop"; -import { getState } from "../redux"; +import { useApplicationState } from "../mobx/State"; export type Variables = | "accent" @@ -57,6 +53,7 @@ export type Fonts = | "Raleway" | "Ubuntu" | "Comic Neue"; + export type MonospaceFonts = | "Fira Code" | "Roboto Mono" @@ -65,9 +62,11 @@ export type MonospaceFonts = | "Ubuntu Mono" | "JetBrains Mono"; -export type Theme = { +export type Overrides = { [variable in Variables]: string; -} & { +}; + +export type Theme = Overrides & { light?: boolean; font?: Fonts; css?: string; @@ -227,7 +226,6 @@ export const DEFAULT_MONO_FONT = "Fira Code"; // Generated from https://gitlab.insrt.uk/revolt/community/themes export const PRESETS: Record = { light: { - light: true, accent: "#FD6671", background: "#F6F6F6", foreground: "#000000", @@ -254,7 +252,6 @@ export const PRESETS: Record = { "status-invisible": "#A5A5A5", }, dark: { - light: false, accent: "#FD6671", background: "#191919", foreground: "#F6F6F6", @@ -282,28 +279,6 @@ export const PRESETS: Record = { }, }; -// todo: store used themes locally -export function getBaseTheme(name: string): Theme { - if (name in PRESETS) { - return PRESETS[name] - } - - // TODO: properly initialize `themes` in state instead of letting it be undefined - const themes = getState().themes ?? {} - - if (name in themes) { - const { theme } = themes[name]; - - return { - ...PRESETS[theme.light ? 'light' : 'dark'], - ...theme - } - } - - // how did we get here - return PRESETS['dark'] -} - const keys = Object.keys(PRESETS.dark); const GlobalTheme = createGlobalStyle<{ theme: Theme }>` :root { @@ -315,39 +290,32 @@ export const generateVariables = (theme: Theme) => { return (Object.keys(theme) as Variables[]).map((key) => { if (!keys.includes(key)) return; return `--${key}: ${theme[key]};`; - }) -} + }); +}; -// Load the default default them and apply extras later -export const ThemeContext = createContext(PRESETS["dark"]); - -interface Props { - children: Children; - options?: ThemeOptions; -} - -function Theme({ children, options }: Props) { - const theme: Theme = { - ...getBaseTheme(options?.base ?? 'dark'), - ...options?.custom, - }; +export default observer(() => { + const settings = useApplicationState().settings; + const theme = settings.theme; const root = document.documentElement.style; useEffect(() => { - const font = theme.font ?? DEFAULT_FONT; + const font = theme.getFont() ?? DEFAULT_FONT; root.setProperty("--font", `"${font}"`); FONTS[font].load(); - }, [root, theme.font]); + }, [root, theme.getFont()]); useEffect(() => { - const font = theme.monospaceFont ?? DEFAULT_MONO_FONT; + const font = theme.getMonospaceFont() ?? DEFAULT_MONO_FONT; root.setProperty("--monospace-font", `"${font}"`); MONOSPACE_FONTS[font].load(); - }, [root, theme.monospaceFont]); + }, [root, theme.getMonospaceFont()]); useEffect(() => { - root.setProperty("--ligatures", options?.ligatures ? "normal" : "none"); - }, [root, options?.ligatures]); + root.setProperty( + "--ligatures", + settings.get("appearance:ligatures") ? "normal" : "none", + ); + }, [root, settings.get("appearance:ligatures")]); useEffect(() => { const resize = () => @@ -358,22 +326,14 @@ function Theme({ children, options }: Props) { return () => window.removeEventListener("resize", resize); }, [root]); + const variables = theme.getVariables(); return ( - + <> - + - - {theme.css && ( -