feat: add emoji auto-complete via :name: (#395)

This commit is contained in:
Luis Padron
2026-04-24 16:14:57 +00:00
committed by GitHub
parent da0506f4ea
commit 4cc8c1df71
4 changed files with 344 additions and 2 deletions
@@ -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<string | null>(null);
const [emojiStartIndex, setEmojiStartIndex] = React.useState(0);
const [emojiSelectedIndex, setEmojiSelectedIndex] = React.useState(0);
const [suggestions, setSuggestions] = React.useState<EmojiSuggestion[]>([]);
const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const latestValueRef = React.useRef<string>("");
const latestCursorRef = React.useRef<number>(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,
};
}
@@ -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<HTMLDivElement>(null);
React.useEffect(() => {
const activeItem = listRef.current?.children[selectedIndex] as
| HTMLElement
| undefined;
activeItem?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (suggestions.length === 0) {
return null;
}
return (
<div
className={cn(
"absolute left-0 right-0 z-50 px-3 sm:px-4",
position === "below" ? "top-full mt-1" : "bottom-full mb-1",
)}
>
<div
className="max-h-48 overflow-y-auto rounded-xl border bg-popover p-1 shadow-lg"
ref={listRef}
>
{suggestions.map((suggestion, index) => (
<button
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/50",
)}
key={suggestion.id}
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
tabIndex={-1}
type="button"
>
<span className="text-lg leading-none">{suggestion.native}</span>
<span className="truncate text-muted-foreground">
:{suggestion.id}:
</span>
</button>
))}
</div>
</div>
);
});
@@ -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<HTMLDivElement>) => {
// 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);
}}
>
<EmojiAutocomplete
onSelect={applyEmojiInsert}
selectedIndex={emojiAutocomplete.emojiSelectedIndex}
suggestions={
emojiAutocomplete.isEmojiAutocompleteOpen
? emojiAutocomplete.emojiSuggestions
: []
}
/>
<ChannelAutocomplete
onSelect={applyChannelInsert}
selectedIndex={channelLinks.channelSelectedIndex}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "sprout",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}