feat(desktop): replace hardcoded emoji pickers with full emoji-mart picker (#122)

This commit is contained in:
Wes
2026-03-19 17:29:59 -07:00
committed by GitHub
parent 12cfc64ec2
commit 1db0da585d
6 changed files with 82 additions and 126 deletions

View File

@@ -20,6 +20,8 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -34,6 +36,7 @@
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",

30
desktop/pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
'@emoji-mart/react':
specifier: ^1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -50,6 +56,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@@ -260,6 +269,15 @@ packages:
cpu: [x64]
os: [win32]
'@emoji-mart/data@1.2.1':
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
'@emoji-mart/react@1.1.1':
resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
peerDependencies:
emoji-mart: ^5.2
react: ^16.8 || ^17 || ^18
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
@@ -1281,6 +1299,9 @@ packages:
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -2142,6 +2163,13 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.6':
optional: true
'@emoji-mart/data@1.2.1': {}
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.4)':
dependencies:
emoji-mart: 5.6.0
react: 19.2.4
'@esbuild/aix-ppc64@0.27.3':
optional: true
@@ -2984,6 +3012,8 @@ snapshots:
electron-to-chromium@1.5.307: {}
emoji-mart@5.6.0: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3

View File

@@ -1,8 +1,9 @@
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import { SmilePlus } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { DEFAULT_EMOJI_OPTIONS } from "./messageTimelineUtils";
type ComposerEmojiPickerProps = {
disabled?: boolean;
@@ -37,36 +38,20 @@ export function ComposerEmojiPicker({
</PopoverTrigger>
<PopoverContent
align="start"
className="w-56 rounded-2xl p-3"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Emoji
</p>
<p className="text-xs text-muted-foreground">
Insert an emoji into your message.
</p>
</div>
<div className="grid grid-cols-4 gap-1">
{DEFAULT_EMOJI_OPTIONS.map((emoji) => (
<button
aria-label={`Insert ${emoji}`}
className="flex h-10 items-center justify-center rounded-xl border border-border/70 bg-muted/40 text-lg transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="composer-emoji-option"
key={emoji}
onClick={() => {
onEmojiSelect(emoji);
}}
type="button"
>
{emoji}
</button>
))}
</div>
</div>
<Picker
data={data}
onEmojiSelect={(emoji: any) => onEmojiSelect(emoji.native)}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
</PopoverContent>
</Popover>
);

View File

@@ -1,3 +1,5 @@
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import { CornerUpLeft, LoaderCircle, SmilePlus } from "lucide-react";
import * as React from "react";
@@ -8,7 +10,6 @@ import type {
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { getReactionOptions } from "./messageTimelineUtils";
export function MessageActionBar({
activeReplyTargetId = null,
@@ -39,7 +40,6 @@ export function MessageActionBar({
const selectedReactionCount = reactions.filter(
(reaction) => reaction.reactedByCurrentUser,
).length;
const reactionOptions = getReactionOptions(reactions);
return (
<div
@@ -84,70 +84,39 @@ export function MessageActionBar({
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 rounded-2xl p-3"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
React
</p>
<p
className={cn(
"text-xs",
reactionErrorMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{reactionErrorMessage ??
"Click any emoji. Click it again to remove your own reaction."}
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
<div className="grid grid-cols-4 gap-1">
{reactionOptions.map((emoji) => {
const isActive = reactions.some(
(reaction) =>
reaction.emoji === emoji &&
reaction.reactedByCurrentUser,
);
) : null}
<Picker
data={data}
onEmojiSelect={(emoji: any) => {
if (!onReactionSelect) {
return;
}
return (
<button
aria-label={`React with ${emoji}`}
aria-pressed={isActive}
className={cn(
"flex h-10 items-center justify-center rounded-xl border text-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary/40 bg-primary/10"
: "border-border/70 bg-muted/40 hover:bg-accent",
)}
data-emoji={emoji}
data-testid={`react-option-${message.id}`}
disabled={reactionPending}
key={`${message.id}-${emoji}`}
onClick={() => {
if (!onReactionSelect) {
return;
}
void onReactionSelect(emoji)
.then(() => {
setIsReactionPickerOpen(false);
})
.catch(() => {
return;
});
}}
type="button"
>
{emoji}
</button>
);
})}
</div>
</div>
void onReactionSelect(emoji.native)
.then(() => {
setIsReactionPickerOpen(false);
})
.catch(() => {
return;
});
}}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
</PopoverContent>
</Popover>
) : null}

View File

@@ -1,16 +1,4 @@
import type { TimelineReaction } from "@/features/messages/types";
const BOTTOM_THRESHOLD_PX = 72;
export const DEFAULT_EMOJI_OPTIONS = [
"👍",
"❤️",
"🎉",
"🚀",
"👀",
"✅",
"🔥",
"👎",
];
export function isNearBottom(container: HTMLDivElement) {
return (
@@ -18,28 +6,3 @@ export function isNearBottom(container: HTMLDivElement) {
BOTTOM_THRESHOLD_PX
);
}
export function getReactionOptions(reactions: TimelineReaction[]) {
const seen = new Set<string>();
const options: string[] = [];
for (const reaction of reactions) {
if (seen.has(reaction.emoji)) {
continue;
}
seen.add(reaction.emoji);
options.push(reaction.emoji);
}
for (const emoji of DEFAULT_EMOJI_OPTIONS) {
if (seen.has(emoji)) {
continue;
}
seen.add(emoji);
options.push(emoji);
}
return options;
}

View File

@@ -75,7 +75,13 @@ test("emoji picker inserts emoji into the draft and keeps focus in the composer"
await input.fill("Ship");
await page.getByTestId("composer-emoji-button").click();
await page.getByRole("button", { name: "Insert 🚀" }).click();
// emoji-mart renders inside a Shadow DOM web component — use the search
// input to find the rocket emoji, then click it.
const pickerEl = page.locator("em-emoji-picker");
const searchInput = pickerEl.locator("input[type='search']");
await searchInput.fill("rocket");
await pickerEl.locator("button[aria-label='🚀']").first().click();
await expect(input).toHaveValue("Ship🚀");
await expect(input).toBeFocused();