A picker for tagging, with fuzzy matching
Typing "@" opens a list of who and what can be tagged here: the people in the conversation, the roles this service lets anybody ping, and its own whole-room words - "@everyone" and "@here" on Discord, "@room" on Matrix, "@everyone" on Sneedchat, nothing on IRC, which has no such idea. Arrows move, Enter or Tab takes, Escape dismisses, and the mouse does the same without stealing the caret. Matching is fuzzy rather than by prefix. The names here are long and full of things nobody types in order - "***GAYMER WORD USER***" - and a prefix match makes somebody type the punctuation. A prefix still wins, then a word start, then a run of adjacent letters, then anything in order. What gets inserted is what the service wants: "@name" everywhere except IRC, where a name at the start of a line becomes "name: " and the "@" would be a character that means nothing to anybody reading it. The composer's hooks were the first thing to go wrong here - two useMemos landed below an early return, which React reports as error #310 and which takes the whole window with it. Every hook is above it now.
This commit is contained in:
+1
-1
Submodule nobilis updated: cd18d6363e...c4cdb04503
@@ -4581,3 +4581,51 @@ video.lightbox-media {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- tagging someone -----------------------------------------------------
|
||||
Above the box, which is the only place it can go without covering what is
|
||||
being typed - and where every other client puts it. */
|
||||
.mention-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
margin: 0 var(--spacing-m);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.mention-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-s);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--surface-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* One highlight for both the keyboard and the mouse: the arrows move it and
|
||||
the pointer moves it, so there is never a second, competing selection. */
|
||||
.mention-option.active {
|
||||
background: var(--primary-hover-light);
|
||||
}
|
||||
|
||||
.mention-glyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--surface-variant-text);
|
||||
}
|
||||
|
||||
/* Pushed to the right, so the names line up and the explanations sit out of
|
||||
the way of reading them. */
|
||||
.mention-detail {
|
||||
margin-left: auto;
|
||||
max-width: 55%;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
|
||||
import { emojiPreview } from '../lib/format'
|
||||
import { bufferDisplayName, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
|
||||
import { completeNick, cyclePrefix, type Completion } from '../lib/completion'
|
||||
import {
|
||||
mentionInsert,
|
||||
mentionKeywords,
|
||||
mentionQuery,
|
||||
rankMentions,
|
||||
type MentionTarget
|
||||
} from '../lib/mentions'
|
||||
|
||||
interface StagedAttachment {
|
||||
id: number
|
||||
@@ -77,7 +85,22 @@ export function Composer(): JSX.Element | null {
|
||||
* would be better still; the roster's own order is what is in hand, and a
|
||||
* name that matches is far more use than no completion at all.
|
||||
*/
|
||||
const nicks = useChat((s) => s.presenceByBuffer)[buffer?.id ?? '']?.map((m) => m.nick) ?? []
|
||||
useEffect(() => {
|
||||
if (!buffer) return
|
||||
let live = true
|
||||
void window.moho
|
||||
.rpc<{ roles: { id: string; name: string; colour?: string }[] }>('listMentionRoles', {
|
||||
bufferId: buffer.id
|
||||
})
|
||||
.then((r) => live && setRoles(r.roles ?? []))
|
||||
.catch(() => live && setRoles([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [buffer?.id])
|
||||
|
||||
const roster = useChat((s) => s.presenceByBuffer)[buffer?.id ?? '']
|
||||
const nicks = roster?.map((m) => m.nick) ?? []
|
||||
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
@@ -85,6 +108,10 @@ export function Composer(): JSX.Element | null {
|
||||
* changing it must not redraw the box somebody is typing in.
|
||||
*/
|
||||
const cycle = useRef<{ last: Completion; attempt: number } | null>(null)
|
||||
/** The "@..." being typed, and which suggestion is selected. */
|
||||
const [mention, setMention] = useState<{ query: string; index: number } | null>(null)
|
||||
/** Discord's mentionable roles here; empty everywhere else. */
|
||||
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const smilies = useChat((s) => s.smilies)
|
||||
@@ -105,6 +132,36 @@ export function Composer(): JSX.Element | null {
|
||||
inputRef.current?.focus()
|
||||
}, [buffer?.id])
|
||||
|
||||
/**
|
||||
* Everything that can be tagged here: the people in the conversation, the
|
||||
* roles the service lets anybody ping, and its whole-room keywords.
|
||||
*/
|
||||
const mentionTargets = useMemo((): MentionTarget[] => {
|
||||
const members: MentionTarget[] = (roster ?? []).map((m) => ({
|
||||
name: m.nick,
|
||||
detail: m.userId && m.userId !== m.nick ? undefined : undefined,
|
||||
kind: 'member' as const,
|
||||
userId: m.userId
|
||||
}))
|
||||
const roleTargets: MentionTarget[] = roles.map((r) => ({
|
||||
name: r.name,
|
||||
detail: 'Role',
|
||||
colour: r.colour,
|
||||
kind: 'role' as const
|
||||
}))
|
||||
const keywords: MentionTarget[] = mentionKeywords(service).map((k) => ({
|
||||
name: k.word,
|
||||
detail: k.detail,
|
||||
kind: 'keyword' as const
|
||||
}))
|
||||
return [...members, ...roleTargets, ...keywords]
|
||||
}, [roster, roles, service])
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => (mention ? rankMentions(mentionTargets, mention.query) : []),
|
||||
[mention, mentionTargets]
|
||||
)
|
||||
|
||||
if (!buffer) return null
|
||||
|
||||
/**
|
||||
@@ -216,6 +273,56 @@ export function Composer(): JSX.Element | null {
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads the "@..." at the caret, so the list follows what is being typed. */
|
||||
const noteMention = (): void => {
|
||||
const selection = window.getSelection()
|
||||
const node = selection?.anchorNode
|
||||
if (!selection || !node || node.nodeType !== Node.TEXT_NODE) {
|
||||
setMention(null)
|
||||
return
|
||||
}
|
||||
const before = (node.textContent ?? '').slice(0, selection.anchorOffset)
|
||||
const query = mentionQuery(before)
|
||||
setMention(query === null ? null : { query, index: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a chosen name in the box, in the form its service wants.
|
||||
*
|
||||
* Replaces the "@query" that was being typed rather than appending, so the
|
||||
* half-typed name does not survive its own completion.
|
||||
*/
|
||||
const takeMention = (target: MentionTarget): void => {
|
||||
const selection = window.getSelection()
|
||||
const node = selection?.anchorNode
|
||||
if (!selection || !node || node.nodeType !== Node.TEXT_NODE || !mention) return
|
||||
|
||||
const offset = selection.anchorOffset
|
||||
const text = node.textContent ?? ''
|
||||
const start = offset - mention.query.length - 1
|
||||
if (start < 0) return
|
||||
|
||||
const atLineStart = text.slice(0, start).trim() === ''
|
||||
// A keyword is the service's own word and is always written with the "@",
|
||||
// even on IRC's own convention - though no IRC service offers one.
|
||||
const insert =
|
||||
target.kind === 'keyword' ? `@${target.name} ` : mentionInsert(service, target.name, atLineStart)
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(node, start)
|
||||
range.setEnd(node, offset)
|
||||
range.deleteContents()
|
||||
const inserted = document.createTextNode(insert)
|
||||
range.insertNode(inserted)
|
||||
range.setStartAfter(inserted)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
setMention(null)
|
||||
if (inputRef.current) setText(composerText(inputRef.current))
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the word before the caret with a roster name.
|
||||
*
|
||||
@@ -262,6 +369,43 @@ export function Composer(): JSX.Element | null {
|
||||
<div className="composer">
|
||||
<div className="divider-h" />
|
||||
|
||||
{/* Who can be tagged, above the box - the same place Discord and Element
|
||||
put it, and the only place it can go without covering what is being
|
||||
typed. Members first, then roles, then the service's whole-room
|
||||
words, because that is the order of how often each is meant. */}
|
||||
{mention && suggestions.length > 0 && (
|
||||
<div className="mention-picker" role="listbox" aria-label="Tag somebody">
|
||||
{suggestions.map((target, i) => (
|
||||
<button
|
||||
key={`${target.kind}:${target.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === mention.index}
|
||||
className={classes('mention-option', i === mention.index && 'active')}
|
||||
// Chosen on mousedown rather than click: a click would first
|
||||
// move focus out of the box, and the caret with it.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
takeMention(target)
|
||||
}}
|
||||
onMouseEnter={() => setMention({ ...mention, index: i })}
|
||||
>
|
||||
{target.kind === 'member' ? (
|
||||
<Avatar name={target.name} size={20} />
|
||||
) : (
|
||||
<span className="mention-glyph" style={target.colour ? { color: target.colour } : undefined}>
|
||||
<Icon name={target.kind === 'role' ? 'group' : 'campaign'} size={16} />
|
||||
</span>
|
||||
)}
|
||||
<span className="ellipsis" style={target.colour ? { color: target.colour } : undefined}>
|
||||
{target.kind === 'keyword' ? `@${target.name}` : target.name}
|
||||
</span>
|
||||
{target.detail && <span className="small muted ellipsis mention-detail">{target.detail}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypingLine bufferId={buffer.id} />
|
||||
|
||||
{whisperingTo && (
|
||||
@@ -335,6 +479,7 @@ export function Composer(): JSX.Element | null {
|
||||
onInput={(e) => {
|
||||
setText(composerText(e.currentTarget))
|
||||
noteTyping()
|
||||
noteMention()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends and Shift+Enter does nothing, which is what the
|
||||
@@ -342,6 +487,27 @@ export function Composer(): JSX.Element | null {
|
||||
// second line, but a newline reaching IRC is a malformed
|
||||
// PRIVMSG - nothing between here and the socket splits one - so
|
||||
// multi-line is a separate change with its own thinking to do.
|
||||
// While the mention list is up it owns the keys somebody is
|
||||
// already using to drive it - a list you steer with the arrows
|
||||
// and take with Enter is one nobody has to be taught.
|
||||
if (mention && suggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const step = e.key === 'ArrowDown' ? 1 : suggestions.length - 1
|
||||
setMention({ ...mention, index: (mention.index + step) % suggestions.length })
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
takeMention(suggestions[mention.index])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setMention(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (!e.shiftKey) submit()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Tagging somebody, in whichever protocol's terms.
|
||||
*
|
||||
* Every service here has the idea and none of them spell it the same way:
|
||||
* Discord pings on an id and treats a typed name as text, Matrix carries a
|
||||
* list of who was meant, IRC has no mentions at all and highlights on a nick
|
||||
* appearing anywhere in a line. What they share is the gesture - "@" and the
|
||||
* start of a name - so that is what this describes, and the differences are
|
||||
* kept here rather than in the composer.
|
||||
*/
|
||||
|
||||
export interface MentionKeyword {
|
||||
/** What is typed, without the "@". */
|
||||
word: string
|
||||
/** What it does, in the service's own terms. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole-room mentions a service understands.
|
||||
*
|
||||
* Only where the service actually acts on them: offering "@everyone" on IRC
|
||||
* would be offering a phrase that notifies nobody and looks like it did.
|
||||
*/
|
||||
export function mentionKeywords(service: string | undefined): MentionKeyword[] {
|
||||
switch (service) {
|
||||
case 'discord':
|
||||
return [
|
||||
{ word: 'everyone', detail: 'Notify everyone in this channel' },
|
||||
{ word: 'here', detail: 'Notify everyone who is online' }
|
||||
]
|
||||
case 'matrix':
|
||||
return [{ word: 'room', detail: 'Notify everyone in this room' }]
|
||||
case 'sneedchat':
|
||||
return [{ word: 'everyone', detail: 'Notify everyone in this room' }]
|
||||
// Kick has no whole-chat mention, and IRC has no mentions at all - a nick
|
||||
// highlights because it appears in the line, not because of the "@".
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to put in the box when a name is chosen.
|
||||
*
|
||||
* IRC addresses somebody at the start of a line with "nick: " and nowhere
|
||||
* else - the "@" is not part of how IRC talks, and leaving one in would put a
|
||||
* character in the message that means nothing to anyone reading it. Everywhere
|
||||
* else keeps the "@", which is both what people expect to see and, for
|
||||
* Discord, what the daemon turns into a real ping on the way out.
|
||||
*/
|
||||
export function mentionInsert(service: string | undefined, name: string, atLineStart: boolean): string {
|
||||
if (service === 'irc') return atLineStart ? `${name}: ` : `${name} `
|
||||
return `@${name} `
|
||||
}
|
||||
|
||||
/**
|
||||
* The mention being typed at the caret, or null.
|
||||
*
|
||||
* The "@" has to start a word, so an email address is not a mention. An empty
|
||||
* query counts: typing "@" alone is somebody asking who is here, which is
|
||||
* exactly when the list is most useful.
|
||||
*/
|
||||
export function mentionQuery(before: string): string | null {
|
||||
const match = before.match(/(?:^|[\s(])@([^\s@]*)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a candidate matches what has been typed, or 0 for not at all.
|
||||
*
|
||||
* Fuzzy rather than prefix, because names here are long and full of things
|
||||
* nobody types in order - "***GAYMER WORD USER***", "Temu Blue Belt" - and a
|
||||
* prefix match makes somebody type the punctuation. The letters must appear
|
||||
* in order, which is what keeps the list from filling with everybody.
|
||||
*
|
||||
* Scoring, highest first: a prefix beats a word start, which beats a match
|
||||
* anywhere; a run of adjacent letters beats a scattered one; and a shorter
|
||||
* name beats a longer one where all else is equal, since the short one is
|
||||
* more likely the thing being aimed at.
|
||||
*/
|
||||
export function fuzzyScore(candidate: string, query: string): number {
|
||||
if (!query) return 1
|
||||
const name = candidate.toLowerCase()
|
||||
const q = query.toLowerCase()
|
||||
|
||||
if (name.startsWith(q)) return 1000 - name.length
|
||||
const wordStart = name.split(/[\s_\-.*]+/).some((word) => word.startsWith(q))
|
||||
let score = wordStart ? 700 - name.length : 0
|
||||
|
||||
// Subsequence: every letter of the query, in order, somewhere in the name.
|
||||
let at = -1
|
||||
let run = 0
|
||||
let best = 0
|
||||
for (const letter of q) {
|
||||
const found = name.indexOf(letter, at + 1)
|
||||
if (found === -1) return score > 0 ? score : 0
|
||||
run = found === at + 1 ? run + 1 : 1
|
||||
best = Math.max(best, run)
|
||||
at = found
|
||||
}
|
||||
return Math.max(score, 300 + best * 10 - name.length)
|
||||
}
|
||||
|
||||
/** One thing that can be tagged: a person, a role, or a whole-room keyword. */
|
||||
export interface MentionTarget {
|
||||
/** What is inserted, without the "@". */
|
||||
name: string
|
||||
/** Shown to the right - a handle, or what a keyword does. */
|
||||
detail?: string
|
||||
/** Roles carry their own colour, which is how people recognise them. */
|
||||
colour?: string
|
||||
kind: 'member' | 'role' | 'keyword'
|
||||
/** Members only, for the picture. */
|
||||
userId?: string
|
||||
}
|
||||
|
||||
/** The targets that match, best first. */
|
||||
export function rankMentions(targets: MentionTarget[], query: string, limit = 8): MentionTarget[] {
|
||||
return targets
|
||||
.map((target) => ({ target, score: fuzzyScore(target.name, query) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ target }) => target)
|
||||
}
|
||||
Reference in New Issue
Block a user