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(