feat: import Chinese-localized Buzz source snapshot
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
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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
double _scaledTextHeight(BuildContext context, TextStyle style) {
|
||||
final scaledFontSize = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(style.fontSize ?? 0);
|
||||
return scaledFontSize * (style.height ?? 1);
|
||||
}
|
||||
|
||||
double _dmAppBarTitleContentHeight(BuildContext context) {
|
||||
const titleStyle = channelTitleTextStyle;
|
||||
final presenceStyle = context.textTheme.bodySmall;
|
||||
if (presenceStyle == null) {
|
||||
return 30;
|
||||
}
|
||||
final textHeight =
|
||||
_scaledTextHeight(context, titleStyle) +
|
||||
_scaledTextHeight(context, presenceStyle);
|
||||
return textHeight > 30 ? textHeight : 30;
|
||||
}
|
||||
|
||||
class _MembersButton extends ConsumerWidget {
|
||||
final String channelId;
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _MembersButton({
|
||||
required this.channelId,
|
||||
required this.channel,
|
||||
required this.currentPubkey,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasWorkingBot = ref
|
||||
.watch(workingBotPubkeysProvider(channelId))
|
||||
.isNotEmpty;
|
||||
|
||||
return IconButton(
|
||||
color: context.colors.primary,
|
||||
onPressed: () {
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) =>
|
||||
MembersSheet(channel: channel, currentPubkey: currentPubkey),
|
||||
);
|
||||
},
|
||||
tooltip: context.l10n.viewMembers,
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(LucideIcons.users, size: 22),
|
||||
if (hasWorkingBot)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: context.colors.surface, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmAppBarTitle extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _DmAppBarTitle({required this.channel, required this.currentPubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final presenceMap = ref.watch(presenceCacheProvider);
|
||||
final normalizedCurrent = currentPubkey?.toLowerCase();
|
||||
|
||||
String? otherPubkey;
|
||||
for (final pk in channel.participantPubkeys) {
|
||||
if (pk.toLowerCase() != normalizedCurrent) {
|
||||
otherPubkey = pk.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
|
||||
|
||||
if (otherPubkey != null) {
|
||||
if (profile == null) {
|
||||
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
|
||||
}
|
||||
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
|
||||
}
|
||||
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ??
|
||||
(channel.participants.isNotEmpty
|
||||
? channel.participants.first[0].toUpperCase()
|
||||
: '?');
|
||||
final presence = otherPubkey != null
|
||||
? (presenceMap[otherPubkey] ?? 'offline')
|
||||
: 'offline';
|
||||
final presenceLabel = switch (presence) {
|
||||
'online' => context.l10n.online,
|
||||
'away' => context.l10n.away,
|
||||
_ => context.l10n.offline,
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: 14,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: switch (presence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
},
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.colors.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
resolveDmChannelDisplayLabel(
|
||||
channel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: channelTitleTextStyle,
|
||||
),
|
||||
),
|
||||
if (channel.isEphemeral) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
_HeaderEphemeralBadge(channel: channel),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
presenceLabel,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _ReadOnlyNotice extends StatelessWidget {
|
||||
final Channel channel;
|
||||
|
||||
const _ReadOnlyNotice({required this.channel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
top: Grid.xxs,
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: context.colors.outlineVariant)),
|
||||
color: context.colors.surface,
|
||||
),
|
||||
child: Text(
|
||||
channel.isArchived
|
||||
? context.l10n.archivedReadOnly(
|
||||
channel.isForum ? context.l10n.forum : context.l10n.channel,
|
||||
)
|
||||
: context.l10n.joinFromManage(
|
||||
channel.isForum ? context.l10n.forum : context.l10n.channel,
|
||||
),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderEphemeralBadge extends StatelessWidget {
|
||||
final Channel channel;
|
||||
|
||||
const _HeaderEphemeralBadge({required this.channel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
|
||||
if (display == null) return const SizedBox.shrink();
|
||||
|
||||
return Tooltip(
|
||||
message: display.tooltipLabel,
|
||||
child: Icon(
|
||||
LucideIcons.clockFading,
|
||||
key: const Key('chat-ephemeral-badge'),
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageTimelineSkeleton extends StatelessWidget {
|
||||
final double appBarTitleContentHeight;
|
||||
final SessionStatus status;
|
||||
|
||||
const _MessageTimelineSkeleton({
|
||||
required this.appBarTitleContentHeight,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => context.l10n.connecting,
|
||||
SessionStatus.reconnecting => context.l10n.reconnecting,
|
||||
SessionStatus.connected || SessionStatus.disconnected =>
|
||||
context.l10n.loading,
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('channel-detail-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: ListView.separated(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
itemCount: 4,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
|
||||
itemBuilder: (_, index) => _MessageSkeletonRow(index: index),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageSkeletonRow extends StatelessWidget {
|
||||
final int index;
|
||||
|
||||
const _MessageSkeletonRow({required this.index});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const authorWidths = <double>[112, 96, 128, 80];
|
||||
const lineWidths = <List<double>>[
|
||||
[280, 224],
|
||||
[272, 184],
|
||||
[232],
|
||||
[288, 216],
|
||||
];
|
||||
final availableWidth = MediaQuery.sizeOf(context).width - 88;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBar(
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBar(width: authorWidths[index], height: 15),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
const SkeletonBar(width: 40, height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
for (final width in lineWidths[index]) ...[
|
||||
SkeletonBar(width: min(width, availableWidth), height: 16),
|
||||
const SizedBox(height: Grid.half),
|
||||
],
|
||||
const Row(
|
||||
children: [
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
SizedBox(width: Grid.xs),
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
SizedBox(width: Grid.xs),
|
||||
SkeletonBar(width: 32, height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForumConnectionSkeleton extends StatelessWidget {
|
||||
final SessionStatus status;
|
||||
|
||||
const _ForumConnectionSkeleton({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final semanticsLabel = switch (status) {
|
||||
SessionStatus.connecting => context.l10n.connecting,
|
||||
SessionStatus.reconnecting => context.l10n.reconnecting,
|
||||
SessionStatus.connected || SessionStatus.disconnected =>
|
||||
context.l10n.loading,
|
||||
};
|
||||
return Semantics(
|
||||
key: const Key('forum-connection-skeleton'),
|
||||
liveRegion: true,
|
||||
label: semanticsLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: IgnorePointer(
|
||||
child: ExcludeFocus(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(color: context.colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
child: SkeletonShimmer(
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SkeletonBar(
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(Radii.full),
|
||||
),
|
||||
),
|
||||
SizedBox(width: Grid.xxs),
|
||||
SkeletonBar(width: 112, height: 14),
|
||||
],
|
||||
),
|
||||
SizedBox(height: Grid.xxs),
|
||||
SkeletonBar(width: 240, height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _MessageBubble extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final bool showAuthor;
|
||||
final Map<String, String> channelNames;
|
||||
final String currentChannelId;
|
||||
final String? currentPubkey;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.showAuthor,
|
||||
required this.channelNames,
|
||||
required this.currentChannelId,
|
||||
required this.currentPubkey,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
final displayName = profile?.label ?? shortPubkey(message.pubkey);
|
||||
final canManageMessage =
|
||||
currentPubkey?.toLowerCase() == pk ||
|
||||
(profile?.ownerPubkey != null &&
|
||||
profile?.ownerPubkey == currentPubkey?.toLowerCase());
|
||||
|
||||
// Build mention names map from event p-tags.
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
|
||||
knownAgentPubkeys: ref.watch(
|
||||
agentMentionPubkeysProvider(currentChannelId),
|
||||
),
|
||||
profileOwnedAgentPubkeys: [
|
||||
for (final profile in userCache.values)
|
||||
if (profile.ownerPubkey != null) profile.pubkey,
|
||||
],
|
||||
);
|
||||
final mentionNames = <String, String>{};
|
||||
final agentMentionPubkeys = <String>{};
|
||||
for (final mpk in message.mentionPubkeys) {
|
||||
final normalizedPubkey = mpk.toLowerCase();
|
||||
final p = userCache[normalizedPubkey];
|
||||
if (p?.displayName != null) {
|
||||
mentionNames[normalizedPubkey] = p!.displayName!;
|
||||
}
|
||||
if (knownAgentPubkeys.contains(normalizedPubkey)) {
|
||||
agentMentionPubkeys.add(normalizedPubkey);
|
||||
}
|
||||
}
|
||||
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
|
||||
mentionPubkeys: message.mentionPubkeys,
|
||||
profileMentionNames: mentionNames,
|
||||
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
);
|
||||
|
||||
void openMessageActions(Rect anchorRect) {
|
||||
showMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
canManageMessage: canManageMessage,
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
anchorRect: anchorRect,
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
// The media carousel intentionally continues through the list's trailing
|
||||
// gutter. InkWell still clips its ink to [borderRadius], while leaving
|
||||
// overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPress: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
// Tap opens the thread; long-press still opens the action sheet.
|
||||
// MessageContent handles mention, channel-link, and media taps.
|
||||
onTap: allMessages == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? 0 : Grid.xxs,
|
||||
bottom: showAuthor ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
GestureDetector(
|
||||
onTap: () => showUserProfileSheet(context, message.pubkey),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(profile),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
l10n: context.l10n,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
context.l10n.edited,
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color:
|
||||
context.colors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
mentionNames: resolvedMentionNames,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(viewerContext).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(context, pubkey),
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
messageId: message.id,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: Grid.xxl),
|
||||
child: Text(
|
||||
key: key,
|
||||
formatMessageTime(createdAt, l10n: context.l10n),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: messageTimestampTextStyle.copyWith(
|
||||
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,
|
||||
this.size = messageAvatarSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: size / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(
|
||||
initial,
|
||||
style:
|
||||
(size > 28
|
||||
? context.textTheme.labelMedium
|
||||
: context.textTheme.labelSmall)
|
||||
?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData channelIcon(Channel channel) {
|
||||
if (channel.isDm) return LucideIcons.messagesSquare;
|
||||
if (channel.isPrivate) return LucideIcons.lock;
|
||||
if (channel.isForum) return LucideIcons.messageSquareText;
|
||||
return LucideIcons.hash;
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
class _MessageList extends HookConsumerWidget {
|
||||
final List<MainTimelineEntry> entries;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
final Set<String> initialOrdinaryUnreadMessageIds;
|
||||
final String? initialOldestOrdinaryUnreadMessageId;
|
||||
final Set<String> initialForcedUnreadMessageIds;
|
||||
final bool hasInitialUnread;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
final double appBarTitleContentHeight;
|
||||
final double composerBottomInset;
|
||||
|
||||
const _MessageList({
|
||||
required this.entries,
|
||||
required this.allMessages,
|
||||
required this.initialMessageId,
|
||||
required this.initialThreadRootId,
|
||||
required this.initialOrdinaryUnreadMessageIds,
|
||||
required this.initialOldestOrdinaryUnreadMessageId,
|
||||
required this.initialForcedUnreadMessageIds,
|
||||
required this.hasInitialUnread,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
required this.appBarTitleContentHeight,
|
||||
required this.composerBottomInset,
|
||||
});
|
||||
|
||||
@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);
|
||||
final isAtLatest = useState(true);
|
||||
final hasUserScrolled = useState(false);
|
||||
final followsLatest = useRef(
|
||||
initialMessageId == null && initialThreadRootId == null,
|
||||
);
|
||||
final isAutoScrolling = useRef(false);
|
||||
final latestRealignmentQueued = useRef(false);
|
||||
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
|
||||
final previousLatestEntryId = useRef<String?>(null);
|
||||
final didOpenInitialThread = useRef(false);
|
||||
final didJumpToInitialMessage = useRef(false);
|
||||
final isUnreadNavigationDismissed = useState(false);
|
||||
final detachedWhileUnreadShown = useRef(false);
|
||||
final oldestUnreadMessageId = useState<String?>(null);
|
||||
final unreadBoundaryLoadFailed = useState(false);
|
||||
final unreadBoundaryFetchCount = useRef(0);
|
||||
final hasUnreadDeepLink =
|
||||
initialMessageId != null || initialThreadRootId != null;
|
||||
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (!hasInitialUnread ||
|
||||
hasUnreadDeepLink ||
|
||||
oldestUnreadMessageId.value != null ||
|
||||
unreadBoundaryLoadFailed.value ||
|
||||
entries.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hasLoadedOrdinaryTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null &&
|
||||
entries.any(
|
||||
(entry) =>
|
||||
entry.message.id == initialOldestOrdinaryUnreadMessageId,
|
||||
);
|
||||
final hasLoadedForcedTarget = entries.any(
|
||||
(entry) => initialForcedUnreadMessageIds.contains(entry.message.id),
|
||||
);
|
||||
final hasKnownTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null ||
|
||||
initialForcedUnreadMessageIds.isNotEmpty;
|
||||
final hasLoadedFetchTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null
|
||||
? hasLoadedOrdinaryTarget
|
||||
: hasLoadedForcedTarget;
|
||||
final canFetchTarget =
|
||||
hasKnownTarget &&
|
||||
!hasLoadedFetchTarget &&
|
||||
!notifier.reachedOldest &&
|
||||
unreadBoundaryFetchCount.value < 4;
|
||||
if (canFetchTarget) {
|
||||
unreadBoundaryFetchCount.value += 1;
|
||||
var cancelled = false;
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
final loaded = await notifier.fetchOlder();
|
||||
if (!cancelled && !loaded && !notifier.reachedOldest) {
|
||||
unreadBoundaryLoadFailed.value = true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return () => cancelled = true;
|
||||
}
|
||||
|
||||
if (hasKnownTarget &&
|
||||
!hasLoadedFetchTarget &&
|
||||
!notifier.reachedOldest) {
|
||||
unreadBoundaryLoadFailed.value = true;
|
||||
}
|
||||
|
||||
final ordinaryUnread = entries
|
||||
.where(
|
||||
(entry) =>
|
||||
initialOrdinaryUnreadMessageIds.contains(entry.message.id),
|
||||
)
|
||||
.map((entry) => entry.message)
|
||||
.firstOrNull;
|
||||
final forcedUnread = entries
|
||||
.where(
|
||||
(entry) =>
|
||||
initialForcedUnreadMessageIds.contains(entry.message.id),
|
||||
)
|
||||
.map((entry) => entry.message)
|
||||
.firstOrNull;
|
||||
final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
oldestUnreadMessageId.value = candidates.firstOrNull?.id;
|
||||
return null;
|
||||
},
|
||||
[
|
||||
hasInitialUnread,
|
||||
hasUnreadDeepLink,
|
||||
initialOrdinaryUnreadMessageIds,
|
||||
initialOldestOrdinaryUnreadMessageId,
|
||||
initialForcedUnreadMessageIds,
|
||||
entries.length,
|
||||
notifier.reachedOldest,
|
||||
unreadBoundaryLoadFailed.value,
|
||||
],
|
||||
);
|
||||
|
||||
final showUnreadNavigation =
|
||||
!isUnreadNavigationDismissed.value &&
|
||||
oldestUnreadMessageId.value != null;
|
||||
|
||||
int? reversedIndexOf(String? messageId) {
|
||||
if (messageId == null) return null;
|
||||
final chronologicalIndex = displayEntries.indexWhere(
|
||||
(group) => group.any((entry) => entry.message.id == messageId),
|
||||
);
|
||||
return chronologicalIndex < 0
|
||||
? null
|
||||
: displayEntries.length - 1 - chronologicalIndex;
|
||||
}
|
||||
|
||||
double latestAlignment() {
|
||||
final viewportHeight = context.size?.height ?? 0;
|
||||
return viewportHeight > 0
|
||||
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
Future<void> scrollToLatest() async {
|
||||
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
|
||||
followsLatest.value = true;
|
||||
hasUserScrolled.value = false;
|
||||
isAutoScrolling.value = true;
|
||||
try {
|
||||
await itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
alignment: latestAlignment(),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
if (context.mounted && !hasUserScrolled.value) {
|
||||
isAtLatest.value = true;
|
||||
}
|
||||
} finally {
|
||||
isAutoScrolling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scrollToOldestUnread() async {
|
||||
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
|
||||
if (targetIndex == null ||
|
||||
!itemScrollController.isAttached ||
|
||||
isAutoScrolling.value) {
|
||||
return;
|
||||
}
|
||||
isUnreadNavigationDismissed.value = true;
|
||||
followsLatest.value = false;
|
||||
hasUserScrolled.value = false;
|
||||
isAtLatest.value = false;
|
||||
isAutoScrolling.value = true;
|
||||
try {
|
||||
await itemScrollController.scrollTo(
|
||||
index: targetIndex,
|
||||
alignment: 0.35,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} finally {
|
||||
isAutoScrolling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool latestIsAtBoundary() {
|
||||
// In this reversed list, item 0's leading edge is the visible bottom
|
||||
// boundary above the composer. Being merely visible is not enough: a
|
||||
// user who has pulled a tall newest row away from that boundary must not
|
||||
// snap back on live updates.
|
||||
final boundary = latestAlignment();
|
||||
return itemPositionsListener.itemPositions.value.any(
|
||||
(position) =>
|
||||
position.index == 0 &&
|
||||
(position.itemLeadingEdge - boundary).abs() < 0.01,
|
||||
);
|
||||
}
|
||||
|
||||
void realignLatestAfterLayoutChange() {
|
||||
if (latestRealignmentQueued.value ||
|
||||
!followsLatest.value ||
|
||||
hasUserScrolled.value) {
|
||||
return;
|
||||
}
|
||||
latestRealignmentQueued.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
latestRealignmentQueued.value = false;
|
||||
if (!context.mounted ||
|
||||
!itemScrollController.isAttached ||
|
||||
!followsLatest.value ||
|
||||
hasUserScrolled.value ||
|
||||
latestIsAtBoundary()) {
|
||||
return;
|
||||
}
|
||||
// A dock or keyboard resize is a layout correction, not a navigation
|
||||
// action. Keeping it instant avoids restarting a smooth scroll for
|
||||
// every position report while the viewport settles.
|
||||
itemScrollController.jumpTo(index: 0);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onPositionsChanged() {
|
||||
final positions = itemPositionsListener.itemPositions.value;
|
||||
if (positions.isEmpty) return;
|
||||
final nextIsAtLatest = latestIsAtBoundary();
|
||||
if (showUnreadNavigation &&
|
||||
nextIsAtLatest &&
|
||||
detachedWhileUnreadShown.value) {
|
||||
isUnreadNavigationDismissed.value = true;
|
||||
}
|
||||
if (nextIsAtLatest) {
|
||||
if (!isAtLatest.value) isAtLatest.value = true;
|
||||
} else if (!followsLatest.value && isAtLatest.value) {
|
||||
isAtLatest.value = false;
|
||||
}
|
||||
|
||||
final oldestVisible = positions
|
||||
.map((position) => position.index)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
if (!hasUserScrolled.value ||
|
||||
oldestVisible < displayEntries.length - 3 ||
|
||||
isLoadingOlder.value) {
|
||||
return;
|
||||
}
|
||||
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
|
||||
if (notifier.reachedOldest) return;
|
||||
isLoadingOlder.value = true;
|
||||
notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
|
||||
}
|
||||
|
||||
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
|
||||
return () => itemPositionsListener.itemPositions.removeListener(
|
||||
onPositionsChanged,
|
||||
);
|
||||
}, [channelId, entries.length, itemPositionsListener]);
|
||||
|
||||
// Composer size changes and keyboard metrics changes arrive in separate
|
||||
// layout passes. Preserve the latest-message anchor for both, but only
|
||||
// while the user has not deliberately left the tail.
|
||||
useEffect(() {
|
||||
realignLatestAfterLayoutChange();
|
||||
return null;
|
||||
}, [composerBottomInset]);
|
||||
|
||||
useEffect(() {
|
||||
final observer = _ChannelLatestMetricsObserver(
|
||||
onMetricsChanged: realignLatestAfterLayoutChange,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
return () => WidgetsBinding.instance.removeObserver(observer);
|
||||
}, [itemScrollController]);
|
||||
|
||||
useEffect(() {
|
||||
if (initialThreadRootId == null || didOpenInitialThread.value) {
|
||||
return null;
|
||||
}
|
||||
final threadHead = allMessages
|
||||
.where((message) => message.id == initialThreadRootId)
|
||||
.firstOrNull;
|
||||
if (threadHead == null) return null;
|
||||
didOpenInitialThread.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: threadHead,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
initialMessageId: initialMessageId,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
return null;
|
||||
}, [initialThreadRootId, allMessages]);
|
||||
|
||||
useEffect(() {
|
||||
final targetIndex = reversedIndexOf(initialMessageId);
|
||||
if (initialThreadRootId != null ||
|
||||
targetIndex == null ||
|
||||
didJumpToInitialMessage.value) {
|
||||
return null;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || !itemScrollController.isAttached) return;
|
||||
didJumpToInitialMessage.value = true;
|
||||
followsLatest.value = false;
|
||||
hasUserScrolled.value = false;
|
||||
isAtLatest.value = false;
|
||||
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
|
||||
});
|
||||
return null;
|
||||
}, [initialMessageId, initialThreadRootId, entries.length]);
|
||||
|
||||
useEffect(() {
|
||||
final previous = previousLatestEntryId.value;
|
||||
previousLatestEntryId.value = latestEntryId;
|
||||
if (previous == null ||
|
||||
latestEntryId == null ||
|
||||
previous == latestEntryId ||
|
||||
!isAtLatest.value) {
|
||||
return null;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) scrollToLatest();
|
||||
});
|
||||
return null;
|
||||
}, [latestEntryId]);
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.messageSquare,
|
||||
size: Grid.xl,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
context.l10n.noMessages,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
context.l10n.beFirstMessage,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build channel names map once for all message bubbles.
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final channelNamesMap = <String, String>{};
|
||||
channelsAsync.whenData((channels) {
|
||||
for (final ch in channels) {
|
||||
channelNamesMap[ch.name.toLowerCase()] = ch.id;
|
||||
}
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification &&
|
||||
notification.direction != ScrollDirection.idle) {
|
||||
hasUserScrolled.value = true;
|
||||
followsLatest.value = false;
|
||||
if (showUnreadNavigation) {
|
||||
detachedWhileUnreadShown.value = true;
|
||||
}
|
||||
} else if (notification is ScrollEndNotification &&
|
||||
hasUserScrolled.value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted || !latestIsAtBoundary()) return;
|
||||
hasUserScrolled.value = false;
|
||||
followsLatest.value = true;
|
||||
if (!isAtLatest.value) isAtLatest.value = true;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: KeyboardDismissOnDrag(
|
||||
child: ScrollablePositionedList.builder(
|
||||
key: const ValueKey('channel-message-list'),
|
||||
itemScrollController: itemScrollController,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(
|
||||
left: Grid.gutter,
|
||||
right: Grid.gutter,
|
||||
top: frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
),
|
||||
bottom: composerBottomInset,
|
||||
),
|
||||
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator at the top (last index in reversed list).
|
||||
if (index >= displayEntries.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
|
||||
child: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 24,
|
||||
semanticLabel: context.l10n.loadingOlderMessages,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Reversed list: index 0 = newest (bottom of screen).
|
||||
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
|
||||
? displayEntries[chronIdx - 1].last
|
||||
: null;
|
||||
final prevMessage = prevEntry?.message;
|
||||
final showDayDivider =
|
||||
prevMessage == null ||
|
||||
!isSameDay(prevMessage.createdAt, message.createdAt);
|
||||
|
||||
final showAuthor =
|
||||
!message.isSystem &&
|
||||
(message.hasAttachments ||
|
||||
prevMessage == null ||
|
||||
prevMessage.isSystem ||
|
||||
showDayDivider ||
|
||||
prevMessage.pubkey.toLowerCase() !=
|
||||
message.pubkey.toLowerCase() ||
|
||||
(message.createdAt - prevMessage.createdAt) > 300);
|
||||
|
||||
return Padding(
|
||||
key: ValueKey('channel-message-group-${message.id}'),
|
||||
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showDayDivider)
|
||||
DayDivider(
|
||||
label: formatDayHeading(
|
||||
message.createdAt,
|
||||
l10n: context.l10n,
|
||||
),
|
||||
),
|
||||
if (message.isSystem)
|
||||
_SystemMessageRow(
|
||||
message: message,
|
||||
groupedMessages: entryGroup.length > 1
|
||||
? entryGroup
|
||||
.map((entry) => entry.message)
|
||||
.toList()
|
||||
: null,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
allMessages: null,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
)
|
||||
else ...[
|
||||
_MessageBubble(
|
||||
message: message,
|
||||
showAuthor: showAuthor,
|
||||
channelNames: channelNamesMap,
|
||||
currentChannelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
allMessages: allMessages,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
if (entry.summary != null)
|
||||
_ThreadSummaryRow(
|
||||
summary: entry.summary!,
|
||||
message: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
child: Center(
|
||||
child: IconButton.filled(
|
||||
key: const ValueKey('channel-jump-to-oldest-unread'),
|
||||
onPressed: scrollToOldestUnread,
|
||||
tooltip: context.l10n.jumpOldestUnread,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
foregroundColor: context.colors.onPrimaryContainer,
|
||||
),
|
||||
icon: const Icon(LucideIcons.chevronUp, size: 20),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!isAtLatest.value)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: composerBottomInset + Grid.xs,
|
||||
child: Center(
|
||||
child: _JumpToLatestButton(
|
||||
key: const ValueKey('channel-jump-to-latest'),
|
||||
onPressed: scrollToLatest,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JumpToLatestButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _JumpToLatestButton({required this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(Radii.full);
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
key: const ValueKey('channel-jump-to-latest-surface'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface.withValues(alpha: 0.5),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: borderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.arrowDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
context.l10n.jumpLatest,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
|
||||
final VoidCallback onMetricsChanged;
|
||||
|
||||
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
|
||||
|
||||
@override
|
||||
void didChangeMetrics() => onMetricsChanged();
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user