Skip to content
Merged
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
164 changes: 113 additions & 51 deletions desktop/src/features/profile/ui/ProfilePopover.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
});
Expand All @@ -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" />
Expand All @@ -193,7 +255,7 @@ export function ProfilePopover({
className={MENU_ITEM_CLASS}
data-testid="profile-popover-settings"
onClick={() => {
onOpenChange(false);
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings();
});
Expand Down
94 changes: 52 additions & 42 deletions desktop/src/features/user-status/ui/SetStatusDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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" },
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/e2e/profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down