forked from Mirrors/gomuks
web: add preference system
This commit is contained in:
parent
f3eb86455f
commit
8f476839eb
17 changed files with 456 additions and 13 deletions
|
@ -3,8 +3,6 @@
|
|||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/png" href="gomuks.png"/>
|
||||
<link rel="stylesheet" media="(prefers-color-scheme: light)" href="_gomuks/codeblock/github.css" />
|
||||
<link rel="stylesheet" media="(prefers-color-scheme: dark)" href="_gomuks/codeblock/github-dark.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>gomuks web</title>
|
||||
</head>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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) {
|
||||
|
|
|
@ -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[] {
|
||||
|
|
|
@ -14,11 +14,12 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly emojiRoomsSub = new Subscribable()
|
||||
readonly preferences: Preferences = getPreferenceProxy(this)
|
||||
#frequentlyUsedEmoji: Map<string, number> | null = null
|
||||
#emojiPackKeys: RoomStateGUID[] | null = null
|
||||
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | 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)
|
||||
|
|
|
@ -13,10 +13,11 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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<EventID> = new Set()
|
||||
readonly requestedMembers: Set<UserID> = new Set()
|
||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly openNotifications: Map<EventRowID, Notification> = new Map()
|
||||
readonly emojiPacks: Map<string, CustomEmojiPack | null> = 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)
|
||||
}
|
||||
|
|
4
web/src/api/types/preferences/index.ts
Normal file
4
web/src/api/types/preferences/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./types.ts"
|
||||
export * from "./preferences.ts"
|
||||
export * from "./proxy.ts"
|
||||
export * from "./localstorage.ts"
|
64
web/src/api/types/preferences/localstorage.ts
Normal file
64
web/src/api/types/preferences/localstorage.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
88
web/src/api/types/preferences/preferences.ts
Normal file
88
web/src/api/types/preferences/preferences.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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<boolean>({
|
||||
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<boolean>({
|
||||
displayName: "Send typing notifications",
|
||||
description: "Should typing notifications be sent to other users?",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
code_block_line_wrap: new Preference<boolean>({
|
||||
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<CodeBlockStyle>({
|
||||
displayName: "Code block theme",
|
||||
description: "The syntax highlighting theme to use for code blocks.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: "auto",
|
||||
allowedValues: codeBlockStyles,
|
||||
}),
|
||||
pointer_cursor: new Preference<boolean>({
|
||||
displayName: "Pointer cursor",
|
||||
description: "Whether to use a pointer cursor for clickable elements.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: false,
|
||||
}),
|
||||
custom_css: new Preference<string>({
|
||||
displayName: "Custom CSS",
|
||||
description: "Arbitrary custom CSS to apply to the client.",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: "",
|
||||
}),
|
||||
show_hidden_events: new Preference<boolean>({
|
||||
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<boolean>({
|
||||
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"]
|
||||
}
|
53
web/src/api/types/preferences/proxy.ts
Normal file
53
web/src/api/types/preferences/proxy.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
75
web/src/api/types/preferences/types.ts
Normal file
75
web/src/api/types/preferences/types.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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<string, unknown>
|
||||
| Record<string, unknown>[]
|
||||
| null;
|
||||
|
||||
interface PreferenceFields<T extends PreferenceValueType = PreferenceValueType> {
|
||||
displayName: string
|
||||
allowedContexts: readonly PreferenceContext[]
|
||||
defaultValue: T
|
||||
description: string
|
||||
allowedValues?: readonly T[]
|
||||
}
|
||||
|
||||
export class Preference<T extends PreferenceValueType = PreferenceValueType> {
|
||||
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<T>) {
|
||||
this.displayName = fields.displayName
|
||||
this.allowedContexts = fields.allowedContexts
|
||||
this.defaultValue = fields.defaultValue
|
||||
this.description = fields.description ?? ""
|
||||
this.allowedValues = fields.allowedValues
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -13,12 +13,13 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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 <MainScreenContext value={context}>
|
||||
<ModalWrapper>
|
||||
<StylePreferences client={client} activeRoom={activeRoom} />
|
||||
<main className={classNames.join(" ")} style={extraStyle}>
|
||||
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
||||
{resizeHandle1}
|
||||
|
|
104
web/src/ui/StylePreferences.tsx
Normal file
104
web/src/ui/StylePreferences.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
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 <>
|
||||
<LightboxContext value={onOpen}>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<string, Set<Subscriber>> = new Map()
|
||||
readonly subscribeFuncs: Map<string, SubscribeFunc> = new Map()
|
||||
|
|
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
@ -2,11 +2,13 @@
|
|||
/// <reference types="vite-plugin-svgr/client" />
|
||||
|
||||
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
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue