Polish mobile message layout and navigation controls (#8427)

Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-18 15:17:25 -04:00
committed by GitHub
parent 3ffbfa693c
commit 27b9c827fc
32 changed files with 1381 additions and 423 deletions

View File

@@ -606,7 +606,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
private func makeAction(_ data: [String: Any]) -> UIAction {
let id = data["id"] as? String ?? ""
let enabled = data["enabled"] as? Bool == true
let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0) }
let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0, size: 24) }
return UIAction(title: data["label"] as? String ?? "", image: symbol,
attributes: enabled ? [] : [.disabled],
state: data["selected"] as? Bool == true ? .on : .off) { [weak self] _ in
@@ -615,8 +615,13 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
}
private func makeControlGroup(_ actions: [[String: Any]]) -> UIBarButtonItem {
if actions.count == 1, actions[0]["plain"] as? Bool == true {
return makeItem(actions[0])
if actions.count == 1 {
let item = makeItem(actions[0])
// Let UIKit retain its navigation typography and sizing for text actions
// such as Edit/Done, rather than inheriting UIButton's default styling.
if actions[0]["plain"] as? Bool == true || (item.image == nil && item.customView == nil) {
return item
}
}
let surface = UIVisualEffectView()
if #available(iOS 26.0, *) {

View File

@@ -452,7 +452,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
)
let pointSize: CGFloat = icon == "shutter"
? controlSize * Self.shutterIconRatio
: 17
: 24
buttonImage = NativeTabler.image(buttonIconName, size: pointSize)
}
updateDisplayedContent()

View File

@@ -72,7 +72,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
configuration.baseBackgroundColor = .secondarySystemBackground
}
configuration.cornerStyle = .capsule
configuration.image = NativeTabler.image("xmark", size: 17)
configuration.image = NativeTabler.image("xmark", size: 24)
configuration.baseForegroundColor = foregroundColor
close.configuration = configuration
close.tintColor = foregroundColor

View File

@@ -16,10 +16,6 @@ enum NativeTabler {
}()
static func image(_ symbol: String, size: CGFloat = 20) -> UIImage? {
// Back and Close deliberately retain native navigation artwork.
if ["chevron.backward", "chevron.left", "xmark", "xmark.circle.fill"].contains(symbol) {
return UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .semibold))
}
guard let code = glyphs[symbol], let scalar = UnicodeScalar(code),
let name = fontName, let font = UIFont(name: name, size: size) else {
assertionFailure("Missing Tabler icon: \(symbol)")

View File

@@ -5,6 +5,8 @@ class _MessageBubble extends HookConsumerWidget {
final bool showAuthor;
final bool typingHandoff;
final bool showAvatar;
final bool startsDay;
final bool endsDay;
final bool hasReplies;
final Widget? replyFooter;
final Map<String, String> channelNames;
@@ -21,6 +23,8 @@ class _MessageBubble extends HookConsumerWidget {
required this.showAuthor,
this.typingHandoff = false,
this.showAvatar = true,
this.startsDay = false,
this.endsDay = false,
required this.hasReplies,
this.replyFooter,
required this.channelNames,
@@ -52,6 +56,7 @@ class _MessageBubble extends HookConsumerWidget {
);
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final ownMessage = currentPubkey?.toLowerCase() == pk;
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
@@ -133,7 +138,7 @@ class _MessageBubble extends HookConsumerWidget {
return true;
},
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
@@ -165,46 +170,60 @@ class _MessageBubble extends HookConsumerWidget {
),
child: Padding(
padding: EdgeInsets.only(
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
bottom: endsDay
? 0
: (showAvatar ? Grid.quarter : Grid.quarter / 2),
),
child: MessageFooterLayout(
animateSize: true,
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
footer: message.reactions.isNotEmpty || replyFooter != null
? Padding(
padding: const EdgeInsets.only(
left:
messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.reactions.isNotEmpty)
ReactionRow(
combined: true,
messageId: message.id,
channelId: currentChannelId,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
? Align(
alignment: ownMessage
? Alignment.centerRight
: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
right: ownMessage ? Grid.xxs : 0,
left: ownMessage
? 0
: messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (message.reactions.isNotEmpty)
ReactionRow(
combined: true,
messageId: message.id,
channelId: currentChannelId,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
if (replyFooter != null)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.xxs,
if (replyFooter != null)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.xxs,
),
child: Directionality(
textDirection: Directionality.of(context),
child: replyFooter!,
),
),
child: replyFooter,
),
],
],
),
),
)
: const SizedBox.shrink(),
@@ -214,29 +233,41 @@ class _MessageBubble extends HookConsumerWidget {
clipBehavior: Clip.none,
children: [
Row(
textDirection: ownMessage
? TextDirection.rtl
: TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
if (!ownMessage)
const SizedBox(width: messageAvatarSize),
if (!ownMessage)
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.half : 0,
top: showAuthor && !startsDay ? Grid.half : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
padding: EdgeInsets.only(
bottom: Grid.quarter,
left: Grid.xs,
left: ownMessage ? 0 : Grid.xs,
right: ownMessage ? Grid.xs : 0,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
showTimestamp: true,
reversed: false,
alignment: ownMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
nameStyle:
messageUsernameTextStyle
.copyWith(
@@ -318,8 +349,8 @@ class _MessageBubble extends HookConsumerWidget {
replyFooter != null,
bubbleEnd: showAvatar,
bubbleStart: showAuthor,
ownMessage:
currentPubkey?.toLowerCase() == pk,
ownMessage: ownMessage,
trimTrailingSpacing: endsDay,
mentionNames: resolvedMentionNames,
mentionLabels: mentionLabels,
agentMentionPubkeys: agentMentionPubkeys,
@@ -392,9 +423,10 @@ class _MessageBubble extends HookConsumerWidget {
),
],
),
if (showAvatar)
if (showAvatar && !ownMessage)
Positioned(
left: 0,
left: ownMessage ? null : 0,
right: ownMessage ? 0 : null,
bottom: 0,
child: MessageMediaAvatar(
offset: mediaOffset,

View File

@@ -963,6 +963,13 @@ class _MessageList extends HookConsumerWidget {
if (message.isSystem)
_SystemMessageRow(
message: message,
startsDay: showDayDivider,
endsDay:
nextMessage != null &&
!isSameDay(
message.createdAt,
nextMessage.createdAt,
),
groupedMessages: entryGroup.length > 1
? entryGroup
.map((entry) => entry.message)
@@ -977,6 +984,13 @@ class _MessageList extends HookConsumerWidget {
else ...[
_MessageBubble(
message: message,
startsDay: showDayDivider,
endsDay:
nextMessage != null &&
!isSameDay(
message.createdAt,
nextMessage.createdAt,
),
showAuthor:
showAuthor ||
isRecentAgentReplyHandoff(

View File

@@ -8,6 +8,8 @@ class _SystemMessageRow extends HookConsumerWidget {
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
final bool startsDay;
final bool endsDay;
const _SystemMessageRow({
required this.message,
@@ -17,6 +19,8 @@ class _SystemMessageRow extends HookConsumerWidget {
this.allMessages,
this.isMember = false,
this.isArchived = false,
this.startsDay = false,
this.endsDay = false,
});
@override
@@ -136,37 +140,43 @@ class _SystemMessageRow extends HookConsumerWidget {
systemEvent.type == SystemEventType.huddleStarted &&
systemEvent.ephemeralChannelId != null;
final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase();
final ownActor = currentPubkey?.toLowerCase() == actor;
final ownHuddle =
ownActor &&
(isHuddle || systemEvent.type == SystemEventType.huddleEnded);
Widget withActor(Widget content) {
if (!isHuddle) return content;
return Row(
key: ValueKey('huddle-activity-${message.id}'),
textDirection: ownHuddle ? TextDirection.rtl : TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Semantics(
container: true,
label: 'Open ${resolveLabel(actor)} profile',
button: true,
excludeSemantics: true,
onTap: () => showUserProfileSheet(
context,
actor,
names: channelIdentityNamesProvider(channelId),
),
child: GestureDetector(
excludeFromSemantics: true,
if (!ownHuddle)
Semantics(
container: true,
label: 'Open ${resolveLabel(actor)} profile',
button: true,
excludeSemantics: true,
onTap: () => showUserProfileSheet(
context,
actor,
names: channelIdentityNamesProvider(channelId),
),
child: _UserAvatar(
profile: userCache[actor],
pubkey: actor,
isAgent: userCache[actor]?.ownerPubkey != null,
child: GestureDetector(
excludeFromSemantics: true,
onTap: () => showUserProfileSheet(
context,
actor,
names: channelIdentityNamesProvider(channelId),
),
child: _UserAvatar(
profile: userCache[actor],
pubkey: actor,
isAgent: userCache[actor]?.ownerPubkey != null,
),
),
),
),
const SizedBox(width: messageAvatarContentGap),
if (!ownHuddle) const SizedBox(width: messageAvatarContentGap),
Expanded(child: content),
],
);
@@ -183,9 +193,14 @@ class _SystemMessageRow extends HookConsumerWidget {
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
padding: EdgeInsets.only(
top: startsDay ? 0 : Grid.xxs,
bottom: endsDay ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: isHuddle
crossAxisAlignment: ownHuddle
? CrossAxisAlignment.end
: isHuddle
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
@@ -193,7 +208,9 @@ class _SystemMessageRow extends HookConsumerWidget {
RepaintBoundary(
key: spotlightKey,
child: Align(
alignment: isHuddle
alignment: ownHuddle
? Alignment.centerRight
: isHuddle
? Alignment.centerLeft
: Alignment.center,
child: Padding(
@@ -244,25 +261,32 @@ class _SystemMessageRow extends HookConsumerWidget {
overlap: reactions.isNotEmpty ? Grid.xs : 0,
footer: reactions.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(
left:
messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
),
child: ReactionRow(
combined: true,
messageId: message.id,
channelId: channelId,
reactions: reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
: Align(
alignment: ownHuddle
? Alignment.centerRight
: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
left: ownHuddle
? 0
: messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
right: ownHuddle ? Grid.xxs : 0,
),
child: ReactionRow(
combined: true,
messageId: message.id,
channelId: channelId,
reactions: reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
),
@@ -609,6 +633,7 @@ class _ThreadSummaryRow extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return ReplySummaryLink(
key: ValueKey('thread-summary-${message.id}'),
mirrored: currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
summary: summary,
hasReactions: message.reactions.isNotEmpty,
onTap: () {

View File

@@ -12,11 +12,16 @@ class _EphemeralBadge extends StatelessWidget {
return Tooltip(
message: display.tooltipLabel,
child: Icon(
BuzzIcons.clockFading,
key: Key('channel-ephemeral-${channel.id}'),
size: 16,
color: context.colors.onSurfaceVariant,
child: SizedBox(
width: _kChannelIconSize,
child: Center(
child: Icon(
BuzzIcons.clockFading,
key: Key('channel-ephemeral-${channel.id}'),
size: 16,
color: context.colors.onSurfaceVariant,
),
),
),
);
}

View File

@@ -86,10 +86,15 @@ class _ChannelTile extends ConsumerWidget {
],
if (isMuted) ...[
const SizedBox(width: Grid.xxs),
Icon(
BuzzIcons.bellOff,
size: 12,
color: context.colors.onSurface.withValues(alpha: 0.4),
SizedBox(
width: _kChannelIconSize,
child: Center(
child: Icon(
BuzzIcons.bellOff,
size: 12,
color: context.colors.onSurface.withValues(alpha: 0.4),
),
),
),
],
if (!channel.isMember && !channel.isDm)

View File

@@ -138,10 +138,9 @@ class _CustomSectionHeader extends StatelessWidget {
),
),
Builder(
builder: (buttonContext) => IconButton(
builder: (buttonContext) => _SectionOptionsButton(
key: ValueKey('section-menu-${section.id}'),
tooltip: '${section.name} options',
visualDensity: VisualDensity.compact,
icon: Icon(
BuzzIcons.ellipsisVertical,
size: _kChannelIconSize,
@@ -209,7 +208,6 @@ class _CustomSectionHeader extends StatelessWidget {
},
),
),
const SizedBox(width: Grid.quarter),
],
),
),
@@ -495,10 +493,9 @@ class _SectionHeader extends StatelessWidget {
),
if (sortMode case final mode?) ...[
Builder(
builder: (buttonContext) => IconButton(
builder: (buttonContext) => _SectionOptionsButton(
key: ValueKey('sort-menu-$label'),
tooltip: '$label options',
visualDensity: VisualDensity.compact,
icon: Icon(
BuzzIcons.ellipsisVertical,
size: _kChannelIconSize,
@@ -509,15 +506,6 @@ class _SectionHeader extends StatelessWidget {
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
color: context.colors.surface,
elevation: 4,
shadowColor: context.colors.shadow.withValues(
alpha: 0.18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outline),
),
surfaceKey: ValueKey('sort-popover-$label'),
items: _sortMenuItems(mode, showDivider: false),
);
@@ -529,7 +517,6 @@ class _SectionHeader extends StatelessWidget {
},
),
),
const SizedBox(width: Grid.quarter),
],
],
),
@@ -587,3 +574,35 @@ class _SectionChevron extends StatelessWidget {
);
}
}
class _SectionOptionsButton extends StatelessWidget {
const _SectionOptionsButton({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
});
final String tooltip;
final Widget icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: tooltip,
onTap: onPressed,
excludeSemantics: true,
child: Tooltip(
message: tooltip,
child: SizedBox.square(
dimension: 40,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(Radii.full),
child: Align(alignment: Alignment.centerRight, child: icon),
),
),
),
);
}

View File

@@ -309,7 +309,7 @@ class _CameraCloseButton extends StatelessWidget {
),
foregroundColor: Colors.white,
),
icon: Icon(BuzzIcons.arrowLeft, size: emphasized ? 24 : 18),
icon: Icon(BuzzIcons.arrowLeft, size: 24),
),
);
}

View File

@@ -179,7 +179,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
: () => _runComposerAction(onBack),
tooltip: 'Back to attachment options',
visualDensity: VisualDensity.compact,
icon: const Icon(BuzzIcons.arrowLeft, size: 20),
icon: const Icon(BuzzIcons.arrowLeft, size: 24),
),
const SizedBox(width: Grid.quarter),
Expanded(

View File

@@ -28,11 +28,49 @@ class DayDivider extends StatelessWidget {
: const Duration(milliseconds: 120),
curve: Curves.easeOutCubic,
opacity: isSticky ? 0 : 1,
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.72),
fontWeight: FontWeight.w500,
child: LayoutBuilder(
builder: (context, constraints) => Row(
children: [
Expanded(
child: Divider(
color: context.colors.outlineVariant,
thickness: 0.5,
),
),
const SizedBox(width: Grid.xs),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: (constraints.maxWidth - Grid.xs * 2) * 0.6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (dayTimestamp != null) ...[
_CalendarDateTile(timestamp: dayTimestamp!),
const SizedBox(width: Grid.xxs),
],
Flexible(
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.72,
),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(width: Grid.xs),
Expanded(
child: Divider(
color: context.colors.outlineVariant,
thickness: 0.5,
),
),
],
),
),
),
@@ -44,7 +82,7 @@ class DayDivider extends StatelessWidget {
final activeTimestamp = stickyDayTimestamp;
final timestamp = dayTimestamp;
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter),
padding: const EdgeInsets.symmetric(vertical: Grid.gutter),
child: Center(
child: activeTimestamp == null || timestamp == null
? _buildOpacity(context, isSticky: false)
@@ -59,3 +97,50 @@ class DayDivider extends StatelessWidget {
);
}
}
/// Decorative desktop calendar artwork; the adjacent label owns semantics.
class _CalendarDateTile extends StatelessWidget {
const _CalendarDateTile({required this.timestamp});
final int timestamp;
@override
Widget build(BuildContext context) {
final day = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000).day;
return ExcludeSemantics(
child: Container(
key: ValueKey('day-calendar-$timestamp'),
width: Grid.gutter,
height: Grid.gutter,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(color: context.colors.outlineVariant, width: 0.5),
),
child: Column(
children: [
const SizedBox(
height: Grid.gutter * 7 / 24,
width: double.infinity,
child: ColoredBox(color: Color(0xFFFF0000)),
),
Expanded(
child: Center(
child: Text(
'$day',
textScaler: TextScaler.noScaling,
style: const TextStyle(
color: Color(0xFF111111),
fontSize: Grid.gutter / 2,
height: 1,
),
),
),
),
],
),
),
);
}
}

View File

@@ -102,7 +102,7 @@ class _MediaViewerCircleButton extends StatelessWidget {
child: IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(icon, color: Colors.white, size: 20),
icon: Icon(icon, color: Colors.white, size: 24),
),
),
);

View File

@@ -101,6 +101,9 @@ class MessageContent extends HookConsumerWidget {
/// Distinguishes messages authored by the signed-in identity.
final bool ownMessage;
/// Lets an adjacent day divider own spacing after the final text segment.
final bool trimTrailingSpacing;
/// Marks the final message in a sender run for rounded corners and dots.
final bool bubbleEnd;
@@ -171,6 +174,7 @@ class MessageContent extends HookConsumerWidget {
required this.content,
this.bubble = false,
this.ownMessage = false,
this.trimTrailingSpacing = false,
this.bubbleEnd = true,
this.bubbleStart = true,
this.hasReactions = false,
@@ -395,7 +399,9 @@ class MessageContent extends HookConsumerWidget {
: const <({String text, bool attachment})>[];
final markdown = bubble
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (final (index, segment) in segments.indexed)
@@ -413,7 +419,14 @@ class MessageContent extends HookConsumerWidget {
else
Padding(
key: ValueKey('message-text-bubble-$index'),
padding: const EdgeInsets.only(bottom: Grid.quarter),
padding: EdgeInsets.only(
bottom:
trimTrailingSpacing &&
trailingGallery == null &&
index == segments.length - 1
? 0
: Grid.quarter,
),
child: LocalSendBubbleSurface(
color: ownMessage
? chatColors.outgoing
@@ -479,7 +492,9 @@ class MessageContent extends HookConsumerWidget {
if (trailingGallery == null) return markdown;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (trailingGallery.content.trim().isNotEmpty) markdown,
@@ -493,6 +508,7 @@ class MessageContent extends HookConsumerWidget {
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
mirrored: ownMessage,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,

View File

@@ -11,21 +11,24 @@ class _MessageGalleryPrecache extends HookWidget {
@override
Widget build(BuildContext context) {
final prefetch = useMemoized(_GalleryPrecache.new, const []);
useEffect(() => prefetch.dispose, [prefetch]);
final providerSignature = Object.hashAll(providers);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled || !context.mounted) {
return;
}
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= providers.length) {
continue;
}
unawaited(
precacheImage(providers[index], context, onError: (_, _) {}),
);
}
if (cancelled || !context.mounted) return;
// Prepare a five-photo strip immediately, then keep four ahead and two
// behind the current page. Two workers bound speculative network work.
final indices = [
for (var i = focusedIndex; i <= focusedIndex + 4; i++) i,
focusedIndex - 1,
focusedIndex - 2,
];
prefetch.update(context, [
for (final i in indices)
if (i >= 0 && i < providers.length) providers[i],
]);
});
return () => cancelled = true;
}, [focusedIndex, providerSignature]);
@@ -33,8 +36,50 @@ class _MessageGalleryPrecache extends HookWidget {
}
}
class _GalleryPrecache {
BuildContext? _context;
List<ImageProvider<Object>> _window = [];
final _ready = <ImageProvider<Object>>{};
final _loading = <ImageProvider<Object>>{};
void update(BuildContext context, List<ImageProvider<Object>> window) {
_context = context;
_window = window;
_ready.removeWhere((provider) => !window.contains(provider));
_drain();
}
void _drain() {
final context = _context;
if (context == null || !context.mounted) return;
for (final provider in _window) {
if (_loading.length >= 2) break;
if (_ready.contains(provider) || _loading.contains(provider)) continue;
_loading.add(provider);
unawaited(_load(context, provider));
}
}
Future<void> _load(
BuildContext context,
ImageProvider<Object> provider,
) async {
await precacheImage(provider, context, onError: (_, _) {});
_loading.remove(provider);
if (_window.contains(provider)) _ready.add(provider);
_drain();
}
void dispose() {
_context = null;
_window = [];
_ready.clear();
}
}
class _MessageImageCarousel extends HookConsumerWidget {
final List<MessageGalleryItem> items;
final bool mirrored;
final double leadingOverflow;
final double trailingOverflow;
final VoidCallback? onReply;
@@ -43,6 +88,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
const _MessageImageCarousel({
super.key,
required this.items,
required this.mirrored,
required this.leadingOverflow,
required this.trailingOverflow,
required this.onReply,
@@ -51,6 +97,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewerContext = context;
final itemSignature = items.map((item) => item.url).join('\u0000');
final heroTags = useMemoized(
() => [for (var index = 0; index < items.length; index++) Object()],
@@ -62,211 +109,223 @@ class _MessageImageCarousel extends HookConsumerWidget {
return MessageGalleryFrame(
count: items.length,
child: LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
final leadingExtent = leadingOverflow;
final isLeftToRight = Directionality.of(context) == TextDirection.ltr;
final itemTrailingPaddings = [
for (var index = 0; index < items.length; index++)
index == items.length - 1 ? Grid.gutter : Grid.half,
];
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(1.0, carouselWidth * 0.9 - itemTrailingPaddings[index]),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
mirrored: mirrored,
child: Directionality(
textDirection: mirrored ? TextDirection.rtl : TextDirection.ltr,
child: LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
// Directionality mirrors both the starting edge and trailing overflow.
final leadingExtent = leadingOverflow;
final isLeftToRight =
Directionality.of(context) == TextDirection.ltr;
final itemTrailingPaddings = [
for (var index = 0; index < items.length; index++)
index == items.length - 1 ? Grid.gutter : Grid.half,
];
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(
1.0,
carouselWidth * 0.9 - itemTrailingPaddings[index],
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final nativeGutter = math.max(
0.0,
MediaQuery.sizeOf(context).width - carouselWidth,
);
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: messageMediaCarouselHeight,
child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
? OverflowBox(
minWidth: carouselWidth + 2 * nativeGutter,
maxWidth: carouselWidth + 2 * nativeGutter,
child: IosMediaCarousel(
key: ValueKey((itemSignature, mediaAuth)),
providers: previewProviders,
labels: [for (final item in items) item.semanticLabel],
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final nativeGutter = math.max(
0.0,
MediaQuery.sizeOf(context).width - carouselWidth,
);
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: messageMediaCarouselHeight,
child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
? OverflowBox(
minWidth: carouselWidth + 2 * nativeGutter,
maxWidth: carouselWidth + 2 * nativeGutter,
child: IosMediaCarousel(
key: ValueKey((itemSignature, mediaAuth)),
providers: previewProviders,
labels: [for (final item in items) item.semanticLabel],
width: carouselWidth,
gutter: nativeGutter,
gap: Grid.half,
trailingPadding: Grid.gutter,
radius:
(MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md))
.topLeft
.x,
onScroll: (offset) => MessageGalleryScrollNotification(
offset,
).dispatch(context),
onPageChanged: (index) {
if (index != currentIndex.value) {
unawaited(HapticFeedback.selectionClick());
}
currentIndex.value = index;
},
onOpen: (index) => openImageViewer(
viewerContext,
imageUrl: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
),
)
: _MessageCarouselViewport(
width: carouselWidth,
gutter: nativeGutter,
gap: Grid.half,
trailingPadding: Grid.gutter,
radius:
(MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md))
.topLeft
.x,
onScroll: (offset) => MessageGalleryScrollNotification(
offset,
).dispatch(context),
currentIndex: currentIndex.value,
itemCount: items.length,
onPageChanged: (index) {
if (index != currentIndex.value) {
unawaited(HapticFeedback.selectionClick());
}
currentIndex.value = index;
},
onOpen: (index) => openImageViewer(
context,
imageUrl: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
),
)
: _MessageCarouselViewport(
width: carouselWidth,
currentIndex: currentIndex.value,
itemCount: items.length,
onPageChanged: (index) {
if (index != currentIndex.value) {
unawaited(HapticFeedback.selectionClick());
}
currentIndex.value = index;
},
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey(
'message-media-carousel-page:${item.url}',
),
padding: EdgeInsetsDirectional.only(
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
excludeSemantics: true,
label: 'Open ${item.semanticLabel}',
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
onTap: () => openImageViewer(
context,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius:
MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant,
),
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey(
'message-media-carousel-page:${item.url}',
),
padding: EdgeInsetsDirectional.only(
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
excludeSemantics: true,
label: 'Open ${item.semanticLabel}',
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
onTap: () => openImageViewer(
viewerContext,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius:
MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
frameBuilder:
(context, child, frame, synchronous) =>
frame != null || synchronous
? child
: const MediaLoadingPlaceholder(
label: 'Loading image',
),
errorBuilder: (_, _, _) =>
const _MediaPreviewFallback(
icon: BuzzIcons.imageOff,
label: 'Image unavailable',
),
border: Border.all(
color: context.colors.outlineVariant,
),
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
borderRadius:
MessageAttachmentCorners.of(context) ??
BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
frameBuilder:
(
context,
child,
frame,
synchronous,
) => frame != null || synchronous
? child
: const MediaLoadingPlaceholder(
label: 'Loading image',
),
errorBuilder: (_, _, _) =>
const _MediaPreviewFallback(
icon: BuzzIcons.imageOff,
label: 'Image unavailable',
),
),
),
),
),
),
),
),
);
},
),
);
);
},
),
);
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS)
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
_MessageGalleryPrecache(
providers: previewProviders,
focusedIndex: currentIndex.value,
),
],
);
],
);
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
),
child: carouselSurface,
),
child: carouselSurface,
),
),
);
},
);
},
),
),
);
}

View File

@@ -11,11 +11,13 @@ class MessageGalleryFrame extends StatelessWidget {
final int count;
final Widget child;
final bool loading;
final bool mirrored;
const MessageGalleryFrame({
required this.count,
required this.child,
this.loading = false,
this.mirrored = false,
super.key,
});
@@ -36,11 +38,16 @@ class MessageGalleryFrame extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: mirrored
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(start: Grid.xs),
padding: EdgeInsets.only(
left: mirrored ? 0 : Grid.xs,
right: mirrored ? Grid.xs : 0,
),
child: loading
? ExcludeSemantics(
child: Stack(

View File

@@ -13,12 +13,16 @@ class ReplySummaryLink extends ConsumerWidget {
required this.summary,
required this.onTap,
this.hasReactions = false,
this.mirrored = false,
});
final ThreadSummary summary;
final VoidCallback onTap;
final bool hasReactions;
/// Places the connector on the outgoing edge without reversing its contents.
final bool mirrored;
@override
Widget build(BuildContext context, WidgetRef ref) {
final participants = summary.participantPubkeys.take(3).toList();
@@ -28,7 +32,10 @@ class ReplySummaryLink extends ConsumerWidget {
: '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}';
final connectorTop = hasReactions ? -Grid.half : Grid.half;
return Padding(
padding: const EdgeInsetsDirectional.only(start: Grid.xxs),
padding: EdgeInsets.only(
left: mirrored ? 0 : Grid.xxs,
right: mirrored ? Grid.xxs : 0,
),
child: Stack(
clipBehavior: Clip.none,
children: [
@@ -38,14 +45,17 @@ class ReplySummaryLink extends ConsumerWidget {
child: CustomPaint(
painter: _ReplyConnector(
context.colors.outlineVariant,
Directionality.of(context),
mirrored ? TextDirection.rtl : TextDirection.ltr,
connectorTop,
),
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(start: Grid.twelve),
padding: EdgeInsets.only(
left: mirrored ? 0 : Grid.twelve,
right: mirrored ? Grid.twelve : 0,
),
child: Semantics(
button: true,
label: 'View thread: $label',

View File

@@ -97,6 +97,7 @@ class _ThreadMessageList extends HookWidget {
),
_ThreadMessage(
message: head,
startsDay: true,
channelNames: channelNames,
channelId: channelId,
currentPubkey: currentPubkey,
@@ -300,6 +301,13 @@ class _ThreadMessageList extends HookWidget {
),
_ThreadMessage(
message: reply,
startsDay: showDayDivider,
endsDay:
nextReply != null &&
!isSameDay(
reply.createdAt,
nextReply.createdAt,
),
channelNames: channelNames,
channelId: channelId,
currentPubkey: currentPubkey,

View File

@@ -23,29 +23,41 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap + Grid.xxs,
bottom: Grid.xxs,
),
child: ReplySummaryLink(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
summary: summary,
hasReactions: replyMessage.reactions.isNotEmpty,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
final ownMessage =
currentPubkey?.toLowerCase() == replyMessage.pubkey.toLowerCase();
return Align(
alignment: ownMessage ? Alignment.centerRight : Alignment.centerLeft,
child: Directionality(
textDirection: Directionality.of(context),
child: Padding(
padding: EdgeInsets.only(
left: ownMessage
? 0
: messageAvatarSize + messageAvatarContentGap + Grid.xxs,
right: ownMessage ? Grid.xxs : 0,
bottom: Grid.xxs,
),
child: ReplySummaryLink(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
summary: summary,
mirrored: ownMessage,
hasReactions: replyMessage.reactions.isNotEmpty,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
),
),
);
}

View File

@@ -7,6 +7,8 @@ class _ThreadMessage extends HookConsumerWidget {
final String? currentPubkey;
final bool showAuthor;
final bool showAvatar;
final bool startsDay;
final bool endsDay;
final bool isHighlighted;
final List<TimelineMessage>? allMessages;
final bool isMember;
@@ -25,6 +27,8 @@ class _ThreadMessage extends HookConsumerWidget {
required this.currentPubkey,
required this.showAuthor,
this.showAvatar = true,
this.startsDay = false,
this.endsDay = false,
this.isHighlighted = false,
this.allMessages,
this.isMember = false,
@@ -39,6 +43,7 @@ class _ThreadMessage extends HookConsumerWidget {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
final pk = message.pubkey.toLowerCase();
final ownMessage = currentPubkey?.toLowerCase() == pk;
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
@@ -134,7 +139,7 @@ class _ThreadMessage extends HookConsumerWidget {
return true;
},
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
@@ -156,31 +161,40 @@ class _ThreadMessage extends HookConsumerWidget {
snapshotKey: messageSnapshotKey,
child: Padding(
padding: EdgeInsets.only(
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
bottom: endsDay
? 0
: (showAvatar ? Grid.quarter : Grid.quarter / 2),
),
child: MessageFooterLayout(
animateSize: true,
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
footer: (isThreadHead || message.reactions.isNotEmpty)
? Padding(
padding: const EdgeInsets.only(
left:
messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
),
child: ReactionRow(
combined: true,
messageId: message.id,
channelId: channelId,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
? Align(
alignment: ownMessage
? Alignment.centerRight
: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
right: ownMessage ? Grid.xxs : 0,
left: ownMessage
? 0
: messageAvatarSize +
messageAvatarContentGap +
Grid.xxs,
),
child: ReactionRow(
combined: true,
messageId: message.id,
channelId: channelId,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
)
@@ -191,29 +205,41 @@ class _ThreadMessage extends HookConsumerWidget {
clipBehavior: Clip.none,
children: [
Row(
textDirection: ownMessage
? TextDirection.rtl
: TextDirection.ltr,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
if (!ownMessage)
const SizedBox(width: messageAvatarSize),
if (!ownMessage)
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.half : 0,
top: showAuthor && !startsDay ? Grid.half : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: ownMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
padding: EdgeInsets.only(
bottom: Grid.quarter,
left: Grid.xs,
left: ownMessage ? 0 : Grid.xs,
right: ownMessage ? Grid.xs : 0,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
showTimestamp: true,
reversed: false,
alignment: ownMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
nameStyle:
messageUsernameTextStyle
.copyWith(
@@ -290,8 +316,8 @@ class _ThreadMessage extends HookConsumerWidget {
message.reactions.isNotEmpty,
bubbleEnd: showAvatar,
bubbleStart: showAuthor,
ownMessage:
currentPubkey?.toLowerCase() == pk,
ownMessage: ownMessage,
trimTrailingSpacing: endsDay,
mentionNames: resolvedMentionNames,
mentionLabels: mentionLabels,
agentMentionPubkeys: agentMentionPubkeys,
@@ -362,9 +388,10 @@ class _ThreadMessage extends HookConsumerWidget {
),
],
),
if (showAvatar)
if (showAvatar && !ownMessage)
Positioned(
left: 0,
left: ownMessage ? null : 0,
right: ownMessage ? 0 : null,
bottom: 0,
child: MessageMediaAvatar(
offset: mediaOffset,

View File

@@ -96,7 +96,7 @@ class ProfileTextEditSheet extends HookWidget {
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(BuzzIcons.x, size: 22),
icon: const Icon(BuzzIcons.x, size: 24),
),
);

View File

@@ -305,7 +305,7 @@ class _ThemePreviewCloseButton extends StatelessWidget {
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(BuzzIcons.x, size: 22),
icon: const Icon(BuzzIcons.x, size: 24),
),
);
}

View File

@@ -107,7 +107,7 @@ class _SheetCloseButton extends StatelessWidget {
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(BuzzIcons.x, size: 22),
icon: const Icon(BuzzIcons.x, size: 24),
),
);
}

View File

@@ -258,7 +258,7 @@ class IosGlassNavigationButton extends HookWidget {
},
size: icon == IosGlassNavigationIcon.shutter
? controlSize * 0.72
: 22,
: 24,
color: icon == IosGlassNavigationIcon.colorSwatch
? swatchColor
: effectiveForeground,

View File

@@ -43,6 +43,12 @@ class MessageAuthorMeta extends StatelessWidget {
/// Base text style for [timestamp], with [metadataColor] applied.
final TextStyle timestampStyle;
/// Mirrors metadata order without changing text reading direction.
final bool reversed;
/// Positions the metadata within the available message width.
final MainAxisAlignment alignment;
/// Creates an inline author row with optional username and tap handling.
const MessageAuthorMeta({
super.key,
@@ -51,6 +57,8 @@ class MessageAuthorMeta extends StatelessWidget {
required this.nameColor,
required this.metadataColor,
this.username,
this.alignment = MainAxisAlignment.start,
this.reversed = false,
this.showTimestamp = true,
this.onAuthorTap,
this.displayNameKey,
@@ -91,37 +99,39 @@ class MessageAuthorMeta extends StatelessWidget {
? constraints.maxWidth / (showUsername ? 3 : 2)
: double.infinity;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(child: authorName),
if (showUsername) ...[
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
normalizedUsername,
key: usernameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
final children = <Widget>[
Flexible(child: authorName),
if (showUsername) ...[
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
normalizedUsername,
key: usernameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
],
if (showTimestamp) ...[
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
timestamp,
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedTimestampStyle,
),
),
],
),
],
if (showTimestamp) ...[
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
timestamp,
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedTimestampStyle,
),
),
],
];
return Row(
mainAxisAlignment: alignment,
crossAxisAlignment: CrossAxisAlignment.center,
children: reversed ? children.reversed.toList() : children,
);
},
);

View File

@@ -5679,7 +5679,7 @@ void main() {
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
for (final multiline in [false, true]) {
testWidgets(
'group avatar stays fixed throughout a local send: $platform, multiline=$multiline',
'outgoing group stays avatar-free throughout a local send: $platform, multiline=$multiline',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = platform;
@@ -5710,7 +5710,7 @@ void main() {
of: find.byKey(ValueKey('message-row-$id')),
matching: find.byType(AvatarImage),
);
final before = tester.getRect(avatar('first'));
expect(avatar('first'), findsNothing);
final container = ProviderScope.containerOf(
tester.element(find.byType(ChannelDetailPage)),
);
@@ -5730,19 +5730,19 @@ void main() {
]);
for (var frame = 0; frame < 32; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final visibleAvatar = avatar('second').evaluate().isNotEmpty
? avatar('second')
: avatar('first');
expect(visibleAvatar, findsOneWidget);
final current = tester.getRect(visibleAvatar);
expect(
current.top,
closeTo(before.top, 1),
reason: 'avatar frame $frame',
avatar('first'),
findsNothing,
reason: 'first avatar frame $frame',
);
expect(
avatar('second'),
findsNothing,
reason: 'second avatar frame $frame',
);
}
await tester.pumpAndSettle();
expect(avatar('second'), findsOneWidget);
expect(avatar('second'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
@@ -6508,6 +6508,51 @@ void main() {
},
);
testWidgets('own ended huddle reclaims avatar space and aligns reactions', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'alice',
messages: [
_huddleMsg(id: 'own-huddle', kind: EventKind.huddleStarted),
_huddleMsg(
id: 'own-ending',
kind: EventKind.huddleEnded,
createdAt: 1500,
),
_reaction(id: 'own-huddle-reaction', targetId: 'own-huddle'),
],
),
);
await tester.pumpAndSettle();
final card = tester.getRect(
find.byKey(const ValueKey('huddle-card-own-huddle')),
);
expect(
find.descendant(
of: find.byKey(const ValueKey('huddle-activity-own-huddle')),
matching: find.byType(AvatarImage),
),
findsNothing,
);
expect(find.text('Huddle ended'), findsOneWidget);
expect(
card.right,
closeTo(
tester
.getRect(find.byKey(const ValueKey('huddle-activity-own-huddle')))
.right,
0.01,
),
);
final tray = tester.getRect(
find.byKey(const ValueKey('combined-reaction-tray')),
);
expect(tray.right, lessThan(card.right));
expect(tray.right, greaterThan(card.center.dx));
});
testWidgets('renders a compact huddle activity row', (tester) async {
await tester.pumpWidget(
_buildTestable(

View File

@@ -1,6 +1,222 @@
part of '../channel_detail_page_test.dart';
void bubbleLayoutTests() {
for (final author in ['self', 'alice']) {
testWidgets('date divider owns equal space around $author messages', (
tester,
) async {
tester.view.physicalSize = const Size(390, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'self',
messages: [
_textMsg(
id: 'before-day',
pubkey: author,
content: 'Before',
createdAt: 1000,
),
_textMsg(
id: 'after-day',
pubkey: author,
content: 'After',
createdAt: 87400,
),
],
),
);
await tester.pumpAndSettle();
final before = tester.getRect(
find
.descendant(
of: find.byKey(const ValueKey('message-row-before-day')),
matching: find.byType(LocalSendBubbleSurface),
)
.first,
);
final divider = tester.getRect(
find.byKey(const ValueKey('channel-day-divider-opacity-87400')),
);
final after = tester.getRect(
find.byKey(const ValueKey('message-author-after-day')),
);
expect(
divider.top - before.bottom,
closeTo(after.top - divider.bottom, 1),
);
expect(divider.top - before.bottom, closeTo(Grid.gutter, 1));
expect(tester.takeException(), isNull);
});
}
for (final personalChat in [false, true]) {
for (final scale in [1.0, 2.0]) {
testWidgets(
'viewer bubbles align right: personal=$personalChat scale=$scale',
(tester) async {
tester.view.physicalSize = const Size(390, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'self',
personalChat: personalChat,
textScaler: TextScaler.linear(scale),
users: const {
'self': UserProfile(pubkey: 'self', displayName: 'Me'),
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
messages: [
_textMsg(
id: 'incoming',
pubkey: 'alice',
content: 'Hello',
createdAt: 1000,
),
_textMsg(
id: 'outgoing',
pubkey: 'self',
content: 'Hi',
createdAt: 1001,
),
_reaction(
id: 'reaction-in',
targetId: 'incoming',
pubkey: 'self',
),
_reaction(
id: 'reaction-out',
targetId: 'outgoing',
pubkey: 'alice',
),
],
),
);
await tester.pumpAndSettle();
Rect bounds(String id, Finder child) => tester.getRect(
find
.descendant(
of: find.byKey(ValueKey('message-row-$id')),
matching: child,
)
.first,
);
final incoming = bounds(
'incoming',
find.byType(LocalSendBubbleSurface),
);
final outgoing = bounds(
'outgoing',
find.byType(LocalSendBubbleSurface),
);
expect(outgoing.center.dx, greaterThan(incoming.center.dx));
expect(
bounds(
'outgoing',
find.byKey(const ValueKey('message-author-outgoing')),
).right,
lessThan(
bounds(
'outgoing',
find.byKey(const ValueKey('message-timestamp-outgoing')),
).left,
),
);
expect(
bounds(
'incoming',
find.byKey(const ValueKey('message-author-incoming')),
).right,
lessThan(
bounds(
'incoming',
find.byKey(const ValueKey('message-timestamp-incoming')),
).left,
),
);
expect(outgoing.right, greaterThan(300));
expect(incoming.left, lessThan(90));
final incomingAvatar = bounds(
'incoming',
find.byType(MessageMediaAvatar),
);
expect(incomingAvatar.right, lessThan(incoming.left));
expect(
find.descendant(
of: find.byKey(const ValueKey('message-row-outgoing')),
matching: find.byType(MessageMediaAvatar),
),
findsNothing,
);
expect(
outgoing.right,
closeTo(
tester
.getRect(find.byKey(const ValueKey('message-row-outgoing')))
.right,
0.01,
),
);
final incomingReaction = bounds(
'incoming',
find.byKey(const ValueKey('combined-reaction-tray')),
);
final outgoingReaction = bounds(
'outgoing',
find.byKey(const ValueKey('combined-reaction-tray')),
);
expect(
outgoingReaction.center.dx,
greaterThan(incomingReaction.center.dx),
);
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets('thread replies reclaim the outgoing avatar gutter', (
tester,
) async {
final root = _textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Root');
final reply = _textMsg(
id: threadIdR1,
pubkey: 'self',
content: 'My reply',
createdAt: 1100,
extraTags: const [
['e', threadIdRoot, '', 'reply'],
],
);
await tester.pumpWidget(
_buildTestable(
signingPubkey: 'self',
messages: [root, reply],
threadReplies: {
threadIdRoot: [reply],
},
initialThreadRootId: threadIdRoot,
),
);
await tester.pumpAndSettle();
final row = find.byKey(const ValueKey('thread-message-row-$threadIdR1'));
final bubble = tester.getRect(
find
.descendant(of: row, matching: find.byType(LocalSendBubbleSurface))
.first,
);
expect(
find.descendant(of: row, matching: find.byType(MessageMediaAvatar)),
findsNothing,
);
expect(bubble.right, closeTo(tester.getRect(row).right, 0.01));
expect(tester.takeException(), isNull);
});
testWidgets('large activity members build and fetch only visible rows', (
tester,
) async {

View File

@@ -248,6 +248,98 @@ void main() {
await tester.pumpWidget(const SizedBox());
});
for (final label in ['Channels options', 'Design options']) {
testWidgets('$label exposes a labeled button and tap action', (
tester,
) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
channelSectionsProvider.overrideWith(
() => _FakeChannelSectionsNotifier(
const ChannelSectionStore(
sections: [
ChannelSection(
id: 'section-1',
name: 'Design',
icon: '🎨',
order: 0,
),
],
),
),
),
],
),
);
await tester.pumpAndSettle();
final control = find.bySemanticsLabel(label);
expect(control, findsOneWidget);
final node = tester.getSemantics(control);
expect(node.flagsCollection.isButton, isTrue);
expect(
node.getSemanticsData().hasAction(ui.SemanticsAction.tap),
isTrue,
);
node.owner!.performAction(node.id, ui.SemanticsAction.tap);
await tester.pumpAndSettle();
expect(
find.byKey(
ValueKey(
label == 'Channels options'
? 'sort-popover-Channels'
: 'section-popover-section-1',
),
),
findsOneWidget,
);
} finally {
semantics.dispose();
}
});
}
testWidgets('section ellipsis aligns with temporary channel icon', (
tester,
) async {
final temporary = Channel(
id: 'temporary',
name: 'temporary',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
ttlSeconds: 3600,
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier([temporary])),
],
),
);
await tester.pumpAndSettle();
final ellipsis = find.descendant(
of: find.byTooltip('Channels options'),
matching: find.byIcon(BuzzIcons.ellipsisVertical),
);
expect(
tester.getCenter(ellipsis).dx,
closeTo(
tester
.getCenter(find.byKey(const Key('channel-ephemeral-temporary')))
.dx,
0.01,
),
);
});
testWidgets('shows grouped channel list when data loads', (tester) async {
// Valid fixture keys whose npub encodings were verified against the
// NIP-19 codec independently of the code under test.
@@ -325,6 +417,17 @@ void main() {
findsOneWidget,
);
expect(
tester
.getTopRight(
find.descendant(
of: find.byTooltip('Channels options'),
matching: find.byIcon(BuzzIcons.ellipsisVertical),
),
)
.dx,
closeTo(tester.getTopRight(find.byTooltip('Channels options')).dx, 0.01),
);
await tester.tap(find.byTooltip('Channels options'));
await tester.pumpAndSettle();
expect(find.text('Sort: Recent'), findsOneWidget);
@@ -986,6 +1089,20 @@ void main() {
AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.07),
);
}
expect(
tester
.getTopRight(
find.descendant(
of: find.byKey(const Key('section-menu-section-1')),
matching: find.byIcon(BuzzIcons.ellipsisVertical),
),
)
.dx,
closeTo(
tester.getTopRight(find.byKey(const Key('section-menu-section-1'))).dx,
0.01,
),
);
await tester.tap(find.byKey(const Key('section-menu-section-1')));
await tester.pumpAndSettle();

View File

@@ -4,6 +4,75 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final dark in [false, true]) {
testWidgets('inline calendar shows local day before label, dark=$dark', (
tester,
) async {
final date = DateTime(2026, 10, 18, 12);
final timestamp = date.millisecondsSinceEpoch ~/ 1000;
await tester.pumpWidget(
MaterialApp(
theme: dark ? AppTheme.dark() : AppTheme.light(),
home: Scaffold(
body: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: SizedBox(
width: 320,
child: DayDivider(
label: 'October 18, 2026',
dayTimestamp: timestamp,
),
),
),
),
),
);
final tile = find.byKey(ValueKey('day-calendar-$timestamp'));
expect(
find.descendant(of: tile, matching: find.text('18')),
findsOneWidget,
);
expect(
tester.getRect(tile).right,
lessThan(tester.getRect(find.text('October 18, 2026')).left),
);
expect(find.byType(Divider), findsNWidgets(2));
expect(tester.takeException(), isNull);
});
}
for (final label in ['Today', 'October 18, 2026']) {
testWidgets('date group centered with full-width rules: $label', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
width: 390,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: DayDivider(label: label, dayTimestamp: 1000),
),
),
),
),
);
final left = tester.getRect(find.byType(Divider).first);
final right = tester.getRect(find.byType(Divider).last);
final icon = tester.getRect(
find.byKey(const ValueKey('day-calendar-1000')),
);
final text = tester.getRect(find.text(label));
expect(left.left, closeTo(Grid.gutter, 0.01));
expect(right.right, closeTo(390 - Grid.gutter, 0.01));
expect(left.width, closeTo(right.width, 0.01));
expect((icon.left + text.right) / 2, closeTo(195, 0.01));
expect(tester.takeException(), isNull);
});
}
testWidgets('fades the in-flow date while that day is sticky', (
tester,
) async {

View File

@@ -0,0 +1,174 @@
part of 'message_content_test.dart';
void carouselPreviewTests() {
testWidgets(
'iOS prepares five thumbnails before scrolling with two preload requests',
(tester) async {
final started = <String>[];
final responses = <String, Completer<http.Response>>{};
final urls = List.generate(
10,
(i) => 'https://example.com/preload-$i.png',
);
final messenger = tester.binding.defaultBinaryMessenger;
const connectivity = MethodChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
messenger.setMockMethodCallHandler(connectivity, (_) async => null);
addTearDown(() => messenger.setMockMethodCallHandler(connectivity, null));
final channels = <MethodChannel>[];
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
call,
) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
final channel = MethodChannel('buzz/ios_media_carousel/$id');
channels.add(channel);
messenger.setMockMethodCallHandler(channel, (_) async => null);
}
return null;
});
final client = MockClient((request) {
final url = request.url.toString();
started.add(url);
final response = responses[url] = Completer<http.Response>();
return response.future;
});
Future<void> waitForCount(int count) async {
for (
var attempt = 0;
attempt < 100 && started.length < count;
attempt++
) {
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
}
expect(started.length, count);
}
final png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=',
);
void release(String url) {
final response = responses[url]!;
if (!response.isCompleted) {
response.complete(http.Response.bytes(png, 200));
}
}
addTearDown(() async {
for (final response in responses.values) {
if (!response.isCompleted) response.complete(http.Response('', 404));
}
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
for (final channel in channels) {
messenger.setMockMethodCallHandler(channel, null);
}
client.close();
});
await tester.pumpWidget(
_testable(
MessageContent(
content: urls.map((url) => '![image]($url)').join('\n'),
),
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://example.com', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(client),
],
),
);
await tester.pump();
{
await waitForCount(2);
expect(started, hasLength(2));
for (var i = 0; i < 3; i++) {
release(started[i]);
await waitForCount(i + 3);
expect(started, hasLength(i + 3));
}
release(started[3]);
release(started[4]);
}
await tester.pumpAndSettle();
expect(started.toSet(), urls.take(5).toSet());
// No native subscribeImage or page callback was sent by this harness.
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets(
'outgoing gallery starts at the right and mirrors its spacing',
(tester) async {
final messenger = tester.binding.defaultBinaryMessenger;
final channels = <MethodChannel>[];
final configs = <Map<Object?, Object?>>[];
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
call,
) async {
if (call.method == 'create') {
final id = (call.arguments as Map)['id'];
final channel = MethodChannel('buzz/ios_media_carousel/$id');
channels.add(channel);
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'configure') {
configs.add(Map<Object?, Object?>.from(call.arguments as Map));
}
return null;
});
}
return null;
});
addTearDown(() {
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
for (final channel in channels) {
messenger.setMockMethodCallHandler(channel, null);
}
});
final counts = <Rect>[];
final widths = <double>[];
for (final own in [false, true]) {
await tester.pumpWidget(
_testable(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 50),
child: MessageContent(
key: ValueKey(own),
ownMessage: own,
bubble: true,
mediaCarouselTrailingOverflow: Grid.gutter,
content:
'![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)',
),
),
),
);
await tester.pumpAndSettle();
counts.add(
tester.getRect(
find.byKey(const ValueKey('message-media-carousel-count')),
),
);
widths.add(
tester
.getSize(find.byKey(const ValueKey('message-media-carousel')))
.width,
);
expect(configs.last['rtl'], own);
}
expect(widths[0], widths[1]);
expect(counts[0].left, closeTo(800 - counts[1].right, 0.1));
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}

View File

@@ -37,6 +37,7 @@ import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
part 'message_content_bubble_cases.dart';
part 'message_carousel_preview_cases.dart';
Widget _testable(
Widget child, {
@@ -379,6 +380,7 @@ class _TestChannelsNotifier extends ChannelsNotifier {
}
void main() {
carouselPreviewTests();
bubbleContentTests();
test('wide voice-note waveforms distribute bars across their full width', () {
const width = 320.0;