mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/composer: add slightly hacky user mention autocompleter
This commit is contained in:
parent
0696a43208
commit
e9abcd50d1
6 changed files with 130 additions and 15 deletions
|
@ -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,
|
||||
|
@ -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[]>([])
|
||||
|
|
|
@ -15,6 +15,9 @@ div.autocompletions {
|
|||
> .autocompletion-item {
|
||||
padding: .25rem;
|
||||
border-radius: .25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
/*cursor: pointer;*/
|
||||
|
||||
&.selected, &:hover {
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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) => {
|
||||
|
|
78
web/src/ui/composer/userautocomplete.ts
Normal file
78
web/src/ui/composer/userautocomplete.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
|
|
27
web/src/util/searchablestring.ts
Normal file
27
web/src/util/searchablestring.ts
Normal file
|
@ -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()
|
||||
}
|
Loading…
Add table
Reference in a new issue