mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add emoji auto-complete via :name: (#395)
This commit is contained in:
@@ -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}
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "sprout",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user