1
0
Fork 0
forked from Mirrors/gomuks

web/roomlist: add space bar

Fixes #518
This commit is contained in:
Tulir Asokan 2024-12-28 21:05:16 +02:00
parent 5483b077c7
commit 5a8139685d
12 changed files with 302 additions and 35 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
main.matrix-main {
--room-list-width: 300px;
--room-list-width: 350px;
--right-panel-width: 300px;
position: fixed;

View file

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

View file

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

View file

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

View 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

View file

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