Refine mobile channel and home UI (#2651)

This commit is contained in:
klopez4212
2026-07-24 18:32:43 +01:00
committed by GitHub
parent b78a684cfa
commit cfdea818db
24 changed files with 1902 additions and 391 deletions

View File

@@ -32,6 +32,7 @@ import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'mentions/mention_candidates_provider.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';
@@ -183,6 +184,7 @@ class ChannelDetailPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: FrostedAppBar(
iconColor: context.colors.primary,
title: resolvedChannel.isDm
? _DmAppBarTitle(
channel: resolvedChannel,
@@ -190,46 +192,31 @@ class ChannelDetailPage extends HookConsumerWidget {
)
: Row(
children: [
Icon(
channelIcon(resolvedChannel),
size: 18,
color: context.colors.onSurfaceVariant,
SizedBox.square(
dimension: 22,
child: Center(
child: Icon(channelIcon(resolvedChannel), size: 18),
),
),
const SizedBox(width: Grid.half),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
overflow: TextOverflow.ellipsis,
),
),
if (resolvedChannel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: resolvedChannel),
],
],
),
if (resolvedChannel.isStream)
Text(
resolvedChannel.description.isNotEmpty
? resolvedChannel.description
: '${resolvedChannel.memberCount} member${resolvedChannel.memberCount == 1 ? '' : 's'}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (resolvedChannel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: resolvedChannel),
],
],
),
),
@@ -243,6 +230,7 @@ class ChannelDetailPage extends HookConsumerWidget {
),
if (!resolvedChannel.isDm)
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showModalBottomSheet<bool>(
context: context,
@@ -259,7 +247,7 @@ class ChannelDetailPage extends HookConsumerWidget {
}
},
tooltip: 'Manage channel',
icon: const Icon(LucideIcons.ellipsis),
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
],
),

View File

@@ -82,6 +82,7 @@ class _MembersButton extends ConsumerWidget {
.isNotEmpty;
return IconButton(
color: context.colors.primary,
onPressed: () {
showModalBottomSheet<void>(
context: context,
@@ -95,7 +96,7 @@ class _MembersButton extends ConsumerWidget {
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(LucideIcons.users),
const Icon(LucideIcons.users, size: 22),
if (hasWorkingBot)
Positioned(
top: -2,

View File

@@ -32,11 +32,19 @@ class _MessageBubble extends ConsumerWidget {
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = ref.watch(
mentionAgentPubkeysProvider(currentChannelId),
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final p = userCache[mpk.toLowerCase()];
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[mpk.toLowerCase()] = p!.displayName!;
mentionNames[normalizedPubkey] = p!.displayName!;
}
if (knownAgentPubkeys.contains(normalizedPubkey)) {
agentMentionPubkeys.add(normalizedPubkey);
}
}
@@ -67,70 +75,74 @@ class _MessageBubble extends ConsumerWidget {
child: _UserAvatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: 28),
const SizedBox(width: 36),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
child: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
),
),
const SizedBox(width: Grid.xxs),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
const SizedBox(width: Grid.xxs),
_messageTimestamp(context, message.createdAt),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
tags: message.tags,
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
],
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
],
@@ -140,11 +152,28 @@ class _MessageBubble extends ConsumerWidget {
}
}
Widget _messageTimestamp(BuildContext context, int createdAt) {
return Text(
formatMessageTime(createdAt),
style: context.textTheme.labelSmall?.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: context.textTheme.titleSmall?.letterSpacing,
color: context.colors.onSurfaceVariant,
),
);
}
class _UserAvatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
final double size;
const _UserAvatar({required this.profile, required this.pubkey});
const _UserAvatar({
required this.profile,
required this.pubkey,
this.size = 36,
});
@override
Widget build(BuildContext context) {
@@ -154,14 +183,18 @@ class _UserAvatar extends StatelessWidget {
return AvatarImage(
imageUrl: avatarUrl,
radius: 14,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
style:
(size > 28
? context.textTheme.labelMedium
: context.textTheme.labelSmall)
?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}

View File

@@ -23,6 +23,7 @@ class _MessageList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
@@ -35,12 +36,12 @@ class _MessageList extends HookConsumerWidget {
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
final chronologicalIndex = entries.indexWhere(
(entry) => entry.message.id == messageId,
final chronologicalIndex = displayEntries.indexWhere(
(group) => group.any((entry) => entry.message.id == messageId),
);
return chronologicalIndex < 0
? null
: entries.length - 1 - chronologicalIndex;
: displayEntries.length - 1 - chronologicalIndex;
}
Future<void> scrollToLatest() async {
@@ -68,7 +69,7 @@ class _MessageList extends HookConsumerWidget {
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < entries.length - 3 ||
oldestVisible < displayEntries.length - 3 ||
isLoadingOlder.value) {
return;
}
@@ -201,10 +202,10 @@ class _MessageList extends HookConsumerWidget {
top: frostedAppBarHeight(context),
bottom: Grid.xxs,
),
itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= entries.length) {
if (index >= displayEntries.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
@@ -218,12 +219,15 @@ class _MessageList extends HookConsumerWidget {
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = entries.length - 1 - index;
final entry = entries[chronIdx];
final chronIdx = displayEntries.length - 1 - index;
final entryGroup = displayEntries[chronIdx];
final entry = entryGroup.first;
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
final prevEntry = chronIdx > 0
? displayEntries[chronIdx - 1].last
: null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
@@ -246,6 +250,9 @@ class _MessageList extends HookConsumerWidget {
if (message.isSystem)
_SystemMessageRow(
message: message,
groupedMessages: entryGroup.length > 1
? entryGroup.map((entry) => entry.message).toList()
: null,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,

View File

@@ -2,6 +2,7 @@ part of '../channel_detail_page.dart';
class _SystemMessageRow extends ConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? groupedMessages;
final String channelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
@@ -10,6 +11,7 @@ class _SystemMessageRow extends ConsumerWidget {
const _SystemMessageRow({
required this.message,
this.groupedMessages,
required this.channelId,
this.currentPubkey,
this.allMessages,
@@ -23,6 +25,11 @@ class _SystemMessageRow extends ConsumerWidget {
if (systemEvent == null) return const SizedBox.shrink();
final userCache = ref.watch(userCacheProvider);
final sourceMessages = groupedMessages ?? [message];
final groupedMembership = _membershipDisplayEvent(sourceMessages);
final channelCreator = systemEvent.type == SystemEventType.channelCreated
? systemEvent.actorPubkey?.trim()
: null;
String resolveLabel(String? pubkey) {
if (pubkey == null) return 'Someone';
@@ -32,7 +39,34 @@ class _SystemMessageRow extends ConsumerWidget {
return profile?.label ?? shortPubkey(pubkey);
}
final description = systemEvent.describe(resolveLabel);
final reactions = groupedMessages == null
? message.reactions
: _aggregateSystemMessageReactions(sourceMessages);
void toggleGroupedReaction(String emoji) {
final actions = ref.read(channelActionsProvider);
final reactedMessages = sourceMessages.where(
(source) => source.reactions.any(
(reaction) =>
reaction.emoji == emoji &&
reaction.reactedByCurrentUser &&
reaction.currentUserReactionId != null,
),
);
if (reactedMessages.isEmpty) {
actions.addReaction(message.id, emoji);
return;
}
for (final source in reactedMessages) {
final reaction = source.reactions.firstWhere(
(candidate) =>
candidate.emoji == emoji &&
candidate.reactedByCurrentUser &&
candidate.currentUserReactionId != null,
);
actions.removeReaction(reaction.currentUserReactionId!, emoji);
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
@@ -52,32 +86,45 @@ class _SystemMessageRow extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
if (groupedMembership != null)
_MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
else if (channelCreator != null && channelCreator.isNotEmpty)
_MessageStyleSystemMessageContent(
displayPubkey: channelCreator,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: const [TextSpan(text: 'created this channel')],
)
else
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
if (message.reactions.isNotEmpty)
_messageTimestamp(context, message.createdAt),
],
),
if (reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 28),
padding: const EdgeInsets.only(left: 36 + Grid.xxs),
child: ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
),
],
@@ -87,6 +134,267 @@ class _SystemMessageRow extends ConsumerWidget {
}
}
const _maxVisibleAdditionalMemberNames = 3;
class _MembershipDisplayEvent {
final String? actorPubkey;
final List<String> targetPubkeys;
final bool isSelfJoin;
const _MembershipDisplayEvent({
required this.actorPubkey,
required this.targetPubkeys,
required this.isSelfJoin,
});
}
_MembershipDisplayEvent? _membershipDisplayEvent(
List<TimelineMessage> messages,
) {
if (messages.isEmpty) return null;
final first = messages.first.systemEvent;
final firstActor = first?.actorPubkey?.trim().toLowerCase();
final firstTarget = first?.targetPubkey?.trim().toLowerCase();
if (first?.type != SystemEventType.memberJoined ||
firstActor == null ||
firstActor.isEmpty ||
firstTarget == null ||
firstTarget.isEmpty) {
return null;
}
final isSelfJoin = firstActor == firstTarget;
final targets = <String>[];
for (final message in messages) {
final event = message.systemEvent;
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (event?.type != SystemEventType.memberJoined ||
actor == null ||
actor.isEmpty ||
target == null ||
target.isEmpty ||
(isSelfJoin
? actor != target
: actor != firstActor || actor == target)) {
return null;
}
targets.add(target);
}
return _MembershipDisplayEvent(
actorPubkey: isSelfJoin ? null : firstActor,
targetPubkeys: targets,
isSelfJoin: isSelfJoin,
);
}
List<TimelineReaction> _aggregateSystemMessageReactions(
List<TimelineMessage> messages,
) {
final byEmoji = <String, List<TimelineReaction>>{};
for (final message in messages) {
for (final reaction in message.reactions) {
byEmoji.putIfAbsent(reaction.emoji, () => []).add(reaction);
}
}
return [
for (final entry in byEmoji.entries)
() {
final reactions = entry.value;
final userPubkeys = {
for (final reaction in reactions) ...reaction.userPubkeys,
}.toList();
final reactedByCurrentUser = reactions.any(
(reaction) => reaction.reactedByCurrentUser,
);
final currentUserReactionId = reactions
.where((reaction) => reaction.currentUserReactionId != null)
.firstOrNull
?.currentUserReactionId;
final fallbackCount = reactions.fold<int>(
0,
(total, reaction) => total + reaction.count,
);
return TimelineReaction(
emoji: entry.key,
count: userPubkeys.isEmpty ? fallbackCount : userPubkeys.length,
reactedByCurrentUser: reactedByCurrentUser,
userPubkeys: userPubkeys,
emojiUrl: reactions.first.emojiUrl,
currentUserReactionId: currentUserReactionId,
);
}(),
];
}
class _MembershipSystemMessageContent extends StatelessWidget {
final _MembershipDisplayEvent event;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
const _MembershipSystemMessageContent({
required this.event,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
});
@override
Widget build(BuildContext context) {
final firstTarget = event.targetPubkeys.first;
final additionalTargets = event.targetPubkeys.skip(1).toList();
final visibleTargets = additionalTargets
.take(_maxVisibleAdditionalMemberNames)
.toList();
final hiddenTargets = additionalTargets
.skip(_maxVisibleAdditionalMemberNames)
.toList();
final actionStyle = _systemActionTextStyle(context);
final actionSpans = <InlineSpan>[
TextSpan(
text: event.isSelfJoin
? 'joined the channel'
: 'was added by ${resolveLabel(event.actorPubkey)}',
),
if (additionalTargets.isNotEmpty)
TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '),
..._memberNameSpans(
context,
visibleTargets: visibleTargets,
hiddenTargets: hiddenTargets,
resolveLabel: resolveLabel,
style: actionStyle,
),
];
return _MessageStyleSystemMessageContent(
displayPubkey: firstTarget,
createdAt: createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: actionSpans,
);
}
}
TextStyle? _systemActionTextStyle(BuildContext context) {
return context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
);
}
class _MessageStyleSystemMessageContent extends StatelessWidget {
final String displayPubkey;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
final List<InlineSpan> actionSpans;
const _MessageStyleSystemMessageContent({
required this.displayPubkey,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
required this.actionSpans,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: 36,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
resolveLabel(displayPubkey),
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
_messageTimestamp(context, createdAt),
],
),
Text.rich(
TextSpan(
style: _systemActionTextStyle(context),
children: actionSpans,
),
),
],
),
),
),
],
);
}
}
List<InlineSpan> _memberNameSpans(
BuildContext context, {
required List<String> visibleTargets,
required List<String> hiddenTargets,
required String Function(String? pubkey) resolveLabel,
required TextStyle? style,
}) {
final spans = <InlineSpan>[];
for (var index = 0; index < visibleTargets.length; index++) {
final isLast = index == visibleTargets.length - 1;
final separator = index == 0
? ''
: isLast && hiddenTargets.isEmpty
? (visibleTargets.length == 2 ? ' and ' : ', and ')
: ', ';
spans.add(
TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'),
);
}
if (hiddenTargets.isNotEmpty) {
final hiddenLabels = hiddenTargets.map(resolveLabel).toList();
spans
..add(const TextSpan(text: ', and '))
..add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Tooltip(
message: hiddenLabels.join('\n'),
triggerMode: TooltipTriggerMode.tap,
child: Text(
'${hiddenTargets.length} others',
key: const Key('membership-overflow'),
style: style?.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
),
),
),
),
);
}
return spans;
}
Widget _systemEventAvatar(
BuildContext context,
SystemEvent event,
@@ -175,7 +483,7 @@ class _ThreadSummaryRow extends ConsumerWidget {
},
child: Padding(
padding: const EdgeInsets.only(
left: 36,
left: 36 + Grid.xxs,
top: Grid.half,
bottom: Grid.half,
),
@@ -184,35 +492,55 @@ class _ThreadSummaryRow extends ConsumerWidget {
children: [
// Stacked participant avatars.
SizedBox(
width: 20.0 + (summary.participantPubkeys.length - 1) * 12.0,
height: 20,
width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 12.0,
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Text(
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
],
],
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronRight,
size: 14,
color: context.colors.primary,
),
],
),
),

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' show pi;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -44,6 +46,7 @@ part 'channels_page/channel_tile.dart';
part 'channels_page/sheets.dart';
part 'channels_page/badges.dart';
part 'channels_page/community.dart';
part 'channels_page/quick_actions.dart';
enum _QuickAction { createChannel, newDm }
@@ -57,8 +60,6 @@ const double _kChannelLabelGap = Grid.xxs;
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
const double _kChannelLabelInset =
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
const double _kCommunityAvatarInset =
_kChannelSectionInset - Grid.quarter; // FrostedAppBar adds Grid.quarter.
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
@@ -155,6 +156,7 @@ class ChannelsPage extends HookConsumerWidget {
cachedChannels.value = data;
}
final channels = cachedChannels.value;
final quickActionsOpen = useState(false);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
@@ -165,16 +167,13 @@ class ChannelsPage extends HookConsumerWidget {
);
}
Future<void> openQuickActions() async {
final action = await showModalBottomSheet<_QuickAction>(
context: context,
showDragHandle: true,
builder: (_) => const _QuickActionsSheet(),
);
if (!context.mounted || action == null) {
return;
Future<void> selectQuickAction(_QuickAction action) async {
final reducedMotion = MediaQuery.of(context).disableAnimations;
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
@@ -242,6 +241,7 @@ class ChannelsPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: FrostedAppBar(
horizontalInset: _kChannelSectionInset,
leading: _CommunityIndicator(
onTap: () => showModalBottomSheet<void>(
context: context,
@@ -256,27 +256,37 @@ class ChannelsPage extends HookConsumerWidget {
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
),
const SizedBox(width: Grid.twelve + Grid.quarter),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'channels-fab',
onPressed: openQuickActions,
tooltip: 'Create or start conversation',
backgroundColor: context.colors.primary,
foregroundColor: context.colors.onPrimary,
shape: const CircleBorder(),
child: const Icon(LucideIcons.plus),
floatingActionButton: _MorphingQuickActionsButton(
open: quickActionsOpen.value,
onToggle: () => quickActionsOpen.value = !quickActionsOpen.value,
onSelected: (action) => unawaited(selectQuickAction(action)),
),
body: _ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
body: Stack(
children: [
_ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: 'Close quick actions',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
],
),
);
}

View File

@@ -67,7 +67,7 @@ class _ChannelTile extends ConsumerWidget {
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
),

View File

@@ -259,35 +259,32 @@ class _CommunityIndicator extends ConsumerWidget {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: _kCommunityAvatarInset),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_CommunityAvatar(name: name),
const SizedBox(width: Grid.xxs),
if (name != null)
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
)
else
Text(
'Community',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_CommunityAvatar(name: name),
const SizedBox(width: Grid.xxs),
if (name != null)
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
)
else
Text(
'Community',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}

View File

@@ -0,0 +1,322 @@
part of '../channels_page.dart';
const _kMorphOpenDuration = Duration(milliseconds: 350);
const _kMorphCloseDuration = Duration(milliseconds: 250);
const _kMorphFadeDuration = Duration(milliseconds: 200);
const _kMorphOpenCurve = Cubic(0.34, 1.25, 0.64, 1);
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 160;
const double _kMorphOpenRadius = 20;
const double _kMorphSlide = 40;
const double _kMorphScale = 0.97;
const double _kMorphBlur = 2;
class _MorphingQuickActionsButton extends HookWidget {
final bool open;
final VoidCallback onToggle;
final ValueChanged<_QuickAction> onSelected;
const _MorphingQuickActionsButton({
required this.open,
required this.onToggle,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final mediaQuery = MediaQuery.of(context);
final openWidth =
(mediaQuery.size.width - mediaQuery.padding.horizontal - (Grid.xs * 2))
.clamp(_kMorphClosedSize, double.infinity)
.toDouble();
final surfaceController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphOpenDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphCloseDuration,
initialValue: open ? 1 : 0,
);
final fadeController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
initialValue: open ? 1 : 0,
);
final surfaceAnimation = useMemoized(
() => CurvedAnimation(
parent: surfaceController,
curve: _kMorphOpenCurve,
reverseCurve: _kMorphCloseCurve,
),
[surfaceController],
);
final fadeAnimation = useMemoized(
() => CurvedAnimation(
parent: fadeController,
curve: _kMorphCloseCurve,
reverseCurve: _kMorphCloseCurve,
),
[fadeController],
);
final animation = useMemoized(
() => Listenable.merge([surfaceAnimation, fadeAnimation]),
[surfaceAnimation, fadeAnimation],
);
useEffect(() => surfaceAnimation.dispose, [surfaceAnimation]);
useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
useEffect(() {
surfaceController.duration = reducedMotion
? Duration.zero
: _kMorphOpenDuration;
surfaceController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphCloseDuration;
fadeController.duration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
fadeController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
if (reducedMotion) {
surfaceController.value = open ? 1 : 0;
fadeController.value = open ? 1 : 0;
} else if (open) {
unawaited(surfaceController.forward());
unawaited(fadeController.forward());
} else {
unawaited(surfaceController.reverse());
unawaited(fadeController.reverse());
}
return null;
}, [fadeController, open, reducedMotion, surfaceController]);
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final surfaceValue = surfaceAnimation.value;
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
final height = lerpDouble(
_kMorphClosedSize,
_kMorphOpenHeight,
surfaceValue,
)!;
final radius = lerpDouble(
_kMorphClosedSize / 2,
_kMorphOpenRadius,
surfaceValue,
)!;
final borderRadius = BorderRadius.circular(radius);
return SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: OverflowBox(
alignment: Alignment.bottomRight,
minWidth: openWidth,
maxWidth: openWidth,
minHeight: _kMorphOpenHeight,
maxHeight: _kMorphOpenHeight,
child: IgnorePointer(
ignoring: !open || fadeValue < 0.9,
child: ExcludeSemantics(
excluding: !open,
child: Opacity(
opacity: fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * (1 - fadeValue),
sigmaY: _kMorphBlur * (1 - fadeValue),
),
child: Transform.translate(
offset: Offset(
_kMorphSlide * (1 - surfaceValue),
0,
),
child: Transform.scale(
alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) * surfaceValue),
child: _QuickActionsMenu(
onSelected: onSelected,
),
),
),
),
),
),
),
),
),
Positioned(
right: 0,
bottom: 0,
width: _kMorphClosedSize,
height: _kMorphClosedSize,
child: IgnorePointer(
ignoring: open,
child: ExcludeSemantics(
excluding: open,
child: Opacity(
opacity: 1 - fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue,
),
child: Transform.translate(
offset: Offset(-_kMorphSlide * surfaceValue, 0),
child: Transform.rotate(
angle: (pi / 4) * surfaceValue,
child: Transform.scale(
scale:
1 - ((1 - _kMorphScale) * surfaceValue),
child: Tooltip(
message: 'Create or start conversation',
child: Semantics(
button: true,
label: 'Create or start conversation',
expanded: open,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
),
),
),
),
),
),
),
),
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}
class _QuickActionsMenu extends StatelessWidget {
final ValueChanged<_QuickAction> onSelected;
const _QuickActionsMenu({required this.onSelected});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickActionItem(
icon: LucideIcons.hash,
title: 'Create channel',
subtitle: 'Start a new stream channel',
onTap: () => onSelected(_QuickAction.createChannel),
),
_QuickActionItem(
icon: LucideIcons.messagesSquare,
title: 'New direct message',
subtitle: 'Message one or more people',
onTap: () => onSelected(_QuickAction.newDm),
),
],
),
);
}
}
class _QuickActionItem extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _QuickActionItem({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onPrimary;
return Expanded(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
Icon(icon, size: 22, color: foreground),
const SizedBox(width: Grid.twelve),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.quarter),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: foreground.withValues(alpha: 0.72),
),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -154,7 +154,7 @@ class _CustomSectionHeader extends ConsumerWidget {
const SizedBox(width: _kChannelLabelGap),
Text(
section.name,
style: context.textTheme.bodyMedium?.copyWith(
style: context.textTheme.bodyLarge?.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
@@ -430,7 +430,7 @@ class _SectionHeader extends StatelessWidget {
const SizedBox(width: _kChannelLabelGap),
Text(
label,
style: context.textTheme.bodyMedium?.copyWith(
style: context.textTheme.bodyLarge?.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),

View File

@@ -1,42 +1,5 @@
part of '../channels_page.dart';
class _QuickActionsSheet extends StatelessWidget {
const _QuickActionsSheet();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(LucideIcons.hash),
title: const Text('Create channel'),
subtitle: const Text('Start a new stream channel'),
onTap: () =>
Navigator.of(context).pop(_QuickAction.createChannel),
),
ListTile(
leading: const Icon(LucideIcons.messagesSquare),
title: const Text('New direct message'),
subtitle: const Text('Open a DM with one or more people'),
onTap: () => Navigator.of(context).pop(_QuickAction.newDm),
),
],
),
),
);
}
}
class _CreateChannelSheet extends HookConsumerWidget {
final String channelType;

View File

@@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
export '../../shared/utils/string_utils.dart' show shortPubkey;
final _fullDateFormat = DateFormat('EEEE, MMMM d, y');
final _shortMonthFormat = DateFormat('MMM');
final _messageTimeFormat = DateFormat('h:mm a', 'en_US');
/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
///
@@ -62,20 +64,48 @@ String relativeTime(int unixSeconds) {
return '${time.month}/${time.day}/${time.year}';
}
/// Compact time label: "HH:MM" for today, "M/D HH:MM" for older messages.
String formatMessageTime(int unixSeconds) {
final dt = DateTime.fromMillisecondsSinceEpoch(
/// Returns desktop-parity thread activity copy such as "just now",
/// "3 hours ago", or "on May 19th".
String formatThreadSummaryLastReplyTime(
int unixSeconds, {
@visibleForTesting int? nowSeconds,
}) {
nowSeconds ??= DateTime.now().millisecondsSinceEpoch ~/ 1000;
var diff = nowSeconds - unixSeconds;
if (diff < 0) diff = 0;
if (diff < 60) return 'just now';
if (diff < 3600) return _formatAgo(diff ~/ 60, 'minute');
if (diff < 86400) return _formatAgo(diff ~/ 3600, 'hour');
if (diff < 604800) return _formatAgo(diff ~/ 86400, 'day');
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
final hh = dt.hour.toString().padLeft(2, '0');
final mm = dt.minute.toString().padLeft(2, '0');
if (diff.inDays > 0) {
return '${dt.month}/${dt.day} $hh:$mm';
}
return '$hh:$mm';
return 'on ${_shortMonthFormat.format(date)} '
'${date.day}${_ordinalSuffix(date.day)}';
}
String _formatAgo(int value, String unit) =>
'$value $unit${value == 1 ? '' : 's'} ago';
String _ordinalSuffix(int day) {
final lastTwoDigits = day % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th';
return switch (day % 10) {
1 => 'st',
2 => 'nd',
3 => 'rd',
_ => 'th',
};
}
/// Desktop-parity message clock time, e.g. "2:34 PM".
String formatMessageTime(int unixSeconds) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
return _messageTimeFormat.format(date);
}

View File

@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
/// A centered label between two horizontal dividers, used to separate
/// messages by calendar day ("TODAY", "YESTERDAY", full dates).
/// Desktop-parity day separator with a centered label over a horizontal rule.
class DayDivider extends StatelessWidget {
final String label;
@@ -13,21 +12,42 @@ class DayDivider extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Row(
children: [
Expanded(child: Divider(color: context.colors.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
letterSpacing: 2.0,
child: SizedBox(
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 0,
right: 0,
child: Divider(
height: 1,
thickness: 1,
color: context.colors.outlineVariant.withValues(alpha: 0.35),
),
),
),
Expanded(child: Divider(color: context.colors.outlineVariant)),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs + Grid.quarter,
vertical: Grid.half,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.7),
letterSpacing: 0.22,
),
),
),
],
),
),
);
}

View File

@@ -41,6 +41,33 @@ final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
return owners;
});
/// Pubkeys currently known to represent agents for rendered mention chips.
///
/// Uses the same three identity sources as mention autocomplete: channel bot
/// roles, relay agent-directory entries, and verified NIP-OA ownership.
final mentionAgentPubkeysProvider = Provider.family<Set<String>, String>((
ref,
channelId,
) {
final members =
ref.watch(channelMembersProvider(channelId)).asData?.value ??
const <ChannelMember>[];
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final userCache = ref.watch(userCacheProvider);
return {
for (final member in members)
if (member.isBot) member.pubkey.toLowerCase(),
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(),
};
});
/// Debounce before a mention query hits the relay search endpoint.
const _mentionSearchDebounce = Duration(milliseconds: 250);

View File

@@ -30,6 +30,10 @@ class MessageContent extends HookConsumerWidget {
/// Keys are lowercase pubkeys, values are display names.
final Map<String, String> mentionNames;
/// Mentioned pubkeys that resolve to agents. Agent chips use the desktop
/// robot treatment instead of an `@` prefix.
final Set<String> agentMentionPubkeys;
/// Known channel names for #channel links. Keys are lowercase channel
/// names, values are channel IDs.
final Map<String, String> channelNames;
@@ -52,6 +56,7 @@ class MessageContent extends HookConsumerWidget {
super.key,
required this.content,
this.mentionNames = const {},
this.agentMentionPubkeys = const {},
this.channelNames = const {},
this.tags = const [],
this.onChannelTap,
@@ -151,7 +156,11 @@ class MessageContent extends HookConsumerWidget {
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
maxLines: maxLines,
inlineComponents: [
_MentionMd(mentionNames: mentionNames, onMentionTap: onMentionTap),
_MentionMd(
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(customEmoji),
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
...MarkdownComponent.inlineComponents,
@@ -578,6 +587,7 @@ class _MessageCodeBlock extends HookWidget {
class _MentionMd extends InlineMd {
final Map<String, String> mentionNames;
final Set<String> agentMentionPubkeys;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
@@ -585,7 +595,11 @@ class _MentionMd extends InlineMd {
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);
_MentionMd({required this.mentionNames, this.onMentionTap});
_MentionMd({
required this.mentionNames,
required this.agentMentionPubkeys,
this.onMentionTap,
});
@override
RegExp get exp => _exp;
@@ -614,8 +628,11 @@ class _MentionMd extends InlineMd {
}
}
final pill = _TokenPill(
text: '@${displayName ?? raw.substring(1)}',
final isAgent =
pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase());
final pill = _MentionPill(
label: displayName ?? raw.substring(1),
isAgent: isAgent,
textStyle: config.style,
);
@@ -629,6 +646,66 @@ class _MentionMd extends InlineMd {
}
}
class _MentionPill extends StatelessWidget {
final String label;
final bool isAgent;
final TextStyle? textStyle;
const _MentionPill({
required this.label,
required this.isAgent,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final style =
(textStyle ?? context.textTheme.bodyMedium)?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
) ??
TextStyle(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Container(
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isAgent) ...[
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter),
] else
Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Text('@', style: style),
),
Text(label, style: style),
],
),
);
}
}
class _ChannelLinkMd extends InlineMd {
final Map<String, String> channelNames;
final void Function(String channelId)? onChannelTap;

View File

@@ -8,8 +8,14 @@ import '../profile/user_profile.dart';
class SmallAvatar extends StatelessWidget {
final String pubkey;
final Map<String, UserProfile> userCache;
final double size;
const SmallAvatar({super.key, required this.pubkey, required this.userCache});
const SmallAvatar({
super.key,
required this.pubkey,
required this.userCache,
this.size = 20,
});
@override
Widget build(BuildContext context) {
@@ -19,20 +25,20 @@ class SmallAvatar extends StatelessWidget {
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return Container(
width: 20,
height: 20,
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
child: AvatarImage(
imageUrl: avatarUrl,
radius: 9,
radius: (size - 2) / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: TextStyle(
fontSize: 8,
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/theme/theme.dart';
@@ -16,9 +15,11 @@ import 'thread_replies_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
import '../profile/user_profile_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'mentions/mention_candidates_provider.dart';
import 'reaction_row.dart';
import 'read_state/read_state_format.dart';
import 'read_state/read_state_provider.dart';
@@ -167,6 +168,7 @@ class ThreadDetailPage extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DayDivider(label: formatDayHeading(liveHead.createdAt)),
_ThreadMessage(
message: liveHead,
channelNames: channelNamesMap,
@@ -205,8 +207,14 @@ class ThreadDetailPage extends HookConsumerWidget {
final chronIdx = replies.length - 1 - index;
final reply = replies[chronIdx];
final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null;
final previousMessage = prevReply ?? liveHead;
final showDayDivider = !isSameDay(
previousMessage.createdAt,
reply.createdAt,
);
final showAuthor =
prevReply == null ||
showDayDivider ||
prevReply.pubkey.toLowerCase() !=
reply.pubkey.toLowerCase() ||
(reply.createdAt - prevReply.createdAt) > 300;
@@ -221,6 +229,8 @@ class ThreadDetailPage extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(reply.createdAt)),
_ThreadMessage(
message: reply,
channelNames: channelNamesMap,
@@ -293,6 +303,7 @@ ThreadSummary _buildNestedSummary(
threadHeadId: messageId,
replyCount: children.length,
participantPubkeys: participants.reversed.toList(),
lastReplyAt: children.last.createdAt,
);
}
@@ -338,7 +349,7 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
},
child: Padding(
padding: const EdgeInsets.only(
left: 36,
left: 36 + Grid.xxs,
top: Grid.half,
bottom: Grid.half,
),
@@ -348,36 +359,56 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
// Stacked participant avatars.
SizedBox(
width:
20.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 12.0,
height: 20,
32.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 12.0,
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Text(
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
],
],
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronRight,
size: 14,
color: context.colors.primary,
),
],
),
),
@@ -415,11 +446,17 @@ class _ThreadMessage extends ConsumerWidget {
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId));
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final p = userCache[mpk.toLowerCase()];
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[mpk.toLowerCase()] = p!.displayName!;
mentionNames[normalizedPubkey] = p!.displayName!;
}
if (knownAgentPubkeys.contains(normalizedPubkey)) {
agentMentionPubkeys.add(normalizedPubkey);
}
}
@@ -450,70 +487,83 @@ class _ThreadMessage extends ConsumerWidget {
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: 28),
const SizedBox(width: 36),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
child: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
),
),
const SizedBox(width: Grid.xxs),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
const SizedBox(width: Grid.xxs),
Text(
'(edited)',
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing:
context.textTheme.titleSmall?.letterSpacing,
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
tags: message.tags,
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(ref, message, emoji),
),
],
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
],
@@ -601,11 +651,11 @@ class _Avatar extends StatelessWidget {
return AvatarImage(
imageUrl: avatarUrl,
radius: 14,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),

View File

@@ -94,7 +94,7 @@ class SystemEvent {
return '$actor joined the channel';
}
final target = resolveLabel(targetPubkey);
return '$actor added $target to the channel';
return '$target was added by $actor';
}(),
SystemEventType.memberLeft => '$actor left the channel',
SystemEventType.memberRemoved => () {
@@ -180,11 +180,13 @@ class ThreadSummary {
/// Up to 3 most recent unique participant pubkeys.
final List<String> participantPubkeys;
final int? lastReplyAt;
const ThreadSummary({
required this.threadHeadId,
required this.replyCount,
required this.participantPubkeys,
this.lastReplyAt,
});
}
@@ -197,6 +199,107 @@ class MainTimelineEntry {
const MainTimelineEntry({required this.message, this.summary});
}
const _membershipGroupWindowSeconds = 5 * 60;
@immutable
class _MembershipChange {
final String? actor;
final bool isSelfJoin;
const _MembershipChange({required this.actor, required this.isSelfJoin});
}
_MembershipChange? _membershipChange(MainTimelineEntry entry) {
final event = entry.message.systemEvent;
if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) {
return null;
}
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (actor == null || actor.isEmpty || target == null || target.isEmpty) {
return null;
}
final isSelfJoin = actor == target;
return _MembershipChange(
actor: isSelfJoin ? null : actor,
isSelfJoin: isSelfJoin,
);
}
bool _membershipChangesCanGroup(
_MembershipChange first,
_MembershipChange second,
) {
return first.isSelfJoin == second.isSelfJoin &&
(first.isSelfJoin || first.actor == second.actor);
}
bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) {
final first = DateTime.fromMillisecondsSinceEpoch(firstTimestamp * 1000);
final second = DateTime.fromMillisecondsSinceEpoch(secondTimestamp * 1000);
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
/// Groups consecutive membership arrivals using the same display rule as
/// desktop: matching additions (or self-joins) within a fixed five-minute
/// window become one render item. Other events and local day boundaries break
/// the group.
///
/// Each inner list is one renderable timeline item. Non-grouped entries are
/// returned as single-item lists.
List<List<MainTimelineEntry>> groupMembershipTimelineEntries(
List<MainTimelineEntry> entries,
) {
final groupsByStart = <int, int>{};
for (var end = entries.length - 1; end >= 0;) {
final newestEntry = entries[end];
final newestChange = _membershipChange(newestEntry);
if (newestChange == null) {
end -= 1;
continue;
}
var start = end;
while (start > 0) {
final candidate = entries[start - 1];
final candidateChange = _membershipChange(candidate);
if (candidateChange == null ||
!_membershipChangesCanGroup(candidateChange, newestChange) ||
!_isSameLocalDay(
candidate.message.createdAt,
newestEntry.message.createdAt,
) ||
newestEntry.message.createdAt < candidate.message.createdAt ||
newestEntry.message.createdAt - candidate.message.createdAt >
_membershipGroupWindowSeconds) {
break;
}
start -= 1;
}
if (start < end) groupsByStart[start] = end;
end = start - 1;
}
final result = <List<MainTimelineEntry>>[];
for (var index = 0; index < entries.length;) {
final groupEnd = groupsByStart[index];
if (groupEnd == null) {
result.add([entries[index]]);
index += 1;
continue;
}
result.add(entries.sublist(index, groupEnd + 1));
index = groupEnd + 1;
}
return result;
}
/// Process a chronologically-sorted list of [NostrEvent]s into a list of
/// [TimelineMessage]s, applying deletions, edits, reactions, and system event
/// parsing.
@@ -419,6 +522,7 @@ ThreadSummary? _buildSummary(
threadHeadId: messageId,
replyCount: relaySummary.replyCount,
participantPubkeys: relaySummary.participantPubkeys.take(3).toList(),
lastReplyAt: relaySummary.lastReplyAt,
);
}
@@ -437,6 +541,7 @@ ThreadSummary? _buildSummary(
threadHeadId: messageId,
replyCount: replies.length,
participantPubkeys: participants.reversed.toList(),
lastReplyAt: replies.last.createdAt,
);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
const _fontFamily = 'Inter';
const _chatLineHeight = 22 / 16;
const textTheme = TextTheme(
displayLarge: TextStyle(
@@ -63,7 +64,7 @@ const textTheme = TextTheme(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.35,
height: _chatLineHeight,
letterSpacing: 0,
),
labelLarge: TextStyle(
@@ -91,7 +92,7 @@ const textTheme = TextTheme(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.38,
height: 20 / 16,
letterSpacing: 0,
),
bodyMedium: TextStyle(

View File

@@ -30,11 +30,19 @@ class FrostedAppBar extends StatelessWidget {
/// Widgets displayed on the trailing (right) side.
final List<Widget> actions;
/// Horizontal inset for the app bar's leading, title, and actions.
final double horizontalInset;
/// Color applied to icons in the app bar.
final Color? iconColor;
const FrostedAppBar({
super.key,
this.leading,
this.title,
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
});
@override
@@ -50,6 +58,7 @@ class FrostedAppBar extends StatelessWidget {
height: 48,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
color: iconColor,
icon: const Icon(LucideIcons.chevronLeft),
tooltip: 'Back',
),
@@ -76,35 +85,38 @@ class FrostedAppBar extends StatelessWidget {
child: SizedBox(
height: _kBarContentHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: Grid.gutter - Grid.quarter,
right: actions.isEmpty
? Grid.gutter - Grid.quarter
: 0,
),
child: DefaultTextStyle.merge(
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
padding: EdgeInsets.symmetric(horizontal: horizontalInset),
child: IconTheme.merge(
data: IconThemeData(color: iconColor),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: Grid.gutter - Grid.quarter,
right: actions.isEmpty
? Grid.gutter - Grid.quarter
: 0,
),
child: DefaultTextStyle.merge(
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
)
else
const Spacer(),
...actions,
],
)
else
const Spacer(),
...actions,
],
),
),
),
),

View File

@@ -11,10 +11,15 @@ import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channel_messages_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/channels/date_formatters.dart';
import 'package:buzz/features/channels/day_divider.dart';
import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/thread_detail_page.dart';
import 'package:buzz/features/channels/thread_replies_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
@@ -70,6 +75,25 @@ NostrEvent _systemMsg({
sig: '',
);
NostrEvent _reaction({
required String id,
required String targetId,
String pubkey = 'bob',
int createdAt = 2000,
String content = '👍',
}) => NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.reaction,
tags: [
['h', _channelId],
['e', targetId],
],
content: content,
sig: '',
);
NostrEvent _deletion({
required String id,
required List<String> targetIds,
@@ -119,6 +143,7 @@ Widget _buildTestable({
ReadStateNotifier? readStateNotifier,
_FakeMessagesNotifier? messagesNotifier,
String? canvasContent,
List<NostrEvent>? threadReplies,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -153,6 +178,10 @@ Widget _buildTestable({
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)
readStateProvider.overrideWith(() => readStateNotifier),
if (threadReplies != null)
threadRepliesProvider(
const ThreadRepliesArgs(channelId: _channelId, rootId: 'thread-root'),
).overrideWith((ref) async => threadReplies),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
@@ -177,6 +206,21 @@ Finder findRichText(String text) {
}, description: 'RichText containing "$text"');
}
double? effectiveFontSizeForText(
InlineSpan span,
String text, [
TextStyle? inheritedStyle,
]) {
if (span is! TextSpan) return null;
final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style;
if ((span.text ?? '').contains(text)) return effectiveStyle?.fontSize;
for (final child in span.children ?? const <InlineSpan>[]) {
final size = effectiveFontSizeForText(child, text, effectiveStyle);
if (size != null) return size;
}
return null;
}
void main() {
group('ChannelDetailPage', () {
testWidgets('defers read-state mark until after build', (tester) async {
@@ -450,6 +494,82 @@ void main() {
expect(findRichText('Hey Alice!'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
final messageAvatars = find.byType(CircleAvatar);
expect(messageAvatars, findsNWidgets(2));
for (final avatar in messageAvatars.evaluate()) {
expect(
tester.getSize(find.byWidget(avatar.widget)),
const Size.square(36),
);
}
final aliceName = find.text('Alice');
final aliceText = tester.widget<Text>(aliceName);
final titleStyle = Theme.of(
tester.element(aliceName),
).textTheme.titleSmall;
expect(aliceText.style?.fontSize, titleStyle?.fontSize);
final helloContent = findRichText('Hello world!');
final helloText = tester.widget<RichText>(helloContent);
final bodyStyle = Theme.of(
tester.element(helloContent),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(helloText.text, 'Hello world!'),
bodyStyle?.fontSize,
);
});
testWidgets('uses larger participant avatars in reply summaries', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'root',
pubkey: 'alice',
content: 'Thread head',
createdAt: 1000,
),
_textMsg(
id: 'reply-1',
pubkey: 'bob',
content: 'First reply',
createdAt: 1100,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
_textMsg(
id: 'reply-2',
pubkey: 'carol',
content: 'Second reply',
createdAt: 1200,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
],
),
);
await tester.pumpAndSettle();
expect(
findRichText(
'2 replies · last reply '
'${formatThreadSummaryLastReplyTime(1200)}',
),
findsOneWidget,
);
expect(find.byIcon(LucideIcons.chevronRight), findsNothing);
final replyAvatars = find.byType(SmallAvatar);
expect(replyAvatars, findsNWidgets(2));
for (final avatar in replyAvatars.evaluate()) {
expect(
tester.getSize(find.byWidget(avatar.widget)),
const Size.square(32),
);
}
});
testWidgets('can jump back to latest when newer messages are offscreen', (
@@ -605,7 +725,26 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Alice created this channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
final createdAction = findRichText('created this channel');
expect(createdAction, findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
final nameRect = tester.getRect(find.text('Alice'));
final nameText = tester.widget<Text>(find.text('Alice'));
final nameStyle = Theme.of(
tester.element(find.text('Alice')),
).textTheme.titleSmall;
expect(nameText.style?.fontSize, nameStyle?.fontSize);
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
final createdText = tester.widget<RichText>(createdAction);
final bodyStyle = Theme.of(
tester.element(createdAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(createdText.text, 'created this channel'),
bodyStyle?.fontSize,
);
});
testWidgets('renders member_joined (self-join) system event', (
@@ -626,7 +765,9 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Bob joined the channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(findRichText('joined the channel'), findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
});
testWidgets('renders member_joined (added by other) system event', (
@@ -650,7 +791,132 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Alice added Bob to the channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
final addedAction = findRichText('was added by Alice');
expect(addedAction, findsOneWidget);
expect(find.text('Alice added Bob to the channel'), findsNothing);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
final nameRect = tester.getRect(find.text('Bob'));
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
final addedText = tester.widget<RichText>(addedAction);
final bodyStyle = Theme.of(
tester.element(addedAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(addedText.text, 'was added by Alice'),
bodyStyle?.fontSize,
);
});
testWidgets('groups member additions with tappable overflow names', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
createdAt: 1000,
),
_systemMsg(
id: 'sys2',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1060,
),
_systemMsg(
id: 'sys3',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'dave',
},
createdAt: 1120,
),
_systemMsg(
id: 'sys4',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'erin',
},
createdAt: 1180,
),
_systemMsg(
id: 'sys5',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'frank',
},
createdAt: 1240,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'),
'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'),
'frank': const UserProfile(pubkey: 'frank', displayName: 'Frank'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
expect(
findRichText('was added by Alice, along with Carol, Dave, Erin, and '),
findsOneWidget,
);
expect(find.byKey(const Key('membership-overflow')), findsOneWidget);
expect(find.text('1 others'), findsOneWidget);
expect(find.byTooltip('Frank'), findsOneWidget);
});
testWidgets('aligns grouped reactions with the system message content', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
createdAt: 1000,
),
_systemMsg(
id: 'sys2',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1060,
),
_reaction(id: 'reaction-1', targetId: 'sys1'),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
},
),
);
await tester.pumpAndSettle();
final avatarRect = tester.getRect(find.byType(CircleAvatar));
final reactionRect = tester.getRect(find.byType(ReactionRow));
expect(reactionRect.left, avatarRect.left + 36 + Grid.xxs);
});
testWidgets('renders member_left system event', (tester) async {
@@ -1126,9 +1392,11 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Alice created this channel'), findsOneWidget);
expect(find.text('Alice'), findsNWidgets(2));
expect(findRichText('created this channel'), findsOneWidget);
expect(findRichText('Welcome everyone!'), findsOneWidget);
expect(find.text('Bob joined the channel'), findsOneWidget);
expect(find.text('Bob'), findsNWidgets(2));
expect(findRichText('joined the channel'), findsOneWidget);
expect(findRichText('Thanks for the invite!'), findsOneWidget);
});
});
@@ -1249,6 +1517,72 @@ void main() {
expect(observer.pushCount, initialPushCount + 1);
});
testWidgets('thread shows day dividers when replies cross days', (
tester,
) async {
final rootCreatedAt =
DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
final nextDayCreatedAt =
DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: rootCreatedAt,
);
final replies = [
_textMsg(
id: 'reply-same-day',
pubkey: 'bob',
content: 'Same day',
createdAt: rootCreatedAt + 60,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
_textMsg(
id: 'reply-next-day',
pubkey: 'bob',
content: 'Next day',
createdAt: nextDayCreatedAt,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: replies,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(DayDivider), findsNWidgets(2));
expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget);
expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget);
});
});
}

View File

@@ -56,4 +56,47 @@ void main() {
expect(isSameDay(a, b), isFalse);
});
});
group('formatThreadSummaryLastReplyTime', () {
const now = 2_000_000;
test('uses expanded relative units', () {
expect(
formatThreadSummaryLastReplyTime(now - 30, nowSeconds: now),
'just now',
);
expect(
formatThreadSummaryLastReplyTime(now - 60, nowSeconds: now),
'1 minute ago',
);
expect(
formatThreadSummaryLastReplyTime(now - 3 * 3600, nowSeconds: now),
'3 hours ago',
);
expect(
formatThreadSummaryLastReplyTime(now - 2 * 86400, nowSeconds: now),
'2 days ago',
);
});
test('uses a short month and ordinal for older replies', () {
final may19 = _ts(DateTime(2026, 5, 19, 12));
final may27 = _ts(DateTime(2026, 5, 27, 12));
expect(
formatThreadSummaryLastReplyTime(
may19,
nowSeconds: _ts(DateTime(2026, 5, 27, 12)),
),
'on May 19th',
);
expect(
formatThreadSummaryLastReplyTime(
may27,
nowSeconds: _ts(DateTime(2026, 6, 4, 12)),
),
'on May 27th',
);
});
});
}

View File

@@ -728,8 +728,10 @@ void main() {
),
);
// Mention should be rendered as @Alice in a highlighted container.
expect(find.text('@Alice'), findsOneWidget);
// The desktop-style mention chip renders the prefix and label
// separately so they can be aligned independently.
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('highlights an entire multi-word display name', (
@@ -744,7 +746,8 @@ void main() {
),
);
expect(find.text('@Kenny Lopez'), findsOneWidget);
expect(find.text('@'), findsOneWidget);
expect(find.text('Kenny Lopez'), findsOneWidget);
expect(find.text('@Kenny'), findsNothing);
expect(_allRichText(tester), isNot(contains('Lopez Lopez')));
});
@@ -759,7 +762,8 @@ void main() {
),
);
expect(find.text('@unknown'), findsOneWidget);
expect(find.text('@'), findsOneWidget);
expect(find.text('unknown'), findsOneWidget);
});
testWidgets('does not treat email addresses as mentions', (tester) async {
@@ -788,7 +792,7 @@ void main() {
),
);
await tester.tap(find.text('@Alice'));
await tester.tap(find.text('Alice'));
expect(tappedPubkey, 'pk1');
});
@@ -806,7 +810,7 @@ void main() {
),
);
await tester.tap(find.text('@Kenny Lopez'));
await tester.tap(find.text('Kenny Lopez'));
expect(tappedPubkey, 'pk1');
});
@@ -822,7 +826,7 @@ void main() {
),
);
await tester.tap(find.text('@unknown'), warnIfMissed: false);
await tester.tap(find.text('unknown'), warnIfMissed: false);
expect(tapped, isFalse);
});
});
@@ -896,7 +900,8 @@ void main() {
);
expect(_hasBoldSpan(tester, 'Important'), isTrue);
expect(find.text('@Alice'), findsOneWidget);
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('preserves markdown around mentions', (tester) async {
@@ -909,7 +914,8 @@ void main() {
),
);
expect(find.text('@Alice'), findsOneWidget);
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(_allRichText(tester), isNot(contains('**')));
});

View File

@@ -243,7 +243,7 @@ void main() {
actorPubkey: 'pk1',
targetPubkey: 'pk2',
);
expect(event.describe(resolve), 'Alice added Bob to the channel');
expect(event.describe(resolve), 'Bob was added by Alice');
});
test('member_left', () {
@@ -776,6 +776,7 @@ void main() {
expect(entries[0].summary, isNotNull);
expect(entries[0].summary!.replyCount, 2);
expect(entries[0].summary!.threadHeadId, 'a');
expect(entries[0].summary!.lastReplyAt, 3000);
});
test('summary counts only direct children, not nested replies', () {
@@ -828,4 +829,154 @@ void main() {
expect(buildMainTimelineEntries([]), isEmpty);
});
});
group('groupMembershipTimelineEntries', () {
List<MainTimelineEntry> entries(List<NostrEvent> events) =>
buildMainTimelineEntries(formatTimeline(events));
test('groups consecutive additions by one actor within five minutes', () {
final grouped = groupMembershipTimelineEntries(
entries([
_systemMsg(
id: 'a',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
},
createdAt: 1000,
),
_systemMsg(
id: 'b',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1060,
),
_systemMsg(
id: 'c',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'dave',
},
createdAt: 1300,
),
]),
);
expect(grouped, hasLength(1));
expect(grouped.single.map((entry) => entry.message.id), ['a', 'b', 'c']);
});
test('groups self-joins from different people', () {
final grouped = groupMembershipTimelineEntries(
entries([
_systemMsg(
id: 'a',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'alice',
},
createdAt: 1000,
),
_systemMsg(
id: 'b',
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
createdAt: 1060,
),
]),
);
expect(grouped.single, hasLength(2));
});
test('uses a fixed window anchored on the newest addition', () {
final grouped = groupMembershipTimelineEntries(
entries([
_systemMsg(
id: 'a',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
},
createdAt: 1000,
),
_systemMsg(
id: 'b',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1240,
),
_systemMsg(
id: 'c',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'dave',
},
createdAt: 1301,
),
]),
);
expect(grouped.map((group) => group.length), [1, 2]);
});
test('actor changes, messages, and day boundaries break groups', () {
final dayOne =
DateTime(2026, 7, 14, 23, 59).millisecondsSinceEpoch ~/ 1000;
final dayTwo = DateTime(2026, 7, 15).millisecondsSinceEpoch ~/ 1000;
final grouped = groupMembershipTimelineEntries(
entries([
_systemMsg(
id: 'a',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
},
createdAt: dayOne,
),
_systemMsg(
id: 'b',
payload: {
'type': 'member_joined',
'actor': 'carol',
'target': 'dave',
},
createdAt: dayOne + 30,
),
_textMsg(id: 'message', createdAt: dayOne + 40),
_systemMsg(
id: 'c',
payload: {
'type': 'member_joined',
'actor': 'carol',
'target': 'erin',
},
createdAt: dayOne + 50,
),
_systemMsg(
id: 'd',
payload: {
'type': 'member_joined',
'actor': 'carol',
'target': 'frank',
},
createdAt: dayTwo,
),
]),
);
expect(grouped.map((group) => group.length), [1, 1, 1, 1, 1]);
});
});
}