mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
Refine mobile channel and home UI (#2651)
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
322
mobile/lib/features/channels/channels_page/quick_actions.dart
Normal file
322
mobile/lib/features/channels/channels_page/quick_actions.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('**')));
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user