Allow sending emojis in the chat

Add an emoji picker to the dashboard reply composer and let generated
replies use emoji.

The picker offers a short hand-picked set rather than a full Unicode
index, and inserts at the caret so the operator can keep typing without
reaching for the mouse again. Insertion respects the Reddit length cap.

The chat reply system prompt now permits at most one or two emoji per
message, only where someone texting casually would use one. Scoped to
ongoing replies — openers build from configurable prompts, so hardcoding
guidance there would fight the operator's own settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-14 09:45:22 +01:00
1 parent 2447563c60
commit b5304e24e3
5 files changed
+118 -1

No files matched your search

@@ -33,6 +33,17 @@ const AT_BOTTOM_THRESHOLD_PX = 48;
const UNREAD_DIVIDER_HEADROOM_PX = 12;
/** Grace period before an open conversation is considered read. */
export const AUTO_MARK_READ_DELAY_MS = 1_000;
/**
* Emoji offered by the composer picker. Deliberately a short hand-picked set
* rather than a full Unicode index — the operator wants the handful that come
* up in casual chat, not a searchable catalogue.
*/
export const COMPOSER_EMOJI = [
'😊', '😄', '😉', '😅', '😂', '🙃',
'😍', '🥰', '😘', '😏', '😈', '🥵',
'🙈', '👀', '🔥', '✨', '❤️', '💜',
'👋', '🙌', '🤔', '😴', '😇', '🥺',
] as const;
interface ConversationDetailProps {
conversationId: string;
@@ -207,8 +218,10 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
const [isMediaShareOpen, setIsMediaShareOpen] = useState(false);
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
const [wasMediaShared, setWasMediaShared] = useState(false);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const mediaShareSummaryRef = useRef<HTMLElement>(null);
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversationId);
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
const sendMutation = useSendReplyMutation(conversationId);
@@ -247,6 +260,28 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
else setRestorableDraft(null);
}
/**
* Splices an emoji in at the caret (replacing any selection) and leaves the
* caret after it, so the operator can keep typing without reaching for the
* mouse again. Silently no-ops when the draft is already at the length cap,
* which matches how the textarea itself refuses further typing.
*/
function insertEmoji(emoji: string) {
const textarea = replyTextareaRef.current;
const start = textarea?.selectionStart ?? draft.length;
const end = textarea?.selectionEnd ?? draft.length;
const next = draft.slice(0, start) + emoji + draft.slice(end);
if (next.length > REDDIT_CHAT_MAX_MESSAGE_LENGTH) return;
updateDraft(next);
setRestorableDraft(null);
setWasSent(false);
const caret = start + emoji.length;
queueMicrotask(() => {
textarea?.focus();
textarea?.setSelectionRange(caret, caret);
});
}
function suggestReply() {
setWasSent(false);
suggestionMutation.mutate(undefined, { onSuccess: applySuggestion });
@@ -301,9 +336,28 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
setWasSent(false);
}}
placeholder="Write or generate a reply"
ref={replyTextareaRef}
rows={5}
value={draft}
/>
<div className="conversation-emoji">
<button
aria-expanded={isEmojiPickerOpen}
onClick={() => setIsEmojiPickerOpen((current) => !current)}
type="button"
>
{isEmojiPickerOpen ? 'Hide emoji' : 'Add emoji'}
</button>
{isEmojiPickerOpen ? (
<div aria-label="Emoji" className="conversation-emoji-options" role="group">
{COMPOSER_EMOJI.map((emoji) => (
<button key={emoji} onClick={() => insertEmoji(emoji)} type="button">
{emoji}
</button>
))}
</div>
) : null}
</div>
{restorableDraft !== null ? (
<div className="conversation-inline-notice" role="status">
<span>The suggestion replaced your draft.</span>
@@ -147,6 +147,10 @@
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.conversation-section-heading h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
.conversation-emoji { display: grid; gap: .5rem; justify-items: start; }
.conversation-emoji > button { min-height: 2rem !important; padding: .3rem .65rem !important; }
.conversation-emoji-options { display: flex; flex-wrap: wrap; gap: .3rem; padding: .5rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
.conversation-emoji-options button { min-height: 2.1rem !important; padding: .2rem .4rem !important; font-size: 1.1rem; line-height: 1; }
.conversation-char-counter.is-warning { color: var(--warning-text) !important; font-weight: 800; }
.conversation-inline-error, .conversation-inline-success, .conversation-inline-notice { padding: .65rem .75rem; border-radius: .55rem; }
.conversation-inline-notice { display: flex; align-items: center; justify-content: space-between; gap: .75rem; color: var(--warning-text); background: var(--warning-bg); border: 1px solid var(--warning-border); }