From 8f476839eb55ceda9a0f72f638e68546ad1a2b1a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 16 Nov 2024 15:55:31 +0200 Subject: [PATCH] web: add preference system --- web/index.html | 2 - web/src/App.tsx | 6 +- web/src/api/statestore/hooks.ts | 12 ++ web/src/api/statestore/main.ts | 10 +- web/src/api/statestore/room.ts | 20 +++- web/src/api/types/preferences/index.ts | 4 + web/src/api/types/preferences/localstorage.ts | 64 +++++++++++ web/src/api/types/preferences/preferences.ts | 88 +++++++++++++++ web/src/api/types/preferences/proxy.ts | 53 +++++++++ web/src/api/types/preferences/types.ts | 75 +++++++++++++ web/src/index.css | 1 - web/src/ui/MainScreen.tsx | 9 +- web/src/ui/StylePreferences.tsx | 104 ++++++++++++++++++ web/src/ui/modal/Lightbox.tsx | 6 +- web/src/ui/timeline/content/index.css | 2 - web/src/util/subscribable.ts | 11 ++ web/src/vite-env.d.ts | 2 + 17 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 web/src/api/types/preferences/index.ts create mode 100644 web/src/api/types/preferences/localstorage.ts create mode 100644 web/src/api/types/preferences/preferences.ts create mode 100644 web/src/api/types/preferences/proxy.ts create mode 100644 web/src/api/types/preferences/types.ts create mode 100644 web/src/ui/StylePreferences.tsx diff --git a/web/index.html b/web/index.html index f83c850..07cd4b7 100644 --- a/web/index.html +++ b/web/index.html @@ -3,8 +3,6 @@ - - gomuks web diff --git a/web/src/App.tsx b/web/src/App.tsx index b5abe43..21f8af9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useEffect, useMemo } from "react" +import { useEffect, useLayoutEffect, useMemo } from "react" import { ScaleLoader } from "react-spinners" import Client from "./api/client.ts" import WSClient from "./api/wsclient.ts" @@ -25,9 +25,11 @@ import { useEventAsState } from "./util/eventdispatcher.ts" function App() { const client = useMemo(() => new Client(new WSClient("_gomuks/websocket")), []) - window.client = client const connState = useEventAsState(client.rpc.connect) const clientState = useEventAsState(client.state) + useLayoutEffect(() => { + window.client = client + }, [client]) useEffect(() => client.start(), [client]) if (connState?.error) { diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 76f5e1e..ce6145f 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -60,6 +60,18 @@ export function useAccountData(ss: StateStore, type: EventType): UnknownEventCon ) } +export function useRoomAccountData(room: RoomStateStore | null, type: EventType): UnknownEventContent | null { + return useSyncExternalStore( + room ? room.accountDataSubs.getSubscriber(type) : noopSubscribe, + () => room?.accountData.get(type) ?? null, + ) +} + +export function usePreferences(ss: StateStore, room: RoomStateStore | null) { + useSyncExternalStore(ss.preferenceSub.subscribe, ss.preferenceSub.getData) + useSyncExternalStore(room?.preferenceSub.subscribe ?? noopSubscribe, room?.preferenceSub.getData ?? returnNull) +} + export function useCustomEmojis( ss: StateStore, room: RoomStateStore, ): CustomEmojiPack[] { diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index d2453c4..6b1d22b 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -14,11 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { getAvatarURL } from "@/api/media.ts" +import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences" import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { focused } from "@/util/focus.ts" import toSearchableString from "@/util/searchablestring.ts" -import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts" +import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" import { ContentURI, EventRowID, @@ -58,10 +59,14 @@ export class StateStore { readonly accountData: Map = new Map() readonly accountDataSubs = new MultiSubscribable() readonly emojiRoomsSub = new Subscribable() + readonly preferences: Preferences = getPreferenceProxy(this) #frequentlyUsedEmoji: Map | null = null #emojiPackKeys: RoomStateGUID[] | null = null #watchedRoomEmojiPacks: Record | null = null #personalEmojiPack: CustomEmojiPack | null = null + readonly preferenceSub = new NoDataSubscribable() + readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify) + serverPreferenceCache: Preferences = {} switchRoom?: (roomID: RoomID | null) => void activeRoomID?: RoomID imageAuthToken?: string @@ -163,6 +168,9 @@ export class StateStore { for (const ad of Object.values(sync.account_data)) { if (ad.type === "io.element.recent_emoji") { this.#frequentlyUsedEmoji = null + } else if (ad.type === "fi.mau.gomuks.preferences") { + this.serverPreferenceCache = ad.content + this.preferenceSub.notify() } this.accountData.set(ad.type, ad.content) this.accountDataSubs.notify(ad.type) diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 612a42a..893b145 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -13,10 +13,11 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences" import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import toSearchableString from "@/util/searchablestring.ts" -import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts" +import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" import { getDisplayname } from "@/util/validation.ts" import { ContentURI, @@ -35,6 +36,7 @@ import { RoomID, SyncRoom, TimelineRowTuple, + UnknownEventContent, UserID, roomStateGUIDToString, } from "../types" @@ -93,8 +95,14 @@ export class RoomStateStore { readonly eventSubs = new MultiSubscribable() readonly requestedEvents: Set = new Set() readonly requestedMembers: Set = new Set() + readonly accountData: Map = new Map() + readonly accountDataSubs = new MultiSubscribable() readonly openNotifications: Map = new Map() readonly emojiPacks: Map = new Map() + readonly preferences: Preferences + readonly localPreferenceCache: Preferences + readonly preferenceSub = new NoDataSubscribable() + serverPreferenceCache: Preferences = {} #membersCache: MemDBEvent[] | null = null #autocompleteMembersCache: AutocompleteMemberEntry[] | null = null membersRequested: boolean = false @@ -107,6 +115,8 @@ export class RoomStateStore { constructor(meta: DBRoom, private parent: StateStore) { this.roomID = meta.room_id this.meta = new NonNullCachedEventDispatcher(meta) + this.localPreferenceCache = getLocalStoragePreferences(`prefs-${this.roomID}`, this.preferenceSub.notify) + this.preferences = getPreferenceProxy(parent, this) } notifyTimelineSubscribers() { @@ -313,6 +323,14 @@ export class RoomStateStore { } else { this.meta.emit(sync.meta) } + for (const ad of Object.values(sync.account_data)) { + if (ad.type === "fi.mau.gomuks.preferences") { + this.serverPreferenceCache = ad.content + this.preferenceSub.notify() + } + this.accountData.set(ad.type, ad.content) + this.accountDataSubs.notify(ad.type) + } for (const evt of sync.events) { this.applyEvent(evt) } diff --git a/web/src/api/types/preferences/index.ts b/web/src/api/types/preferences/index.ts new file mode 100644 index 0000000..096e5a0 --- /dev/null +++ b/web/src/api/types/preferences/index.ts @@ -0,0 +1,4 @@ +export * from "./types.ts" +export * from "./preferences.ts" +export * from "./proxy.ts" +export * from "./localstorage.ts" diff --git a/web/src/api/types/preferences/localstorage.ts b/web/src/api/types/preferences/localstorage.ts new file mode 100644 index 0000000..c74c984 --- /dev/null +++ b/web/src/api/types/preferences/localstorage.ts @@ -0,0 +1,64 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { Preferences, existingPreferenceKeys, preferences } from "./preferences.ts" +import { PreferenceContext } from "./types.ts" + +function getObjectFromLocalStorage(key: string): Preferences { + const localStorageVal = localStorage.getItem(key) + if (localStorageVal) { + try { + return JSON.parse(localStorageVal) + } catch { + return {} + } + } + return {} +} + +export function getLocalStoragePreferences(localStorageKey: string, onChange: () => void): Preferences { + return new Proxy(getObjectFromLocalStorage(localStorageKey), { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(target: Preferences, key: keyof Preferences, newValue: any): boolean { + if (!existingPreferenceKeys.has(key)) { + return false + } + target[key] = newValue + localStorage.setItem(localStorageKey, JSON.stringify(target)) + onChange() + return true + }, + deleteProperty(target: Preferences, key: keyof Preferences): boolean { + if (!existingPreferenceKeys.has(key)) { + return false + } + delete target[key] + if (Object.keys(target).length === 0) { + localStorage.removeItem(localStorageKey) + } + onChange() + return true + }, + ownKeys(): string[] { + console.warn("localStorage preference proxy ownKeys called") + // This is only for debugging, so the performance doesn't matter that much + return Object.entries(preferences) + .filter(([,pref]) => + pref.allowedContexts.includes(localStorageKey === "global_prefs" + ? PreferenceContext.Device : PreferenceContext.RoomDevice)) + .map(([key]) => key) + }, + }) +} diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts new file mode 100644 index 0000000..3e4d2de --- /dev/null +++ b/web/src/api/types/preferences/preferences.ts @@ -0,0 +1,88 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { Preference, anyContext } from "./types.ts" + +export const codeBlockStyles = [ + "auto", "abap", "algol_nu", "algol", "arduino", "autumn", "average", "base16-snazzy", "borland", "bw", + "catppuccin-frappe", "catppuccin-latte", "catppuccin-macchiato", "catppuccin-mocha", "colorful", "doom-one2", + "doom-one", "dracula", "emacs", "friendly", "fruity", "github-dark", "github", "gruvbox-light", "gruvbox", + "hrdark", "hr_high_contrast", "igor", "lovelace", "manni", "modus-operandi", "modus-vivendi", "monokailight", + "monokai", "murphy", "native", "nord", "onedark", "onesenterprise", "paraiso-dark", "paraiso-light", "pastie", + "perldoc", "pygments", "rainbow_dash", "rose-pine-dawn", "rose-pine-moon", "rose-pine", "rrt", "solarized-dark256", + "solarized-dark", "solarized-light", "swapoff", "tango", "tokyonight-day", "tokyonight-moon", "tokyonight-night", + "tokyonight-storm", "trac", "vim", "vs", "vulcan", "witchhazel", "xcode-dark", "xcode", +] as const + +export type CodeBlockStyle = typeof codeBlockStyles[number] + +/* eslint-disable max-len */ +export const preferences = { + send_read_receipts: new Preference({ + displayName: "Send read receipts", + description: "Should read receipts be sent to other users? If disabled, read receipts will use the `m.read.private` type, which only syncs to your own devices.", + allowedContexts: anyContext, + defaultValue: true, + }), + send_typing_notifications: new Preference({ + displayName: "Send typing notifications", + description: "Should typing notifications be sent to other users?", + allowedContexts: anyContext, + defaultValue: true, + }), + code_block_line_wrap: new Preference({ + displayName: "Code block line wrap", + description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.", + allowedContexts: anyContext, + defaultValue: false, + }), + code_block_theme: new Preference({ + displayName: "Code block theme", + description: "The syntax highlighting theme to use for code blocks.", + allowedContexts: anyContext, + defaultValue: "auto", + allowedValues: codeBlockStyles, + }), + pointer_cursor: new Preference({ + displayName: "Pointer cursor", + description: "Whether to use a pointer cursor for clickable elements.", + allowedContexts: anyContext, + defaultValue: false, + }), + custom_css: new Preference({ + displayName: "Custom CSS", + description: "Arbitrary custom CSS to apply to the client.", + allowedContexts: anyContext, + defaultValue: "", + }), + show_hidden_events: new Preference({ + displayName: "Show hidden events", + description: "Whether hidden events should be visible in the room timeline.", + allowedContexts: anyContext, + defaultValue: true, + }), + show_room_emoji_packs: new Preference({ + displayName: "Show room emoji packs", + description: "Whether to show custom emoji packs provided by the room. If disabled, only your personal packs are shown in all rooms.", + allowedContexts: anyContext, + defaultValue: true, + }), +} as const + +export const existingPreferenceKeys = new Set(Object.keys(preferences)) + +export type Preferences = { + -readonly [name in keyof typeof preferences]?: typeof preferences[name]["defaultValue"] +} diff --git a/web/src/api/types/preferences/proxy.ts b/web/src/api/types/preferences/proxy.ts new file mode 100644 index 0000000..97818bd --- /dev/null +++ b/web/src/api/types/preferences/proxy.ts @@ -0,0 +1,53 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { RoomStateStore, StateStore } from "@/api/statestore" +import { Preferences, existingPreferenceKeys, preferences } from "./preferences.ts" +import { PreferenceContext, PreferenceValueType } from "./types.ts" + +export function getPreferenceProxy(store: StateStore, room?: RoomStateStore): Preferences { + return new Proxy({}, { + set(): boolean { + throw new Error("The preference proxy is read-only") + }, + get(_target: never, key: keyof Preferences): PreferenceValueType | undefined { + const pref = preferences[key] + if (!pref) { + return undefined + } + let val: typeof pref.defaultValue | undefined + for (const ctx of pref.allowedContexts) { + if (ctx === PreferenceContext.Account) { + val = store.serverPreferenceCache?.[key] + } else if (ctx === PreferenceContext.Device) { + val = store.localPreferenceCache?.[key] + } else if (ctx === PreferenceContext.RoomAccount && room) { + val = room.serverPreferenceCache?.[key] + } else if (ctx === PreferenceContext.RoomDevice && room) { + val = room.localPreferenceCache?.[key] + } else if (ctx === PreferenceContext.Config) { + // TODO + } + if (val !== undefined) { + return val + } + } + return pref.defaultValue + }, + ownKeys(): string[] { + return Array.from(existingPreferenceKeys) + }, + }) +} diff --git a/web/src/api/types/preferences/types.ts b/web/src/api/types/preferences/types.ts new file mode 100644 index 0000000..1b0e0a0 --- /dev/null +++ b/web/src/api/types/preferences/types.ts @@ -0,0 +1,75 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +export enum PreferenceContext { + Config = "config", + Account = "account", + Device = "device", + RoomAccount = "room_account", + RoomDevice = "room_device", +} + +export const anyContext = [ + PreferenceContext.RoomDevice, + PreferenceContext.RoomAccount, + PreferenceContext.Device, + PreferenceContext.Account, + PreferenceContext.Config, +] as const + +export const anyGlobalContext = [ + PreferenceContext.Device, + PreferenceContext.Account, + PreferenceContext.Config, +] as const + +export const deviceSpecific = [ + PreferenceContext.RoomDevice, + PreferenceContext.Device, +] as const + +export type PreferenceValueType = + | boolean + | number + | string + | number[] + | string[] + | Record + | Record[] + | null; + +interface PreferenceFields { + displayName: string + allowedContexts: readonly PreferenceContext[] + defaultValue: T + description: string + allowedValues?: readonly T[] +} + +export class Preference { + public readonly displayName: string + public readonly allowedContexts: readonly PreferenceContext[] + public readonly defaultValue: T + public readonly description?: string + public readonly allowedValues?: readonly T[] + + constructor(fields: PreferenceFields) { + this.displayName = fields.displayName + this.allowedContexts = fields.allowedContexts + this.defaultValue = fields.defaultValue + this.description = fields.description ?? "" + this.allowedValues = fields.allowedValues + } +} diff --git a/web/src/index.css b/web/src/index.css index 77ab9a2..faf450b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -61,7 +61,6 @@ --sent-error-color: var(--error-color); --blockquote-border-color: var(--border-color); --lightbox-button-color: var(--border-color); - --codeblock-background-color: inherit; --clickable-cursor: default; @media (prefers-color-scheme: dark) { diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 3d71af3..01d811b 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -13,12 +13,13 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useEffect, useInsertionEffect, useMemo, useReducer, useState } from "react" +import { use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState } from "react" import Client from "@/api/client.ts" import { RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" import ClientContext from "./ClientContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" +import StylePreferences from "./StylePreferences.tsx" import Keybindings from "./keybindings.ts" import { ModalWrapper } from "./modal/Modal.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" @@ -57,12 +58,12 @@ class ContextFields implements MainScreenContextFields { ) { this.keybindings = new Keybindings(client.store, this) client.store.switchRoom = this.setActiveRoom - window.mainScreenContext = this } setActiveRoom = (roomID: RoomID | null) => { console.log("Switching to room", roomID) const room = (roomID && this.client.store.rooms.get(roomID)) || null + window.activeRoom = room this.directSetActiveRoom(room) this.setRightPanel(null) if (room?.stateLoaded === false) { @@ -109,6 +110,9 @@ const MainScreen = () => { () => new ContextFields(setRightPanel, directSetActiveRoom, client), [client], ) + useLayoutEffect(() => { + window.mainScreenContext = context + }, [context]) useEffect(() => context.keybindings.listen(), [context]) useInsertionEffect(() => { const styleTags = document.createElement("style") @@ -142,6 +146,7 @@ const MainScreen = () => { } return +
{resizeHandle1} diff --git a/web/src/ui/StylePreferences.tsx b/web/src/ui/StylePreferences.tsx new file mode 100644 index 0000000..9e742d9 --- /dev/null +++ b/web/src/ui/StylePreferences.tsx @@ -0,0 +1,104 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React, { useInsertionEffect } from "react" +import type Client from "@/api/client.ts" +import { RoomStateStore, usePreferences } from "@/api/statestore" + +interface StylePreferencesProps { + client: Client + activeRoom: RoomStateStore | null +} + +function newStyleSheet(sheet: string): CSSStyleSheet { + const style = new CSSStyleSheet() + style.replaceSync(sheet) + return style +} + +function css(strings: TemplateStringsArray, ...values: unknown[]) { + return newStyleSheet(String.raw(strings, ...values)) +} + +function useStyle(callback: () => CSSStyleSheet | false | undefined | null | "", dependencies: unknown[]) { + useInsertionEffect(() => { + const sheet = callback() + if (!sheet) { + return + } + document.adoptedStyleSheets.push(sheet) + return () => { + const idx = document.adoptedStyleSheets.indexOf(sheet) + if (idx !== -1) { + document.adoptedStyleSheets.splice(idx, 1) + } + } + }, dependencies) +} + +function useAsyncStyle(callback: () => string | false | undefined | null, dependencies: unknown[]) { + useInsertionEffect(() => { + const sheet = callback() + if (!sheet) { + return + } + const styleTags = document.createElement("style") + styleTags.textContent = sheet + document.head.appendChild(styleTags) + return () => { + document.head.removeChild(styleTags) + } + }, dependencies) +} + +const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => { + usePreferences(client.store, activeRoom) + const preferences = activeRoom?.preferences ?? client.store.preferences + useStyle(() => css` + div.html-body > a.hicli-matrix-uri-user[href="matrix:u/${CSS.escape(client.userID.slice(1))}"] { + background-color: var(--highlight-pill-background-color); + color: var(--highlight-pill-text-color); + } + `, [client.userID]) + useStyle(() => preferences.code_block_line_wrap && css` + pre.chroma { + text-wrap: wrap; + } + `, [preferences.code_block_line_wrap]) + useStyle(() => preferences.pointer_cursor && css` + :root { + --clickable-cursor: pointer; + } + `, [preferences.pointer_cursor]) + useStyle(() => !preferences.show_hidden_events && css` + div.timeline-list > div.hidden-event { + display: none; + } + `, [preferences.show_hidden_events]) + useAsyncStyle(() => preferences.code_block_theme === "auto" ? ` + @import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light); + @import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark); + + pre.chroma { + background-color: inherit; + } + ` : ` + @import url("_gomuks/codeblock/${preferences.code_block_theme}.css"); + `, [preferences.code_block_theme]) + useStyle(() => preferences.custom_css && newStyleSheet(preferences.custom_css), [preferences.custom_css]) + return null +} + +export default React.memo(StylePreferences) diff --git a/web/src/ui/modal/Lightbox.tsx b/web/src/ui/modal/Lightbox.tsx index ac825f3..b09138b 100644 --- a/web/src/ui/modal/Lightbox.tsx +++ b/web/src/ui/modal/Lightbox.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { Component, RefObject, createContext, createRef, useCallback, useState } from "react" +import React, { Component, RefObject, createContext, createRef, useCallback, useLayoutEffect, useState } from "react" import CloseIcon from "@/icons/close.svg?react" import DownloadIcon from "@/icons/download.svg?react" import RotateLeftIcon from "@/icons/rotate-left.svg?react" @@ -51,7 +51,9 @@ export const LightboxWrapper = ({ children }: { children: React.ReactNode }) => setParams(params as LightboxParams) } }, []) - window.openLightbox = onOpen + useLayoutEffect(() => { + window.openLightbox = onOpen + }, [onOpen]) const onClose = useCallback(() => setParams(null), []) return <> diff --git a/web/src/ui/timeline/content/index.css b/web/src/ui/timeline/content/index.css index bd00295..09efa0d 100644 --- a/web/src/ui/timeline/content/index.css +++ b/web/src/ui/timeline/content/index.css @@ -117,8 +117,6 @@ div.html-body { padding-bottom: .5rem; &.chroma { - background-color: var(--codeblock-background-color); - span.line > span.ln { -webkit-user-select: initial; user-select: initial; diff --git a/web/src/util/subscribable.ts b/web/src/util/subscribable.ts index 39d1a2f..5692bbe 100644 --- a/web/src/util/subscribable.ts +++ b/web/src/util/subscribable.ts @@ -32,6 +32,17 @@ export default class Subscribable { } } +export class NoDataSubscribable extends Subscribable { + data: number = 0 + + notify = () => { + this.data++ + super.notify() + } + + getData = () => this.data +} + export class MultiSubscribable { readonly subscribers: Map> = new Map() readonly subscribeFuncs: Map = new Map() diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index dc847ca..475ffd3 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -2,11 +2,13 @@ /// import type Client from "@/api/client.ts" +import type { RoomStateStore } from "@/api/statestore" import type { MainScreenContextFields } from "@/ui/MainScreenContext.ts" declare global { interface Window { client: Client + activeRoom?: RoomStateStore | null mainScreenContext: MainScreenContextFields openLightbox: (params: { src: string, alt: string }) => void }