([])
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()
+}