diff --git a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
index a1d7d7551..14942db04 100644
--- a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
+++ b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx
@@ -1,38 +1,18 @@
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
-import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
-import type { Channel, PresenceStatus } from "@/shared/api/types";
type ChannelHeaderStatusBadgeProps = {
- channelType?: Channel["channelType"];
ephemeralDisplay: EphemeralChannelDisplay | null;
- presenceStatus: PresenceStatus | null;
};
export function ChannelHeaderStatusBadge({
- channelType,
ephemeralDisplay,
- presenceStatus,
}: ChannelHeaderStatusBadgeProps) {
- const ephemeralBadge = ephemeralDisplay ? (
+ return ephemeralDisplay ? (
) : null;
-
- if (channelType === "dm" && presenceStatus) {
- return (
- <>
-
- {ephemeralBadge}
- >
- );
- }
-
- return ephemeralBadge;
}
diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
index 311d22449..b971d584b 100644
--- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx
@@ -8,11 +8,21 @@ import { getChannelDescription } from "@/features/channels/lib/channelDescriptio
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+ DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+ ProfileAvatarWithStatus,
+ scaleProfileAvatarStatusGeometry,
+} from "@/features/profile/ui/ProfileAvatarWithStatus";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
+const DM_HEADER_AVATAR_SIZE = 32;
+const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
+ DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+ DM_HEADER_AVATAR_SIZE,
+);
+
type ChannelScreenHeaderProps = {
activeChannel: Channel | null;
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
@@ -102,11 +112,16 @@ export function ChannelScreenHeader({
participants={activeDmHeaderParticipants}
/>
) : (
-
)
@@ -114,9 +129,7 @@ export function ChannelScreenHeader({
}
statusBadge={
}
title={activeChannelTitle}
@@ -137,7 +150,7 @@ function DmHeaderParticipantStack({
return (
{visibleParticipants.map((participant, index) => (
@@ -148,15 +161,15 @@ function DmHeaderParticipantStack({
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
- mask: "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
+ mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
- "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
+ "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
@@ -168,7 +181,7 @@ function DmHeaderParticipantStack({
data-testid="chat-header-dm-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
-
+
+{hiddenCount}
diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx
index 39d8b46dc..814030ef4 100644
--- a/desktop/src/features/chat/ui/ChatHeader.tsx
+++ b/desktop/src/features/chat/ui/ChatHeader.tsx
@@ -120,7 +120,7 @@ export function ChatHeader({
-
+
{leadingContent ?? (
diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts
index 6d1f28bd1..1afae6f64 100644
--- a/desktop/src/features/home/lib/inbox.ts
+++ b/desktop/src/features/home/lib/inbox.ts
@@ -50,6 +50,7 @@ export type InboxTypeLabel = {
export type InboxReply = {
authorLabel: string;
+ authorPubkey: string;
avatarUrl: string | null;
content: string;
depth?: number;
diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx
index cf2974c85..6dabe35e3 100644
--- a/desktop/src/features/home/ui/HomeView.tsx
+++ b/desktop/src/features/home/ui/HomeView.tsx
@@ -279,9 +279,12 @@ export function HomeView({
return timelineMessages.map((message) => {
const event = eventById.get(message.id);
+ const authorPubkey =
+ message.pubkey ?? event?.pubkey ?? selectedItem.item.pubkey;
return {
id: message.id,
authorLabel: message.author,
+ authorPubkey,
avatarUrl: message.avatarUrl ?? null,
content: message.body,
depth: event ? getContextMessageDepth(event, eventById) : message.depth,
@@ -604,6 +607,7 @@ export function HomeView({
pubkey: authorPubkey,
})
: "You",
+ authorPubkey,
avatarUrl:
currentPubkey && feedProfiles
? (feedProfiles[currentPubkey.trim().toLowerCase()]
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx
index 33848e0f7..971a2db28 100644
--- a/desktop/src/features/home/ui/InboxDetailPane.tsx
+++ b/desktop/src/features/home/ui/InboxDetailPane.tsx
@@ -185,6 +185,7 @@ export function InboxDetailPane({
: [
{
authorLabel: item.senderLabel,
+ authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
depth: 0,
diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index ed8130edb..d2f4fcdbc 100644
--- a/desktop/src/features/home/ui/InboxListPane.tsx
+++ b/desktop/src/features/home/ui/InboxListPane.tsx
@@ -13,6 +13,7 @@ import {
type InboxItem,
type InboxTypeLabel,
} from "@/features/home/lib/inbox";
+import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
@@ -186,19 +187,35 @@ export function InboxListPane({
/>
-
+
+
+
+
+
-
- {item.senderLabel}
-
+
+
+
+ {item.senderLabel}
+
+
+
-
+
+
+
+
+
-
- {message.authorLabel}
-
+
+
+ {message.authorLabel}
+
+
{message.fullTimestampLabel}
diff --git a/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx
new file mode 100644
index 000000000..426e22660
--- /dev/null
+++ b/desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx
@@ -0,0 +1,444 @@
+import type * as React from "react";
+
+import { motion } from "motion/react";
+
+import { cn } from "@/shared/lib/cn";
+
+export type AvatarBadgeCircle = {
+ cx: number;
+ cy: number;
+ r: number;
+};
+
+export type AvatarBadgeBox = {
+ bottom: number;
+ height: number;
+ right: number;
+ width: number;
+};
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+type RoundedAvatarMaskGeometry = {
+ avatar: AvatarBadgeCircle;
+ avatarLower: Point;
+ avatarUpper: Point;
+ cutout: AvatarBadgeCircle;
+ cutoutLower: Point;
+ cutoutUpper: Point;
+ lowerHandleLength: number;
+ upperHandleLength: number;
+};
+
+type BadgeMotionTarget = {
+ height: string;
+ left: string;
+ top: string;
+ width: string;
+};
+
+type MaskedAvatarBadgeFrameProps = {
+ badge?: React.ReactNode;
+ badgeBox?: AvatarBadgeBox;
+ children: React.ReactNode;
+ className?: string;
+ clipTestId?: string;
+ curve?: AvatarBadgeCurve;
+ cutout?: AvatarBadgeCircle;
+ maskTransition?: React.ComponentProps
["transition"];
+ size: number;
+};
+
+export type AvatarBadgeCurve = Partial<{
+ avatarRoundingAngle: number;
+ cutoutRoundingLength: number;
+ cutoutRoundingMinAngle: number;
+ cutoutRoundingMaxAngle: number;
+ handleDistanceRatio: number;
+ handleLengthRatio: number;
+}>;
+
+const DEFAULT_AVATAR_BADGE_CURVE = {
+ avatarRoundingAngle: 0.075,
+ cutoutRoundingLength: 5.5,
+ cutoutRoundingMinAngle: 0.22,
+ cutoutRoundingMaxAngle: 0.38,
+ handleDistanceRatio: 0.42,
+ handleLengthRatio: 0.14,
+} as const;
+
+export const STATUS_DOT_MASK_CURVE = {
+ avatarRoundingAngle: 0.11,
+ cutoutRoundingLength: 6.5,
+ cutoutRoundingMinAngle: 0.28,
+ cutoutRoundingMaxAngle: 0.44,
+ handleDistanceRatio: 0.5,
+ handleLengthRatio: 0.2,
+} satisfies AvatarBadgeCurve;
+
+function getCircleIntersections(
+ avatar: AvatarBadgeCircle,
+ cutout: AvatarBadgeCircle,
+): [Point, Point] {
+ const dx = cutout.cx - avatar.cx;
+ const dy = cutout.cy - avatar.cy;
+ const distance = Math.hypot(dx, dy);
+ const distanceToMidpoint =
+ (avatar.r * avatar.r - cutout.r * cutout.r + distance * distance) /
+ (2 * distance);
+ const halfChord = Math.sqrt(
+ Math.max(0, avatar.r * avatar.r - distanceToMidpoint * distanceToMidpoint),
+ );
+ const ux = dx / distance;
+ const uy = dy / distance;
+ const midpoint = {
+ x: avatar.cx + distanceToMidpoint * ux,
+ y: avatar.cy + distanceToMidpoint * uy,
+ };
+ const perpendicular = { x: -uy, y: ux };
+
+ return [
+ {
+ x: midpoint.x + halfChord * perpendicular.x,
+ y: midpoint.y + halfChord * perpendicular.y,
+ },
+ {
+ x: midpoint.x - halfChord * perpendicular.x,
+ y: midpoint.y - halfChord * perpendicular.y,
+ },
+ ];
+}
+
+function getAngle(circle: AvatarBadgeCircle, point: Point) {
+ return Math.atan2(point.y - circle.cy, point.x - circle.cx);
+}
+
+function getPointOnCircle(circle: AvatarBadgeCircle, angle: number): Point {
+ return {
+ x: circle.cx + circle.r * Math.cos(angle),
+ y: circle.cy + circle.r * Math.sin(angle),
+ };
+}
+
+function getTangent(angle: number, direction: 1 | -1): Point {
+ return direction === 1
+ ? { x: -Math.sin(angle), y: Math.cos(angle) }
+ : { x: Math.sin(angle), y: -Math.cos(angle) };
+}
+
+function getControlPoint(
+ point: Point,
+ tangent: Point,
+ distance: number,
+): Point {
+ return {
+ x: point.x + tangent.x * distance,
+ y: point.y + tangent.y * distance,
+ };
+}
+
+function getDistance(a: Point, b: Point) {
+ return Math.hypot(a.x - b.x, a.y - b.y);
+}
+
+function toRem(value: number) {
+ return `${value / 16}rem`;
+}
+
+function toPercent(value: number) {
+ return `${value * 100}%`;
+}
+
+function getBadgeMotionTarget(
+ size: number,
+ badgeBox: AvatarBadgeBox,
+ cutout: AvatarBadgeCircle,
+): BadgeMotionTarget {
+ return {
+ height: toPercent(badgeBox.height / size),
+ left: toPercent(cutout.cx / size),
+ top: toPercent(cutout.cy / size),
+ width: toPercent(badgeBox.width / size),
+ };
+}
+
+function getBadgeStyle(target: BadgeMotionTarget): React.CSSProperties {
+ return {
+ ...target,
+ transform: "translate3d(-50%, -50%, 0)",
+ transformOrigin: "center",
+ };
+}
+
+function getRoundedAvatarMaskGeometry(
+ size: number,
+ cutout: AvatarBadgeCircle,
+ curve?: AvatarBadgeCurve,
+): RoundedAvatarMaskGeometry {
+ const resolvedCurve = { ...DEFAULT_AVATAR_BADGE_CURVE, ...curve };
+ const avatar = { cx: size / 2, cy: size / 2, r: size / 2 };
+ const [firstIntersection, secondIntersection] = getCircleIntersections(
+ avatar,
+ cutout,
+ );
+ const upperIntersection =
+ firstIntersection.y < secondIntersection.y
+ ? firstIntersection
+ : secondIntersection;
+ const lowerIntersection =
+ firstIntersection.y < secondIntersection.y
+ ? secondIntersection
+ : firstIntersection;
+ const cutoutRoundingAngle = Math.min(
+ resolvedCurve.cutoutRoundingMaxAngle,
+ Math.max(
+ resolvedCurve.cutoutRoundingMinAngle,
+ resolvedCurve.cutoutRoundingLength / cutout.r,
+ ),
+ );
+
+ const avatarUpper = getPointOnCircle(
+ avatar,
+ getAngle(avatar, upperIntersection) - resolvedCurve.avatarRoundingAngle,
+ );
+ const avatarLower = getPointOnCircle(
+ avatar,
+ getAngle(avatar, lowerIntersection) + resolvedCurve.avatarRoundingAngle,
+ );
+ const cutoutUpper = getPointOnCircle(
+ cutout,
+ getAngle(cutout, upperIntersection) - cutoutRoundingAngle,
+ );
+ const cutoutLower = getPointOnCircle(
+ cutout,
+ getAngle(cutout, lowerIntersection) + cutoutRoundingAngle,
+ );
+
+ const upperHandleLength = Math.min(
+ cutout.r * resolvedCurve.handleLengthRatio,
+ getDistance(cutoutUpper, avatarUpper) * resolvedCurve.handleDistanceRatio,
+ );
+ const lowerHandleLength = Math.min(
+ cutout.r * resolvedCurve.handleLengthRatio,
+ getDistance(avatarLower, cutoutLower) * resolvedCurve.handleDistanceRatio,
+ );
+ return {
+ avatar,
+ avatarLower,
+ avatarUpper,
+ cutout,
+ cutoutLower,
+ cutoutUpper,
+ lowerHandleLength,
+ upperHandleLength,
+ };
+}
+
+function getCubicPoint(
+ start: Point,
+ firstControl: Point,
+ secondControl: Point,
+ end: Point,
+ progress: number,
+): Point {
+ const remaining = 1 - progress;
+
+ return {
+ x:
+ remaining * remaining * remaining * start.x +
+ 3 * remaining * remaining * progress * firstControl.x +
+ 3 * remaining * progress * progress * secondControl.x +
+ progress * progress * progress * end.x,
+ y:
+ remaining * remaining * remaining * start.y +
+ 3 * remaining * remaining * progress * firstControl.y +
+ 3 * remaining * progress * progress * secondControl.y +
+ progress * progress * progress * end.y,
+ };
+}
+
+function sampleCubic(
+ start: Point,
+ firstControl: Point,
+ secondControl: Point,
+ end: Point,
+ segments: number,
+) {
+ return Array.from({ length: segments }, (_, index) =>
+ getCubicPoint(
+ start,
+ firstControl,
+ secondControl,
+ end,
+ (index + 1) / segments,
+ ),
+ );
+}
+
+function getArcSweep(
+ startAngle: number,
+ endAngle: number,
+ direction: 1 | -1,
+ largeArc: boolean,
+) {
+ const fullTurn = Math.PI * 2;
+
+ if (direction === 1) {
+ let sweep = endAngle - startAngle;
+ while (sweep < 0) sweep += fullTurn;
+ if (largeArc && sweep < Math.PI) sweep += fullTurn;
+ if (!largeArc && sweep > Math.PI) sweep -= fullTurn;
+ return sweep;
+ }
+
+ let sweep = startAngle - endAngle;
+ while (sweep < 0) sweep += fullTurn;
+ if (largeArc && sweep < Math.PI) sweep += fullTurn;
+ if (!largeArc && sweep > Math.PI) sweep -= fullTurn;
+ return -sweep;
+}
+
+function sampleArc(
+ circle: AvatarBadgeCircle,
+ startAngle: number,
+ endAngle: number,
+ direction: 1 | -1,
+ largeArc: boolean,
+ segments: number,
+) {
+ const sweep = getArcSweep(startAngle, endAngle, direction, largeArc);
+
+ return Array.from({ length: segments }, (_, index) =>
+ getPointOnCircle(circle, startAngle + sweep * ((index + 1) / segments)),
+ );
+}
+
+function toPolygonPoint(point: Point, size: number) {
+ return `${toPercent(point.x / size)} ${toPercent(point.y / size)}`;
+}
+
+function getRoundedAvatarMaskPolygon(
+ size: number,
+ cutout: AvatarBadgeCircle,
+ curve?: AvatarBadgeCurve,
+) {
+ const {
+ avatar,
+ avatarLower,
+ avatarUpper,
+ cutout: resolvedCutout,
+ cutoutLower,
+ cutoutUpper,
+ lowerHandleLength,
+ upperHandleLength,
+ } = getRoundedAvatarMaskGeometry(size, cutout, curve);
+ const upperCutoutTangent = getTangent(
+ getAngle(resolvedCutout, cutoutUpper),
+ 1,
+ );
+ const upperAvatarTangent = getTangent(getAngle(avatar, avatarUpper), -1);
+ const lowerAvatarTangent = getTangent(getAngle(avatar, avatarLower), -1);
+ const lowerCutoutTangent = getTangent(
+ getAngle(resolvedCutout, cutoutLower),
+ 1,
+ );
+ const points = [
+ cutoutUpper,
+ ...sampleCubic(
+ cutoutUpper,
+ getControlPoint(cutoutUpper, upperCutoutTangent, upperHandleLength),
+ getControlPoint(avatarUpper, upperAvatarTangent, -upperHandleLength),
+ avatarUpper,
+ 12,
+ ),
+ ...sampleArc(
+ avatar,
+ getAngle(avatar, avatarUpper),
+ getAngle(avatar, avatarLower),
+ -1,
+ true,
+ 96,
+ ),
+ ...sampleCubic(
+ avatarLower,
+ getControlPoint(avatarLower, lowerAvatarTangent, lowerHandleLength),
+ getControlPoint(cutoutLower, lowerCutoutTangent, -lowerHandleLength),
+ cutoutLower,
+ 12,
+ ),
+ ...sampleArc(
+ resolvedCutout,
+ getAngle(resolvedCutout, cutoutLower),
+ getAngle(resolvedCutout, cutoutUpper),
+ 1,
+ false,
+ 24,
+ ),
+ ];
+
+ return `polygon(${points.map((point) => toPolygonPoint(point, size)).join(", ")})`;
+}
+
+export function MaskedAvatarBadgeFrame({
+ badge,
+ badgeBox,
+ children,
+ className,
+ clipTestId,
+ curve,
+ cutout,
+ maskTransition,
+ size,
+}: MaskedAvatarBadgeFrameProps) {
+ const shouldMask = Boolean(badge && badgeBox && cutout);
+ const maskPolygon = cutout
+ ? getRoundedAvatarMaskPolygon(size, cutout, curve)
+ : undefined;
+ const sizeStyle = { height: toRem(size), width: toRem(size) };
+ const badgeMotionTarget =
+ badgeBox && cutout
+ ? getBadgeMotionTarget(size, badgeBox, cutout)
+ : undefined;
+ const badgeStyle = badgeMotionTarget
+ ? getBadgeStyle(badgeMotionTarget)
+ : undefined;
+
+ if (!shouldMask) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+
+ {children}
+
+
+
+ {badge}
+
+
+ );
+}
diff --git a/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
new file mode 100644
index 000000000..2842f66a9
--- /dev/null
+++ b/desktop/src/features/profile/ui/ProfileAvatarWithStatus.tsx
@@ -0,0 +1,109 @@
+import { getPresenceLabel } from "@/features/presence/lib/presence";
+import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
+import type { PresenceStatus } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import {
+ MaskedAvatarBadgeFrame,
+ STATUS_DOT_MASK_CURVE,
+} from "./MaskedAvatarBadgeFrame";
+import { ProfileAvatar } from "./ProfileAvatar";
+
+export type ProfileAvatarStatusGeometry = {
+ dotSize: number;
+ cutoutSize: number;
+ centerX: number;
+ centerY: number;
+};
+
+type ProfileAvatarWithStatusProps = {
+ avatarClassName?: string;
+ avatarUrl: string | null;
+ className?: string;
+ geometry?: ProfileAvatarStatusGeometry;
+ iconClassName?: string;
+ label: string;
+ size: number;
+ status?: PresenceStatus;
+ statusTestId?: string;
+ testId?: string;
+};
+
+export const DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY = {
+ dotSize: 10,
+ cutoutSize: 16,
+ centerX: 34,
+ centerY: 34,
+} satisfies ProfileAvatarStatusGeometry;
+
+export function scaleProfileAvatarStatusGeometry(
+ geometry: ProfileAvatarStatusGeometry,
+ size: number,
+ baseSize = 40,
+): ProfileAvatarStatusGeometry {
+ const scale = size / baseSize;
+ return {
+ dotSize: geometry.dotSize * scale,
+ cutoutSize: geometry.cutoutSize * scale,
+ centerX: geometry.centerX * scale,
+ centerY: geometry.centerY * scale,
+ };
+}
+
+export function ProfileAvatarWithStatus({
+ avatarClassName,
+ avatarUrl,
+ className,
+ geometry = DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+ iconClassName,
+ label,
+ size,
+ status,
+ statusTestId,
+ testId,
+}: ProfileAvatarWithStatusProps) {
+ const cutout = status
+ ? {
+ cx: geometry.centerX,
+ cy: geometry.centerY,
+ r: geometry.cutoutSize / 2,
+ }
+ : undefined;
+ const badgeBox = status
+ ? {
+ bottom: size - geometry.centerY - geometry.dotSize / 2,
+ height: geometry.dotSize,
+ right: size - geometry.centerX - geometry.dotSize / 2,
+ width: geometry.dotSize,
+ }
+ : undefined;
+
+ return (
+
+
+
+ ) : undefined
+ }
+ badgeBox={badgeBox}
+ className={cn("inline-flex", className)}
+ curve={STATUS_DOT_MASK_CURVE}
+ cutout={cutout}
+ size={size}
+ >
+
+
+ );
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 7562d649c..780bb56dd 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -1,6 +1,15 @@
import * as React from "react";
-import { Activity } from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
+import { Activity, Headphones, MessageSquare } from "lucide-react";
+import { toast } from "sonner";
+import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+import { useHuddle } from "@/features/huddle";
+import {
+ channelsQueryKey,
+ useChannelsQuery,
+ useOpenDmMutation,
+} from "@/features/channels/hooks";
import { useUserProfileQuery } from "@/features/profile/hooks";
import {
useRelayAgentsQuery,
@@ -13,22 +22,23 @@ import { truncatePubkey } from "@/features/profile/lib/identity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
-import { useChannelsQuery } from "@/features/channels/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
-import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
-import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
-import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
+import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWithStatus";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useNow } from "@/shared/lib/useNow";
+import { Button } from "@/shared/ui/button";
+import { Spinner } from "@/shared/ui/spinner";
type UserProfilePopoverProps = {
children: React.ReactNode;
pubkey: string;
triggerElement?: "div" | "span";
+ /** Set false when the trigger is inside another interactive control. */
+ enableProfilePanel?: boolean;
/** When set to "bot", a BotIdenticon badge renders next to the display name. */
role?: string;
/** Value used to generate the BotIdenticon glyph (typically the author name). */
@@ -57,17 +67,69 @@ function InfoBadge({ children }: { children: React.ReactNode }) {
);
}
+const TEXT_SWAP_BASE_CLASS =
+ "col-start-1 row-start-1 min-w-0 truncate transition-[opacity,filter] duration-[250ms] ease-in-out motion-reduce:transition-none";
+const TEXT_SWAP_VISIBLE_CLASS = "opacity-100 blur-0";
+const TEXT_SWAP_HIDDEN_CLASS = "opacity-0 blur-0";
+const TEXT_SWAP_HOVER_VISIBLE_CLASS =
+ "group-hover/name:opacity-100 group-hover/name:blur-0";
+const TEXT_SWAP_HOVER_HIDDEN_CLASS =
+ "group-hover/name:opacity-0 group-hover/name:blur-[2px]";
+
+function HoverPubkeyName({
+ displayName,
+ pubkey,
+}: {
+ displayName: string;
+ pubkey: string;
+}) {
+ return (
+
+
+ {displayName}
+
+
+ {truncatePubkey(pubkey)}
+
+
+ );
+}
+
+function StatusLine({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function UserProfilePopover({
children,
pubkey,
triggerElement = "div",
+ enableProfilePanel = true,
role,
botIdenticonValue,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
+ const [pendingAction, setPendingAction] = React.useState<
+ "message" | "huddle" | null
+ >(null);
+ const isMountedRef = React.useRef(false);
const hoverTimerRef = React.useRef | null>(
null,
);
+ const queryClient = useQueryClient();
+ const { goChannel } = useAppNavigation();
+ const openDmMutation = useOpenDmMutation();
+ const { isStarting: isStartingHuddle, startHuddle } = useHuddle();
const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
const relayAgentsQuery = useRelayAgentsQuery({
enabled: open && role === "bot",
@@ -82,11 +144,13 @@ export function UserProfilePopover({
const { onOpenAgentSession } = useAgentSession();
const { openProfilePanel } = useProfilePanel();
+ const canOpenProfilePanel = enableProfilePanel && Boolean(openProfilePanel);
const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey);
const managedAgent = managedAgentsQuery.data?.find(
(a) => a.pubkey === pubkey,
);
const profile = profileQuery.data;
+ const displayName = profile?.displayName ?? truncatePubkey(pubkey);
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
// wrongly hides the affordance from them — and gating on bot-ness alone shows
@@ -96,6 +160,10 @@ export function UserProfilePopover({
const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
const ownerPubkey = profile?.ownerPubkey ?? null;
const currentPubkey = useIdentityQuery().data?.pubkey;
+ const isSelf =
+ currentPubkey !== undefined &&
+ currentPubkey.toLowerCase() === pubkey.toLowerCase();
+ const showProfileActions = currentPubkey !== undefined && !isSelf;
const isCurrentUserOwner =
currentPubkey !== undefined &&
ownerPubkey !== null &&
@@ -105,6 +173,10 @@ export function UserProfilePopover({
role === "bot" && viewerIsOwner && Boolean(onOpenAgentSession);
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
+ const userStatusText = userStatus?.text.trim() ?? "";
+ const hasUserStatus = Boolean(userStatusText || userStatus?.emoji);
+ const profileDescription = profile?.about?.trim() ?? "";
+ const profileSubheader = profileDescription || profile?.nip05Handle?.trim();
const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
const channelsQuery = useChannelsQuery();
const channelIdToName = React.useMemo(() => {
@@ -143,18 +215,92 @@ export function UserProfilePopover({
const handleTriggerClick = React.useCallback(
(event: React.MouseEvent) => {
clearHoverTimer();
- if (openProfilePanel) {
+ if (canOpenProfilePanel && openProfilePanel) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
openProfilePanel(pubkey);
}
},
- [clearHoverTimer, openProfilePanel, pubkey],
+ [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey],
);
+ const handleMessage = React.useCallback(async () => {
+ if (!showProfileActions || pendingAction !== null) return;
+
+ clearHoverTimer();
+ setPendingAction("message");
+
+ try {
+ const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+ await goChannel(dm.id);
+ if (isMountedRef.current) {
+ setOpen(false);
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to open direct message.",
+ );
+ } finally {
+ if (isMountedRef.current) {
+ setPendingAction(null);
+ }
+ }
+ }, [
+ clearHoverTimer,
+ goChannel,
+ openDmMutation,
+ pendingAction,
+ pubkey,
+ showProfileActions,
+ ]);
+
+ const handleHuddle = React.useCallback(async () => {
+ if (!showProfileActions || pendingAction !== null || isStartingHuddle) {
+ return;
+ }
+
+ clearHoverTimer();
+ setPendingAction("huddle");
+
+ try {
+ const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
+ await goChannel(dm.id);
+ await startHuddle(dm.id, []);
+ await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
+ if (isMountedRef.current) {
+ setOpen(false);
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to start huddle.",
+ );
+ } finally {
+ if (isMountedRef.current) {
+ setPendingAction(null);
+ }
+ }
+ }, [
+ clearHoverTimer,
+ goChannel,
+ isStartingHuddle,
+ openDmMutation,
+ pendingAction,
+ pubkey,
+ queryClient,
+ showProfileActions,
+ startHuddle,
+ ]);
+
React.useEffect(() => {
- return () => clearHoverTimer();
+ isMountedRef.current = true;
+
+ return () => {
+ isMountedRef.current = false;
+ clearHoverTimer();
+ };
}, [clearHoverTimer]);
const TriggerElement = triggerElement;
@@ -163,11 +309,15 @@ export function UserProfilePopover({
{
- if ((e.key === "Enter" || e.key === " ") && openProfilePanel) {
+ if (
+ (e.key === "Enter" || e.key === " ") &&
+ canOpenProfilePanel &&
+ openProfilePanel
+ ) {
e.preventDefault();
e.stopPropagation();
clearHoverTimer();
@@ -192,32 +342,22 @@ export function UserProfilePopover({
sideOffset={8}
>
-
- {profile?.avatarUrl ? (
-

- ) : (
-
- {(profile?.displayName ?? pubkey.slice(0, 2))
- .slice(0, 2)
- .toUpperCase()}
-
- )}
+
+
-
- {profile?.displayName ?? truncatePubkey(pubkey)}
-
+
{role === "bot" && botIdenticonValue ? (
) : null}
- {profile?.nip05Handle ? (
-
- {profile.nip05Handle}
-
- ) : null}
- {profile?.displayName ? (
-
- {truncatePubkey(pubkey)}
+ {profileSubheader ? (
+
+ {profileSubheader}
) : null}
-
- {presenceStatus ?
: null}
- {userStatus ? (
-
- {userStatus.emoji ? (
-
- ) : null}
- {userStatus.text}
-
- ) : null}
-
{role === "bot" && (managedAgent || relayAgent) ? (
{managedAgent?.agentCommand ? (
@@ -284,12 +405,6 @@ export function UserProfilePopover({
) : null}
- {profile?.about ? (
-
- {profile.about}
-
- ) : null}
-
{canViewActivity ? (
diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx
index 723e58f48..d435553b4 100644
--- a/desktop/src/features/sidebar/ui/SidebarSection.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx
@@ -20,7 +20,11 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+ DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+ ProfileAvatarWithStatus,
+ scaleProfileAvatarStatusGeometry,
+} from "@/features/profile/ui/ProfileAvatarWithStatus";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
@@ -33,8 +37,6 @@ import {
SidebarMenuItem,
} from "@/shared/ui/sidebar";
-import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
-
const SECTION_LABEL_BUTTON_CLASS =
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
const SECTION_LABEL_CHEVRON_CLASS =
@@ -47,6 +49,11 @@ const SIDEBAR_ROW_ACTION_REPLACED_BADGE_CLASS =
"max-md:opacity-0 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0";
const SIDEBAR_ROW_ICON_ACTION_CLASS =
"flex size-6 items-center justify-center p-1 text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-sidebar-ring peer-data-[active=true]/menu-button:text-sidebar-active-foreground/75 peer-data-[active=true]/menu-button:hover:text-sidebar-active-foreground [&>svg]:size-4 [&>svg]:shrink-0";
+const DM_AVATAR_SIZE = 24;
+const DM_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry(
+ DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
+ DM_AVATAR_SIZE,
+);
function formatUnreadCount(count: number): string {
return count > 99 ? "99+" : String(count);
@@ -151,7 +158,7 @@ function DmChannelIcon({
return (
@@ -163,22 +170,18 @@ function DmChannelIcon({
if (isPair || !participants || participants.length <= 1) {
return (
-
-
+
- {presenceStatus ? (
-
-
-
- ) : null}
);
}