From 4cc8c1df71306932bd021e012f84f3b200e716d5 Mon Sep 17 00:00:00 2001 From: Luis Padron Date: Fri, 24 Apr 2026 12:14:57 -0400 Subject: [PATCH] feat: add emoji auto-complete via :name: (#395) --- .../messages/lib/useEmojiAutocomplete.ts | 223 ++++++++++++++++++ .../messages/ui/EmojiAutocomplete.tsx | 68 ++++++ .../features/messages/ui/MessageComposer.tsx | 49 +++- package-lock.json | 6 + 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/lib/useEmojiAutocomplete.ts create mode 100644 desktop/src/features/messages/ui/EmojiAutocomplete.tsx create mode 100644 package-lock.json diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts new file mode 100644 index 000000000..d655911d2 --- /dev/null +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -0,0 +1,223 @@ +import * as React from "react"; + +import { init, SearchIndex } from "emoji-mart"; +import data from "@emoji-mart/data"; + +export type EmojiSuggestion = { + id: string; + name: string; + native: string; +}; + +const EMOJI_DEBOUNCE_MS = 120; +const MIN_QUERY_LENGTH = 2; +const MAX_RESULTS = 8; + +// Initialize emoji-mart search index once +init({ data }); + +/** + * Detect an emoji shortcode query at the cursor position. + * Matches `:query` where `:` is preceded by whitespace or start-of-string, + * and `query` contains no whitespace or `:`. + */ +function detectEmojiQuery( + value: string, + cursorPosition: number, +): { query: string; startIndex: number } | null { + const beforeCursor = value.slice(0, cursorPosition); + const match = beforeCursor.match(/(?:^|[\s])(:([^\s:]{2,})?)$/); + if (!match) return null; + + const full = match[1]; // includes the `:` + const query = match[2]; // just the text after `:` + if (!query || query.length < MIN_QUERY_LENGTH) return null; + + const startIndex = beforeCursor.length - full.length; + return { query, startIndex }; +} + +export function useEmojiAutocomplete() { + const [emojiQuery, setEmojiQuery] = React.useState(null); + const [emojiStartIndex, setEmojiStartIndex] = React.useState(0); + const [emojiSelectedIndex, setEmojiSelectedIndex] = React.useState(0); + const [suggestions, setSuggestions] = React.useState([]); + + const debounceTimerRef = React.useRef | null>( + null, + ); + const latestValueRef = React.useRef(""); + const latestCursorRef = React.useRef(0); + + // Clean up pending timeout on unmount + React.useEffect(() => { + return () => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Search emoji-mart when query changes + React.useEffect(() => { + if (emojiQuery === null) { + setSuggestions([]); + return; + } + + let cancelled = false; + SearchIndex.search(emojiQuery) + .then( + ( + results: Array<{ + id: string; + name: string; + skins: Array<{ native: string }>; + }> | null, + ) => { + if (cancelled) return; + const mapped: EmojiSuggestion[] = (results ?? []) + .slice(0, MAX_RESULTS) + .map((emoji) => ({ + id: emoji.id, + name: emoji.name, + native: emoji.skins[0]?.native ?? "", + })) + .filter((e) => e.native !== ""); + setSuggestions(mapped); + setEmojiSelectedIndex(0); + }, + ) + .catch(() => { + if (cancelled) return; + setSuggestions([]); + }); + + return () => { + cancelled = true; + }; + }, [emojiQuery]); + + const isEmojiAutocompleteOpen = emojiQuery !== null && suggestions.length > 0; + + const insertEmoji = React.useCallback( + ( + suggestion: EmojiSuggestion, + content: string, + selectionEnd: number, + ): { nextContent: string; nextCursor: number } => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + const before = content.slice(0, emojiStartIndex); + const after = content.slice(selectionEnd); + const inserted = `${suggestion.native} `; + const nextContent = `${before}${inserted}${after}`; + const nextCursor = before.length + inserted.length; + + setEmojiQuery(null); + setEmojiSelectedIndex(0); + + return { nextContent, nextCursor }; + }, + [emojiStartIndex], + ); + + const updateEmojiQuery = React.useCallback( + (value: string, cursorPosition: number) => { + latestValueRef.current = value; + latestCursorRef.current = cursorPosition; + + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + const result = detectEmojiQuery( + latestValueRef.current, + latestCursorRef.current, + ); + if (result) { + setEmojiQuery(result.query); + setEmojiStartIndex(result.startIndex); + } else { + setEmojiQuery(null); + } + }, EMOJI_DEBOUNCE_MS); + }, + [], + ); + + const clearEmojis = React.useCallback(() => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setEmojiQuery(null); + setEmojiSelectedIndex(0); + setSuggestions([]); + }, []); + + const handleEmojiKeyDown = React.useCallback( + ( + event: React.KeyboardEvent, + ): { handled: boolean; suggestion?: EmojiSuggestion } => { + if (!isEmojiAutocompleteOpen) { + return { handled: false }; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setEmojiSelectedIndex((current) => + current < suggestions.length - 1 ? current + 1 : 0, + ); + return { handled: true }; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setEmojiSelectedIndex((current) => + current > 0 ? current - 1 : suggestions.length - 1, + ); + return { handled: true }; + } + + if ( + event.key === "Tab" || + (event.key === "Enter" && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey) + ) { + event.preventDefault(); + return { + handled: true, + suggestion: suggestions[emojiSelectedIndex], + }; + } + + if (event.key === "Escape") { + event.preventDefault(); + setEmojiQuery(null); + return { handled: true }; + } + + return { handled: false }; + }, + [isEmojiAutocompleteOpen, emojiSelectedIndex, suggestions], + ); + + return { + clearEmojis, + emojiSelectedIndex, + emojiSuggestions: suggestions, + handleEmojiKeyDown, + insertEmoji, + isEmojiAutocompleteOpen, + updateEmojiQuery, + }; +} diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx new file mode 100644 index 000000000..e458d8cce --- /dev/null +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; +import { cn } from "@/shared/lib/cn"; + +type EmojiAutocompleteProps = { + suggestions: EmojiSuggestion[]; + selectedIndex: number; + onSelect: (suggestion: EmojiSuggestion) => void; + position?: "above" | "below"; +}; + +export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ + suggestions, + selectedIndex, + onSelect, + position = "above", +}: EmojiAutocompleteProps) { + const listRef = React.useRef(null); + + React.useEffect(() => { + const activeItem = listRef.current?.children[selectedIndex] as + | HTMLElement + | undefined; + activeItem?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (suggestions.length === 0) { + return null; + } + + return ( +
+
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
+ ); +}); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 06bad1f23..3a5380a82 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -5,6 +5,8 @@ import { X } from "lucide-react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; +import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { ALLOWED_MEDIA_TYPES, @@ -20,6 +22,7 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments } from "./ComposerAttachments"; +import { EmojiAutocomplete } from "./EmojiAutocomplete"; import { MentionAutocomplete, type MentionSuggestion, @@ -90,6 +93,7 @@ export function MessageComposer({ const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); + const emojiAutocomplete = useEmojiAutocomplete(); const notifyTyping = useTypingBroadcast( channelId, typingParentEventId, @@ -118,7 +122,9 @@ export function MessageComposer({ // ── Refs consumed by Tiptap's submitOnEnter extension ────────────── const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = - mentions.isMentionOpen || channelLinks.isChannelOpen; + mentions.isMentionOpen || + channelLinks.isChannelOpen || + emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); @@ -142,10 +148,11 @@ export function MessageComposer({ setContent(markdown); contentRef.current = markdown; - // Bridge to existing mention/channel detection hooks. + // Bridge to existing mention/channel/emoji detection hooks. const { cursor } = richText.getTextAndCursor(); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); + emojiAutocomplete.updateEmojiQuery(text, cursor); if (text.trim().length > 0) { notifyTyping(); @@ -180,6 +187,7 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); return () => { if (effectiveDraftKey) { @@ -237,6 +245,21 @@ export function MessageComposer({ ], ); + const applyEmojiInsert = React.useCallback( + (suggestion: EmojiSuggestion) => { + const { text, cursor } = richText.getTextAndCursor(); + const result = emojiAutocomplete.insertEmoji(suggestion, text, cursor); + richText.setContentWithTrailingSpace(result.nextContent); + setContent(result.nextContent); + contentRef.current = result.nextContent; + }, + [ + emojiAutocomplete.insertEmoji, + richText.getTextAndCursor, + richText.setContentWithTrailingSpace, + ], + ); + // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -293,6 +316,7 @@ export function MessageComposer({ richText.clearContent(); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); setIsEmojiPickerOpen(false); try { @@ -350,6 +374,7 @@ export function MessageComposer({ media.setPendingImeta([]); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); setIsEmojiPickerOpen(false); const sendChannelId = channelIdRef.current; @@ -373,6 +398,7 @@ export function MessageComposer({ channelLinks.clearChannels, richText.clearContent, richText.setContent, + emojiAutocomplete.clearEmojis, ]); submitMessageRef.current = submitMessage; @@ -392,6 +418,14 @@ export function MessageComposer({ const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { // Let autocomplete handle keys first + const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); + if (emojiResult.handled) { + if (emojiResult.suggestion) { + applyEmojiInsert(emojiResult.suggestion); + } + return; + } + const channelResult = channelLinks.handleChannelKeyDown(event); if (channelResult.handled) { if (channelResult.suggestion) { @@ -416,6 +450,8 @@ export function MessageComposer({ } }, [ + emojiAutocomplete.handleEmojiKeyDown, + applyEmojiInsert, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, @@ -511,6 +547,15 @@ export function MessageComposer({ handleSubmit(event); }} > +