Private
Public Access
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user