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 :react:. 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(); });