1
0
Fork 0
forked from Mirrors/gomuks

web: add preference system

This commit is contained in:
Tulir Asokan 2024-11-16 15:55:31 +02:00
parent f3eb86455f
commit 8f476839eb
17 changed files with 456 additions and 13 deletions

View file

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

View file

@ -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) {

View file

@ -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[] {

View file

@ -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)

View file

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

View file

@ -0,0 +1,4 @@
export * from "./types.ts"
export * from "./preferences.ts"
export * from "./proxy.ts"
export * from "./localstorage.ts"

View 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)
},
})
}

View 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"]
}

View 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)
},
})
}

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

View file

@ -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) {

View file

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

View 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)

View file

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

View file

@ -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;

View file

@ -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()

View file

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