Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Additional rules:
- No `unsafe` code
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
- New public API must have doc comments
- **DCO Sign-off**: Always sign off your commits (e.g. `git commit -s`) to comply with the Developer Certificate of Origin (DCO) check.

---

Expand Down
54 changes: 48 additions & 6 deletions desktop/src/features/channels/ui/ChannelScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type * as React from "react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
Expand Down Expand Up @@ -113,6 +114,30 @@ export function ChannelScreenHeader({
<DmHeaderParticipantStack
participants={activeDmHeaderParticipants}
/>
) : activeDmHeaderParticipants.length === 1 ? (
<UserProfilePopover
pubkey={activeDmHeaderParticipants[0].pubkey}
hideMessageAction
>
<button
className="flex shrink-0 items-start focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring rounded-full"
type="button"
data-testid="chat-header-dm-avatar-button"
>
<ProfileAvatarWithStatus
avatarClassName="text-xs"
avatarUrl={activeDmAvatarUrl}
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"
/>
</button>
</UserProfilePopover>
) : (
<ProfileAvatarWithStatus
avatarClassName="text-xs"
Expand All @@ -137,6 +162,12 @@ export function ChannelScreenHeader({
title={activeChannelTitle}
transparentChrome={transparentChrome}
visibility={activeChannel?.visibility}
dmUserPubkey={
activeChannel?.channelType === "dm" &&
activeDmHeaderParticipants.length === 1
? activeDmHeaderParticipants[0].pubkey
: undefined
}
/>
);
}
Expand Down Expand Up @@ -170,12 +201,23 @@ function DmHeaderParticipantStack({
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-8 w-8 text-xs"
displayName={participant.displayName}
size="sm"
/>
<UserProfilePopover
pubkey={participant.pubkey}
hideMessageAction
triggerElement="span"
>
<button
className="flex shrink-0 items-start focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring rounded-full"
type="button"
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-8 w-8 text-xs"
displayName={participant.displayName}
size="sm"
/>
</button>
</UserProfilePopover>
</div>
))}
{hiddenCount > 0 ? (
Expand Down
69 changes: 48 additions & 21 deletions desktop/src/features/chat/ui/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { toast } from "sonner";

import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
Expand All @@ -35,6 +36,7 @@ type ChatHeaderProps = {
statusBadge?: React.ReactNode;
/** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */
transparentChrome?: boolean;
dmUserPubkey?: string;
};

const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground";
Expand Down Expand Up @@ -97,6 +99,7 @@ export function ChatHeader({
overlaysContent = false,
statusBadge,
transparentChrome = false,
dmUserPubkey,
}: ChatHeaderProps) {
const trimmedDescription = description?.trim() ?? "";

Expand Down Expand Up @@ -133,27 +136,51 @@ export function ChatHeader({
/>
)}
</div>
<h1
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}
>
{title}
</h1>
<Button
aria-label={`Copy channel name: ${title}`}
className="h-6 w-6 shrink-0 opacity-0 text-muted-foreground transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/title:opacity-100"
onClick={() => void handleCopyTitle()}
size="icon-xs"
title="Copy channel name"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
</Button>
{dmUserPubkey ? (
<UserProfilePopover
pubkey={dmUserPubkey}
hideMessageAction
triggerElement="span"
>
<button
className="min-w-0 text-left rounded leading-6 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring hover:underline"
type="button"
data-testid="chat-header-title-button"
>
<h1
className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight"
data-testid="chat-title"
title={trimmedDescription || undefined}
>
{title}
</h1>
</button>
</UserProfilePopover>
) : (
<h1
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}
>
{title}
</h1>
)}
{channelType !== "dm" && (
<Button
aria-label={`Copy channel name: ${title}`}
className="h-6 w-6 shrink-0 opacity-0 text-muted-foreground transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/title:opacity-100"
onClick={() => void handleCopyTitle()}
size="icon-xs"
title="Copy channel name"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
{statusBadge ? (
<div className="flex shrink-0 flex-wrap items-center gap-1">
{statusBadge}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type UserProfilePopoverProps = {
role?: string;
/** Value used to generate the BotIdenticon glyph (typically the author name). */
botIdenticonValue?: string;
hideMessageAction?: boolean;
};

const HOVER_OPEN_DELAY_MS = 500;
Expand Down Expand Up @@ -175,6 +176,7 @@ export function UserProfilePopover({
enableProfilePanel = true,
role,
botIdenticonValue,
hideMessageAction = false,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<
Expand Down Expand Up @@ -260,6 +262,7 @@ export function UserProfilePopover({
const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey);
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
const showMessageAction =
!hideMessageAction &&
showProfileActions &&
!isAgentClassificationPending &&
(!isBotProfile || viewerIsOwner);
Expand Down