Add emoji shortcode autocomplete to the composer (#54)

Typing ":name" now shows a matching-shortcode dropdown (same
join/leave/arrow-key UX as the existing @mention and #room autocompletes),
selecting one inserts the actual glyph immediately rather than leaving
literal ":name:" text. A bare ":" with nothing typed yet suggests
recently-used emoji instead of an arbitrary slice of the ~950 known
shortcodes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:59:51 -06:00
co-authored by Claude Sonnet 5
parent 4cc3823adf
commit 2fd055f7d6
4 changed files with 154 additions and 0 deletions
+94
View File
@@ -12,9 +12,12 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus' import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms' import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads' import { getUploadLimit } from '../api/uploads'
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete'
import { MentionAutocomplete } from './MentionAutocomplete' import { MentionAutocomplete } from './MentionAutocomplete'
import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete' import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete'
import './Composer.css' import './Composer.css'
@@ -63,6 +66,19 @@ function detectRoomReferenceQuery(text: string, cursor: number): TriggerQuery |
return detectTriggerQuery(text, cursor, '#') return detectTriggerQuery(text, cursor, '#')
} }
// #54: a dedicated scan rather than detectTriggerQuery(text, cursor, ':')
// -- shortcode names (see emojiShortcodes.ts) can contain '+'/'-' (':+1:',
// ':t-rex:') but never '.', the reverse of what the shared @/# charset
// allows, so it doesn't fit that helper's single fixed charset.
function detectEmojiQuery(text: string, cursor: number): TriggerQuery | null {
let i = cursor - 1
while (i >= 0 && /[a-zA-Z0-9_+-]/.test(text[i])) i--
if (i < 0 || text[i] !== ':') return null
const prevChar = text[i - 1]
if (prevChar && /\w/.test(prevChar)) return null
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
}
interface AttachMenuProps { interface AttachMenuProps {
onPickPhoto: () => void onPickPhoto: () => void
onPickFile: () => void onPickFile: () => void
@@ -104,6 +120,8 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
const [mentionActiveIndex, setMentionActiveIndex] = useState(0) const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null) const [roomQuery, setRoomQuery] = useState<TriggerQuery | null>(null)
const [roomActiveIndex, setRoomActiveIndex] = useState(0) const [roomActiveIndex, setRoomActiveIndex] = useState(0)
const [emojiQuery, setEmojiQuery] = useState<TriggerQuery | null>(null)
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false) const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -136,6 +154,26 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8) return rooms.filter((r) => r.name.toLowerCase().startsWith(q)).slice(0, 8)
}, [roomQuery, rooms]) }, [roomQuery, rooms])
const emojiMatches = useMemo((): EmojiShortcodeMatch[] => {
if (!emojiQuery) return []
const q = emojiQuery.text.toLowerCase()
// A bare ":" with nothing typed yet -- suggest recently-used emoji
// (already capped to 8, see recentEmoji.ts) rather than an arbitrary
// slice of the ~950 known shortcodes.
if (!q) {
return getRecentEmoji()
.map((glyph) => {
const shortcode = SHORTCODE_BY_GLYPH[glyph]
return shortcode ? { shortcode, glyph } : null
})
.filter((match): match is EmojiShortcodeMatch => match !== null)
}
return Object.keys(EMOJI_SHORTCODES)
.filter((shortcode) => shortcode.startsWith(q))
.slice(0, 8)
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
}, [emojiQuery])
useEffect(() => { useEffect(() => {
getUploadLimit() getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes)) .then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -159,6 +197,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
setValue('') setValue('')
setMentionQuery(null) setMentionQuery(null)
setRoomQuery(null) setRoomQuery(null)
setEmojiQuery(null)
removePendingImage() removePendingImage()
setPendingFile(null) setPendingFile(null)
requestAnimationFrame(autoGrow) requestAnimationFrame(autoGrow)
@@ -196,6 +235,27 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
}) })
} }
function selectEmojiShortcode(shortcode: string) {
const query = emojiQuery
const glyph = EMOJI_SHORTCODES[shortcode]
if (!query || !glyph) return
// Matches EmojiPicker's own insertEmoji -- a shortcode-completed emoji
// counts as "used" the same as one picked from the picker, so it
// shows up there too next time.
recordEmojiUsed(glyph)
const el = textareaRef.current
const next = value.slice(0, query.start) + glyph + ' ' + value.slice(query.end)
setValue(next)
setEmojiQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + glyph.length + 1 // glyph + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) { if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@@ -241,6 +301,28 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
return return
} }
} }
if (emojiQuery && emojiMatches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setEmojiActiveIndex((i) => (i + 1) % emojiMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setEmojiActiveIndex((i) => (i - 1 + emojiMatches.length) % emojiMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectEmojiShortcode(emojiMatches[emojiActiveIndex].shortcode)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setEmojiQuery(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
@@ -258,6 +340,8 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(el.value, cursor)) setRoomQuery(detectRoomReferenceQuery(el.value, cursor))
setRoomActiveIndex(0) setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(el.value, cursor))
setEmojiActiveIndex(0)
} }
async function handleFile(file: File) { async function handleFile(file: File) {
@@ -486,6 +570,8 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
setMentionActiveIndex(0) setMentionActiveIndex(0)
setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor)) setRoomQuery(detectRoomReferenceQuery(e.target.value, cursor))
setRoomActiveIndex(0) setRoomActiveIndex(0)
setEmojiQuery(detectEmojiQuery(e.target.value, cursor))
setEmojiActiveIndex(0)
}} }}
onSelect={handleSelectionChange} onSelect={handleSelectionChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -514,6 +600,14 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, onS
onHover={setRoomActiveIndex} onHover={setRoomActiveIndex}
/> />
)} )}
{emojiQuery && emojiMatches.length > 0 && (
<EmojiShortcodeAutocomplete
matches={emojiMatches}
activeIndex={emojiActiveIndex}
onPick={selectEmojiShortcode}
onHover={setEmojiActiveIndex}
/>
)}
</div> </div>
<button <button
type="button" type="button"
@@ -40,6 +40,11 @@
color: var(--ds-accent); color: var(--ds-accent);
} }
.composer-autocomplete-emoji-glyph {
font-size: 1rem;
line-height: 1;
}
.composer-autocomplete-secondary { .composer-autocomplete-secondary {
font-size: 0.76rem; font-size: 0.76rem;
color: var(--ds-muted); color: var(--ds-muted);
@@ -0,0 +1,43 @@
import './ComposerAutocomplete.css'
export interface EmojiShortcodeMatch {
shortcode: string
glyph: string
}
interface EmojiShortcodeAutocompleteProps {
matches: EmojiShortcodeMatch[]
activeIndex: number
onPick: (shortcode: string) => void
onHover: (index: number) => void
}
export function EmojiShortcodeAutocomplete({
matches,
activeIndex,
onPick,
onHover,
}: EmojiShortcodeAutocompleteProps) {
return (
<div className="composer-autocomplete" role="listbox">
{matches.map((match, i) => (
<button
key={match.shortcode}
type="button"
role="option"
aria-selected={i === activeIndex}
className={`composer-autocomplete-item${i === activeIndex ? ' composer-autocomplete-item-active' : ''}`}
// Selecting must survive the textarea's blur (which would
// otherwise fire first and could dismiss the dropdown) --
// onMouseDown fires before blur, onClick fires after.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(match.shortcode)}
onMouseEnter={() => onHover(i)}
>
<span className="composer-autocomplete-emoji-glyph">{match.glyph}</span>
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
</button>
))}
</div>
)
}
+12
View File
@@ -965,3 +965,15 @@ export const EMOJI_SHORTCODES: Record<string, string> = {
'zebra': '🦓', 'zebra': '🦓',
'zipper_mouth_face': '🤐', 'zipper_mouth_face': '🤐',
} }
// Reverse of the above, for #54's composer autocomplete: showing recently-
// used emoji (tracked by glyph, see recentEmoji.ts) as suggestions when the
// user has just typed a bare ":" with nothing after it yet. Several glyphs
// have more than one valid shortcode (e.g. 🖕 is both 'fu' and
// 'middle_finger') -- first one wins, in the object's own key order, which
// is deterministic but otherwise arbitrary.
export const SHORTCODE_BY_GLYPH: Record<string, string> = Object.fromEntries(
Object.entries(EMOJI_SHORTCODES)
.reverse()
.map(([shortcode, glyph]) => [glyph, shortcode]),
)