mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 18:43:41 -05:00
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
// 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 { JSX, RefObject, use, useEffect } from "react"
|
|
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
|
|
import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
|
import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
|
|
import { escapeMarkdown } from "@/util/markdown.ts"
|
|
import useEvent from "@/util/useEvent.ts"
|
|
import ClientContext from "../ClientContext.ts"
|
|
import type { ComposerState } from "./MessageComposer.tsx"
|
|
import { useFilteredMembers } from "./userautocomplete.ts"
|
|
import "./Autocompleter.css"
|
|
|
|
export interface AutocompleteQuery {
|
|
type: "user" | "room" | "emoji"
|
|
query: string
|
|
startPos: number
|
|
endPos: number
|
|
frozenQuery?: string
|
|
selected?: number
|
|
close?: boolean
|
|
}
|
|
|
|
export interface AutocompleterProps {
|
|
setState: (state: Partial<ComposerState>) => void
|
|
setAutocomplete: (params: AutocompleteQuery | null) => void
|
|
textInput: RefObject<HTMLTextAreaElement | null>
|
|
state: ComposerState
|
|
params: AutocompleteQuery
|
|
room: RoomStateStore
|
|
}
|
|
|
|
const positiveMod = (val: number, div: number) => (val % div + div) % div
|
|
|
|
interface InnerAutocompleterProps<T> extends AutocompleterProps {
|
|
items: T[]
|
|
getText: (item: T) => string
|
|
getKey: (item: T) => string
|
|
render: (item: T) => JSX.Element
|
|
}
|
|
|
|
function useAutocompleter<T>({
|
|
params, state, setState, setAutocomplete, textInput,
|
|
items, getText, getKey, render,
|
|
}: InnerAutocompleterProps<T>) {
|
|
const onSelect = useEvent((index: number) => {
|
|
if (items.length === 0) {
|
|
return
|
|
}
|
|
index = positiveMod(index, items.length)
|
|
const replacementText = getText(items[index])
|
|
const newText = state.text.slice(0, params.startPos) + replacementText + state.text.slice(params.endPos)
|
|
const endPos = params.startPos + replacementText.length
|
|
if (textInput.current) {
|
|
// React messes up the selection when changing the value for some reason,
|
|
// so bypass react here to avoid the caret jumping to the end and closing the autocompleter
|
|
textInput.current.value = newText
|
|
textInput.current.setSelectionRange(endPos, endPos)
|
|
}
|
|
setState({
|
|
text: newText,
|
|
})
|
|
setAutocomplete({
|
|
...params,
|
|
endPos,
|
|
frozenQuery: params.frozenQuery ?? params.query,
|
|
})
|
|
document.querySelector(`div.autocompletion-item[data-index='${index}']`)?.scrollIntoView({ block: "nearest" })
|
|
})
|
|
const onClick = (evt: React.MouseEvent<HTMLDivElement>) => {
|
|
const idx = evt.currentTarget.getAttribute("data-index")
|
|
if (idx) {
|
|
onSelect(+idx)
|
|
setAutocomplete(null)
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
if (params.selected !== undefined) {
|
|
onSelect(params.selected)
|
|
if (params.close) {
|
|
setAutocomplete(null)
|
|
}
|
|
}
|
|
}, [onSelect, setAutocomplete, params.selected, params.close])
|
|
const selected = params.selected !== undefined ? positiveMod(params.selected, items.length) : -1
|
|
return <div
|
|
className={`autocompletions ${items.length === 0 ? "empty" : "has-items"}`}
|
|
id="composer-autocompletions"
|
|
>
|
|
{items.map((item, i) => <div
|
|
onClick={onClick}
|
|
data-index={i}
|
|
className={`autocompletion-item ${selected === i ? "selected" : ""}`}
|
|
key={getKey(item)}
|
|
>{render(item)}</div>)}
|
|
</div>
|
|
}
|
|
|
|
const emojiFuncs = {
|
|
getText: (emoji: Emoji) => emojiToMarkdown(emoji),
|
|
getKey: (emoji: Emoji) => `${emoji.c}-${emoji.u}`,
|
|
render: (emoji: Emoji) => <>{emoji.u.startsWith("mxc://")
|
|
? <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
|
|
: emoji.u
|
|
} :{emoji.n}:</>,
|
|
}
|
|
|
|
export const EmojiAutocompleter = ({ params, room, ...rest }: AutocompleterProps) => {
|
|
const client = use(ClientContext)!
|
|
const customEmojiPacks = useCustomEmojis(client.store, room)
|
|
const items = useSortedAndFilteredEmojis((params.frozenQuery ?? params.query).slice(1), {
|
|
frequentlyUsed: client.store.frequentlyUsedEmoji,
|
|
customEmojiPacks,
|
|
})
|
|
return useAutocompleter({ params, room, ...rest, items, ...emojiFuncs })
|
|
}
|
|
|
|
const escapeDisplayname = (input: string) => escapeMarkdown(input).replace("\n", " ")
|
|
|
|
const userFuncs = {
|
|
getText: (user: AutocompleteMemberEntry) =>
|
|
`[${escapeDisplayname(user.displayName)}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `,
|
|
getKey: (user: AutocompleteMemberEntry) => user.userID,
|
|
render: (user: AutocompleteMemberEntry) => <>
|
|
<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) => {
|
|
return <div className="autocompletions">
|
|
Autocomplete {params.type} {params.query}
|
|
</div>
|
|
}
|