diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 5c5d7dde71..5a2d79251b 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -12,6 +12,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; function formatSearchUserName(user: UserSearchResult) { return ( @@ -179,6 +180,11 @@ export function ChannelMemberInviteCard({ data-testid={`selected-invitee-${invitee.pubkey}`} key={invitee.pubkey} > + {formatSearchUserName(invitee)} @@ -219,13 +225,20 @@ export function ChannelMemberInviteCard({ }} type="button" > -
-

- {formatSearchUserName(result)} -

-

- {formatSearchUserSecondary(result)} -

+
+ +
+

+ {formatSearchUserName(result)} +

+

+ {formatSearchUserSecondary(result)} +

+
Add diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 3979782976..656c2e5998 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -316,6 +316,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit={onCancelEdit} onEditSave={onEditSave} onSend={onSendMessage} + profiles={profiles} placeholder={ activeChannel?.archivedAt ? "Archived channels are read-only." diff --git a/desktop/src/features/home/ui/FeedSection.tsx b/desktop/src/features/home/ui/FeedSection.tsx index 12b78b7679..f926b348c9 100644 --- a/desktop/src/features/home/ui/FeedSection.tsx +++ b/desktop/src/features/home/ui/FeedSection.tsx @@ -20,6 +20,7 @@ import { import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; import { Markdown } from "@/shared/ui/markdown"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto", @@ -190,7 +191,19 @@ export function FeedSection({ > {feedHeadline(item)} - + + {resolveUserLabel({ pubkey: item.pubkey, currentPubkey, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 75da235df1..819a8dee3b 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -7,6 +7,7 @@ import { import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import type { ChannelMember } from "@/shared/api/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { hasMention } from "./hasMention"; @@ -16,6 +17,7 @@ const MENTION_DEBOUNCE_MS = 120; export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], + profiles?: UserProfileLookup, ) { const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionStartIndex, setMentionStartIndex] = React.useState(0); @@ -152,10 +154,17 @@ export function useMentions( .map(({ member, label, personaName }) => ({ pubkey: member.pubkey, displayName: label, + avatarUrl: profiles?.[member.pubkey.toLowerCase()]?.avatarUrl ?? null, role: member.role === "admin" ? "admin" : null, personaName, })); - }, [managedAgentNamesByPubkey, members, mentionQuery, personaNameByPubkey]); + }, [ + managedAgentNamesByPubkey, + members, + mentionQuery, + personaNameByPubkey, + profiles, + ]); const isMentionOpen = mentionQuery !== null && suggestions.length > 0; diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 8e35b9e5f6..369ff78333 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -2,10 +2,12 @@ import * as React from "react"; import { Badge } from "@/shared/ui/badge"; import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; export type MentionSuggestion = { pubkey: string; displayName: string; + avatarUrl?: string | null; role?: string | null; personaName?: string | null; }; @@ -63,6 +65,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ tabIndex={-1} type="button" > + {suggestion.displayName} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 3a5380a822..a635c556ca 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -13,6 +13,7 @@ import { useMediaUpload, } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { hasMentionClipboardHtml, normalizeMentionClipboardHtml, @@ -49,6 +50,7 @@ type MessageComposerProps = { mediaTags?: string[][], ) => Promise; placeholder?: string; + profiles?: UserProfileLookup; replyTarget?: { author: string; body: string; @@ -70,6 +72,7 @@ export function MessageComposer({ onEditSave, onSend, placeholder, + profiles, replyTarget = null, typingParentEventId = null, typingRootEventId = null, @@ -91,7 +94,7 @@ export function MessageComposer({ const effectiveDraftKey = draftKey ?? channelId; const previousDraftKeyRef = React.useRef(null); - const mentions = useMentions(channelId); + const mentions = useMentions(channelId, undefined, profiles); const channelLinks = useChannelLinks(); const emojiAutocomplete = useEmojiAutocomplete(); const notifyTyping = useTypingBroadcast( diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index a333e602d2..eee147d758 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -13,6 +13,7 @@ import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { MessageTimestamp } from "./MessageTimestamp"; type SystemMessagePayload = { @@ -43,36 +44,165 @@ function resolvePersonaSuffix( return personaName ? ` (${personaName})` : ""; } -function describeSystemEvent( +function resolveAvatarUrl( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string | null { + if (!pubkey || !profiles) return null; + return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null; +} + +function UserChip({ + pubkey, + currentPubkey, + profiles, + suffix, +}: { + pubkey: string | undefined; + currentPubkey: string | undefined; + profiles: UserProfileLookup | undefined; + suffix?: string; +}) { + const label = resolveLabel(pubkey, currentPubkey, profiles); + return ( + + + + {label} + {suffix} + + + ); +} + +function describeSystemEventStructured( payload: SystemMessagePayload, currentPubkey: string | undefined, profiles: UserProfileLookup | undefined, personaLookup?: Map, -): string | null { - const actor = resolveLabel(payload.actor, currentPubkey, profiles); +): React.ReactNode | null { + const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup); switch (payload.type) { case "member_joined": { - const target = resolveLabel(payload.target, currentPubkey, profiles); - const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup); if (payload.actor === payload.target) { - return `${actor}${personaSuffix} joined the channel`; + return ( + + + joined the channel + + ); } - return `${actor} added ${target}${personaSuffix} to the channel`; - } - case "member_left": { - return `${actor} left the channel`; - } - case "member_removed": { - const target = resolveLabel(payload.target, currentPubkey, profiles); - return `${actor} removed ${target} from the channel`; + return ( + + + added + + to the channel + + ); } + case "member_left": + return ( + + + left the channel + + ); + case "member_removed": + return ( + + + removed + + from the channel + + ); case "topic_changed": - return `${actor} changed the topic to "${payload.topic}"`; + return ( + + + changed the topic to “{payload.topic}” + + ); case "purpose_changed": - return `${actor} changed the purpose to "${payload.purpose}"`; + return ( + + + changed the purpose to “{payload.purpose}” + + ); case "channel_created": - return `${actor} created this channel`; + return ( + + + created this channel + + ); + case "channel_archived": + return ( + + + archived this channel + + ); + case "channel_unarchived": + return ( + + + unarchived this channel + + ); default: return null; } @@ -112,7 +242,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ return null; } - const description = describeSystemEvent( + const description = describeSystemEventStructured( payload, currentPubkey, profiles, diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx index 80c5aa129a..99aaa723ec 100644 --- a/desktop/src/features/search/ui/SearchDialog.tsx +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -26,6 +26,7 @@ import { import { Badge } from "@/shared/ui/badge"; import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; const MIN_QUERY_LENGTH = 2; @@ -343,9 +344,17 @@ export function SearchDialog({ {describeSearchHit(hit)} -

+ + {authorLabel} -

+

{formatRelativeTime(hit.createdAt)}

diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 8416b9ab5c..9a3322fde6 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -9,6 +9,7 @@ import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; +import '../channels/small_avatar.dart'; import '../profile/user_cache_provider.dart'; import 'activity_provider.dart'; import 'feed_item.dart'; @@ -240,9 +241,8 @@ class _FeedItemTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profile = ref.watch( - userCacheProvider.select((cache) => cache[item.pubkey.toLowerCase()]), - ); + final userCache = ref.watch(userCacheProvider); + final profile = userCache[item.pubkey.toLowerCase()]; final authorLabel = profile?.label ?? _shortPubkey(item.pubkey); return InkWell( @@ -274,6 +274,8 @@ class _FeedItemTile extends ConsumerWidget { ), ), const SizedBox(width: Grid.xxs), + SmallAvatar(pubkey: item.pubkey, userCache: userCache), + const SizedBox(width: Grid.quarter), Flexible( child: Text( authorLabel, diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index e8604c0404..31e72666b8 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -599,19 +599,7 @@ class _SystemMessageRow extends ConsumerWidget { children: [ Row( children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - LucideIcons.arrowLeftRight, - size: 12, - color: context.colors.onSurfaceVariant, - ), - ), + _systemEventAvatar(context, systemEvent, userCache), const SizedBox(width: Grid.xxs), Expanded( child: Text( @@ -644,6 +632,54 @@ class _SystemMessageRow extends ConsumerWidget { } } +Widget _systemEventAvatar( + BuildContext context, + SystemEvent event, + Map userCache, +) { + final hasTarget = + event.targetPubkey != null && event.targetPubkey != event.actorPubkey; + + if (event.actorPubkey != null && hasTarget) { + // Two-avatar stack: actor + target (e.g. "Alice added Bob"). + return SizedBox( + width: 32, + height: 20, + child: Stack( + children: [ + SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache), + Positioned( + left: 12, + child: SmallAvatar( + pubkey: event.targetPubkey!, + userCache: userCache, + ), + ), + ], + ), + ); + } + + if (event.actorPubkey != null) { + return SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache); + } + + // Fallback: generic icon when no actor is available. + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.arrowLeftRight, + size: 12, + color: context.colors.onSurfaceVariant, + ), + ); +} + // --------------------------------------------------------------------------- // Thread summary row (shown below messages that have replies) // --------------------------------------------------------------------------- @@ -1022,18 +1058,45 @@ class _TypingIndicator extends ConsumerWidget { _ => '${names[0]} and ${names.length - 1} others are typing…', }; + final visibleEntries = entries.take(3).toList(); + final avatarCount = visibleEntries.length; + return Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: Grid.xs, vertical: Grid.quarter + 2, ), - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), + child: Row( + children: [ + SizedBox( + width: 20.0 + (avatarCount - 1) * 12.0, + height: 20, + child: Stack( + children: [ + for (var i = 0; i < avatarCount; i++) + Positioned( + left: i * 12.0, + child: SmallAvatar( + pubkey: visibleEntries[i].pubkey, + userCache: userCache, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 42634678bf..0968b7825b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -477,18 +477,45 @@ class _ThreadTypingIndicator extends ConsumerWidget { _ => '${names[0]} and ${names.length - 1} others are typing...', }; + final visibleEntries = entries.take(3).toList(); + final avatarCount = visibleEntries.length; + return Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: Grid.xs, vertical: Grid.quarter + 2, ), - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), + child: Row( + children: [ + SizedBox( + width: 20.0 + (avatarCount - 1) * 12.0, + height: 20, + child: Stack( + children: [ + for (var i = 0; i < avatarCount; i++) + Positioned( + left: i * 12.0, + child: SmallAvatar( + pubkey: visibleEntries[i].pubkey, + userCache: userCache, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ); } diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 6f58b96fd9..9818d128b7 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -10,6 +10,7 @@ import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; import '../channels/channels_provider.dart'; +import '../channels/small_avatar.dart'; import '../channels/date_formatters.dart'; import '../forum/forum_thread_page.dart'; import '../profile/profile_provider.dart'; @@ -335,6 +336,7 @@ class _MessagesSection extends ConsumerWidget { _MessageTile( hit: hit, authorProfile: profiles[hit.pubkey.toLowerCase()], + userCache: profiles, channel: channels.where((c) => c.id == hit.channelId).firstOrNull, currentPubkey: currentPubkey, ), @@ -346,12 +348,14 @@ class _MessagesSection extends ConsumerWidget { class _MessageTile extends StatelessWidget { final SearchHit hit; final UserProfile? authorProfile; + final Map userCache; final Channel? channel; final String? currentPubkey; const _MessageTile({ required this.hit, required this.authorProfile, + required this.userCache, required this.channel, required this.currentPubkey, }); @@ -362,6 +366,7 @@ class _MessageTile extends StatelessWidget { final timeAgo = relativeTime(hit.createdAt); return ListTile( + leading: SmallAvatar(pubkey: hit.pubkey, userCache: userCache), title: Row( children: [ Expanded(