Polish profile hover avatars

This commit is contained in:
klopez4212
2026-06-29 10:18:47 +01:00
parent 433b1794d5
commit c78e2c1c78
12 changed files with 912 additions and 134 deletions
@@ -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 ? (
<EphemeralChannelBadge
display={ephemeralDisplay}
testId="chat-ephemeral-badge"
variant="header"
/>
) : null;
if (channelType === "dm" && presenceStatus) {
return (
<>
<PresenceBadge
data-testid="chat-presence-badge"
status={presenceStatus}
/>
{ephemeralBadge}
</>
);
}
return ephemeralBadge;
}
@@ -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}
/>
) : (
<ProfileAvatar
<ProfileAvatarWithStatus
avatarClassName="text-xs"
avatarUrl={activeDmAvatarUrl}
className="h-6 w-6 rounded-full text-2xs"
iconClassName="h-3.5 w-3.5"
className="mr-1.5 h-8 w-8"
geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY}
iconClassName="h-4 w-4"
label={activeChannelTitle}
size={DM_HEADER_AVATAR_SIZE}
status={activeDmPresenceStatus ?? "offline"}
statusTestId="chat-presence-badge"
testId="chat-header-dm-avatar"
/>
)
@@ -114,9 +129,7 @@ export function ChannelScreenHeader({
}
statusBadge={
<ChannelHeaderStatusBadge
channelType={activeChannel?.channelType}
ephemeralDisplay={activeChannelEphemeralDisplay}
presenceStatus={isGroupDm ? null : activeDmPresenceStatus}
/>
}
title={activeChannelTitle}
@@ -137,7 +150,7 @@ function DmHeaderParticipantStack({
return (
<div
aria-hidden="true"
className="mr-1 flex shrink-0 items-center"
className="mr-1.5 flex shrink-0 items-center"
data-testid="chat-header-dm-avatar-stack"
>
{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%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-7 w-7 text-2xs"
className="h-8 w-8 text-xs"
displayName={participant.displayName}
size="sm"
/>
@@ -168,7 +181,7 @@ function DmHeaderParticipantStack({
data-testid="chat-header-dm-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-2xs leading-none">+{hiddenCount}</span>
</span>
</div>
+5 -2
View File
@@ -120,7 +120,7 @@ export function ChatHeader({
<div className="flex h-9 min-w-0 items-center gap-2.5">
<div className="min-w-0 flex-1">
<div className="group/title flex min-w-0 items-center gap-[4px] overflow-hidden">
<div className="shrink-0">
<div className="flex shrink-0 items-center">
{leadingContent ?? (
<ChannelIcon
channelType={channelType}
@@ -130,7 +130,10 @@ export function ChatHeader({
)}
</div>
<h1
className="min-w-0 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
className={cn(
"min-w-0 truncate text-base font-semibold leading-6 tracking-tight",
channelType !== "dm" && "translate-y-px",
)}
data-testid="chat-title"
title={trimmedDescription || undefined}
>
+1
View File
@@ -50,6 +50,7 @@ export type InboxTypeLabel = {
export type InboxReply = {
authorLabel: string;
authorPubkey: string;
avatarUrl: string | null;
content: string;
depth?: number;
@@ -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()]
@@ -185,6 +185,7 @@ export function InboxDetailPane({
: [
{
authorLabel: item.senderLabel,
authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
depth: 0,
+26 -9
View File
@@ -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({
/>
<div className="relative flex min-w-0 items-start gap-2.5">
<div className="relative shrink-0">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-9 w-9"
displayName={item.senderLabel}
size="md"
/>
<UserProfilePopover
enableProfilePanel={false}
pubkey={item.item.pubkey}
triggerElement="span"
>
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-9 w-9"
displayName={item.senderLabel}
size="md"
/>
</span>
</UserProfilePopover>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-semibold leading-4 text-foreground">
{item.senderLabel}
</p>
<span className="min-w-0 flex-1">
<UserProfilePopover
enableProfilePanel={false}
pubkey={item.item.pubkey}
triggerElement="span"
>
<span className="block max-w-full truncate rounded text-sm font-semibold leading-4 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
{item.senderLabel}
</span>
</UserProfilePopover>
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 transition-opacity group-hover/inbox-item:opacity-0 group-focus-within/inbox-item:opacity-0",
@@ -6,6 +6,7 @@ import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -22,6 +23,7 @@ function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
body: message.content,
createdAt: 0,
depth: message.depth,
pubkey: message.authorPubkey,
reactions: message.reactions ?? [],
tags: message.tags,
time: message.fullTimestampLabel,
@@ -114,19 +116,31 @@ export function InboxMessageRow({
) : null}
<div className="relative shrink-0">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-9 w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
<UserProfilePopover
pubkey={message.authorPubkey}
triggerElement="span"
>
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-9 w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
</span>
</UserProfilePopover>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<p className="truncate text-sm font-semibold text-foreground">
{message.authorLabel}
</p>
<UserProfilePopover
pubkey={message.authorPubkey}
triggerElement="span"
>
<span className="block max-w-full truncate rounded text-sm font-semibold text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
{message.authorLabel}
</span>
</UserProfilePopover>
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
@@ -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<typeof motion.path>["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 (
<div className={cn("relative shrink-0", className)} style={sizeStyle}>
{children}
</div>
);
}
return (
<div className={cn("relative shrink-0", className)} style={sizeStyle}>
<motion.div
animate={maskTransition ? { clipPath: maskPolygon } : undefined}
className="h-full w-full"
data-testid={clipTestId}
initial={false}
style={{
WebkitClipPath: maskPolygon,
clipPath: maskPolygon,
}}
transition={maskTransition}
>
{children}
</motion.div>
<motion.span
animate={maskTransition ? badgeMotionTarget : undefined}
className="absolute z-20 flex items-center justify-center rounded-full"
initial={false}
style={badgeStyle}
transition={maskTransition}
>
{badge}
</motion.span>
</div>
);
}
@@ -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 (
<MaskedAvatarBadgeFrame
badge={
status ? (
<span
aria-label={getPresenceLabel(status)}
className="flex h-full w-full items-center justify-center rounded-full"
data-testid={statusTestId}
role="img"
>
<PresenceDot className="h-full w-full" status={status} />
</span>
) : undefined
}
badgeBox={badgeBox}
className={cn("inline-flex", className)}
curve={STATUS_DOT_MASK_CURVE}
cutout={cutout}
size={size}
>
<ProfileAvatar
avatarUrl={avatarUrl}
className={cn("h-full w-full rounded-full", avatarClassName)}
iconClassName={iconClassName}
label={label}
testId={testId}
/>
</MaskedAvatarBadgeFrame>
);
}
@@ -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 (
<span className="group/name inline-grid h-5 min-w-0 flex-1 overflow-hidden text-sm font-semibold leading-5">
<span
className={`${TEXT_SWAP_BASE_CLASS} ${TEXT_SWAP_VISIBLE_CLASS} ${TEXT_SWAP_HOVER_HIDDEN_CLASS}`}
>
{displayName}
</span>
<span
className={`${TEXT_SWAP_BASE_CLASS} ${TEXT_SWAP_HIDDEN_CLASS} ${TEXT_SWAP_HOVER_VISIBLE_CLASS}`}
>
{truncatePubkey(pubkey)}
</span>
</span>
);
}
function StatusLine({ children }: { children: React.ReactNode }) {
return (
<div
className="flex w-full min-w-0 items-center gap-1 py-1 text-xs leading-4 text-muted-foreground"
data-testid="user-profile-status"
>
{children}
</div>
);
}
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<ReturnType<typeof setTimeout> | 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({
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<TriggerElement
role="button"
tabIndex={0}
role={canOpenProfilePanel ? "button" : undefined}
tabIndex={canOpenProfilePanel ? 0 : undefined}
onClick={handleTriggerClick}
onKeyDown={(e) => {
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}
>
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{profile?.avatarUrl ? (
<img
alt={profile.displayName ?? "User avatar"}
className="h-10 w-10 shrink-0 rounded-lg object-cover shadow-xs"
referrerPolicy="no-referrer"
// The popover only shows while hovering, so animated avatars
// play their animation here instead of the static poster frame.
src={rewriteRelayUrl(
parseAnimatedAvatarUrl(profile.avatarUrl)?.animationUrl ??
profile.avatarUrl,
)}
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-xs font-semibold text-secondary-foreground shadow-xs">
{(profile?.displayName ?? pubkey.slice(0, 2))
.slice(0, 2)
.toUpperCase()}
</div>
)}
<div className="flex items-center gap-3">
<ProfileAvatarWithStatus
avatarClassName="text-xs"
avatarUrl={profile?.avatarUrl ?? null}
className="h-10 w-10"
iconClassName="h-5 w-5"
label={displayName}
size={40}
status={presenceStatus ?? "offline"}
statusTestId="user-profile-popover-presence-badge"
testId="user-profile-popover-avatar"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-semibold">
{profile?.displayName ?? truncatePubkey(pubkey)}
</p>
<HoverPubkeyName displayName={displayName} pubkey={pubkey} />
{role === "bot" && botIdenticonValue ? (
<BotIdenticon
value={botIdenticonValue}
@@ -226,36 +366,17 @@ export function UserProfilePopover({
/>
) : null}
</div>
{profile?.nip05Handle ? (
<p className="truncate text-xs text-muted-foreground">
{profile.nip05Handle}
</p>
) : null}
{profile?.displayName ? (
<p className="truncate font-mono text-2xs text-muted-foreground/50">
{truncatePubkey(pubkey)}
{profileSubheader ? (
<p
className="mt-0.5 truncate text-xs leading-4 text-muted-foreground"
data-testid="user-profile-description"
>
{profileSubheader}
</p>
) : null}
</div>
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
</div>
{userStatus ? (
<p
className="text-xs text-muted-foreground"
data-testid="user-profile-status"
>
{userStatus.emoji ? (
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={userStatus.emoji}
/>
) : null}
{userStatus.text}
</p>
) : null}
{role === "bot" && (managedAgent || relayAgent) ? (
<div className="flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
@@ -284,12 +405,6 @@ export function UserProfilePopover({
</div>
) : null}
{profile?.about ? (
<p className="text-xs leading-relaxed text-muted-foreground">
{profile.about}
</p>
) : null}
{canViewActivity ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
@@ -304,6 +419,80 @@ export function UserProfilePopover({
View activity log
</button>
) : null}
{hasUserStatus || showProfileActions ? (
<>
<div
aria-hidden="true"
className="my-1 border-t border-border/60"
/>
{hasUserStatus ? (
<StatusLine>
{userStatus?.emoji ? (
<StatusEmoji
className="h-3.5 w-3.5 shrink-0"
value={userStatus.emoji}
/>
) : null}
{userStatusText ? (
<span className="truncate">{userStatusText}</span>
) : null}
</StatusLine>
) : null}
{showProfileActions ? (
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full"
data-testid={`user-profile-popover-message-${pubkey}`}
disabled={
pendingAction !== null || openDmMutation.isPending
}
onClick={() => {
void handleMessage();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "message" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<MessageSquare />
)}
Message
</Button>
<Button
className="w-full"
data-testid={`user-profile-popover-huddle-${pubkey}`}
disabled={
pendingAction !== null ||
openDmMutation.isPending ||
isStartingHuddle
}
onClick={() => {
void handleHuddle();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "huddle" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<Headphones />
)}
Huddle
</Button>
</div>
) : null}
</>
) : null}
</div>
</PopoverContent>
</Popover>
@@ -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 (
<span
aria-hidden="true"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-2xs font-semibold leading-none text-sidebar-foreground shadow-none"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-sidebar-accent/80 text-2xs font-semibold leading-none text-sidebar-foreground shadow-none"
data-testid={`channel-dm-count-${channelName}`}
>
<span className="translate-x-px leading-none">
@@ -163,22 +170,18 @@ function DmChannelIcon({
if (isPair || !participants || participants.length <= 1) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
<ProfileAvatar
<span className="relative flex h-6 w-6 shrink-0 items-center justify-center">
<ProfileAvatarWithStatus
avatarClassName="bg-sidebar-accent/80 text-2xs text-sidebar-foreground shadow-none"
avatarUrl={primaryParticipant.avatarUrl}
className="h-5 w-5 rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-2xs text-sidebar-foreground shadow-none"
iconClassName="h-3 w-3"
className="h-6 w-6"
geometry={DM_AVATAR_STATUS_GEOMETRY}
iconClassName="h-3.5 w-3.5"
label={primaryParticipant.label}
size={DM_AVATAR_SIZE}
status={presenceStatus}
statusTestId={`channel-presence-${channelName}`}
/>
{presenceStatus ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5 items-center justify-center rounded-full bg-sidebar">
<PresenceDot
className="h-1.5 w-1.5"
data-testid={`channel-presence-${channelName}`}
status={presenceStatus}
/>
</span>
) : null}
</span>
);
}