mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): redesign status UI with full emoji picker and presence sub-menu (#760)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { MessageSquare, Settings } from "lucide-react";
|
||||
import { ChevronRight, MessageSquare, Settings } from "lucide-react";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
PresenceDot,
|
||||
PresenceBadge,
|
||||
} from "@/features/presence/ui/PresenceBadge";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { SetStatusDialog } from "@/features/user-status/ui/SetStatusDialog";
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
|
||||
@@ -40,12 +38,6 @@ const MENU_ITEM_CLASS =
|
||||
|
||||
const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"];
|
||||
|
||||
const STATUS_ACTION_LABELS: Record<PresenceStatus, string> = {
|
||||
online: "Set yourself as online",
|
||||
away: "Set yourself as away",
|
||||
offline: "Set yourself as offline",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProfilePopover
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,16 +58,59 @@ export function ProfilePopover({
|
||||
onOpenSettings,
|
||||
children,
|
||||
}: ProfilePopoverProps) {
|
||||
const otherStatuses = ALL_STATUSES.filter((s) => s !== currentStatus);
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
|
||||
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
|
||||
const presenceHoverTimer = React.useRef<number | null>(null);
|
||||
const hasUserStatus = Boolean(userStatusText || userStatusEmoji);
|
||||
|
||||
function clearPresenceHoverTimer() {
|
||||
if (presenceHoverTimer.current !== null) {
|
||||
window.clearTimeout(presenceHoverTimer.current);
|
||||
presenceHoverTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePresenceMenu(nextOpen: boolean) {
|
||||
clearPresenceHoverTimer();
|
||||
presenceHoverTimer.current = window.setTimeout(
|
||||
() => setPresenceMenuOpen(nextOpen),
|
||||
nextOpen ? 80 : 160,
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (presenceHoverTimer.current !== null) {
|
||||
window.clearTimeout(presenceHoverTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
function handlePopoverOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) {
|
||||
setPresenceMenuOpen(false);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
clearPresenceHoverTimer();
|
||||
setPresenceMenuOpen(false);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handlePresenceSelect(status: PresenceStatus) {
|
||||
onSetStatus(status);
|
||||
closePopover();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Popover open={open} onOpenChange={handlePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
@@ -103,11 +138,13 @@ export function ProfilePopover({
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{nip05 ? <span className="truncate">@{nip05}</span> : null}
|
||||
{nip05 ? <span aria-hidden="true">·</span> : null}
|
||||
<PresenceBadge
|
||||
className="border-0 bg-transparent px-0 py-0 text-xs"
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-testid="profile-popover-current-status"
|
||||
status={currentStatus}
|
||||
/>
|
||||
>
|
||||
<PresenceDot status={currentStatus} />
|
||||
<span>{getPresenceLabel(currentStatus)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{hasUserStatus ? (
|
||||
<p
|
||||
@@ -131,7 +168,7 @@ export function ProfilePopover({
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-set-status"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
closePopover();
|
||||
window.requestAnimationFrame(() => {
|
||||
setStatusDialogOpen(true);
|
||||
});
|
||||
@@ -144,45 +181,70 @@ export function ProfilePopover({
|
||||
{hasUserStatus ? "Update status" : "Set a status"}
|
||||
</span>
|
||||
</button>
|
||||
{hasUserStatus ? (
|
||||
<button
|
||||
className="w-full px-3 py-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||
data-testid="profile-popover-clear-status"
|
||||
onClick={() => {
|
||||
onClearUserStatus();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
Clear status
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── Presence status options ───────────────────────── */}
|
||||
<div className="px-1.5 py-1">
|
||||
{otherStatuses.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`profile-popover-status-${status}`}
|
||||
disabled={isStatusPending}
|
||||
onClick={() => {
|
||||
onSetStatus(status);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
<Popover
|
||||
onOpenChange={setPresenceMenuOpen}
|
||||
open={presenceMenuOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={presenceMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-presence-trigger"
|
||||
disabled={isStatusPending}
|
||||
onClick={() => {
|
||||
clearPresenceHoverTimer();
|
||||
setPresenceMenuOpen((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={() => schedulePresenceMenu(true)}
|
||||
onMouseLeave={() => schedulePresenceMenu(false)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={currentStatus}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-popover-foreground">
|
||||
{getPresenceLabel(currentStatus)}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-44 rounded-xl border border-border bg-popover p-1.5 shadow-lg"
|
||||
onMouseEnter={() => schedulePresenceMenu(true)}
|
||||
onMouseLeave={() => schedulePresenceMenu(false)}
|
||||
side="right"
|
||||
sideOffset={4}
|
||||
>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={status} />
|
||||
<span className="text-sm text-popover-foreground">
|
||||
{STATUS_ACTION_LABELS[status]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div aria-label="Presence status" role="menu">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`profile-popover-status-${status}`}
|
||||
disabled={isStatusPending}
|
||||
key={status}
|
||||
onClick={() => handlePresenceSelect(status)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={status} />
|
||||
<span className="text-sm text-popover-foreground">
|
||||
{getPresenceLabel(status)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
@@ -193,7 +255,7 @@ export function ProfilePopover({
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-settings"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
closePopover();
|
||||
window.requestAnimationFrame(() => {
|
||||
onOpenSettings();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as React from "react";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import data from "@emoji-mart/data";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,21 +12,7 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curated emoji list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMOJI_OPTIONS = [
|
||||
{ emoji: "\uD83D\uDDE3\uFE0F", label: "In a meeting" },
|
||||
{ emoji: "\uD83D\uDE8C", label: "Commuting" },
|
||||
{ emoji: "\uD83E\uDD12", label: "Out sick" },
|
||||
{ emoji: "\uD83C\uDFD6\uFE0F", label: "Vacationing" },
|
||||
{ emoji: "\uD83C\uDFE0", label: "Working remotely" },
|
||||
{ emoji: "\uD83C\uDF54", label: "Lunch" },
|
||||
{ emoji: "\uD83C\uDFAF", label: "Focus" },
|
||||
{ emoji: "\uD83D\uDCAA", label: "Exercising" },
|
||||
] as const;
|
||||
import { Popover, PopoverTrigger } from "@/shared/ui/popover";
|
||||
|
||||
const PRESETS = [
|
||||
{ text: "In a meeting", emoji: "\uD83D\uDDE3\uFE0F" },
|
||||
@@ -62,6 +51,7 @@ export function SetStatusDialog({
|
||||
}: SetStatusDialogProps) {
|
||||
const [text, setText] = React.useState(initialText);
|
||||
const [emoji, setEmoji] = React.useState(initialEmoji);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -70,15 +60,16 @@ export function SetStatusDialog({
|
||||
}
|
||||
}, [open, initialText, initialEmoji]);
|
||||
|
||||
function handleEmojiClick(clickedEmoji: string) {
|
||||
setEmoji((prev) => (prev === clickedEmoji ? "" : clickedEmoji));
|
||||
}
|
||||
|
||||
function handlePresetClick(preset: { text: string; emoji: string }) {
|
||||
setText(preset.text);
|
||||
setEmoji(preset.emoji);
|
||||
}
|
||||
|
||||
function handleEmojiSelect(selectedEmoji: { native: string }) {
|
||||
setEmoji(selectedEmoji.native);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(text.trim(), emoji);
|
||||
onOpenChange(false);
|
||||
@@ -111,9 +102,48 @@ export function SetStatusDialog({
|
||||
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-input text-lg">
|
||||
{emoji || "\uD83D\uDCAC"}
|
||||
</span>
|
||||
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
|
||||
<div className="relative shrink-0">
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Choose status emoji"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-input text-lg transition-colors hover:bg-accent"
|
||||
type="button"
|
||||
>
|
||||
{emoji || "\uD83D\uDCAC"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{emoji ? (
|
||||
<button
|
||||
aria-label="Clear status emoji"
|
||||
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-muted text-[10px] leading-none text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEmoji("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<PopoverPrimitive.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="z-50 w-auto overflow-hidden rounded-2xl shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
maxFrequentRows={2}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
previewPosition="none"
|
||||
set="native"
|
||||
skinTonePosition="search"
|
||||
theme="auto"
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
<Input
|
||||
autoFocus
|
||||
data-testid="set-status-input"
|
||||
@@ -124,26 +154,6 @@ export function SetStatusDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{EMOJI_OPTIONS.map((option) => (
|
||||
<button
|
||||
aria-label={option.label}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md text-base transition-colors ${
|
||||
emoji === option.emoji
|
||||
? "bg-accent ring-1 ring-ring"
|
||||
: "hover:bg-accent/60"
|
||||
}`}
|
||||
data-testid={`set-status-emoji-${option.label.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
key={option.emoji}
|
||||
onClick={() => handleEmojiClick(option.emoji)}
|
||||
title={option.label}
|
||||
type="button"
|
||||
>
|
||||
{option.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -53,12 +53,14 @@ test("updates presence from the profile menu", async ({ page }) => {
|
||||
page.getByTestId("profile-popover-current-status"),
|
||||
).toContainText("Online");
|
||||
|
||||
await page.getByTestId("profile-popover-presence-trigger").click();
|
||||
await page.getByTestId("profile-popover-status-away").click();
|
||||
await openProfileMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("profile-popover-current-status"),
|
||||
).toContainText("Away");
|
||||
|
||||
await page.getByTestId("profile-popover-presence-trigger").click();
|
||||
await page.getByTestId("profile-popover-status-offline").click();
|
||||
await openProfileMenu(page);
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user