Skip to content

Commit

Permalink
web/composer: add slightly hacky user mention autocompleter
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Oct 23, 2024
1 parent 0696a43 commit e9abcd5
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 15 deletions.
11 changes: 1 addition & 10 deletions web/src/api/statestore/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
//
// 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 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,
Expand Down Expand Up @@ -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<RoomID, RoomStateStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
Expand Down
3 changes: 3 additions & 0 deletions web/src/ui/composer/Autocompleter.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ div.autocompletions {
> .autocompletion-item {
padding: .25rem;
border-radius: .25rem;
display: flex;
align-items: center;
gap: .25rem;
/*cursor: pointer;*/

&.selected, &:hover {
Expand Down
24 changes: 20 additions & 4 deletions web/src/ui/composer/Autocompleter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
// 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 { 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 {
Expand Down Expand Up @@ -100,10 +102,24 @@ export const EmojiAutocompleter = ({ params, ...rest }: AutocompleterProps) => {
return useAutocompleter({ params, ...rest, items, ...emojiFuncs })
}

export const UserAutocompleter = ({ params }: AutocompleterProps) => {
return <div className="autocompletions">
Autocomplete {params.type} {params.query}
</div>
const userFuncs = {
getText: (user: AutocompleteUser) =>
`[${user.displayName}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `,
getKey: (user: AutocompleteUser) => user.userID,
render: (user: AutocompleteUser) => <>
<img
className="small avatar"
loading="lazy"
src={getAvatarURL(user.userID, { displayname: user.displayName, avatar_url: user.avatarURL })}
alt=""
/>
{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) => {
Expand Down
78 changes: 78 additions & 0 deletions web/src/ui/composer/userautocomplete.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
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<filteredUserCache>({ 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
}
2 changes: 1 addition & 1 deletion web/src/ui/roomlist/RoomList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
// 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, 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"
Expand Down
27 changes: 27 additions & 0 deletions web/src/util/searchablestring.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
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()
}

0 comments on commit e9abcd5

Please sign in to comment.