Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";

import { reactionGlyphPresentation } from "./reactionGlyphPresentation.ts";

test("uses compact layout only for one native emoji cluster", () => {
for (const emoji of ["😀", "❤️", "👍🏽", "👨‍👩‍👧‍👦", "🇺🇸"]) {
assert.deepEqual(reactionGlyphPresentation(emoji), {
kind: "native",
text: emoji,
});
}

for (const text of ["a", "ship it", "😀😀", "👩‍a"]) {
assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text });
}
});

test("only unwraps valid outer shortcode delimiters for text fallbacks", () => {
assert.deepEqual(reactionGlyphPresentation(":missing_reaction:"), {
kind: "text",
text: "missing_reaction",
});
assert.deepEqual(reactionGlyphPresentation(":party_parrot:"), {
kind: "text",
text: "party_parrot",
});
for (const text of [
":ship it:",
"::",
":missing_reaction",
"missing_reaction:",
]) {
assert.deepEqual(reactionGlyphPresentation(text), { kind: "text", text });
}
});
22 changes: 22 additions & 0 deletions desktop/src/features/messages/lib/reactionGlyphPresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isSingleNativeEmoji } from "@/shared/lib/emojiOnly";

const WRAPPED_SHORTCODE = /^:([a-z0-9_-]+):$/i;

export type ReactionGlyphPresentation =
| { kind: "native"; text: string }
| { kind: "text"; text: string };

/**
* Chooses the no-image reaction fallback. A native emoji gets the compact glyph
* treatment; every other relay-valid reaction value gets text layout instead.
*/
export function reactionGlyphPresentation(
emoji: string,
): ReactionGlyphPresentation {
if (isSingleNativeEmoji(emoji)) {
return { kind: "native", text: emoji };
}

const shortcode = emoji.match(WRAPPED_SHORTCODE)?.[1];
return { kind: "text", text: shortcode ?? emoji };
}
84 changes: 49 additions & 35 deletions desktop/src/features/messages/ui/MessageReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { reactionGlyphPresentation } from "@/features/messages/lib/reactionGlyphPresentation";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
Expand All @@ -19,9 +20,15 @@ const REACTION_PILL_BASE_CLASSES =
"inline-flex h-7 items-center rounded-full border text-xs font-medium leading-none transition-colors";
const REACTION_CUSTOM_GLYPH_CLASSES = "h-3.5 w-3.5";
const REACTION_NATIVE_GLYPH_CLASSES = "h-3 w-3 text-xs";
const REACTION_TEXT_GLYPH_CLASSES =
"max-w-32 shrink-0 justify-start truncate text-left text-xs";
const REACTION_POPOVER_NATIVE_GLYPH_CLASSES = "text-4xl";
const REACTION_POPOVER_TEXT_GLYPH_CLASSES =
"w-full min-w-0 justify-start truncate text-left text-sm leading-snug";
const REACTION_COUNT_CLASSES = "text-muted-foreground";
const REACTION_NATIVE_COUNT_CLASSES =
"text-muted-foreground translate-y-[0.5px]";
const REACTION_TEXT_COUNT_CLASSES = "text-muted-foreground shrink-0";
const REACTION_PILL_HOVER_CLASSES =
"hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
const BADGE_BURST_STABLE_FRAMES = 2;
Expand Down Expand Up @@ -67,9 +74,11 @@ function isSameBadgeBurstRect(
function EmojiGlyph({
reaction,
className,
text,
}: {
reaction: TimelineReaction;
className?: string;
text?: string;
}) {
const displayName = emojiDisplayName(reaction.emoji);
if (reaction.emojiUrl) {
Expand All @@ -94,7 +103,7 @@ function EmojiGlyph({
)}
title={displayName}
>
{reaction.emoji}
{text ?? reaction.emoji}
</span>
);
}
Expand All @@ -114,13 +123,25 @@ function formatReactionUsers(reaction: TimelineReaction): string {
function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
const displayName = emojiDisplayName(reaction.emoji);
const userText = formatReactionUsers(reaction);
const presentation = reaction.emojiUrl
? null
: reactionGlyphPresentation(reaction.emoji);
const glyphClasses = reaction.emojiUrl
? "h-12 w-12"
: presentation?.kind === "native"
? REACTION_POPOVER_NATIVE_GLYPH_CLASSES
: REACTION_POPOVER_TEXT_GLYPH_CLASSES;

return (
<div className="flex flex-col items-center text-center">
<div className="mb-2 flex h-14 w-14 items-center justify-center">
<div
className="mb-2 flex h-14 w-14 items-center justify-center overflow-hidden"
data-testid="reaction-popover-glyph-container"
>
<EmojiGlyph
reaction={reaction}
className={reaction.emojiUrl ? "h-12 w-12" : "text-4xl"}
className={glyphClasses}
text={presentation?.text}
/>
</div>
<div className="max-w-[14rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
Expand Down Expand Up @@ -430,6 +451,29 @@ function ReactionPill({
};

const displayName = emojiDisplayName(reaction.emoji);
const presentation = reaction.emojiUrl
? null
: reactionGlyphPresentation(reaction.emoji);
const glyphClasses = reaction.emojiUrl
? REACTION_CUSTOM_GLYPH_CLASSES
: presentation?.kind === "native"
? REACTION_NATIVE_GLYPH_CLASSES
: REACTION_TEXT_GLYPH_CLASSES;
const countClasses = reaction.emojiUrl
? REACTION_COUNT_CLASSES
: presentation?.kind === "native"
? REACTION_NATIVE_COUNT_CLASSES
: REACTION_TEXT_COUNT_CLASSES;
const pillContents = (
<>
<EmojiGlyph
reaction={reaction}
className={glyphClasses}
text={presentation?.text}
/>
<AnimatedCount className={countClasses} value={reaction.count} />
</>
);

if (reaction.users.length === 0) {
return (
Expand All @@ -443,22 +487,7 @@ function ReactionPill({
ref={setPillRef}
type="button"
>
<EmojiGlyph
reaction={reaction}
className={
reaction.emojiUrl
? REACTION_CUSTOM_GLYPH_CLASSES
: REACTION_NATIVE_GLYPH_CLASSES
}
/>
<AnimatedCount
className={
reaction.emojiUrl
? REACTION_COUNT_CLASSES
: REACTION_NATIVE_COUNT_CLASSES
}
value={reaction.count}
/>
{pillContents}
</button>
);
}
Expand All @@ -484,22 +513,7 @@ function ReactionPill({
ref={setPillRef}
type="button"
>
<EmojiGlyph
reaction={reaction}
className={
reaction.emojiUrl
? REACTION_CUSTOM_GLYPH_CLASSES
: REACTION_NATIVE_GLYPH_CLASSES
}
/>
<AnimatedCount
className={
reaction.emojiUrl
? REACTION_COUNT_CLASSES
: REACTION_NATIVE_COUNT_CLASSES
}
value={reaction.count}
/>
{pillContents}
</button>
</span>
</PopoverTrigger>
Expand Down
20 changes: 18 additions & 2 deletions desktop/src/shared/lib/emojiOnly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ function buildNativeEmojiSet(): Set<string> {
return set;
}

function isNativeEmojiCluster(cluster: string): boolean {
export function isNativeEmojiCluster(cluster: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document or stop exporting the emoji helpers

This commit turns both isNativeEmojiCluster and readGrapheme into exported APIs without adding doc comments, even though both remain implementation details used only within emojiOnly.ts. Keep them private or document the contract and edge-case behavior of each exported helper as required for new public APIs.

AGENTS.md reference: AGENTS.md:L116-L116

Useful? React with 👍 / 👎.

nativeEmojiSet ??= buildNativeEmojiSet();
return (
nativeEmojiSet.has(cluster) || /\p{Extended_Pictographic}/u.test(cluster)
);
}

function readGrapheme(text: string, start: number): string {
export function readGrapheme(text: string, start: number): string {
const firstCodePoint = text.codePointAt(start);
if (firstCodePoint === undefined) {
return "";
Expand Down Expand Up @@ -136,3 +136,19 @@ export function isEmojiOnlyMessage(

return sawEmoji;
}
/** True only when the entire value is one native emoji grapheme cluster. */
export function isSingleNativeEmoji(value: string): boolean {
if (!value) return false;
const cluster = readGrapheme(value, 0);
if (cluster !== value) return false;

nativeEmojiSet ??= buildNativeEmojiSet();
if (nativeEmojiSet.has(cluster)) return true;

// Keep future pictographs working without accepting arbitrary text that a
// malformed ZWJ sequence caused readGrapheme() to consume (for example,
// `👩‍a`). Every ZWJ component must itself be pictographic.
return /^\p{Extended_Pictographic}(?:\ufe0f|[\u{1f3fb}-\u{1f3ff}])?(?:\u200d\p{Extended_Pictographic}(?:\ufe0f|[\u{1f3fb}-\u{1f3ff}])?)*$/u.test(
cluster,
);
}
Loading
Loading