mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
parent
5483b077c7
commit
5a8139685d
12 changed files with 302 additions and 35 deletions
|
@ -12,5 +12,16 @@
|
|||
<div id="root"></div>
|
||||
<script type="module" src="src/main.tsx"></script>
|
||||
<audio id="default-notification-sound" preload="auto" src="sounds/bright.flac"></audio>
|
||||
<svg style="position: absolute; width: 0; height: 0;" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="squircle" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5
|
||||
C 0,0 0,0 0.5,0
|
||||
1,0 1,0 1,0.5
|
||||
1,1 1,1 0.5,1
|
||||
0,1 0,1 0,0.5"></path>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from "../types"
|
||||
import { InvitedRoomStore } from "./invitedroom.ts"
|
||||
import { RoomStateStore } from "./room.ts"
|
||||
import { RoomListFilter, SpaceEdgeStore } from "./space.ts"
|
||||
|
||||
export interface RoomListEntry {
|
||||
room_id: RoomID
|
||||
|
@ -72,7 +73,10 @@ export class StateStore {
|
|||
readonly rooms: Map<RoomID, RoomStateStore> = new Map()
|
||||
readonly inviteRooms: Map<RoomID, InvitedRoomStore> = new Map()
|
||||
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
|
||||
currentRoomListFilter: string = ""
|
||||
readonly topLevelSpaces = new NonNullCachedEventDispatcher<RoomID[]>([])
|
||||
readonly spaceEdges: Map<RoomID, SpaceEdgeStore> = new Map()
|
||||
currentRoomListQuery: string = ""
|
||||
currentRoomListFilter: RoomListFilter | null = null
|
||||
readonly accountData: Map<string, UnknownEventContent> = new Map()
|
||||
readonly accountDataSubs = new MultiSubscribable()
|
||||
readonly emojiRoomsSub = new Subscribable()
|
||||
|
@ -89,11 +93,25 @@ export class StateStore {
|
|||
activeRoomIsPreview: boolean = false
|
||||
imageAuthToken?: string
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
if (!this.currentRoomListFilter) {
|
||||
return this.roomList.current
|
||||
#roomListFilterFunc = (entry: RoomListEntry) => {
|
||||
if (this.currentRoomListQuery && !entry.search_name.includes(this.currentRoomListQuery)) {
|
||||
return false
|
||||
} else if (this.currentRoomListFilter && !this.currentRoomListFilter.include(entry)) {
|
||||
return false
|
||||
}
|
||||
return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter))
|
||||
return true
|
||||
}
|
||||
|
||||
get roomListFilterFunc(): ((entry: RoomListEntry) => boolean) | null {
|
||||
if (!this.currentRoomListFilter && !this.currentRoomListQuery) {
|
||||
return null
|
||||
}
|
||||
return this.#roomListFilterFunc
|
||||
}
|
||||
|
||||
getFilteredRoomList(): RoomListEntry[] {
|
||||
const fn = this.roomListFilterFunc
|
||||
return fn ? this.roomList.current.filter(fn) : this.roomList.current
|
||||
}
|
||||
|
||||
#shouldHideRoom(entry: SyncRoom): boolean {
|
||||
|
@ -259,6 +277,12 @@ export class StateStore {
|
|||
if (updatedRoomList) {
|
||||
this.roomList.emit(updatedRoomList)
|
||||
}
|
||||
for (const [spaceID, children] of Object.entries(sync.space_edges ?? {})) {
|
||||
this.getSpaceStore(spaceID, true).children = children
|
||||
}
|
||||
if (sync.top_level_spaces) {
|
||||
this.topLevelSpaces.emit(sync.top_level_spaces)
|
||||
}
|
||||
}
|
||||
|
||||
invalidateEmojiPackKeyCache() {
|
||||
|
@ -324,6 +348,20 @@ export class StateStore {
|
|||
return this.#watchedRoomEmojiPacks ?? {}
|
||||
}
|
||||
|
||||
getSpaceStore(spaceID: RoomID, force: true): SpaceEdgeStore
|
||||
getSpaceStore(spaceID: RoomID): SpaceEdgeStore | null
|
||||
getSpaceStore(spaceID: RoomID, force?: true): SpaceEdgeStore | null {
|
||||
let store = this.spaceEdges.get(spaceID)
|
||||
if (!store) {
|
||||
if (!force && this.rooms.get(spaceID)?.meta.current.creation_content?.type !== "m.space") {
|
||||
return null
|
||||
}
|
||||
store = new SpaceEdgeStore(spaceID, this)
|
||||
this.spaceEdges.set(spaceID, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
get frequentlyUsedEmoji(): Map<string, number> {
|
||||
if (this.#frequentlyUsedEmoji === null) {
|
||||
const emojiData = this.accountData.get("io.element.recent_emoji")
|
||||
|
@ -433,9 +471,12 @@ export class StateStore {
|
|||
clear() {
|
||||
this.rooms.clear()
|
||||
this.inviteRooms.clear()
|
||||
this.spaceEdges.clear()
|
||||
this.roomList.emit([])
|
||||
this.topLevelSpaces.emit([])
|
||||
this.accountData.clear()
|
||||
this.currentRoomListFilter = ""
|
||||
this.currentRoomListQuery = ""
|
||||
this.currentRoomListFilter = null
|
||||
this.#frequentlyUsedEmoji = null
|
||||
this.#emojiPackKeys = null
|
||||
this.#watchedRoomEmojiPacks = null
|
||||
|
|
109
web/src/api/statestore/space.ts
Normal file
109
web/src/api/statestore/space.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
// 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 { RoomListEntry, StateStore } from "@/api/statestore/main.ts"
|
||||
import { DBSpaceEdge, RoomID } from "@/api/types"
|
||||
|
||||
export interface RoomListFilter {
|
||||
id: unknown
|
||||
include(room: RoomListEntry): boolean
|
||||
}
|
||||
|
||||
export class SpaceEdgeStore {
|
||||
#children: DBSpaceEdge[] = []
|
||||
#childRooms: Set<RoomID> = new Set()
|
||||
#flattenedRooms: Set<RoomID> = new Set()
|
||||
#childSpaces: Set<SpaceEdgeStore> = new Set()
|
||||
readonly #parentSpaces: Set<SpaceEdgeStore> = new Set()
|
||||
|
||||
constructor(public id: RoomID, private parent: StateStore) {
|
||||
}
|
||||
|
||||
addParent(parent: SpaceEdgeStore) {
|
||||
this.#parentSpaces.add(parent)
|
||||
}
|
||||
|
||||
removeParent(parent: SpaceEdgeStore) {
|
||||
this.#parentSpaces.delete(parent)
|
||||
}
|
||||
|
||||
include(room: RoomListEntry) {
|
||||
return this.#flattenedRooms.has(room.room_id)
|
||||
}
|
||||
|
||||
get children() {
|
||||
return this.#children
|
||||
}
|
||||
|
||||
#updateFlattened(recalculate: boolean, added: Set<RoomID>) {
|
||||
if (recalculate) {
|
||||
let flattened = new Set(this.#childRooms)
|
||||
for (const space of this.#childSpaces) {
|
||||
flattened = flattened.union(space.#flattenedRooms)
|
||||
}
|
||||
this.#flattenedRooms = flattened
|
||||
} else if (added.size > 50) {
|
||||
this.#flattenedRooms = this.#flattenedRooms.union(added)
|
||||
} else if (added.size > 0) {
|
||||
for (const room of added) {
|
||||
this.#flattenedRooms.add(room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#notifyParentsOfChange(recalculate: boolean, added: Set<RoomID>, stack: WeakSet<SpaceEdgeStore>) {
|
||||
if (stack.has(this)) {
|
||||
return
|
||||
}
|
||||
stack.add(this)
|
||||
for (const parent of this.#parentSpaces) {
|
||||
parent.#updateFlattened(recalculate, added)
|
||||
parent.#notifyParentsOfChange(recalculate, added, stack)
|
||||
}
|
||||
stack.delete(this)
|
||||
}
|
||||
|
||||
set children(newChildren: DBSpaceEdge[]) {
|
||||
const newChildRooms = new Set<RoomID>()
|
||||
const newChildSpaces = new Set<SpaceEdgeStore>()
|
||||
for (const child of newChildren) {
|
||||
const spaceStore = this.parent.getSpaceStore(child.child_id)
|
||||
if (spaceStore) {
|
||||
newChildSpaces.add(spaceStore)
|
||||
spaceStore.addParent(this)
|
||||
} else {
|
||||
newChildRooms.add(child.child_id)
|
||||
}
|
||||
}
|
||||
for (const space of this.#childSpaces) {
|
||||
if (!newChildSpaces.has(space)) {
|
||||
space.removeParent(this)
|
||||
}
|
||||
}
|
||||
const addedRooms = newChildRooms.difference(this.#childRooms)
|
||||
const removedRooms = this.#childRooms.difference(newChildRooms)
|
||||
this.#children = newChildren
|
||||
this.#childRooms = newChildRooms
|
||||
this.#childSpaces = newChildSpaces
|
||||
if (this.#childSpaces.size > 0) {
|
||||
this.#updateFlattened(removedRooms.size > 0, addedRooms)
|
||||
} else {
|
||||
this.#flattenedRooms = newChildRooms
|
||||
}
|
||||
if (this.#parentSpaces.size > 0) {
|
||||
this.#notifyParentsOfChange(removedRooms.size > 0, addedRooms, new WeakSet())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -91,7 +91,7 @@ export interface SyncCompleteData {
|
|||
invited_rooms: DBInvitedRoom[] | null
|
||||
left_rooms: RoomID[] | null
|
||||
account_data: Record<EventType, DBAccountData> | null
|
||||
space_edges: Record<RoomID, Omit<DBSpaceEdge, "space_id">[]> | null
|
||||
space_edges: Record<RoomID, DBSpaceEdge[]> | null
|
||||
top_level_spaces: RoomID[] | null
|
||||
since?: string
|
||||
clear_state?: boolean
|
||||
|
|
|
@ -72,7 +72,7 @@ export interface DBRoom {
|
|||
}
|
||||
|
||||
export interface DBSpaceEdge {
|
||||
space_id: RoomID
|
||||
// space_id: RoomID
|
||||
child_id: RoomID
|
||||
|
||||
child_event_rowid?: EventRowID
|
||||
|
|
1
web/src/icons/home.svg
Normal file
1
web/src/icons/home.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-200h120v-240h240v240h120v-360L480-740 240-560v360Zm-80 80v-480l320-240 320 240v480H520v-240h-80v240H160Zm320-350Z"/></svg>
|
After Width: | Height: | Size: 244 B |
|
@ -1,5 +1,5 @@
|
|||
main.matrix-main {
|
||||
--room-list-width: 300px;
|
||||
--room-list-width: 350px;
|
||||
--right-panel-width: 300px;
|
||||
|
||||
position: fixed;
|
||||
|
|
|
@ -318,7 +318,7 @@ const MainScreen = () => {
|
|||
}, [context, client])
|
||||
useEffect(() => context.keybindings.listen(), [context])
|
||||
const [roomListWidth, resizeHandle1] = useResizeHandle(
|
||||
300, 48, Math.min(900, window.innerWidth * 0.4),
|
||||
350, 96, Math.min(900, window.innerWidth * 0.4),
|
||||
"roomListWidth", { className: "room-list-resizer" },
|
||||
)
|
||||
const [rightPanelWidth, resizeHandle2] = useResizeHandle(
|
||||
|
|
|
@ -5,14 +5,53 @@ div.room-list-wrapper {
|
|||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
scrollbar-color: var(--room-list-scrollbar-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"spacebar search" 3.5rem
|
||||
"spacebar roomlist" 1fr
|
||||
/ 3rem 1fr;
|
||||
}
|
||||
|
||||
div.room-list {
|
||||
background-color: var(--room-list-background-overlay);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
grid-area: roomlist;
|
||||
}
|
||||
|
||||
div.space-bar {
|
||||
background-color: var(--space-list-background-overlay);
|
||||
grid-area: spacebar;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
> div.space-entry {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: .25rem;
|
||||
margin: .25rem;
|
||||
border-radius: .25rem;
|
||||
cursor: var(--clickable-cursor);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--room-list-entry-hover-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--room-list-entry-selected-color);
|
||||
}
|
||||
|
||||
> svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> img.avatar {
|
||||
border-radius: 0;
|
||||
clip-path: url(#squircle);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.room-search-wrapper {
|
||||
|
@ -21,6 +60,7 @@ div.room-search-wrapper {
|
|||
align-items: center;
|
||||
height: 3.5rem;
|
||||
background-color: var(--room-list-search-background-overlay);
|
||||
grid-area: search;
|
||||
|
||||
> input {
|
||||
padding: 0 0 0 1rem;
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
//
|
||||
// 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, { use, useRef, useState } from "react"
|
||||
import React, { use, useCallback, useRef, useState } from "react"
|
||||
import type { RoomListFilter } from "@/api/statestore/space.ts"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import reverseMap from "@/util/reversemap.ts"
|
||||
|
@ -22,7 +23,9 @@ import ClientContext from "../ClientContext.ts"
|
|||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import { keyToString } from "../keybindings.ts"
|
||||
import Entry from "./Entry.tsx"
|
||||
import Space from "./Space.tsx"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
import HomeIcon from "@/icons/home.svg?react"
|
||||
import SearchIcon from "@/icons/search.svg?react"
|
||||
import "./RoomList.css"
|
||||
|
||||
|
@ -34,20 +37,27 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
const client = use(ClientContext)!
|
||||
const mainScreen = use(MainScreenContext)
|
||||
const roomList = useEventAsState(client.store.roomList)
|
||||
const roomFilterRef = useRef<HTMLInputElement>(null)
|
||||
const [roomFilter, setRoomFilter] = useState("")
|
||||
const [realRoomFilter, setRealRoomFilter] = useState("")
|
||||
const spaces = useEventAsState(client.store.topLevelSpaces)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [query, directSetQuery] = useState("")
|
||||
const [space, directSetSpace] = useState<RoomListFilter | null>(null)
|
||||
|
||||
const updateRoomFilter = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRoomFilter(evt.target.value)
|
||||
client.store.currentRoomListFilter = toSearchableString(evt.target.value)
|
||||
setRealRoomFilter(client.store.currentRoomListFilter)
|
||||
const setQuery = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
client.store.currentRoomListQuery = toSearchableString(evt.target.value)
|
||||
directSetQuery(evt.target.value)
|
||||
}
|
||||
const setSpace = useCallback((space: RoomListFilter | null) => {
|
||||
directSetSpace(space)
|
||||
client.store.currentRoomListFilter = space
|
||||
}, [client])
|
||||
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
|
||||
setSpace(store)
|
||||
}, [setSpace, client])
|
||||
const clearQuery = () => {
|
||||
setRoomFilter("")
|
||||
client.store.currentRoomListFilter = ""
|
||||
setRealRoomFilter("")
|
||||
roomFilterRef.current?.focus()
|
||||
client.store.currentRoomListQuery = ""
|
||||
directSetQuery("")
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
const onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const key = keyToString(evt)
|
||||
|
@ -64,28 +74,40 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
const roomListFilter = client.store.roomListFilterFunc
|
||||
return <div className="room-list-wrapper">
|
||||
<div className="room-search-wrapper">
|
||||
<input
|
||||
value={roomFilter}
|
||||
onChange={updateRoomFilter}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
className="room-search"
|
||||
type="text"
|
||||
placeholder="Search rooms"
|
||||
ref={roomFilterRef}
|
||||
ref={searchInputRef}
|
||||
id="room-search"
|
||||
/>
|
||||
<button onClick={clearQuery} disabled={roomFilter === ""}>
|
||||
{roomFilter !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-bar">
|
||||
<div className={`space-entry ${space === null ? "active" : ""}`} onClick={() => setSpace(null)}>
|
||||
<HomeIcon />
|
||||
</div>
|
||||
{spaces.map(roomID => <Space
|
||||
roomID={roomID}
|
||||
client={client}
|
||||
onClick={onClickSpace}
|
||||
isActive={space?.id === roomID}
|
||||
/>)}
|
||||
</div>
|
||||
<div className="room-list">
|
||||
{reverseMap(roomList, room =>
|
||||
<Entry
|
||||
key={room.room_id}
|
||||
isActive={room.room_id === activeRoomID}
|
||||
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
|
||||
hidden={roomListFilter ? !roomListFilter(room) : false}
|
||||
room={room}
|
||||
/>,
|
||||
)}
|
||||
|
|
40
web/src/ui/roomlist/Space.tsx
Normal file
40
web/src/ui/roomlist/Space.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
// 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 from "react"
|
||||
import Client from "@/api/client.ts"
|
||||
import { getRoomAvatarURL } from "@/api/media.ts"
|
||||
import type { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import "./RoomList.css"
|
||||
|
||||
export interface SpaceProps {
|
||||
roomID: RoomID
|
||||
client: Client
|
||||
onClick: (evt: React.MouseEvent<HTMLDivElement>) => void
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const Space = ({ roomID, client, onClick, isActive }: SpaceProps) => {
|
||||
const room = useEventAsState(client.store.rooms.get(roomID)?.meta)
|
||||
if (!room) {
|
||||
return
|
||||
}
|
||||
return <div className={`space-entry ${isActive ? "active" : ""}`} onClick={onClick} data-target-space={roomID}>
|
||||
<img src={getRoomAvatarURL(room)} alt={room.name} title={room.name} className="avatar" />
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Space
|
|
@ -15,12 +15,15 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const noop = () => {}
|
||||
const noopListen = () => noop
|
||||
|
||||
export function useEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T
|
||||
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null
|
||||
export function useEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null {
|
||||
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null
|
||||
export function useEventAsState<T>(dispatcher?: CachedEventDispatcher<T>): T | null {
|
||||
return useSyncExternalStore(
|
||||
dispatcher.listenChange,
|
||||
() => dispatcher.current,
|
||||
dispatcher ? dispatcher.listenChange : noopListen,
|
||||
() => dispatcher ? dispatcher.current : null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue