From 5a8139685dc28c3d67cc37064980edb233874ea8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Dec 2024 21:05:16 +0200 Subject: [PATCH] web/roomlist: add space bar Fixes #518 --- web/index.html | 11 ++++ web/src/api/statestore/main.ts | 53 +++++++++++++-- web/src/api/statestore/space.ts | 109 +++++++++++++++++++++++++++++++ web/src/api/types/hievents.ts | 2 +- web/src/api/types/hitypes.ts | 2 +- web/src/icons/home.svg | 1 + web/src/ui/MainScreen.css | 2 +- web/src/ui/MainScreen.tsx | 2 +- web/src/ui/roomlist/RoomList.css | 46 ++++++++++++- web/src/ui/roomlist/RoomList.tsx | 58 +++++++++++----- web/src/ui/roomlist/Space.tsx | 40 ++++++++++++ web/src/util/eventdispatcher.ts | 11 ++-- 12 files changed, 302 insertions(+), 35 deletions(-) create mode 100644 web/src/api/statestore/space.ts create mode 100644 web/src/icons/home.svg create mode 100644 web/src/ui/roomlist/Space.tsx diff --git a/web/index.html b/web/index.html index 40252d6..483ad69 100644 --- a/web/index.html +++ b/web/index.html @@ -12,5 +12,16 @@
+ + + + + + + diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 1bfde16..71db297 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -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 = new Map() readonly inviteRooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) - currentRoomListFilter: string = "" + readonly topLevelSpaces = new NonNullCachedEventDispatcher([]) + readonly spaceEdges: Map = new Map() + currentRoomListQuery: string = "" + currentRoomListFilter: RoomListFilter | null = null readonly accountData: Map = 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 { 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 diff --git a/web/src/api/statestore/space.ts b/web/src/api/statestore/space.ts new file mode 100644 index 0000000..f3a3fc0 --- /dev/null +++ b/web/src/api/statestore/space.ts @@ -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 . +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 = new Set() + #flattenedRooms: Set = new Set() + #childSpaces: Set = new Set() + readonly #parentSpaces: Set = 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) { + 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, stack: WeakSet) { + 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() + const newChildSpaces = new Set() + 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()) + } + } +} diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index 7c64596..125eb57 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -91,7 +91,7 @@ export interface SyncCompleteData { invited_rooms: DBInvitedRoom[] | null left_rooms: RoomID[] | null account_data: Record | null - space_edges: Record[]> | null + space_edges: Record | null top_level_spaces: RoomID[] | null since?: string clear_state?: boolean diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index a859a47..521a9dd 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -72,7 +72,7 @@ export interface DBRoom { } export interface DBSpaceEdge { - space_id: RoomID + // space_id: RoomID child_id: RoomID child_event_rowid?: EventRowID diff --git a/web/src/icons/home.svg b/web/src/icons/home.svg new file mode 100644 index 0000000..cc29681 --- /dev/null +++ b/web/src/icons/home.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/MainScreen.css b/web/src/ui/MainScreen.css index bfe308d..4e9bc21 100644 --- a/web/src/ui/MainScreen.css +++ b/web/src/ui/MainScreen.css @@ -1,5 +1,5 @@ main.matrix-main { - --room-list-width: 300px; + --room-list-width: 350px; --right-panel-width: 300px; position: fixed; diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 63173ed..05f8b59 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -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( diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index 933fba2..ca6504a 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -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; diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 93eb074..324c144 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -13,7 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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(null) - const [roomFilter, setRoomFilter] = useState("") - const [realRoomFilter, setRealRoomFilter] = useState("") + const spaces = useEventAsState(client.store.topLevelSpaces) + const searchInputRef = useRef(null) + const [query, directSetQuery] = useState("") + const [space, directSetSpace] = useState(null) - const updateRoomFilter = (evt: React.ChangeEvent) => { - setRoomFilter(evt.target.value) - client.store.currentRoomListFilter = toSearchableString(evt.target.value) - setRealRoomFilter(client.store.currentRoomListFilter) + const setQuery = (evt: React.ChangeEvent) => { + 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) => { + 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) => { const key = keyToString(evt) @@ -64,28 +74,40 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { } } + const roomListFilter = client.store.roomListFilterFunc return
-
+
+
setSpace(null)}> + +
+ {spaces.map(roomID => )} +
{reverseMap(roomList, room =>