9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
650 lines
21 KiB
Dart
650 lines
21 KiB
Dart
part of '../channel_detail_page.dart';
|
|
|
|
class _SystemMessageRow extends HookConsumerWidget {
|
|
final TimelineMessage message;
|
|
final List<TimelineMessage>? groupedMessages;
|
|
final String channelId;
|
|
final String? currentPubkey;
|
|
final List<TimelineMessage>? allMessages;
|
|
final bool isMember;
|
|
final bool isArchived;
|
|
|
|
const _SystemMessageRow({
|
|
required this.message,
|
|
this.groupedMessages,
|
|
required this.channelId,
|
|
this.currentPubkey,
|
|
this.allMessages,
|
|
this.isMember = false,
|
|
this.isArchived = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final spotlightKey = useMemoized(() => GlobalKey());
|
|
final systemEvent = message.systemEvent;
|
|
if (systemEvent == null) return const SizedBox.shrink();
|
|
|
|
final userCache = ref.watch(userCacheProvider);
|
|
final sourceMessages = groupedMessages ?? [message];
|
|
final groupedMembership = _membershipDisplayEvent(sourceMessages);
|
|
final messageStyleAction = switch (systemEvent.type) {
|
|
SystemEventType.channelCreated => context.l10n.systemCreatedThisChannel,
|
|
SystemEventType.huddleStarted => context.l10n.systemStartedHuddle,
|
|
SystemEventType.huddleEnded => context.l10n.systemEndedHuddle,
|
|
_ => null,
|
|
};
|
|
final messageStyleActor = messageStyleAction == null
|
|
? null
|
|
: systemEvent.actorPubkey?.trim();
|
|
final usesMessageStyleLayout =
|
|
groupedMembership != null ||
|
|
(messageStyleActor != null && messageStyleActor.isNotEmpty);
|
|
|
|
String resolveLabel(String? pubkey) {
|
|
if (pubkey == null) return context.l10n.systemSomeone;
|
|
final profile =
|
|
userCache[pubkey.toLowerCase()] ??
|
|
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
|
|
return profile?.label ?? shortPubkey(pubkey);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
void openReactionPopover(Rect anchorRect) {
|
|
final spotlightRenderObject = spotlightKey.currentContext
|
|
?.findRenderObject();
|
|
final spotlightRect =
|
|
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
|
|
? spotlightRenderObject.localToGlobal(Offset.zero) &
|
|
spotlightRenderObject.size
|
|
: anchorRect;
|
|
showMessageActions(
|
|
context: context,
|
|
ref: ref,
|
|
message: message,
|
|
channelId: channelId,
|
|
canManageMessage: false,
|
|
allMessages: null,
|
|
currentPubkey: currentPubkey,
|
|
isMember: isMember,
|
|
isArchived: isArchived,
|
|
anchorRect: spotlightRect,
|
|
popoverSpotlightPadding: EdgeInsets.fromLTRB(
|
|
Grid.xxs,
|
|
Grid.xxs,
|
|
Grid.xxs,
|
|
reactions.isEmpty ? Grid.xxs : Grid.quarter,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: MessageLongPressInkWell(
|
|
key: ValueKey('system-message-row-${message.id}'),
|
|
onLongPress: openReactionPopover,
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
|
|
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
KeyedSubtree(
|
|
key: spotlightKey,
|
|
child: groupedMembership != null
|
|
? _MembershipSystemMessageContent(
|
|
event: groupedMembership,
|
|
createdAt: message.createdAt,
|
|
resolveLabel: resolveLabel,
|
|
userCache: userCache,
|
|
)
|
|
: messageStyleActor != null &&
|
|
messageStyleActor.isNotEmpty &&
|
|
messageStyleAction != null
|
|
? _MessageStyleSystemMessageContent(
|
|
displayPubkey: messageStyleActor,
|
|
createdAt: message.createdAt,
|
|
resolveLabel: resolveLabel,
|
|
userCache: userCache,
|
|
actionSpans: [TextSpan(text: messageStyleAction)],
|
|
)
|
|
: Row(
|
|
children: [
|
|
_systemEventAvatar(context, systemEvent, userCache),
|
|
const SizedBox(width: Grid.xxs),
|
|
Expanded(
|
|
child: Text(
|
|
systemEvent.describeLocalized(
|
|
context.l10n,
|
|
resolveLabel,
|
|
),
|
|
style: systemMessageBodyTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
_messageTimestamp(
|
|
context,
|
|
message.createdAt,
|
|
key: ValueKey(
|
|
'system-message-timestamp-${message.id}',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (reactions.isNotEmpty)
|
|
Padding(
|
|
padding: EdgeInsets.only(
|
|
left:
|
|
(usesMessageStyleLayout ? messageAvatarSize : 36) +
|
|
(usesMessageStyleLayout
|
|
? messageAvatarContentGap
|
|
: Grid.xxs),
|
|
),
|
|
child: ReactionRow(
|
|
messageId: message.id,
|
|
reactions: reactions,
|
|
onToggle: groupedMessages == null
|
|
? (emoji) => toggleReaction(ref, message, emoji)
|
|
: toggleGroupedReaction,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
? context.l10n.systemJoinedChannelAction
|
|
// No "was": the name renders on the line above via
|
|
// MessageAuthorMeta, so this reads as a status line rather than a
|
|
// sentence continuing across the metadata row. Matches desktop's
|
|
// SystemMessageRow. `SystemEvent.describe` keeps "was added by"
|
|
// because it builds subject and predicate into one string.
|
|
: context.l10n.systemAddedBy(resolveLabel(event.actorPubkey)),
|
|
),
|
|
if (additionalTargets.isNotEmpty)
|
|
TextSpan(
|
|
text: event.isSelfJoin
|
|
? context.l10n.systemAlongWith
|
|
: context.l10n.systemCommaAlongWith,
|
|
),
|
|
..._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 systemMessageBodyTextStyle.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: [
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => showUserProfileSheet(context, displayPubkey),
|
|
child: _UserAvatar(
|
|
profile: userCache[displayPubkey.toLowerCase()],
|
|
pubkey: displayPubkey,
|
|
size: messageAvatarSize,
|
|
),
|
|
),
|
|
const SizedBox(width: messageAvatarContentGap),
|
|
Expanded(
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -Grid.quarter),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MessageAuthorMeta(
|
|
displayName: resolveLabel(displayPubkey),
|
|
username: messageUsernameLabel(
|
|
userCache[displayPubkey.toLowerCase()],
|
|
),
|
|
timestamp: formatMessageTime(createdAt, l10n: context.l10n),
|
|
nameColor: context.colors.onSurface,
|
|
metadataColor: context.colors.onSurfaceVariant,
|
|
nameStyle: systemMessageHeadingTextStyle,
|
|
displayNameKey: ValueKey(
|
|
'system-message-author-$displayPubkey',
|
|
),
|
|
usernameKey: ValueKey(
|
|
'system-message-username-$displayPubkey',
|
|
),
|
|
timestampKey: ValueKey(
|
|
'system-message-timestamp-$displayPubkey',
|
|
),
|
|
),
|
|
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
|
|
? context.l10n.systemAnd
|
|
: context.l10n.systemCommaAnd)
|
|
: ', ';
|
|
spans.add(
|
|
TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'),
|
|
);
|
|
}
|
|
|
|
if (hiddenTargets.isNotEmpty) {
|
|
final hiddenLabels = hiddenTargets.map(resolveLabel).toList();
|
|
spans
|
|
..add(TextSpan(text: context.l10n.systemCommaAnd))
|
|
..add(
|
|
WidgetSpan(
|
|
alignment: PlaceholderAlignment.baseline,
|
|
baseline: TextBaseline.alphabetic,
|
|
child: Tooltip(
|
|
message: hiddenLabels.join('\n'),
|
|
triggerMode: TooltipTriggerMode.tap,
|
|
child: Text(
|
|
context.l10n.othersCount(hiddenTargets.length),
|
|
key: const Key('membership-overflow'),
|
|
style: style?.copyWith(
|
|
decoration: TextDecoration.underline,
|
|
decorationStyle: TextDecorationStyle.dotted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return spans;
|
|
}
|
|
|
|
Widget _systemEventAvatar(
|
|
BuildContext context,
|
|
SystemEvent event,
|
|
Map<String, UserProfile> userCache,
|
|
) {
|
|
final hasTarget =
|
|
event.targetPubkey != null && event.targetPubkey != event.actorPubkey;
|
|
|
|
if (event.actorPubkey != null && hasTarget) {
|
|
// Two-avatar stack: actor + target (e.g. "Alice added Bob").
|
|
return SizedBox(
|
|
width: 32,
|
|
height: 20,
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
|
|
child: SmallAvatar(
|
|
pubkey: event.actorPubkey!,
|
|
userCache: userCache,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 12,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => showUserProfileSheet(context, event.targetPubkey!),
|
|
child: SmallAvatar(
|
|
pubkey: event.targetPubkey!,
|
|
userCache: userCache,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (event.actorPubkey != null) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
|
|
child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
|
|
);
|
|
}
|
|
|
|
// Fallback: generic icon when no actor is available.
|
|
return Container(
|
|
width: 20,
|
|
height: 20,
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHighest,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
LucideIcons.arrowLeftRight,
|
|
size: 12,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _ThreadSummaryRow extends ConsumerWidget {
|
|
final ThreadSummary summary;
|
|
final TimelineMessage message;
|
|
final List<TimelineMessage> allMessages;
|
|
final String channelId;
|
|
final String? currentPubkey;
|
|
final bool isMember;
|
|
final bool isArchived;
|
|
|
|
const _ThreadSummaryRow({
|
|
required this.summary,
|
|
required this.message,
|
|
required this.allMessages,
|
|
required this.channelId,
|
|
required this.currentPubkey,
|
|
required this.isMember,
|
|
required this.isArchived,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final userCache = ref.watch(userCacheProvider);
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ThreadDetailPage(
|
|
threadHead: message,
|
|
allMessages: allMessages,
|
|
channelId: channelId,
|
|
currentPubkey: currentPubkey,
|
|
isMember: isMember,
|
|
isArchived: isArchived,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: Padding(
|
|
key: ValueKey('thread-summary-${message.id}'),
|
|
padding: const EdgeInsets.only(
|
|
left: messageAvatarSize + messageAvatarContentGap,
|
|
top: Grid.half,
|
|
bottom: Grid.xs,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Stacked participant avatars.
|
|
SizedBox(
|
|
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 * 20.0,
|
|
child: SmallAvatar(
|
|
pubkey: summary.participantPubkeys[i],
|
|
userCache: userCache,
|
|
size: 32,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Flexible(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text:
|
|
context.l10n.replyCount(summary.replyCount),
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
if (summary.lastReplyAt case final lastReplyAt?) ...[
|
|
TextSpan(
|
|
text: ' · ',
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: context.l10n.lastReply(
|
|
formatThreadSummaryLastReplyTime(
|
|
lastReplyAt,
|
|
l10n: context.l10n,
|
|
),
|
|
),
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|