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