mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish profile hover avatars
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user