diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 73c825c..7ef7434 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -13,10 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import unhomoglyph from "unhomoglyph" import { getAvatarURL } from "@/api/media.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { focused } from "@/util/focus.ts" +import toSearchableString from "@/util/searchablestring.ts" import type { ContentURI, EventRowID, @@ -44,15 +44,6 @@ export interface RoomListEntry { unread_highlights: number } -// 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").toLowerCase().replace(removeHiddenCharsRegex, "")) - .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") - .toLowerCase() -} - export class StateStore { readonly rooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) diff --git a/web/src/ui/composer/Autocompleter.css b/web/src/ui/composer/Autocompleter.css index 4045be9..71d28e0 100644 --- a/web/src/ui/composer/Autocompleter.css +++ b/web/src/ui/composer/Autocompleter.css @@ -15,6 +15,9 @@ div.autocompletions { > .autocompletion-item { padding: .25rem; border-radius: .25rem; + display: flex; + align-items: center; + gap: .25rem; /*cursor: pointer;*/ &.selected, &:hover { diff --git a/web/src/ui/composer/Autocompleter.tsx b/web/src/ui/composer/Autocompleter.tsx index a702cc4..6061946 100644 --- a/web/src/ui/composer/Autocompleter.tsx +++ b/web/src/ui/composer/Autocompleter.tsx @@ -14,10 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { JSX, useEffect } from "react" +import { getAvatarURL } from "@/api/media.ts" import { RoomStateStore } from "@/api/statestore" import { Emoji, useFilteredEmojis } from "@/util/emoji" import useEvent from "@/util/useEvent.ts" import type { ComposerState } from "./MessageComposer.tsx" +import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts" import "./Autocompleter.css" export interface AutocompleteQuery { @@ -100,10 +102,24 @@ export const EmojiAutocompleter = ({ params, ...rest }: AutocompleterProps) => { return useAutocompleter({ params, ...rest, items, ...emojiFuncs }) } -export const UserAutocompleter = ({ params }: AutocompleterProps) => { - return
- Autocomplete {params.type} {params.query} -
+const userFuncs = { + getText: (user: AutocompleteUser) => + `[${user.displayName}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `, + getKey: (user: AutocompleteUser) => user.userID, + render: (user: AutocompleteUser) => <> + + {user.displayName} + , +} + +export const UserAutocompleter = ({ params, room, ...rest }: AutocompleterProps) => { + const items = useFilteredMembers(room, (params.frozenQuery ?? params.query).slice(1)) + return useAutocompleter({ params, room, ...rest, items, ...userFuncs }) } export const RoomAutocompleter = ({ params }: AutocompleterProps) => { diff --git a/web/src/ui/composer/userautocomplete.ts b/web/src/ui/composer/userautocomplete.ts new file mode 100644 index 0000000..9a636d2 --- /dev/null +++ b/web/src/ui/composer/userautocomplete.ts @@ -0,0 +1,78 @@ +// 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 { useMemo, useRef } from "react" +import { RoomStateStore } from "@/api/statestore" +import type { ContentURI, MemberEventContent, UserID } from "@/api/types" +import toSearchableString from "@/util/searchablestring.ts" + +export interface AutocompleteUser { + userID: UserID + displayName: string + avatarURL?: ContentURI + searchString: string +} + +export function filterAndSort(users: AutocompleteUser[], query: string): AutocompleteUser[] { + query = toSearchableString(query) + return users + .map(user => ({ user, matchIndex: user.searchString.indexOf(query) })) + .filter(({ matchIndex }) => matchIndex !== -1) + .sort((e1, e2) => e1.matchIndex - e2.matchIndex) + .map(({ user }) => user) +} + +export function getAutocompleteMemberList(room: RoomStateStore) { + const states = room.state.get("m.room.member") + if (!states) { + return [] + } + const output = [] + for (const [stateKey, rowID] of states) { + const memberEvt = room.eventsByRowID.get(rowID) + if (!memberEvt) { + continue + } + const content = memberEvt.content as MemberEventContent + output.push({ + userID: stateKey, + displayName: content.displayname ?? stateKey, + avatarURL: content.avatar_url, + searchString: toSearchableString(`${content.displayname ?? ""}${stateKey.slice(1)}`), + }) + } + return output +} + +interface filteredUserCache { + query: string + result: AutocompleteUser[] +} + +export function useFilteredMembers(room: RoomStateStore, query: string): AutocompleteUser[] { + const allMembers = useMemo(() => getAutocompleteMemberList(room), [room]) + const prev = useRef({ query: "", result: allMembers }) + if (!query) { + prev.current.query = "" + prev.current.result = allMembers + } else if (prev.current.query !== query) { + prev.current.result = filterAndSort( + query.startsWith(prev.current.query) ? prev.current.result : allMembers, + query, + ) + prev.current.query = query + } + return prev.current.result +} diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 6d95555..d9933fe 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -14,9 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback, useRef, useState } from "react" -import { toSearchableString } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" +import toSearchableString from "@/util/searchablestring.ts" import { ClientContext } from "../ClientContext.ts" import Entry from "./Entry.tsx" import "./RoomList.css" diff --git a/web/src/util/searchablestring.ts b/web/src/util/searchablestring.ts new file mode 100644 index 0000000..d147505 --- /dev/null +++ b/web/src/util/searchablestring.ts @@ -0,0 +1,27 @@ +// 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 unhomoglyph from "unhomoglyph" + +// Based on matrix-js-sdk: https://github.com/matrix-org/matrix-js-sdk/blob/v34.9.0/src/utils.ts#L309-L355 + +// eslint-disable-next-line no-misleading-character-class +const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g + +export default function toSearchableString(str: string): string { + return unhomoglyph(str.normalize("NFD").toLowerCase().replace(removeHiddenCharsRegex, "")) + .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") + .toLowerCase() +}