mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
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>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<link rel="icon" type="image/png" href="gomuks.png"/>
|
<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"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>gomuks web</title>
|
<title>gomuks web</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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 { ScaleLoader } from "react-spinners"
|
||||||
import Client from "./api/client.ts"
|
import Client from "./api/client.ts"
|
||||||
import WSClient from "./api/wsclient.ts"
|
import WSClient from "./api/wsclient.ts"
|
||||||
|
@ -25,9 +25,11 @@ import { useEventAsState } from "./util/eventdispatcher.ts"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const client = useMemo(() => new Client(new WSClient("_gomuks/websocket")), [])
|
const client = useMemo(() => new Client(new WSClient("_gomuks/websocket")), [])
|
||||||
window.client = client
|
|
||||||
const connState = useEventAsState(client.rpc.connect)
|
const connState = useEventAsState(client.rpc.connect)
|
||||||
const clientState = useEventAsState(client.state)
|
const clientState = useEventAsState(client.state)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
window.client = client
|
||||||
|
}, [client])
|
||||||
useEffect(() => client.start(), [client])
|
useEffect(() => client.start(), [client])
|
||||||
|
|
||||||
if (connState?.error) {
|
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(
|
export function useCustomEmojis(
|
||||||
ss: StateStore, room: RoomStateStore,
|
ss: StateStore, room: RoomStateStore,
|
||||||
): CustomEmojiPack[] {
|
): CustomEmojiPack[] {
|
||||||
|
|
|
@ -14,11 +14,12 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { getAvatarURL } from "@/api/media.ts"
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
|
import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences"
|
||||||
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
import { focused } from "@/util/focus.ts"
|
import { focused } from "@/util/focus.ts"
|
||||||
import toSearchableString from "@/util/searchablestring.ts"
|
import toSearchableString from "@/util/searchablestring.ts"
|
||||||
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
|
import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts"
|
||||||
import {
|
import {
|
||||||
ContentURI,
|
ContentURI,
|
||||||
EventRowID,
|
EventRowID,
|
||||||
|
@ -58,10 +59,14 @@ export class StateStore {
|
||||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||||
readonly accountDataSubs = new MultiSubscribable()
|
readonly accountDataSubs = new MultiSubscribable()
|
||||||
readonly emojiRoomsSub = new Subscribable()
|
readonly emojiRoomsSub = new Subscribable()
|
||||||
|
readonly preferences: Preferences = getPreferenceProxy(this)
|
||||||
#frequentlyUsedEmoji: Map<string, number> | null = null
|
#frequentlyUsedEmoji: Map<string, number> | null = null
|
||||||
#emojiPackKeys: RoomStateGUID[] | null = null
|
#emojiPackKeys: RoomStateGUID[] | null = null
|
||||||
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
|
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
|
||||||
#personalEmojiPack: 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
|
switchRoom?: (roomID: RoomID | null) => void
|
||||||
activeRoomID?: RoomID
|
activeRoomID?: RoomID
|
||||||
imageAuthToken?: string
|
imageAuthToken?: string
|
||||||
|
@ -163,6 +168,9 @@ export class StateStore {
|
||||||
for (const ad of Object.values(sync.account_data)) {
|
for (const ad of Object.values(sync.account_data)) {
|
||||||
if (ad.type === "io.element.recent_emoji") {
|
if (ad.type === "io.element.recent_emoji") {
|
||||||
this.#frequentlyUsedEmoji = null
|
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.accountData.set(ad.type, ad.content)
|
||||||
this.accountDataSubs.notify(ad.type)
|
this.accountDataSubs.notify(ad.type)
|
||||||
|
|
|
@ -13,10 +13,11 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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 { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
import toSearchableString from "@/util/searchablestring.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 { getDisplayname } from "@/util/validation.ts"
|
||||||
import {
|
import {
|
||||||
ContentURI,
|
ContentURI,
|
||||||
|
@ -35,6 +36,7 @@ import {
|
||||||
RoomID,
|
RoomID,
|
||||||
SyncRoom,
|
SyncRoom,
|
||||||
TimelineRowTuple,
|
TimelineRowTuple,
|
||||||
|
UnknownEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
roomStateGUIDToString,
|
roomStateGUIDToString,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
|
@ -93,8 +95,14 @@ export class RoomStateStore {
|
||||||
readonly eventSubs = new MultiSubscribable()
|
readonly eventSubs = new MultiSubscribable()
|
||||||
readonly requestedEvents: Set<EventID> = new Set()
|
readonly requestedEvents: Set<EventID> = new Set()
|
||||||
readonly requestedMembers: Set<UserID> = 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 openNotifications: Map<EventRowID, Notification> = new Map()
|
||||||
readonly emojiPacks: Map<string, CustomEmojiPack | null> = 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
|
#membersCache: MemDBEvent[] | null = null
|
||||||
#autocompleteMembersCache: AutocompleteMemberEntry[] | null = null
|
#autocompleteMembersCache: AutocompleteMemberEntry[] | null = null
|
||||||
membersRequested: boolean = false
|
membersRequested: boolean = false
|
||||||
|
@ -107,6 +115,8 @@ export class RoomStateStore {
|
||||||
constructor(meta: DBRoom, private parent: StateStore) {
|
constructor(meta: DBRoom, private parent: StateStore) {
|
||||||
this.roomID = meta.room_id
|
this.roomID = meta.room_id
|
||||||
this.meta = new NonNullCachedEventDispatcher(meta)
|
this.meta = new NonNullCachedEventDispatcher(meta)
|
||||||
|
this.localPreferenceCache = getLocalStoragePreferences(`prefs-${this.roomID}`, this.preferenceSub.notify)
|
||||||
|
this.preferences = getPreferenceProxy(parent, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyTimelineSubscribers() {
|
notifyTimelineSubscribers() {
|
||||||
|
@ -313,6 +323,14 @@ export class RoomStateStore {
|
||||||
} else {
|
} else {
|
||||||
this.meta.emit(sync.meta)
|
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) {
|
for (const evt of sync.events) {
|
||||||
this.applyEvent(evt)
|
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);
|
--sent-error-color: var(--error-color);
|
||||||
--blockquote-border-color: var(--border-color);
|
--blockquote-border-color: var(--border-color);
|
||||||
--lightbox-button-color: var(--border-color);
|
--lightbox-button-color: var(--border-color);
|
||||||
--codeblock-background-color: inherit;
|
|
||||||
--clickable-cursor: default;
|
--clickable-cursor: default;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
|
@ -13,12 +13,13 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// 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 Client from "@/api/client.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import type { RoomID } from "@/api/types"
|
import type { RoomID } from "@/api/types"
|
||||||
import ClientContext from "./ClientContext.ts"
|
import ClientContext from "./ClientContext.ts"
|
||||||
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
||||||
|
import StylePreferences from "./StylePreferences.tsx"
|
||||||
import Keybindings from "./keybindings.ts"
|
import Keybindings from "./keybindings.ts"
|
||||||
import { ModalWrapper } from "./modal/Modal.tsx"
|
import { ModalWrapper } from "./modal/Modal.tsx"
|
||||||
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
|
||||||
|
@ -57,12 +58,12 @@ class ContextFields implements MainScreenContextFields {
|
||||||
) {
|
) {
|
||||||
this.keybindings = new Keybindings(client.store, this)
|
this.keybindings = new Keybindings(client.store, this)
|
||||||
client.store.switchRoom = this.setActiveRoom
|
client.store.switchRoom = this.setActiveRoom
|
||||||
window.mainScreenContext = this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveRoom = (roomID: RoomID | null) => {
|
setActiveRoom = (roomID: RoomID | null) => {
|
||||||
console.log("Switching to room", roomID)
|
console.log("Switching to room", roomID)
|
||||||
const room = (roomID && this.client.store.rooms.get(roomID)) || null
|
const room = (roomID && this.client.store.rooms.get(roomID)) || null
|
||||||
|
window.activeRoom = room
|
||||||
this.directSetActiveRoom(room)
|
this.directSetActiveRoom(room)
|
||||||
this.setRightPanel(null)
|
this.setRightPanel(null)
|
||||||
if (room?.stateLoaded === false) {
|
if (room?.stateLoaded === false) {
|
||||||
|
@ -109,6 +110,9 @@ const MainScreen = () => {
|
||||||
() => new ContextFields(setRightPanel, directSetActiveRoom, client),
|
() => new ContextFields(setRightPanel, directSetActiveRoom, client),
|
||||||
[client],
|
[client],
|
||||||
)
|
)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
window.mainScreenContext = context
|
||||||
|
}, [context])
|
||||||
useEffect(() => context.keybindings.listen(), [context])
|
useEffect(() => context.keybindings.listen(), [context])
|
||||||
useInsertionEffect(() => {
|
useInsertionEffect(() => {
|
||||||
const styleTags = document.createElement("style")
|
const styleTags = document.createElement("style")
|
||||||
|
@ -142,6 +146,7 @@ const MainScreen = () => {
|
||||||
}
|
}
|
||||||
return <MainScreenContext value={context}>
|
return <MainScreenContext value={context}>
|
||||||
<ModalWrapper>
|
<ModalWrapper>
|
||||||
|
<StylePreferences client={client} activeRoom={activeRoom} />
|
||||||
<main className={classNames.join(" ")} style={extraStyle}>
|
<main className={classNames.join(" ")} style={extraStyle}>
|
||||||
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
|
||||||
{resizeHandle1}
|
{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
|
// 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/>.
|
// 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 CloseIcon from "@/icons/close.svg?react"
|
||||||
import DownloadIcon from "@/icons/download.svg?react"
|
import DownloadIcon from "@/icons/download.svg?react"
|
||||||
import RotateLeftIcon from "@/icons/rotate-left.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)
|
setParams(params as LightboxParams)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
useLayoutEffect(() => {
|
||||||
window.openLightbox = onOpen
|
window.openLightbox = onOpen
|
||||||
|
}, [onOpen])
|
||||||
const onClose = useCallback(() => setParams(null), [])
|
const onClose = useCallback(() => setParams(null), [])
|
||||||
return <>
|
return <>
|
||||||
<LightboxContext value={onOpen}>
|
<LightboxContext value={onOpen}>
|
||||||
|
|
|
@ -117,8 +117,6 @@ div.html-body {
|
||||||
padding-bottom: .5rem;
|
padding-bottom: .5rem;
|
||||||
|
|
||||||
&.chroma {
|
&.chroma {
|
||||||
background-color: var(--codeblock-background-color);
|
|
||||||
|
|
||||||
span.line > span.ln {
|
span.line > span.ln {
|
||||||
-webkit-user-select: initial;
|
-webkit-user-select: initial;
|
||||||
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 {
|
export class MultiSubscribable {
|
||||||
readonly subscribers: Map<string, Set<Subscriber>> = new Map()
|
readonly subscribers: Map<string, Set<Subscriber>> = new Map()
|
||||||
readonly subscribeFuncs: Map<string, SubscribeFunc> = 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" />
|
/// <reference types="vite-plugin-svgr/client" />
|
||||||
|
|
||||||
import type Client from "@/api/client.ts"
|
import type Client from "@/api/client.ts"
|
||||||
|
import type { RoomStateStore } from "@/api/statestore"
|
||||||
import type { MainScreenContextFields } from "@/ui/MainScreenContext.ts"
|
import type { MainScreenContextFields } from "@/ui/MainScreenContext.ts"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
client: Client
|
client: Client
|
||||||
|
activeRoom?: RoomStateStore | null
|
||||||
mainScreenContext: MainScreenContextFields
|
mainScreenContext: MainScreenContextFields
|
||||||
openLightbox: (params: { src: string, alt: string }) => void
|
openLightbox: (params: { src: string, alt: string }) => void
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue