1
0
Fork 0
forked from Mirrors/gomuks

web/roomlist: add filter bar

This commit is contained in:
Tulir Asokan 2024-10-13 20:52:20 +03:00
parent 8f43d00d06
commit c281ba90ee
7 changed files with 65 additions and 10 deletions

8
web/package-lock.json generated
View file

@ -15,7 +15,8 @@
"react": "^19.0.0-rc-0751fac7-20241002", "react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
"sanitize-html": "^2.13.1" "sanitize-html": "^2.13.1",
"unhomoglyph": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
@ -4849,6 +4850,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/unhomoglyph": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",

View file

@ -17,7 +17,8 @@
"react": "^19.0.0-rc-0751fac7-20241002", "react": "^19.0.0-rc-0751fac7-20241002",
"react-dom": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
"sanitize-html": "^2.13.1" "sanitize-html": "^2.13.1",
"unhomoglyph": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",

View file

@ -14,6 +14,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 { useSyncExternalStore } from "react" import { useSyncExternalStore } from "react"
import unhomoglyph from "unhomoglyph"
import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
import type { import type {
ContentURI, ContentURI,
@ -220,9 +221,20 @@ export interface RoomListEntry {
preview_event?: MemDBEvent preview_event?: MemDBEvent
preview_sender?: MemDBEvent preview_sender?: MemDBEvent
name: string name: string
search_name: string
avatar?: ContentURI avatar?: ContentURI
} }
// eslint-disable-next-line no-misleading-character-class
const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g
export function toSearchableString(str: string): string {
return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, ""))
.toLowerCase()
.replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "")
.toLowerCase()
}
export class StateStore { export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
@ -239,12 +251,14 @@ export class StateStore {
} }
const preview_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid) const preview_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid)
const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender) const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender)
const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, room_id: entry.meta.room_id,
sorting_timestamp: entry.meta.sorting_timestamp, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
name: entry.meta.name ?? "Unnamed room", name,
search_name: toSearchableString(name),
avatar: entry.meta.avatar, avatar: entry.meta.avatar,
} }
} }

View file

@ -30,3 +30,7 @@ button {
--error-color: red; --error-color: red;
--error-color-light: #ff6666; --error-color-light: #ff6666;
} }
.hidden {
display: none !important;
}

View file

@ -23,6 +23,7 @@ export interface RoomListEntryProps {
room: RoomListEntry room: RoomListEntry
setActiveRoom: (evt: React.MouseEvent) => void setActiveRoom: (evt: React.MouseEvent) => void
isActive: boolean isActive: boolean
hidden: boolean
} }
function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent): [string, string] { function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent): [string, string] {
@ -45,10 +46,10 @@ function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent): [string
return ["", ""] return ["", ""]
} }
const Entry = ({ room, setActiveRoom, isActive }: RoomListEntryProps) => { const Entry = ({ room, setActiveRoom, isActive, hidden }: RoomListEntryProps) => {
const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender) const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender)
return <div return <div
className={`room-entry ${isActive ? "active" : ""}`} className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}
onClick={setActiveRoom} onClick={setActiveRoom}
data-room-id={room.room_id} data-room-id={room.room_id}
> >

View file

@ -2,12 +2,25 @@ div.room-list-wrapper {
grid-area: roomlist; grid-area: roomlist;
background: linear-gradient(in hsl longer hue, red 0 0, magenta); background: linear-gradient(in hsl longer hue, red 0 0, magenta);
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow: hidden;
scrollbar-color: rgba(0, 0, 0, 0.4) rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
} }
div.room-list { div.room-list {
background-color: hsla(0, 0%, 96%, .9); background-color: hsla(0, 0%, 96%, .9);
min-height: 100vh; overflow-y: auto;
flex: 1;
}
input.room-search {
width: 100%;
padding: 1rem;
box-sizing: border-box;
border: none;
background-color: hsla(0, 0%, 96%, .9);
outline: none;
} }
div.room-entry { div.room-entry {
@ -19,11 +32,11 @@ div.room-entry {
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background-color: rgba(5, 38, 87, 0.1); background-color: rgba(0, 0, 0, 0.075);
} }
&.active { &.active {
background-color: rgba(5, 38, 87, 0.15); background-color: rgba(0, 0, 0, 0.125);
} }
> div.room-entry-left { > div.room-entry-left {

View file

@ -13,7 +13,8 @@
// //
// 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, { use, useCallback } from "react" import React, { use, useCallback, useState } from "react"
import { toSearchableString } from "@/api/statestore.ts"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
@ -27,6 +28,8 @@ interface RoomListProps {
const RoomList = ({ setActiveRoom, activeRoomID }: RoomListProps) => { const RoomList = ({ setActiveRoom, activeRoomID }: RoomListProps) => {
const roomList = useNonNullEventAsState(use(ClientContext)!.store.roomList) const roomList = useNonNullEventAsState(use(ClientContext)!.store.roomList)
const [roomFilter, setRoomFilter] = useState("")
const [realRoomFilter, setRealRoomFilter] = useState("")
const clickRoom = useCallback((evt: React.MouseEvent) => { const clickRoom = useCallback((evt: React.MouseEvent) => {
const roomID = evt.currentTarget.getAttribute("data-room-id") const roomID = evt.currentTarget.getAttribute("data-room-id")
if (roomID) { if (roomID) {
@ -36,12 +39,25 @@ const RoomList = ({ setActiveRoom, activeRoomID }: RoomListProps) => {
} }
}, [setActiveRoom]) }, [setActiveRoom])
const updateRoomFilter = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
setRoomFilter(evt.target.value)
setRealRoomFilter(toSearchableString(evt.target.value))
}, [])
return <div className="room-list-wrapper"> return <div className="room-list-wrapper">
<input
value={roomFilter}
onChange={updateRoomFilter}
className="room-search"
type="text"
placeholder="Search rooms"
/>
<div className="room-list"> <div className="room-list">
{reverseMap(roomList, room => {reverseMap(roomList, room =>
<Entry <Entry
key={room.room_id} key={room.room_id}
isActive={room.room_id === activeRoomID} isActive={room.room_id === activeRoomID}
hidden={roomFilter ? !room.search_name.includes(realRoomFilter) : false}
room={room} room={room}
setActiveRoom={clickRoom} setActiveRoom={clickRoom}
/>, />,