diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx
index a0d42c456..151283829 100644
--- a/desktop/src/features/messages/ui/MessageActionBar.tsx
+++ b/desktop/src/features/messages/ui/MessageActionBar.tsx
@@ -15,12 +15,20 @@ import { toast } from "sonner";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
+import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { getThreadReference } from "@/features/messages/lib/threading";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
+import {
+ recordQuickReactionEmoji,
+ useQuickReactionEmojis,
+} from "@/features/messages/ui/useQuickReactionEmojis";
+import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { cn } from "@/shared/lib/cn";
+import { emojiDisplayName } from "@/shared/lib/emojiName";
+import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
AlertDialog,
AlertDialogAction,
@@ -43,6 +51,9 @@ import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
+const ACTION_ICON_CLASS = "!h-4 !w-4";
+
function copyToClipboard(text: string, successMessage: string) {
void navigator.clipboard
.writeText(text)
@@ -104,13 +115,13 @@ function MoreActionsMenu({
@@ -256,6 +267,59 @@ function MoreActionsMenu({
// MessageActionBar — reaction picker, reply button, and more-actions menu
// ---------------------------------------------------------------------------
+function QuickReactionButton({
+ active,
+ customEmojiUrl,
+ emoji,
+ onSelect,
+}: {
+ active: boolean;
+ customEmojiUrl?: string;
+ emoji: string;
+ onSelect: (emoji: string) => void;
+}) {
+ const displayName = emojiDisplayName(emoji);
+ const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null;
+
+ return (
+
+
+
+
+ {displayName}
+
+ );
+}
+
+function isCustomEmojiShortcode(emoji: string) {
+ return emoji.startsWith(":") && emoji.endsWith(":");
+}
+
export function MessageActionBar({
channelId,
message,
@@ -289,6 +353,20 @@ export function MessageActionBar({
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
+ const customEmoji = useCustomEmoji();
+ const quickReactionEmojis = useQuickReactionEmojis(4);
+ const quickReactionItems = React.useMemo(
+ () =>
+ quickReactionEmojis
+ .map((emoji) => ({
+ customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
+ emoji,
+ }))
+ .filter(
+ (item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl,
+ ),
+ [customEmoji, quickReactionEmojis],
+ );
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
@@ -300,22 +378,53 @@ export function MessageActionBar({
Boolean(onUnfollowThread) ||
!message.pending;
- if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) {
- return null;
- }
-
const selectedReactionCount = reactions.filter(
(reaction) => reaction.reactedByCurrentUser,
).length;
- const wouldAddReaction = (emoji: string) =>
- !reactions.some(
- (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
- );
+ const selectedReactionEmojis = new Set(
+ reactions
+ .filter((reaction) => reaction.reactedByCurrentUser)
+ .map((reaction) => reaction.emoji),
+ );
+ const wouldAddReaction = React.useCallback(
+ (emoji: string) =>
+ !reactions.some(
+ (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
+ ),
+ [reactions],
+ );
+ const handleReactionSelection = React.useCallback(
+ (emoji: string, closePicker = false) => {
+ if (!onReactionSelect) {
+ return;
+ }
+
+ if (wouldAddReaction(emoji) && isPositiveEmojiParticle(emoji)) {
+ onReactionBadgeBurstRequest?.(emoji);
+ }
+
+ void onReactionSelect(emoji)
+ .then(() => {
+ recordQuickReactionEmoji(emoji);
+ })
+ .catch(() => {})
+ .finally(() => {
+ if (closePicker) {
+ setIsReactionPickerOpen(false);
+ }
+ });
+ },
+ [onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction],
+ );
+
+ if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) {
+ return null;
+ }
return (
-
- {hasReactionAction ? (
-
+
+
+ {hasReactionAction && quickReactionItems.length > 0 ? (
+ <>
+
+ {quickReactionItems.map(({ customEmojiUrl, emoji }) => (
+
+ ))}
+
+
+ >
+ ) : null}
+
+ {hasReactionAction ? (
+
+
+
+
+
+
+
+ React
+
+
+ {reactionErrorMessage ? (
+
+
+ {reactionErrorMessage}
+
+
+ ) : null}
+ {
+ // `value` is already a `native` glyph or a `:shortcode:` for
+ // custom emoji; the toggle mutation resolves the URL.
+ handleReactionSelection(value, true);
+ }}
+ />
+
+
+ ) : null}
+
+ {hasReplyAction ? (
-
-
-
+
- React
+ Reply
-
- {reactionErrorMessage ? (
-
-
- {reactionErrorMessage}
-
-
- ) : null}
- {
- if (!onReactionSelect) {
- return;
- }
- // `value` is already a `native` glyph or a `:shortcode:` for
- // custom emoji; the toggle mutation resolves the URL.
- if (
- wouldAddReaction(value) &&
- isPositiveEmojiParticle(value)
- ) {
- onReactionBadgeBurstRequest?.(value);
- }
- void onReactionSelect(value).finally(() => {
- setIsReactionPickerOpen(false);
- });
- }}
- />
-
-
- ) : null}
-
- {hasReplyAction ? (
-
-
-
-
- Reply
-
- ) : null}
+ ) : null}
- {hasMoreMenuActions ? (
-
- ) : null}
+ {hasMoreMenuActions ? (
+
+ ) : null}
+
);
diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx
index b43088128..1f9343d01 100644
--- a/desktop/src/features/messages/ui/MessageReactions.tsx
+++ b/desktop/src/features/messages/ui/MessageReactions.tsx
@@ -3,6 +3,7 @@ import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineReaction } from "@/features/messages/types";
+import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -329,6 +330,7 @@ function InlineReactionPicker({
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
requestBadgeBurst(value);
}
+ recordQuickReactionEmoji(value);
onSelect(value);
setOpen(false);
}}
@@ -416,6 +418,7 @@ function ReactionPill({
) {
burstEmoji(reaction.emoji, event);
}
+ recordQuickReactionEmoji(reaction.emoji);
onSelect(reaction.emoji);
};
diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx
index 7833fd0af..d9647eab7 100644
--- a/desktop/src/features/messages/ui/SystemMessageRow.tsx
+++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx
@@ -5,6 +5,7 @@ import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
+import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
@@ -21,6 +22,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { MessageTimestamp } from "./MessageTimestamp";
+const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0";
+const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4";
+
type SystemMessagePayload = {
type: string;
actor?: string;
@@ -376,7 +380,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
) : null}
-
+
{canToggleReactions ? (
@@ -432,9 +436,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
) {
setBadgeBurstEmoji(value);
}
- void handleReactionSelect(value).finally(() => {
- setIsReactionPickerOpen(false);
- });
+ void handleReactionSelect(value)
+ .then(() => {
+ recordQuickReactionEmoji(value);
+ })
+ .catch(() => {})
+ .finally(() => {
+ setIsReactionPickerOpen(false);
+ });
}}
/>
diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts
new file mode 100644
index 000000000..bcf5830f5
--- /dev/null
+++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts
@@ -0,0 +1,231 @@
+import * as React from "react";
+
+import {
+ loadActiveWorkspaceId,
+ loadWorkspaces,
+} from "@/features/workspaces/workspaceStorage";
+
+const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
+const QUICK_REACTION_UPDATED_EVENT = "buzz:quick-reaction-emojis-updated";
+const DEFAULT_QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉"] as const;
+const MAX_STORED_REACTIONS = 24;
+const sessionQuickReactionEmojis = new Map
();
+
+type QuickReactionEntry = {
+ count: number;
+ emoji: string;
+ lastUsedAt: number;
+};
+
+function canUseLocalStorage() {
+ if (typeof window === "undefined") return false;
+
+ try {
+ return Boolean(window.localStorage);
+ } catch {
+ return false;
+ }
+}
+
+function getActiveWorkspaceScope() {
+ if (!canUseLocalStorage()) return null;
+
+ try {
+ return loadActiveWorkspaceId() ?? loadWorkspaces()[0]?.id ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function quickReactionStorageKey(workspaceScope: string | null) {
+ return workspaceScope
+ ? `${QUICK_REACTION_STORAGE_KEY}:${workspaceScope}`
+ : QUICK_REACTION_STORAGE_KEY;
+}
+
+function quickReactionSessionKey(limit: number, workspaceScope: string | null) {
+ return `${workspaceScope ?? "global"}:${limit}`;
+}
+
+function normalizeEntry(entry: unknown): QuickReactionEntry | null {
+ if (!entry || typeof entry !== "object") return null;
+
+ const candidate = entry as Partial;
+ if (
+ typeof candidate.emoji !== "string" ||
+ candidate.emoji.trim().length === 0
+ ) {
+ return null;
+ }
+
+ return {
+ count: Math.max(1, Math.floor(Number(candidate.count) || 1)),
+ emoji: candidate.emoji,
+ lastUsedAt: Math.max(0, Number(candidate.lastUsedAt) || 0),
+ };
+}
+
+function sortEntries(entries: QuickReactionEntry[]) {
+ return [...entries].sort((left, right) => {
+ const countDelta = right.count - left.count;
+ if (countDelta !== 0) return countDelta;
+ return right.lastUsedAt - left.lastUsedAt;
+ });
+}
+
+function readQuickReactionEntries(storageKey: string) {
+ if (!canUseLocalStorage()) return [];
+
+ try {
+ const raw = window.localStorage.getItem(storageKey);
+ const parsed = raw ? JSON.parse(raw) : [];
+ if (!Array.isArray(parsed)) return [];
+ return sortEntries(
+ parsed
+ .map((entry) => normalizeEntry(entry))
+ .filter((entry): entry is QuickReactionEntry => entry !== null),
+ );
+ } catch {
+ return [];
+ }
+}
+
+function writeQuickReactionEntries(
+ entries: QuickReactionEntry[],
+ storageKey: string,
+) {
+ if (!canUseLocalStorage()) return;
+
+ try {
+ window.localStorage.setItem(
+ storageKey,
+ JSON.stringify(sortEntries(entries).slice(0, MAX_STORED_REACTIONS)),
+ );
+ } catch {
+ // Ignore storage failures; the reaction itself should still work.
+ }
+}
+
+function getQuickReactionEmojis(limit: number, workspaceScope: string | null) {
+ const seen = new Set();
+ const next: string[] = [];
+
+ for (const entry of readQuickReactionEntries(
+ quickReactionStorageKey(workspaceScope),
+ )) {
+ if (seen.has(entry.emoji)) continue;
+ seen.add(entry.emoji);
+ next.push(entry.emoji);
+ if (next.length >= limit) return next;
+ }
+
+ for (const emoji of DEFAULT_QUICK_REACTIONS) {
+ if (seen.has(emoji)) continue;
+ seen.add(emoji);
+ next.push(emoji);
+ if (next.length >= limit) return next;
+ }
+
+ return next;
+}
+
+function getSessionQuickReactionEmojis(
+ limit: number,
+ workspaceScope: string | null,
+) {
+ const sessionKey = quickReactionSessionKey(limit, workspaceScope);
+ const cached = sessionQuickReactionEmojis.get(sessionKey);
+ if (cached) return cached;
+
+ const emojis = getQuickReactionEmojis(limit, workspaceScope);
+ sessionQuickReactionEmojis.set(sessionKey, emojis);
+ return emojis;
+}
+
+function invalidateSessionQuickReactions(workspaceScope: string | null) {
+ const prefix = `${workspaceScope ?? "global"}:`;
+ for (const key of sessionQuickReactionEmojis.keys()) {
+ if (key.startsWith(prefix)) {
+ sessionQuickReactionEmojis.delete(key);
+ }
+ }
+}
+
+function notifyQuickReactionUpdate(workspaceScope: string | null) {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(
+ new CustomEvent(QUICK_REACTION_UPDATED_EVENT, {
+ detail: { workspaceScope },
+ }),
+ );
+}
+
+export function recordQuickReactionEmoji(emoji: string) {
+ const trimmed = emoji.trim();
+ if (!trimmed) return;
+
+ const workspaceScope = getActiveWorkspaceScope();
+ const storageKey = quickReactionStorageKey(workspaceScope);
+ const entries = readQuickReactionEntries(storageKey);
+ const existing = entries.find((entry) => entry.emoji === trimmed);
+ let didAddEntry = false;
+ if (existing) {
+ existing.count += 1;
+ existing.lastUsedAt = Date.now();
+ } else {
+ didAddEntry = true;
+ entries.push({
+ count: 1,
+ emoji: trimmed,
+ lastUsedAt: Date.now(),
+ });
+ }
+
+ writeQuickReactionEntries(entries, storageKey);
+ if (didAddEntry) {
+ invalidateSessionQuickReactions(workspaceScope);
+ notifyQuickReactionUpdate(workspaceScope);
+ }
+}
+
+export function useQuickReactionEmojis(limit = 4) {
+ const workspaceScope = getActiveWorkspaceScope();
+ const [emojis, setEmojis] = React.useState(() =>
+ getSessionQuickReactionEmojis(limit, workspaceScope),
+ );
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const storageKey = quickReactionStorageKey(workspaceScope);
+ const sessionKey = quickReactionSessionKey(limit, workspaceScope);
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === storageKey) {
+ sessionQuickReactionEmojis.delete(sessionKey);
+ setEmojis(getSessionQuickReactionEmojis(limit, workspaceScope));
+ }
+ };
+ const handleLocalUpdate = (event: Event) => {
+ if (
+ event instanceof CustomEvent &&
+ event.detail?.workspaceScope === workspaceScope
+ ) {
+ setEmojis(getSessionQuickReactionEmojis(limit, workspaceScope));
+ }
+ };
+
+ window.addEventListener("storage", handleStorage);
+ window.addEventListener(QUICK_REACTION_UPDATED_EVENT, handleLocalUpdate);
+ setEmojis(getSessionQuickReactionEmojis(limit, workspaceScope));
+
+ return () => {
+ window.removeEventListener("storage", handleStorage);
+ window.removeEventListener(
+ QUICK_REACTION_UPDATED_EVENT,
+ handleLocalUpdate,
+ );
+ };
+ }, [limit, workspaceScope]);
+
+ return emojis;
+}
diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts
index 490280e67..9e37f578e 100644
--- a/desktop/tests/e2e/custom-emoji.spec.ts
+++ b/desktop/tests/e2e/custom-emoji.spec.ts
@@ -151,7 +151,12 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
// The reaction pill renders the custom emoji as an
. Its
// src must be the localhost proxy URL — proving rewriteRelayUrl() ran. A raw
// relay URL here is the bug.
- const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
+ const reactionPill = row.getByLabel(
+ `Toggle :${REACTION_SHORTCODE}: reaction`,
+ );
+ const reactionImg = reactionPill.locator(
+ `img[alt=':${REACTION_SHORTCODE}:']`,
+ );
await expect(reactionImg).toBeVisible();
await expect(reactionImg).toHaveAttribute(
"src",
@@ -199,7 +204,7 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
// 64-hex id, because the timeline only honors deletions whose `e` tag is
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
- await row.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`).click();
+ await reactionPill.click();
await expect(reactionImg).toHaveCount(0);
});
@@ -324,6 +329,8 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => {
.first()
.click();
- const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
+ const reactionImg = row
+ .getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`)
+ .locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
});