Polish mobile message and search layouts (#3121)

## Summary

- align message typography, avatars, metadata, and spacing across mobile
surfaces
- improve message follow behavior, touch feedback, and Activity popover
motion
- refine Search motion, gutters, and explicit recent-search history

## Snapshots

### Home


![Home](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--01-home.png)

### Activity


![Activity](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--02-activity.png)

### Search


![Search](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--03-search.png)

## Testing

- `just mobile-check`
- `just mobile-test` (749 passed, 1 skipped)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-07-28 17:20:50 +01:00
committed by GitHub
parent 7dfea2634f
commit 6da45ac5cf
36 changed files with 3648 additions and 870 deletions

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -11,6 +13,7 @@ import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channels_provider.dart';
@@ -30,6 +33,7 @@ import 'reminders_provider.dart';
part 'activity_page/header_actions.dart';
part 'activity_page/inbox_row.dart';
part 'activity_page/lists.dart';
part 'activity_page/popover_menu.dart';
part 'activity_page/status_views.dart';
/// Conversation-oriented Activity inbox.
@@ -281,6 +285,7 @@ class ActivityPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
automaticallyImplyLeading: false,
title: const Text('Activity'),
titleStyle: headerTitleStyle,
actions: [

View File

@@ -28,64 +28,101 @@ class _FilterMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<InboxFilter>(
key: const ValueKey('activity-filter-menu'),
onSelected: onChanged,
itemBuilder: (context) => [
for (final entry in _filterLabels.entries)
PopupMenuItem(
value: entry.key,
child: Row(
children: [
SizedBox(
width: Grid.sm,
child: entry.key == filter
? Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
)
: null,
return Builder(
builder: (buttonContext) => InkWell(
key: const ValueKey('activity-filter-menu'),
borderRadius: BorderRadius.circular(Radii.md),
onTap: () async {
final selected = await _showActivityPopover<InboxFilter>(
context: buttonContext,
width: 240,
alignment: _ActivityPopoverAlignment.start,
offset: const Offset(0, Grid.half),
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
color: context.colors.surface.withValues(alpha: 0.98),
elevation: 8,
shadowColor: context.colors.shadow.withValues(alpha: 0.18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.card),
side: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.45),
),
),
surfaceKey: const ValueKey('activity-filter-popover'),
items: [
for (final entry in _filterLabels.entries)
PopupMenuItem(
value: entry.key,
height: Grid.xl,
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
child: Row(
children: [
SizedBox(
width: Grid.sm,
child: entry.key == filter
? Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
)
: null,
),
Expanded(
child: Text(
entry.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
),
),
),
if (entry.key == InboxFilter.reminders &&
dueReminderCount > 0)
_CountBadge(count: dueReminderCount)
else if (entry.key == InboxFilter.drafts &&
draftCount > 0)
_CountBadge(count: draftCount),
],
),
),
Text(entry.value),
const Spacer(),
if (entry.key == InboxFilter.reminders && dueReminderCount > 0)
_CountBadge(count: dueReminderCount)
else if (entry.key == InboxFilter.drafts && draftCount > 0)
_CountBadge(count: draftCount),
],
);
if (buttonContext.mounted && selected != null) onChanged(selected);
},
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: Grid.xl),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_filterLabels[filter]!,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.quarter),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
if (dueReminderCount > 0 || draftCount > 0) ...[
const SizedBox(width: Grid.quarter),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary,
),
),
],
],
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_filterLabels[filter]!,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.quarter),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
if (dueReminderCount > 0 || draftCount > 0) ...[
const SizedBox(width: Grid.quarter),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary,
),
),
],
],
),
),
);
@@ -136,45 +173,64 @@ class _InboxOptionsButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
key: const ValueKey('activity-options-menu'),
icon: const Icon(LucideIcons.ellipsis, size: 20),
onSelected: (value) {
if (value == 'unread-only') onUnreadOnlyChanged(!unreadOnly);
if (value == 'mark-all-read') onMarkAllRead();
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'unread-only',
child: Row(
children: [
Expanded(child: Text(unreadOnly ? 'Show all' : 'Show unread')),
if (unreadOnly)
Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
return Builder(
builder: (buttonContext) => IconButton(
key: const ValueKey('activity-options-menu'),
tooltip: 'Activity options',
icon: const Icon(LucideIcons.ellipsis, size: 20),
onPressed: () async {
final selected = await _showActivityPopover<String>(
context: buttonContext,
width: 216,
alignment: _ActivityPopoverAlignment.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: const ValueKey('activity-options-popover'),
items: [
PopupMenuItem(
value: 'unread-only',
child: Row(
children: [
Expanded(
child: Text(unreadOnly ? 'Show all' : 'Show unread'),
),
if (unreadOnly)
Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
),
],
),
],
),
),
PopupMenuItem(
value: 'mark-all-read',
enabled: unreadCount > 0,
child: Row(
children: [
const Expanded(child: Text('Mark all as read')),
if (unreadCount > 0)
Text(
'$unreadCount',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
PopupMenuItem(
value: 'mark-all-read',
enabled: unreadCount > 0,
child: Row(
children: [
const Expanded(child: Text('Mark all as read')),
if (unreadCount > 0)
Text(
'$unreadCount',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
),
),
],
);
if (!buttonContext.mounted || selected == null) return;
if (selected == 'unread-only') onUnreadOnlyChanged(!unreadOnly);
if (selected == 'mark-all-read') onMarkAllRead();
},
),
);
}
}

View File

@@ -91,7 +91,7 @@ class _InboxRow extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowAvatar(pubkey: item.item.pubkey, profile: profile),
const SizedBox(width: Grid.twelve),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -100,19 +100,25 @@ class _InboxRow extends ConsumerWidget {
Row(
children: [
Expanded(
child: Text(
senderLabel,
// Compact label scale — matches the old
// "@ Mention" headline treatment while staying
// the row's primary label.
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
child: MessageAuthorMeta(
displayName: senderLabel,
username: messageUsernameLabel(profile),
timestamp: _inboxTimestamp(item.latestActivityAt),
nameColor: context.colors.onSurface,
metadataColor: mutedColor,
nameStyle: activityUsernameTextStyle,
metadataStyle: activityTimestampTextStyle,
displayNameKey: ValueKey(
'activity-author-${item.id}',
),
usernameKey: ValueKey('activity-username-${item.id}'),
timestampKey: ValueKey(
'activity-timestamp-${item.id}',
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: Grid.xxs),
if (!isDone) ...[
const SizedBox(width: Grid.xxs),
Container(
key: ValueKey('inbox-unread-dot-${item.id}'),
width: 6,
@@ -122,17 +128,7 @@ class _InboxRow extends ConsumerWidget {
color: context.colors.primary,
),
),
const SizedBox(width: Grid.half),
],
Text(
_inboxTimestamp(item.latestActivityAt),
style: context.textTheme.labelSmall?.copyWith(
color: mutedColor,
fontWeight: isDone
? FontWeight.w400
: FontWeight.w500,
),
),
],
),
const SizedBox(height: Grid.quarter),
@@ -142,9 +138,8 @@ class _InboxRow extends ConsumerWidget {
Flexible(
child: Text(
label.text,
style: context.textTheme.labelSmall?.copyWith(
style: activityContextTextStyle.copyWith(
color: labelColor,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
@@ -163,7 +158,7 @@ class _InboxRow extends ConsumerWidget {
),
child: Text(
'#${label.channelLabel}',
style: context.textTheme.labelSmall?.copyWith(
style: activityContextTextStyle.copyWith(
color: mutedColor,
),
overflow: TextOverflow.ellipsis,
@@ -174,14 +169,13 @@ class _InboxRow extends ConsumerWidget {
],
),
const SizedBox(height: Grid.half),
// Preview (bold while unread — desktop parity).
// Message preview.
MessageContent(
content: item.item.displayContent,
tags: item.item.tags,
maxLines: 2,
baseStyle: context.textTheme.bodySmall?.copyWith(
color: isDone ? mutedColor : context.colors.onSurface,
fontWeight: isDone ? FontWeight.w400 : FontWeight.w600,
baseStyle: activityPreviewTextStyle.copyWith(
color: context.colors.onSurface,
),
),
],
@@ -238,7 +232,7 @@ class _RowAvatar extends StatelessWidget {
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
radius: activityAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,

View File

@@ -0,0 +1,231 @@
part of '../activity_page.dart';
const _activityPopoverEnterDuration = Duration(milliseconds: 150);
const _activityPopoverExitDuration = Duration(milliseconds: 110);
const _activityPopoverStartScale = 0.96;
enum _ActivityPopoverAlignment { start, end }
Future<T?> _showActivityPopover<T>({
required BuildContext context,
required List<PopupMenuEntry<T>> items,
required double width,
required _ActivityPopoverAlignment alignment,
required Color color,
required ShapeBorder shape,
required double elevation,
required Color shadowColor,
Offset offset = Offset.zero,
EdgeInsetsGeometry menuPadding = EdgeInsets.zero,
Clip clipBehavior = Clip.antiAlias,
Key? surfaceKey,
}) {
final navigator = Navigator.of(context);
final overlay = navigator.overlay;
final triggerRenderObject = context.findRenderObject();
final overlayRenderObject = overlay?.context.findRenderObject();
if (triggerRenderObject is! RenderBox || overlayRenderObject is! RenderBox) {
return Future<T?>.value();
}
final triggerRect = MatrixUtils.transformRect(
triggerRenderObject.getTransformTo(overlayRenderObject),
Offset.zero & triggerRenderObject.size,
);
final overlayRect = Offset.zero & overlayRenderObject.size;
final mediaQuery = MediaQuery.of(context);
return navigator.push<T>(
_ActivityPopoverRoute<T>(
position: RelativeRect.fromRect(triggerRect, overlayRect),
items: items,
width: width,
alignment: alignment,
offset: offset,
color: color,
shape: shape,
elevation: elevation,
shadowColor: shadowColor,
menuPadding: menuPadding,
clipBehavior: clipBehavior,
surfaceKey: surfaceKey,
screenPadding: EdgeInsets.fromLTRB(
math.max(Grid.xxs, mediaQuery.padding.left),
math.max(Grid.xxs, mediaQuery.padding.top),
math.max(Grid.xxs, mediaQuery.padding.right),
math.max(Grid.xxs, mediaQuery.padding.bottom),
),
reducedMotion: mediaQuery.disableAnimations,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
),
);
}
class _ActivityPopoverRoute<T> extends PopupRoute<T> {
final RelativeRect position;
final List<PopupMenuEntry<T>> items;
final double width;
final _ActivityPopoverAlignment alignment;
final Offset offset;
final Color color;
final ShapeBorder shape;
final double elevation;
final Color shadowColor;
final EdgeInsetsGeometry menuPadding;
final Clip clipBehavior;
final Key? surfaceKey;
final EdgeInsets screenPadding;
final bool reducedMotion;
final String _barrierLabel;
_ActivityPopoverRoute({
required this.position,
required this.items,
required this.width,
required this.alignment,
required this.offset,
required this.color,
required this.shape,
required this.elevation,
required this.shadowColor,
required this.menuPadding,
required this.clipBehavior,
required this.surfaceKey,
required this.screenPadding,
required this.reducedMotion,
required String barrierLabel,
}) : _barrierLabel = barrierLabel;
@override
Color? get barrierColor => null;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => _barrierLabel;
@override
Duration get transitionDuration =>
reducedMotion ? Duration.zero : _activityPopoverEnterDuration;
@override
Duration get reverseTransitionDuration =>
reducedMotion ? Duration.zero : _activityPopoverExitDuration;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
final curvedAnimation = animation.drive(
CurveTween(curve: Curves.easeOutCubic),
);
final scaleAnimation = Tween<double>(
begin: _activityPopoverStartScale,
end: 1,
).animate(curvedAnimation);
final transformOrigin = switch (alignment) {
_ActivityPopoverAlignment.start => Alignment.topLeft,
_ActivityPopoverAlignment.end => Alignment.topRight,
};
return CustomSingleChildLayout(
delegate: _ActivityPopoverLayoutDelegate(
position: position,
alignment: alignment,
offset: offset,
screenPadding: screenPadding,
),
child: FadeTransition(
key: const ValueKey('activity-popover-fade'),
opacity: curvedAnimation,
child: ScaleTransition(
key: const ValueKey('activity-popover-scale'),
scale: scaleAnimation,
alignment: transformOrigin,
child: Material(
key: surfaceKey,
type: MaterialType.card,
color: color,
surfaceTintColor: Colors.transparent,
elevation: elevation,
shadowColor: shadowColor,
shape: shape,
clipBehavior: clipBehavior,
child: SizedBox(
width: width,
child: Semantics(
role: SemanticsRole.menu,
scopesRoute: true,
namesRoute: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: menuPadding,
child: ListBody(children: items),
),
),
),
),
),
),
);
}
}
class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate {
final RelativeRect position;
final _ActivityPopoverAlignment alignment;
final Offset offset;
final EdgeInsets screenPadding;
const _ActivityPopoverLayoutDelegate({
required this.position,
required this.alignment,
required this.offset,
required this.screenPadding,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(
Size(
constraints.maxWidth - screenPadding.horizontal,
constraints.maxHeight - screenPadding.vertical,
),
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final anchorBottom = size.height - position.bottom;
final desiredX = switch (alignment) {
_ActivityPopoverAlignment.start => position.left + offset.dx,
_ActivityPopoverAlignment.end =>
size.width - position.right - childSize.width + offset.dx,
};
final minX = screenPadding.left;
final maxX = size.width - screenPadding.right - childSize.width;
final x = desiredX.clamp(minX, maxX).toDouble();
final belowY = anchorBottom + offset.dy;
final aboveY = position.top - childSize.height - offset.dy;
final maxY = size.height - screenPadding.bottom - childSize.height;
final desiredY =
belowY + childSize.height <= size.height - screenPadding.bottom
? belowY
: aboveY;
final y = desiredY.clamp(screenPadding.top, maxY).toDouble();
return Offset(x, y);
}
@override
bool shouldRelayout(_ActivityPopoverLayoutDelegate oldDelegate) {
return position != oldDelegate.position ||
alignment != oldDelegate.alignment ||
offset != oldDelegate.offset ||
screenPadding != oldDelegate.screenPadding;
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math' show min;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -12,6 +13,7 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
@@ -216,6 +218,7 @@ class ChannelDetailPage extends HookConsumerWidget {
appBar: FrostedAppBar(
iconColor: context.colors.primary,
titleContentHeight: appBarTitleContentHeight,
titleStyle: channelTitleTextStyle,
title: resolvedChannel.isDm
? _DmAppBarTitle(
channel: resolvedChannel,

View File

@@ -8,9 +8,9 @@ double _scaledTextHeight(BuildContext context, TextStyle style) {
}
double _dmAppBarTitleContentHeight(BuildContext context) {
final titleStyle = context.textTheme.titleSmall;
const titleStyle = channelTitleTextStyle;
final presenceStyle = context.textTheme.bodySmall;
if (titleStyle == null || presenceStyle == null) {
if (presenceStyle == null) {
return 30;
}
final textHeight =
@@ -240,7 +240,7 @@ class _DmAppBarTitle extends ConsumerWidget {
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall,
style: channelTitleTextStyle,
),
),
if (channel.isEphemeral) ...[

View File

@@ -52,147 +52,171 @@ class _MessageBubble extends ConsumerWidget {
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.half),
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: 36),
const SizedBox(width: Grid.xxs),
Expanded(
child: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: Text(
displayName,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: Grid.xxs),
_messageTimestamp(context, message.createdAt),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
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,
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.xs : 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: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
),
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}',
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
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(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
),
],
],
),
),
),
);
}
}
Widget _messageTimestamp(BuildContext context, int createdAt) {
return Text(
formatMessageTime(createdAt),
style: context.textTheme.labelSmall?.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: context.textTheme.titleSmall?.letterSpacing,
color: context.colors.onSurfaceVariant,
Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
key: key,
formatMessageTime(createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
@@ -205,7 +229,7 @@ class _UserAvatar extends StatelessWidget {
const _UserAvatar({
required this.profile,
required this.pubkey,
this.size = 36,
this.size = messageAvatarSize,
});
@override

View File

@@ -31,6 +31,11 @@ class _MessageList extends HookConsumerWidget {
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final hasUserScrolled = useState(false);
final followsLatest = useRef(
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final autoScrollScheduled = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
@@ -47,24 +52,57 @@ class _MessageList extends HookConsumerWidget {
}
Future<void> scrollToLatest() async {
if (!itemScrollController.isAttached) return;
await itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
followsLatest.value = true;
hasUserScrolled.value = false;
isAutoScrolling.value = true;
try {
await itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
}
} finally {
isAutoScrolling.value = false;
}
}
void scheduleAutoScrollToLatest() {
if (autoScrollScheduled.value || isAutoScrolling.value) return;
autoScrollScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
autoScrollScheduled.value = false;
if (!context.mounted || !followsLatest.value || hasUserScrolled.value) {
return;
}
scrollToLatest();
});
}
bool latestIsAtBoundary() {
// In this reversed list, item 0's leading edge is the bottom boundary.
return itemPositionsListener.itemPositions.value.any(
(position) => position.index == 0 && position.itemLeadingEdge >= 0,
);
if (context.mounted) isAtLatest.value = true;
}
useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final nextIsAtLatest = positions.any(
(position) => position.index == 0 && position.itemLeadingEdge < 1,
);
if (isAtLatest.value != nextIsAtLatest) {
isAtLatest.value = nextIsAtLatest;
final nextIsAtLatest = latestIsAtBoundary();
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (followsLatest.value && !hasUserScrolled.value) {
// The viewport can shrink when the composer or keyboard opens.
// Preserve auto-follow until the user scrolls the timeline.
if (!isAtLatest.value) isAtLatest.value = true;
scheduleAutoScrollToLatest();
} else if (isAtLatest.value) {
isAtLatest.value = false;
}
final oldestVisible = positions
@@ -124,8 +162,11 @@ class _MessageList extends HookConsumerWidget {
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
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]);
@@ -187,9 +228,18 @@ class _MessageList extends HookConsumerWidget {
children: [
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollStartNotification &&
notification.dragDetails != null) {
if (notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle) {
hasUserScrolled.value = true;
followsLatest.value = false;
} 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;
},
@@ -205,7 +255,7 @@ class _MessageList extends HookConsumerWidget {
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: Grid.xxs,
bottom: 0,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
@@ -248,46 +298,50 @@ class _MessageList extends HookConsumerWidget {
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
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!,
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)),
if (message.isSystem)
_SystemMessageRow(
message: message,
allMessages: allMessages,
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,
),
],
],
],
),
);
},
),

View File

@@ -30,6 +30,9 @@ class _SystemMessageRow extends ConsumerWidget {
final channelCreator = systemEvent.type == SystemEventType.channelCreated
? systemEvent.actorPubkey?.trim()
: null;
final usesMessageStyleLayout =
groupedMembership != null ||
(channelCreator != null && channelCreator.isNotEmpty);
String resolveLabel(String? pubkey) {
if (pubkey == null) return 'Someone';
@@ -68,66 +71,83 @@ class _SystemMessageRow extends ConsumerWidget {
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (groupedMembership != null)
_MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
else if (channelCreator != null && channelCreator.isNotEmpty)
_MessageStyleSystemMessageContent(
displayPubkey: channelCreator,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: const [TextSpan(text: 'created this channel')],
)
else
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('system-message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (groupedMembership != null)
_MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
else if (channelCreator != null && channelCreator.isNotEmpty)
_MessageStyleSystemMessageContent(
displayPubkey: channelCreator,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: const [TextSpan(text: 'created this channel')],
)
else
Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describe(resolveLabel),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
_messageTimestamp(context, message.createdAt),
],
),
if (reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 36 + Grid.xxs),
child: ReactionRow(
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
_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(
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
),
],
),
),
),
);
@@ -282,7 +302,7 @@ class _MembershipSystemMessageContent extends StatelessWidget {
}
TextStyle? _systemActionTextStyle(BuildContext context) {
return context.textTheme.bodyLarge?.copyWith(
return systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
);
}
@@ -310,28 +330,33 @@ class _MessageStyleSystemMessageContent extends StatelessWidget {
_UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: 36,
size: messageAvatarSize,
),
const SizedBox(width: Grid.xxs),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
resolveLabel(displayPubkey),
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
_messageTimestamp(context, createdAt),
],
MessageAuthorMeta(
displayName: resolveLabel(displayPubkey),
username: messageUsernameLabel(
userCache[displayPubkey.toLowerCase()],
),
timestamp: formatMessageTime(createdAt),
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(
@@ -482,10 +507,11 @@ class _ThreadSummaryRow extends ConsumerWidget {
);
},
child: Padding(
key: ValueKey('thread-summary-${message.id}'),
padding: const EdgeInsets.only(
left: 36 + Grid.xxs,
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -509,36 +535,38 @@ class _ThreadSummaryRow extends ConsumerWidget {
),
),
const SizedBox(width: Grid.xxs),
Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
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:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],

View File

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

View File

@@ -154,7 +154,7 @@ class _CustomSectionHeader extends ConsumerWidget {
const SizedBox(width: _kChannelLabelGap),
Text(
section.name,
style: context.textTheme.bodyLarge?.copyWith(
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
@@ -318,7 +318,7 @@ class _ChannelSection extends StatelessWidget {
),
child: Text(
emptyLabel,
style: context.textTheme.bodySmall?.copyWith(
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
@@ -430,7 +430,7 @@ class _SectionHeader extends StatelessWidget {
const SizedBox(width: _kChannelLabelGap),
Text(
label,
style: context.textTheme.bodyLarge?.copyWith(
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),

View File

@@ -75,11 +75,10 @@ class ReactionRow extends StatelessWidget {
const SizedBox(width: Grid.quarter),
Text(
'${reaction.count}',
style: context.textTheme.labelSmall?.copyWith(
style: reactionCountTextStyle.copyWith(
color: reaction.reactedByCurrentUser
? context.colors.primary
: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],

View File

@@ -7,6 +7,7 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_link_navigation.dart';
@@ -153,11 +154,15 @@ class ThreadDetailPage extends HookConsumerWidget {
});
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Thread')),
appBar: const FrostedAppBar(
title: Text('Thread'),
titleStyle: channelTitleTextStyle,
),
body: Column(
children: [
Expanded(
child: ScrollablePositionedList.builder(
key: const ValueKey('thread-message-list'),
itemScrollController: itemScrollController,
// Reversed so the list opens pinned to the newest reply,
// matching the channel message list.
@@ -166,48 +171,54 @@ class ThreadDetailPage extends HookConsumerWidget {
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
bottom: Grid.xxs,
bottom: 0,
),
itemCount: replies.length + 1, // +1 for thread head
itemBuilder: (context, index) {
if (index == replies.length) {
// Thread head.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DayDivider(label: formatDayHeading(liveHead.createdAt)),
_ThreadMessage(
message: liveHead,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: true,
isHighlighted: liveHead.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Row(
children: [
Text(
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Divider(
color: context.colors.outlineVariant,
),
),
],
return Padding(
key: ValueKey('thread-message-group-${liveHead.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DayDivider(label: formatDayHeading(liveHead.createdAt)),
_ThreadMessage(
message: liveHead,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: true,
isHighlighted: liveHead.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
),
),
],
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.xxs,
),
child: Row(
children: [
Text(
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Divider(
color: context.colors.outlineVariant,
),
),
],
),
),
],
),
);
}
@@ -221,7 +232,6 @@ class ThreadDetailPage extends HookConsumerWidget {
reply.createdAt,
);
final showAuthor =
reply.hasAttachments ||
prevReply == null ||
showDayDivider ||
prevReply.pubkey.toLowerCase() !=
@@ -235,33 +245,37 @@ class ThreadDetailPage extends HookConsumerWidget {
? _buildNestedSummary(reply.id, nestedChildren)
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(reply.createdAt)),
_ThreadMessage(
message: reply,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: showAuthor,
isHighlighted: reply.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
),
if (nestedSummary != null)
_NestedThreadSummaryRow(
summary: nestedSummary,
replyMessage: reply,
allMessages: allMsgs,
return Padding(
key: ValueKey('thread-message-group-${reply.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(reply.createdAt)),
_ThreadMessage(
message: reply,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: showAuthor,
isHighlighted: reply.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
),
],
if (nestedSummary != null)
_NestedThreadSummaryRow(
summary: nestedSummary,
replyMessage: reply,
allMessages: allMsgs,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
),
);
},
),
@@ -358,10 +372,11 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
);
},
child: Padding(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
padding: const EdgeInsets.only(
left: 36 + Grid.xxs,
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -387,36 +402,38 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
),
),
const SizedBox(width: Grid.xxs),
Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
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:
'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -476,154 +493,166 @@ class _ThreadMessage extends ConsumerWidget {
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
return DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
color: isHighlighted
? context.colors.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
color: isHighlighted
? context.colors.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(Grid.half),
),
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(context, message.pubkey),
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: 36),
const SizedBox(width: Grid.xxs),
Expanded(
child: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => showUserProfileSheet(
context,
message.pubkey,
),
child: Text(
displayName,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: Grid.xxs),
Text(
formatMessageTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: context
.textTheme
.titleSmall
?.letterSpacing,
color: context.colors.onSurfaceVariant,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('thread-message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? Grid.xs : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(context, message.pubkey),
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Transform.translate(
offset: Offset(0, showAuthor ? -Grid.quarter : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
),
nameColor: context.colors.onSurface,
metadataColor:
context.colors.onSurfaceVariant,
onAuthorTap: () => showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'thread-message-author-${message.id}',
),
usernameKey: ValueKey(
'thread-message-username-${message.id}',
),
timestampKey: ValueKey(
'thread-message-timestamp-${message.id}',
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
],
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
),
],
],
),
),
),
),
@@ -709,7 +738,7 @@ class _Avatar extends StatelessWidget {
return AvatarImage(
imageUrl: avatarUrl,
radius: 18,
radius: messageAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,

View File

@@ -77,17 +77,22 @@ class ForumPostCard extends ConsumerWidget {
onTap: () => showUserProfileSheet(context, post.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
formatRelativeTime(post.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTime(post.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.half),
@@ -124,6 +129,9 @@ class ForumPostCard extends ConsumerWidget {
content: preview,
mentionNames: mentionNames,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
),
),

View File

@@ -335,22 +335,29 @@ class _OriginalPost extends ConsumerWidget {
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, post.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
Expanded(
child: GestureDetector(
onTap: () => showUserProfileSheet(context, post.pubkey),
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
Text(
formatRelativeTime(post.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTime(post.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
@@ -363,6 +370,9 @@ class _OriginalPost extends ConsumerWidget {
content: post.content,
mentionNames: mentionNames,
tags: post.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
],
@@ -417,20 +427,28 @@ class _ReplyRow extends ConsumerWidget {
Expanded(
child: Row(
children: [
GestureDetector(
onTap: () => showUserProfileSheet(context, reply.pubkey),
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
Expanded(
child: GestureDetector(
onTap: () =>
showUserProfileSheet(context, reply.pubkey),
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageUsernameTextStyle,
),
),
),
const SizedBox(width: Grid.xxs),
Text(
formatRelativeTime(reply.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatRelativeTime(reply.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
@@ -458,6 +476,9 @@ class _ReplyRow extends ConsumerWidget {
content: reply.content,
mentionNames: mentionNames,
tags: reply.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey),
),
),

View File

@@ -39,3 +39,10 @@ class UserProfile {
(displayName?.isNotEmpty == true ? displayName! : pubkey)[0]
.toUpperCase();
}
/// Optional profile handle shown beside a message author's display name.
String? messageUsernameLabel(UserProfile? profile) {
final handle = profile?.nip05Handle?.trim();
if (handle != null && handle.isNotEmpty) return handle;
return null;
}

View File

@@ -198,20 +198,24 @@ class _ReplyContext extends ConsumerWidget {
children: [
Row(
children: [
Flexible(
Expanded(
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: Grid.half),
Text(
formatPulseRelativeTime(note.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
formatPulseRelativeTime(note.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
@@ -229,6 +233,9 @@ class _ReplyContext extends ConsumerWidget {
child: MessageContent(
content: note.content,
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
),
),

View File

@@ -89,33 +89,47 @@ class NoteCard extends HookConsumerWidget {
onTap: () => showUserProfileSheet(context, note.pubkey),
child: Row(
children: [
Flexible(
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
displayName,
maxLines: 1,
style: messageUsernameTextStyle,
overflow: TextOverflow.ellipsis,
),
),
if (isAgent) ...[
const SizedBox(width: Grid.half),
Icon(
LucideIcons.bot,
size: 13,
color: context.colors.primary,
),
],
],
),
),
if (isAgent) ...[
const SizedBox(width: Grid.half),
Icon(
LucideIcons.bot,
size: 13,
color: context.colors.primary,
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Grid.xl,
),
],
child: Text(
formatPulseRelativeTime(note.createdAt),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
),
Text(
formatPulseRelativeTime(note.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (canFollow) ...[
const SizedBox(width: Grid.half),
_FollowButton(
@@ -142,7 +156,13 @@ class NoteCard extends HookConsumerWidget {
),
],
const SizedBox(height: Grid.half),
MessageContent(content: note.content, tags: note.tags),
MessageContent(
content: note.content,
tags: note.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xxs),
Row(
children: [

View File

@@ -0,0 +1,64 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
const _recentSearchesPrefsKey = 'recent_searches_v1';
const _maxRecentSearches = 6;
/// Device-local history of explicitly submitted searches, newest first.
///
/// Queries are scoped by community and account so searches from one identity
/// cannot appear after switching to another.
class RecentSearchesNotifier extends Notifier<List<String>> {
late String _prefsKey;
@override
List<String> build() {
final config = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
_prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey';
final stored =
ref.read(savedPrefsProvider).getStringList(_prefsKey) ?? const [];
return List.unmodifiable(
stored
.map((query) => query.trim())
.where((query) => query.isNotEmpty)
.take(_maxRecentSearches),
);
}
/// Records [query] as the most recent search after trimming whitespace.
///
/// Empty queries are ignored. Existing matches are deduplicated
/// case-insensitively, the newest spelling is retained, the history is capped
/// at six entries, and the result is persisted for the current community and
/// account.
void record(String query) {
final trimmed = query.trim();
if (trimmed.isEmpty) return;
final normalized = trimmed.toLowerCase();
final next = [
trimmed,
...state.where((item) => item.toLowerCase() != normalized),
].take(_maxRecentSearches).toList(growable: false);
_persist(next);
}
/// Clears the current community and account's history and persists it empty.
void clear() => _persist(const []);
void _persist(List<String> searches) {
state = List.unmodifiable(searches);
ref.read(savedPrefsProvider).setStringList(_prefsKey, searches);
}
}
/// Provides device-local recent searches scoped to the active community and
/// account.
final recentSearchesProvider =
NotifierProvider<RecentSearchesNotifier, List<String>>(
RecentSearchesNotifier.new,
);

View File

@@ -8,6 +8,7 @@ import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/filter_chip_bar.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
@@ -19,20 +20,22 @@ import '../forum/forum_thread_page.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'recent_searches_provider.dart';
import 'search_provider.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchFieldMinHeight = 36.0;
const _searchFieldVerticalPadding = Grid.xxs;
const _searchFieldHint = 'Search messages, channels, people\u2026';
const _searchCancelEnterDuration = Duration(milliseconds: 160);
const _searchCancelExitDuration = Duration(milliseconds: 120);
double _searchFieldHeight(BuildContext context) {
final style =
context.textTheme.bodyMedium ??
const TextStyle(fontSize: 14, height: 1.3);
const style = searchInputTextStyle;
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 14);
).scale(style.fontSize ?? 15);
final contentHeight =
scaledFontSize * (style.height ?? 1) + _searchFieldVerticalPadding * 2;
return contentHeight > _searchFieldMinHeight
@@ -52,10 +55,12 @@ class SearchPage extends HookConsumerWidget {
.value;
final activeFilter = useState(_SearchFilter.all);
final textController = useTextEditingController();
final hasText = useListenableSelector(
textController,
() => textController.text.isNotEmpty,
final focusNode = useFocusNode();
final isSearchFocused = useListenableSelector(
focusNode,
() => focusNode.hasFocus,
);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final isBuzzTheme = context.appColors.topSectionGradient != null;
final buzzSearchColor = context.theme.brightness == Brightness.dark
? Colors.white
@@ -71,7 +76,20 @@ class SearchPage extends HookConsumerWidget {
fontWeight: FontWeight.w600,
);
final searchFieldHeight = _searchFieldHeight(context);
final searchHeaderBottomHeight = searchFieldHeight + Grid.twelve;
final searchControlHeight = searchFieldHeight > Grid.xl
? searchFieldHeight
: Grid.xl;
final searchHeaderBottomHeight = searchControlHeight + Grid.twelve;
void runRecentSearch(String query) {
textController.value = TextEditingValue(
text: query,
selection: TextSelection.collapsed(offset: query.length),
);
focusNode.requestFocus();
ref.read(recentSearchesProvider.notifier).record(query);
ref.read(searchProvider.notifier).search(query);
}
return FrostedScaffold(
// Keep the empty state centered in the page rather than the portion left
@@ -89,51 +107,120 @@ class SearchPage extends HookConsumerWidget {
Grid.gutter,
Grid.twelve,
),
child: Container(
key: const Key('search-field-container'),
height: searchFieldHeight,
padding: const EdgeInsets.symmetric(horizontal: Grid.half),
decoration: BoxDecoration(
color: searchSurfaceColor,
borderRadius: BorderRadius.circular(Radii.lg),
),
child: TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'Search messages, channels, people\u2026',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: searchMutedColor,
),
prefixIcon: Icon(
LucideIcons.search,
size: 16,
color: searchMutedColor,
),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: _searchFieldVerticalPadding,
child: Row(
children: [
Expanded(
child: Container(
key: const Key('search-field-container'),
height: searchFieldHeight,
padding: const EdgeInsets.symmetric(horizontal: Grid.half),
decoration: BoxDecoration(
color: searchSurfaceColor,
borderRadius: BorderRadius.circular(Radii.lg),
),
child: TextField(
key: const Key('search-field'),
controller: textController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: isSearchFocused ? null : _searchFieldHint,
hintStyle: searchInputTextStyle.copyWith(
color: searchMutedColor,
),
prefixIcon: Icon(
LucideIcons.search,
size: 16,
color: searchMutedColor,
),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: _searchFieldVerticalPadding,
),
),
style: searchInputTextStyle.copyWith(
color: context.colors.onSurface,
),
textInputAction: TextInputAction.search,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
onSubmitted: (value) {
final query = value.trim();
if (query.isEmpty) return;
ref.read(recentSearchesProvider.notifier).record(query);
},
),
),
),
style: context.textTheme.bodyMedium,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
),
AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: _searchCancelEnterDuration,
reverseDuration: reduceMotion
? Duration.zero
: _searchCancelExitDuration,
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SizeTransition(
sizeFactor: curvedAnimation,
axis: Axis.horizontal,
axisAlignment: 1,
child: FadeTransition(
opacity: curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.35, 0),
end: Offset.zero,
).animate(curvedAnimation),
child: child,
),
),
);
},
child: isSearchFocused
? Padding(
key: const ValueKey('search-cancel-visible'),
padding: const EdgeInsets.only(left: Grid.xxs),
child: TextButton(
key: const Key('search-cancel'),
onPressed: () {
textController.clear();
ref.read(searchProvider.notifier).clear();
focusNode.unfocus();
},
style: TextButton.styleFrom(
foregroundColor: context.colors.primary,
minimumSize: Size(0, searchControlHeight),
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.xxs,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Cancel',
style: filterChipTextStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
),
),
),
)
: const SizedBox.shrink(
key: ValueKey('search-cancel-hidden'),
),
),
],
),
),
actions: [
if (hasText)
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () {
textController.clear();
ref.read(searchProvider.notifier).clear();
},
),
],
actions: const [],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -162,6 +249,7 @@ class SearchPage extends HookConsumerWidget {
state: searchState,
filter: activeFilter.value,
currentPubkey: currentPubkey,
onRecentSearchSelected: runRecentSearch,
),
),
],
@@ -174,16 +262,27 @@ class _SearchBody extends ConsumerWidget {
final SearchState state;
final _SearchFilter filter;
final String? currentPubkey;
final ValueChanged<String> onRecentSearchSelected;
const _SearchBody({
required this.state,
required this.filter,
required this.currentPubkey,
required this.onRecentSearchSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (state.query.isEmpty) {
final recentSearches = ref.watch(recentSearchesProvider);
if (recentSearches.isNotEmpty) {
return _RecentSearches(
searches: recentSearches,
onSelected: onRecentSearchSelected,
onClear: ref.read(recentSearchesProvider.notifier).clear,
);
}
return Center(
child: Padding(
key: const Key('search-empty-state'),
@@ -221,6 +320,8 @@ class _SearchBody extends ConsumerWidget {
state.channelResults.isNotEmpty ||
state.userResults.isNotEmpty ||
state.messageResults.isNotEmpty;
void recordResultSelection() =>
ref.read(recentSearchesProvider.notifier).record(state.query);
if (!state.isLoading && !hasAnyResults) {
return Padding(
@@ -246,13 +347,20 @@ class _SearchBody extends ConsumerWidget {
),
children: [
if (showChannels && state.channelResults.isNotEmpty)
_ChannelsSection(channels: state.channelResults),
_ChannelsSection(
channels: state.channelResults,
onResultSelected: recordResultSelection,
),
if (showPeople && state.userResults.isNotEmpty)
_PeopleSection(users: state.userResults),
_PeopleSection(
users: state.userResults,
onResultSelected: recordResultSelection,
),
if (showMessages && state.messageResults.isNotEmpty)
_MessagesSection(
hits: state.messageResults,
currentPubkey: currentPubkey,
onResultSelected: recordResultSelection,
),
if (state.isLoading)
const Padding(
@@ -264,10 +372,106 @@ class _SearchBody extends ConsumerWidget {
}
}
class _RecentSearches extends StatelessWidget {
final List<String> searches;
final ValueChanged<String> onSelected;
final VoidCallback onClear;
const _RecentSearches({
required this.searches,
required this.onSelected,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return ListView(
key: const Key('recent-searches-list'),
padding: EdgeInsets.only(
bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.xxs,
Grid.half,
),
child: Row(
children: [
Expanded(
child: Text(
'Recent searches',
key: const Key('recent-searches-heading'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
TextButton(
key: const Key('clear-recent-searches'),
onPressed: onClear,
child: Text(
'Clear',
style: activityContextTextStyle.copyWith(
color: context.colors.primary,
),
),
),
],
),
),
for (var index = 0; index < searches.length; index++)
InkWell(
key: ValueKey('recent-search-$index'),
onTap: () => onSelected(searches[index]),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: Grid.xl),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.twelve,
),
child: Row(
children: [
Icon(
LucideIcons.clock,
size: 18,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.twelve),
Expanded(
child: Text(
searches[index],
style: contentListTitleTextStyle.copyWith(
color: context.colors.onSurface,
),
),
),
Icon(
LucideIcons.chevronRight,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),
],
);
}
}
class _ChannelsSection extends StatelessWidget {
final List<Channel> channels;
final VoidCallback onResultSelected;
const _ChannelsSection({required this.channels});
const _ChannelsSection({
required this.channels,
required this.onResultSelected,
});
@override
Widget build(BuildContext context) {
@@ -277,11 +481,21 @@ class _ChannelsSection extends StatelessWidget {
_SectionLabel(label: 'Channels'),
for (final channel in channels)
ListTile(
leading: Icon(channelIcon(channel), size: 20),
title: Text(channel.name),
key: ValueKey('search-channel-row-${channel.id}'),
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
leading: Icon(
channelIcon(channel),
key: ValueKey('search-channel-leading-${channel.id}'),
size: 20,
),
title: Text(
channel.name,
key: ValueKey('search-channel-title-${channel.id}'),
style: contentListTitleTextStyle,
),
subtitle: Text(
'${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}',
style: context.textTheme.bodySmall?.copyWith(
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
@@ -304,11 +518,14 @@ class _ChannelsSection extends StatelessWidget {
),
)
: null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
),
onTap: () {
onResultSelected();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
},
),
],
);
@@ -317,8 +534,9 @@ class _ChannelsSection extends StatelessWidget {
class _PeopleSection extends ConsumerWidget {
final List<DirectoryUser> users;
final VoidCallback onResultSelected;
const _PeopleSection({required this.users});
const _PeopleSection({required this.users, required this.onResultSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -328,19 +546,27 @@ class _PeopleSection extends ConsumerWidget {
_SectionLabel(label: 'People'),
for (final user in users)
ListTile(
key: ValueKey('search-person-row-${user.pubkey}'),
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
leading: AvatarImage(
key: ValueKey('search-person-leading-${user.pubkey}'),
imageUrl: user.avatarUrl,
radius: 20,
fallback: Text(user.label.substring(0, 1).toUpperCase()),
),
title: Text(user.label),
title: Text(
user.label,
key: ValueKey('search-person-title-${user.pubkey}'),
style: contentListTitleTextStyle,
),
subtitle: Text(
user.secondaryLabel,
style: context.textTheme.bodySmall?.copyWith(
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
onTap: () async {
onResultSelected();
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [user.pubkey]);
@@ -360,8 +586,13 @@ class _PeopleSection extends ConsumerWidget {
class _MessagesSection extends ConsumerWidget {
final List<SearchHit> hits;
final String? currentPubkey;
final VoidCallback onResultSelected;
const _MessagesSection({required this.hits, required this.currentPubkey});
const _MessagesSection({
required this.hits,
required this.currentPubkey,
required this.onResultSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -383,6 +614,7 @@ class _MessagesSection extends ConsumerWidget {
userCache: profiles,
channel: channels.where((c) => c.id == hit.channelId).firstOrNull,
currentPubkey: currentPubkey,
onResultSelected: onResultSelected,
),
],
);
@@ -395,6 +627,7 @@ class _MessageTile extends StatelessWidget {
final Map<String, UserProfile> userCache;
final Channel? channel;
final String? currentPubkey;
final VoidCallback onResultSelected;
const _MessageTile({
required this.hit,
@@ -402,68 +635,99 @@ class _MessageTile extends StatelessWidget {
required this.userCache,
required this.channel,
required this.currentPubkey,
required this.onResultSelected,
});
@override
Widget build(BuildContext context) {
final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey);
final timeAgo = relativeTime(hit.createdAt);
final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), '');
final hasChannelName = channelName != null && channelName.isNotEmpty;
final isDm = channel?.isDm ?? false;
return ListTile(
leading: SmallAvatar(pubkey: hit.pubkey, userCache: userCache),
title: Row(
children: [
Expanded(
child: Text(
authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (hit.channelName != null) ...[
const SizedBox(width: Grid.half),
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
hit.channelName!,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
],
key: ValueKey('search-message-row-${hit.eventId}'),
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
titleAlignment: ListTileTitleAlignment.top,
horizontalTitleGap: messageAvatarContentGap,
leading: SmallAvatar(
key: ValueKey('search-message-avatar-${hit.eventId}'),
pubkey: hit.pubkey,
userCache: userCache,
size: compactMessageAvatarSize,
),
title: MessageAuthorMeta(
displayName: authorName,
username: messageUsernameLabel(authorProfile),
timestamp: timeAgo,
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
displayNameKey: ValueKey('search-message-author-${hit.eventId}'),
usernameKey: ValueKey('search-message-username-${hit.eventId}'),
timestampKey: ValueKey('search-message-timestamp-${hit.eventId}'),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Row(
key: ValueKey('search-message-context-${hit.eventId}'),
children: [
Flexible(
child: Text(
isDm
? 'Direct message'
: hasChannelName
? 'Message in'
: 'Message',
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
if (!isDm && hasChannelName) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + Grid.quarter,
vertical: Grid.quarter / 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.xs),
),
child: Text(
'#$channelName',
key: ValueKey('search-message-channel-${hit.eventId}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
const SizedBox(height: Grid.half),
MessageContent(
key: ValueKey('search-message-body-${hit.eventId}'),
content: hit.content,
tags: hit.tags,
maxLines: 2,
baseStyle: context.textTheme.bodyMedium,
),
const SizedBox(height: 2),
Text(
timeAgo,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
baseStyle: activityPreviewTextStyle.copyWith(
color: context.colors.onSurface,
),
),
],
),
onTap: () => _navigateToHit(context, hit, channel),
onTap: () {
onResultSelected();
_navigateToHit(context, hit, channel);
},
);
}
@@ -507,11 +771,10 @@ class _SectionLabel extends StatelessWidget {
Grid.half,
),
child: Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
label,
key: ValueKey('search-section-${label.toLowerCase()}'),
style: activityContextTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'grid.dart';
const _fontFamily = 'Inter';
/// Avatar size for full channel and thread messages.
const messageAvatarSize = 42.0;
/// Avatar size for conversation-oriented Activity rows.
const activityAvatarSize = messageAvatarSize;
/// Avatar size for compact message-result rows.
const compactMessageAvatarSize = messageAvatarSize;
/// Horizontal space between a message avatar and its content.
const messageAvatarContentGap = Grid.twelve;
/// Primary message copy: 15sp regular on a 20sp line height.
const messageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Message author names: 15sp semibold on a 17sp line height.
const messageUsernameTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// Secondary author metadata: 15sp regular on a tight 17sp line height.
const messageMetadataTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 17 / 15,
letterSpacing: 0,
);
/// Message timestamps share the secondary author metadata style.
const messageTimestampTextStyle = messageMetadataTextStyle;
/// Compact reply previews: 13.1sp regular on a 17sp line height.
const replyPreviewTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Reaction counts: 13.1sp medium on a 17sp line height.
const reactionCountTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Channel and thread titles: 20sp bold on a 24sp line height.
const channelTitleTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
height: 24 / 20,
letterSpacing: 0,
);
/// Primary labels in compact content lists.
const contentListTitleTextStyle = messageUsernameTextStyle;
/// Secondary copy in compact content lists.
const contentListBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Timestamps in compact content lists.
const contentListTimestampTextStyle = messageMetadataTextStyle;
/// Filter chip labels use the compact 15sp type ramp.
const filterChipTextStyle = messageMetadataTextStyle;
/// Search fields use the primary 15sp body treatment.
const searchInputTextStyle = messageBodyTextStyle;
/// System message actor names: 15sp semibold on a 17sp line height.
const systemMessageHeadingTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w600,
height: 17 / 15,
letterSpacing: 0,
);
/// System message copy: 15sp regular on a 20sp line height.
const systemMessageBodyTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 20 / 15,
letterSpacing: 0,
);
/// Activity sender names share the primary author style.
const activityUsernameTextStyle = messageUsernameTextStyle;
/// Activity timestamps share the secondary author metadata style.
const activityTimestampTextStyle = messageMetadataTextStyle;
/// Activity context labels: 13.1sp medium on a 17sp line height.
const activityContextTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w500,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Activity message previews use the primary message copy style.
const activityPreviewTextStyle = messageBodyTextStyle;

View File

@@ -5,6 +5,7 @@ export 'app_theme.dart';
export 'buzz_theme.dart';
export 'color_scheme.dart';
export 'grid.dart';
export 'message_typography.dart';
export 'theme_catalog.dart';
export 'theme_extensions.dart';
export 'theme_pairs.dart';

View File

@@ -101,7 +101,7 @@ class FilterChipBar<T> extends StatelessWidget {
final fg = isSelected
? context.colors.onPrimary
: context.colors.onSurfaceVariant;
final labelStyle = context.textTheme.bodyLarge?.copyWith(
final labelStyle = filterChipTextStyle.copyWith(
color: fg,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
);

View File

@@ -61,6 +61,9 @@ class FrostedAppBar extends StatelessWidget {
/// can pop, a back button is shown automatically.
final Widget? leading;
/// Whether to infer a back button from the current navigator.
final bool automaticallyImplyLeading;
/// Widget displayed in the center/title area.
final Widget? title;
@@ -95,6 +98,7 @@ class FrostedAppBar extends StatelessWidget {
const FrostedAppBar({
super.key,
this.leading,
this.automaticallyImplyLeading = true,
this.title,
this.titleStyle,
this.titleContentHeight = 0,
@@ -119,7 +123,7 @@ class FrostedAppBar extends StatelessWidget {
final effectiveLeading =
leading ??
(canPop
(automaticallyImplyLeading && canPop
? SizedBox(
width: 48,
height: 48,

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A consistent inline author row for message-oriented surfaces.
class MessageAuthorMeta extends StatelessWidget {
/// Primary author name shown at the start of the row.
final String displayName;
/// Optional secondary username, hidden when blank or equal to [displayName].
final String? username;
/// Timestamp label shown after the author metadata.
final String timestamp;
/// Color applied to [displayName].
final Color nameColor;
/// Color applied to the username, separator, and [timestamp].
final Color metadataColor;
/// Optional callback invoked when [displayName] is tapped.
final VoidCallback? onAuthorTap;
/// Optional key assigned to the display-name text.
final Key? displayNameKey;
/// Optional key assigned to the username text.
final Key? usernameKey;
/// Optional key assigned to the timestamp text.
final Key? timestampKey;
/// Base text style for [displayName], with [nameColor] applied.
final TextStyle nameStyle;
/// Base text style for secondary metadata, with [metadataColor] applied.
final TextStyle metadataStyle;
/// Creates an inline author row with optional username and tap handling.
const MessageAuthorMeta({
super.key,
required this.displayName,
required this.timestamp,
required this.nameColor,
required this.metadataColor,
this.username,
this.onAuthorTap,
this.displayNameKey,
this.usernameKey,
this.timestampKey,
this.nameStyle = messageUsernameTextStyle,
this.metadataStyle = messageMetadataTextStyle,
});
@override
Widget build(BuildContext context) {
final normalizedUsername = username?.trim();
final showUsername =
normalizedUsername != null &&
normalizedUsername.isNotEmpty &&
normalizedUsername != displayName.trim();
final resolvedNameStyle = nameStyle.copyWith(color: nameColor);
final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor);
Widget authorName = Text(
displayName,
key: displayNameKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedNameStyle,
);
if (onAuthorTap != null) {
authorName = GestureDetector(onTap: onAuthorTap, child: authorName);
}
return LayoutBuilder(
builder: (context, constraints) {
final metadataMaxWidth = constraints.hasBoundedWidth
? constraints.maxWidth / (showUsername ? 3 : 2)
: double.infinity;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(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,
),
),
],
const SizedBox(width: Grid.half),
Text('·', style: resolvedMetadataStyle),
const SizedBox(width: Grid.half),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
timestamp,
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
),
),
],
);
},
);
}
}

View File

@@ -3,14 +3,18 @@ import 'dart:async';
import 'package:buzz/features/activity/activity_page.dart';
import 'package:buzz/features/activity/activity_provider.dart';
import 'package:buzz/features/activity/feed_item.dart';
import 'package:buzz/features/activity/inbox_item.dart';
import 'package:buzz/features/activity/reminders_provider.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -91,7 +95,11 @@ void main() {
];
final testUsers = <String, UserProfile>{
'alice_pk': const UserProfile(pubkey: 'alice_pk', displayName: 'Alice'),
'alice_pk': const UserProfile(
pubkey: 'alice_pk',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'),
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
};
@@ -102,6 +110,7 @@ void main() {
Map<String, UserProfile>? users,
Map<String, int> readContexts = const {},
List<Channel>? channels,
TextScaler? textScaler,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
@@ -122,7 +131,16 @@ void main() {
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
],
child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()),
child: MaterialApp(
theme: AppTheme.light(),
builder: textScaler == null
? null
: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: child!,
),
home: const ActivityPage(),
),
);
}
@@ -153,6 +171,17 @@ void main() {
expect(find.text('No activity yet'), findsOneWidget);
});
testWidgets('does not imply a back button for the top-level Activity tab', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.automaticallyImplyLeading, isFalse);
expect(find.byTooltip('Back'), findsNothing);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
await buildTestable(activityNotifier: _ErrorActivityNotifier.new),
@@ -163,6 +192,88 @@ void main() {
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('activity popovers use fixed-layout scale and opacity motion', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final filterTrigger = find.byKey(const ValueKey('activity-filter-menu'));
expect(
tester.getSize(filterTrigger).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'The Activity filter trigger must keep a 48dp touch target.',
);
await tester.tap(filterTrigger);
await tester.pump();
final surface = find.byKey(const ValueKey('activity-filter-popover'));
final fade = find.byKey(const ValueKey('activity-popover-fade'));
final scale = find.byKey(const ValueKey('activity-popover-scale'));
expect(surface, findsOneWidget);
expect(fade, findsOneWidget);
expect(scale, findsOneWidget);
final initialSize = tester.getSize(surface);
final initialFade = tester.widget<FadeTransition>(fade);
final initialScale = tester.widget<ScaleTransition>(scale);
expect(initialSize.width, 240);
expect(initialFade.opacity.value, lessThan(1));
expect(initialScale.scale.value, greaterThanOrEqualTo(0.96));
expect(initialScale.scale.value, lessThan(1));
expect(initialScale.alignment, Alignment.topLeft);
await tester.pump(const Duration(milliseconds: 75));
final movingFade = tester.widget<FadeTransition>(fade);
final movingScale = tester.widget<ScaleTransition>(scale);
expect(movingFade.opacity.value, greaterThan(0));
expect(movingFade.opacity.value, lessThan(1));
expect(movingScale.scale.value, greaterThan(0.96));
expect(movingScale.scale.value, lessThan(1));
expect(tester.getSize(surface), initialSize);
await tester.pump(const Duration(milliseconds: 75));
expect(tester.widget<FadeTransition>(fade).opacity.value, 1);
expect(tester.widget<ScaleTransition>(scale).scale.value, 1);
expect(tester.getSize(surface), initialSize);
final material = tester.widget<Material>(surface);
final shape = material.shape! as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(Radii.card));
expect(material.surfaceTintColor, Colors.transparent);
expect(material.clipBehavior, Clip.antiAlias);
final items = tester.widgetList<PopupMenuItem<InboxFilter>>(
find.byType(PopupMenuItem<InboxFilter>),
);
expect(items, hasLength(InboxFilter.values.length));
expect(
items.every((item) => item.height >= Grid.xl),
isTrue,
reason: 'Activity filter choices must keep 48dp touch targets.',
);
await tester.tap(find.descendant(of: surface, matching: find.text('All')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
await tester.pump();
final optionsSurface = find.byKey(
const ValueKey('activity-options-popover'),
);
expect(tester.getSize(optionsSurface).width, 216);
expect(
tester
.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
)
.alignment,
Alignment.topRight,
);
});
testWidgets('rows lead with sender, contextual label, and preview', (
tester,
) async {
@@ -171,6 +282,7 @@ void main() {
// Sender names resolved from the user cache.
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Scout'), findsOneWidget);
@@ -180,18 +292,62 @@ void main() {
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
expect(find.text('#engineering'), findsOneWidget);
// Context labels and channel pills share the compact Activity style.
final contextLabel = tester.widget<Text>(find.text('Mentioned in'));
final channelLabel = tester.widgetList<Text>(find.text('#general')).first;
expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(contextLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(contextLabel.style?.height, activityContextTextStyle.height);
expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(channelLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(channelLabel.style?.height, activityContextTextStyle.height);
// Message previews.
expect(find.textContaining('Hey check this out'), findsOneWidget);
expect(find.textContaining('Deployed the fix'), findsOneWidget);
// Sender uses the compact label scale (labelMedium), not a
// headline-like title scale.
// Activity rows use their conversation-oriented scale.
final senderText = tester.widget<Text>(find.text('Alice'));
final textTheme = Theme.of(tester.element(find.text('Alice'))).textTheme;
expect(senderText.style?.fontSize, textTheme.labelMedium?.fontSize);
final theme = Theme.of(tester.element(find.text('Alice')));
expect(senderText.style?.fontSize, activityUsernameTextStyle.fontSize);
expect(senderText.style?.fontWeight, activityUsernameTextStyle.fontWeight);
expect(senderText.style?.height, activityUsernameTextStyle.height);
expect(senderText.style?.color, theme.colorScheme.onSurface);
final usernameText = tester.widget<Text>(
find.byKey(const ValueKey('activity-username-m1')),
);
final timestampText = tester.widget<Text>(
find.byKey(const ValueKey('activity-timestamp-m1')),
);
expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(usernameText.style?.fontWeight, FontWeight.w400);
expect(usernameText.style?.height, messageMetadataTextStyle.height);
expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(timestampText.style?.fontWeight, FontWeight.w400);
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
expect(avatars, isNotEmpty);
expect(
senderText.style!.fontSize!,
lessThan(textTheme.titleSmall!.fontSize!),
avatars.every((avatar) => avatar.radius == activityAvatarSize / 2),
isTrue,
);
final previews = tester.widgetList<MessageContent>(
find.byType(MessageContent),
);
expect(previews, isNotEmpty);
expect(
previews.every(
(preview) =>
preview.baseStyle?.fontSize == activityPreviewTextStyle.fontSize &&
preview.baseStyle?.fontWeight ==
activityPreviewTextStyle.fontWeight &&
preview.baseStyle?.height == activityPreviewTextStyle.height &&
preview.baseStyle?.letterSpacing ==
activityPreviewTextStyle.letterSpacing &&
preview.baseStyle?.color == theme.colorScheme.onSurface,
),
isTrue,
);
});
@@ -293,6 +449,24 @@ void main() {
expect(find.text('Nothing needs your action'), findsOneWidget);
});
testWidgets('filter menu supports accessibility text scaling', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(textScaler: const TextScaler.linear(3)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-filter-popover')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('opens a thread mention at the referenced message', (
tester,
) async {

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -203,16 +204,15 @@ Widget _buildTestable({
],
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: child!,
),
navigatorObservers: navigatorObservers,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: ChannelDetailPage(
channel: resolvedChannel,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
),
),
home: ChannelDetailPage(
channel: resolvedChannel,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
),
),
);
@@ -680,7 +680,11 @@ void main() {
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'alice': const UserProfile(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
@@ -690,29 +694,58 @@ void main() {
expect(findRichText('Hello world!'), findsOneWidget);
expect(findRichText('Hey Alice!'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
final messageAvatars = find.byType(CircleAvatar);
expect(messageAvatars, findsNWidgets(2));
for (final avatar in messageAvatars.evaluate()) {
expect(
tester.getSize(find.byWidget(avatar.widget)),
const Size.square(36),
const Size.square(messageAvatarSize),
);
}
final aliceName = find.text('Alice');
final aliceText = tester.widget<Text>(aliceName);
final titleStyle = Theme.of(
tester.element(aliceName),
).textTheme.titleSmall;
expect(aliceText.style?.fontSize, titleStyle?.fontSize);
expect(aliceText.style?.fontSize, messageUsernameTextStyle.fontSize);
expect(aliceText.style?.fontWeight, messageUsernameTextStyle.fontWeight);
expect(aliceText.style?.height, messageUsernameTextStyle.height);
final aliceUsername = tester.widget<Text>(
find.byKey(const ValueKey('message-username-msg1')),
);
final aliceTimestamp = tester.widget<Text>(
find.byKey(const ValueKey('message-timestamp-msg1')),
);
expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(aliceUsername.style?.fontWeight, FontWeight.w400);
expect(aliceUsername.style?.height, messageMetadataTextStyle.height);
expect(aliceTimestamp.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(aliceTimestamp.style?.fontWeight, FontWeight.w400);
final helloContent = findRichText('Hello world!');
final helloText = tester.widget<RichText>(helloContent);
final bodyStyle = Theme.of(
tester.element(helloContent),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(helloText.text, 'Hello world!'),
bodyStyle?.fontSize,
messageBodyTextStyle.fontSize,
);
final messageList = tester.widget<ScrollablePositionedList>(
find.byKey(const ValueKey('channel-message-list')),
);
expect(messageList.padding!.bottom, 0);
final newestMessageGroup = tester.widget<Padding>(
find.byKey(const ValueKey('channel-message-group-msg2')),
);
expect(
newestMessageGroup.padding,
const EdgeInsets.only(bottom: Grid.xs),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
});
@@ -767,9 +800,62 @@ void main() {
const Size.square(32),
);
}
final summaryPadding = tester.widget<Padding>(
find.byKey(const ValueKey('thread-summary-root')),
);
expect(
summaryPadding.padding,
const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
);
});
testWidgets('can jump back to latest when newer messages are offscreen', (
testWidgets('constrains reply summaries at accessibility text sizes', (
tester,
) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final lastReplyAt =
DateTime.now().millisecondsSinceEpoch ~/ 1000 - 59 * 60;
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'root',
pubkey: 'alice',
content: 'Thread head',
createdAt: lastReplyAt - 300,
),
for (var i = 0; i < 3; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'participant-$i',
content: 'Reply $i',
createdAt: lastReplyAt - 2 + i,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
],
channel: _testChannel.copyWith(archivedAt: DateTime.now()),
textScaler: const TextScaler.linear(2),
),
);
await tester.pumpAndSettle();
final summaryText = tester.widget<RichText>(findRichText('3 replies'));
expect(summaryText.maxLines, 2);
expect(summaryText.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
testWidgets('can jump back to latest after a non-drag user scroll', (
tester,
) async {
final initialMessages = [
@@ -794,9 +880,21 @@ void main() {
);
await tester.pumpAndSettle();
final listView = tester.widget<ScrollablePositionedList>(
find.byKey(const ValueKey('channel-message-list')),
);
final messageList = find.byKey(const ValueKey('channel-message-list'));
final messageListElement = tester.element(messageList);
UserScrollNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 100,
pixels: 0,
viewportDimension: 100,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: messageListElement,
direction: ScrollDirection.reverse,
).dispatch(messageListElement);
final listView = tester.widget<ScrollablePositionedList>(messageList);
listView.itemScrollController!.jumpTo(index: 39);
await tester.pumpAndSettle();
expect(
@@ -822,6 +920,185 @@ void main() {
expect(findRichText('Newest live update'), findsOneWidget);
});
testWidgets(
'keeps follow mode off while a tall newest message stays visible',
(tester) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final tallMessage = List.generate(
12,
(index) => 'Newest message line $index',
).join('\n');
final initialMessages = [
for (var i = 0; i < 12; i++)
_textMsg(
id: 'msg$i',
pubkey: i.isEven ? 'alice' : 'bob',
content: 'Message $i',
createdAt: 1000 + i * 1000,
),
_textMsg(
id: 'tall-newest',
pubkey: 'alice',
content: tallMessage,
createdAt: 20_000,
),
];
final messagesNotifier = _FakeMessagesNotifier(initialMessages);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final messageList = find.byKey(const ValueKey('channel-message-list'));
await tester.drag(messageList, const Offset(0, 120));
await tester.pumpAndSettle();
expect(findRichText('Newest message line 0'), findsOneWidget);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
messagesNotifier.setMessages([
...initialMessages,
_textMsg(
id: 'newest-live',
pubkey: 'alice',
content: 'Newest live update',
createdAt: 30_000,
),
]);
await tester.pumpAndSettle();
expect(findRichText('Newest live update'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
},
);
testWidgets('preserves an initial message deep-link position', (
tester,
) async {
final initialMessages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(initialMessages);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
initialMessageId: 'msg5',
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('Message 5'), findsOneWidget);
expect(findRichText('Message 39'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
messagesNotifier.setMessages([
...initialMessages,
_textMsg(
id: 'newest',
pubkey: 'alice',
content: 'Newest live update',
createdAt: 2000,
),
]);
await tester.pumpAndSettle();
expect(findRichText('Message 5'), findsOneWidget);
expect(findRichText('Newest live update'), findsNothing);
});
testWidgets(
'keeps a deep-linked message in view when its page arrives after a '
'small scroll near the latest message',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// The deep-link target lives in an older page that has not loaded yet.
final messagesNotifier = _FakeMessagesNotifier([
for (var i = 30; i < 60; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i * 1000,
),
]);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
initialMessageId: 'msg3',
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
// Small scrolls that keep the newest message visible, so isAtLatest
// stays true while the scroll offset becomes non-zero. This lets a
// later programmatic jumpTo dispatch ScrollEndNotification.
for (final dy in const [10.0, 20.0, 30.0]) {
await tester.drag(
find.byKey(const ValueKey('channel-message-list')),
Offset(0, dy),
);
await tester.pumpAndSettle();
}
// The older page containing the deep-link target arrives.
messagesNotifier.setMessages([
for (var i = 0; i < 60; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i * 1000,
),
]);
await tester.pumpAndSettle();
// The deep-link jump must stick rather than snapping back to newest.
expect(findRichText('Message 3'), findsOneWidget);
expect(findRichText('Message 59'), findsNothing);
},
);
testWidgets('groups consecutive messages from same author', (tester) async {
final messages = [
_textMsg(
@@ -925,22 +1202,29 @@ void main() {
expect(find.text('Alice'), findsOneWidget);
final createdAction = findRichText('created this channel');
expect(createdAction, findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
expect(
tester.getSize(find.byType(CircleAvatar)),
const Size.square(messageAvatarSize),
);
final nameRect = tester.getRect(find.text('Alice'));
final nameText = tester.widget<Text>(find.text('Alice'));
final nameStyle = Theme.of(
tester.element(find.text('Alice')),
).textTheme.titleSmall;
expect(nameText.style?.fontSize, nameStyle?.fontSize);
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
expect(nameText.style?.fontSize, systemMessageHeadingTextStyle.fontSize);
expect(
nameText.style?.fontWeight,
systemMessageHeadingTextStyle.fontWeight,
);
expect(
find.byKey(const ValueKey('system-message-username-alice')),
findsNothing,
);
final timestampRect = tester.getRect(
find.byKey(const ValueKey('system-message-timestamp-alice')),
);
expect(timestampRect.left, greaterThan(nameRect.right));
final createdText = tester.widget<RichText>(createdAction);
final bodyStyle = Theme.of(
tester.element(createdAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(createdText.text, 'created this channel'),
bodyStyle?.fontSize,
systemMessageBodyTextStyle.fontSize,
);
});
@@ -964,7 +1248,10 @@ void main() {
expect(find.text('Bob'), findsOneWidget);
expect(findRichText('joined the channel'), findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
expect(
tester.getSize(find.byType(CircleAvatar)),
const Size.square(messageAvatarSize),
);
});
testWidgets('renders member_joined (added by other) system event', (
@@ -992,17 +1279,23 @@ void main() {
final addedAction = findRichText('was added by Alice');
expect(addedAction, findsOneWidget);
expect(find.text('Alice added Bob to the channel'), findsNothing);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
expect(
tester.getSize(find.byType(CircleAvatar)),
const Size.square(messageAvatarSize),
);
final nameRect = tester.getRect(find.text('Bob'));
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
expect(
find.byKey(const ValueKey('system-message-username-bob')),
findsNothing,
);
final timestampRect = tester.getRect(
find.byKey(const ValueKey('system-message-timestamp-bob')),
);
expect(timestampRect.left, greaterThan(nameRect.right));
final addedText = tester.widget<RichText>(addedAction);
final bodyStyle = Theme.of(
tester.element(addedAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(addedText.text, 'was added by Alice'),
bodyStyle?.fontSize,
systemMessageBodyTextStyle.fontSize,
);
});
@@ -1113,7 +1406,10 @@ void main() {
final avatarRect = tester.getRect(find.byType(CircleAvatar));
final reactionRect = tester.getRect(find.byType(ReactionRow));
expect(reactionRect.left, avatarRect.left + 36 + Grid.xxs);
expect(
reactionRect.left,
avatarRect.left + messageAvatarSize + messageAvatarContentGap,
);
});
testWidgets('renders member_left system event', (tester) async {
@@ -1135,6 +1431,51 @@ void main() {
expect(find.text('Bob left the channel'), findsOneWidget);
});
testWidgets(
'constrains generic system timestamps at accessibility text sizes',
(tester) async {
tester.view.physicalSize = const Size(240, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_buildTestable(
messages: [
_systemMsg(
id: 'sys-accessible',
payload: {
'type': 'topic_changed',
'actor': 'alice',
'topic': 'Release planning',
},
createdAt:
DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/
1000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
textScaler: const TextScaler.linear(3),
),
);
await tester.pumpAndSettle();
final timestampFinder = find.byKey(
const ValueKey('system-message-timestamp-sys-accessible'),
);
final timestamp = tester.widget<Text>(timestampFinder);
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(
tester.getSize(timestampFinder).width,
lessThanOrEqualTo(Grid.xxl),
);
expect(tester.takeException(), isNull);
},
);
testWidgets('renders member_removed system event', (tester) async {
final messages = [
_systemMsg(
@@ -1851,6 +2192,14 @@ void main() {
expect(find.byType(DayDivider), findsNWidgets(2));
expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget);
expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget);
final threadList = tester.widget<ScrollablePositionedList>(
find.byKey(const ValueKey('thread-message-list')),
);
expect(threadList.padding!.bottom, 0);
final newestThreadGroup = tester.widget<Padding>(
find.byKey(const ValueKey('thread-message-group-reply-next-day')),
);
expect(newestThreadGroup.padding, const EdgeInsets.only(bottom: Grid.xs));
});
});
}

View File

@@ -131,6 +131,15 @@ void main() {
expect(find.text('DMs'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
for (final label in ['general', 'Alice']) {
final text = tester.widget<Text>(find.text(label));
expect(text.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(text.style?.height, contentListTitleTextStyle.height);
}
final sectionTitle = tester.widget<Text>(find.text('Channels'));
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
});
testWidgets('aligns the top, section, row, and skeleton label columns', (

View File

@@ -61,6 +61,7 @@ Widget _buildPostCard({
Map<String, UserProfile> users = const {},
VoidCallback? onTap,
void Function(String)? onDelete,
TextScaler textScaler = TextScaler.noScaling,
}) {
return ProviderScope(
overrides: [
@@ -68,12 +69,17 @@ Widget _buildPostCard({
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ForumPostCard(
post: post,
currentPubkey: currentPubkey,
onTap: onTap ?? () {},
onDelete: onDelete,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: Scaffold(
body: ForumPostCard(
post: post,
currentPubkey: currentPubkey,
onTap: onTap ?? () {},
onDelete: onDelete,
),
),
),
),
),
@@ -115,6 +121,7 @@ Widget _buildThreadPage({
bool isMember = true,
bool isArchived = false,
Map<String, UserProfile> users = const {},
TextScaler textScaler = TextScaler.noScaling,
}) {
return ProviderScope(
overrides: [
@@ -131,12 +138,17 @@ Widget _buildThreadPage({
],
child: MaterialApp(
theme: AppTheme.light(),
home: ForumThreadPage(
channelId: _channelId,
postEventId: postEventId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: ForumThreadPage(
channelId: _channelId,
postEventId: postEventId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
),
),
);
@@ -171,6 +183,63 @@ void main() {
expect(find.text('abcdef12\u2026'), findsOneWidget);
});
testWidgets(
'constrains an older timestamp at large accessible text sizes',
(tester) async {
_setSurfaceSize(tester, const Size(240, 600));
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
_buildPostCard(
post: _makePost(
createdAt:
DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000,
),
users: const {
'alice': UserProfile(
pubkey: 'alice',
displayName: 'A very long display name',
),
},
textScaler: const TextScaler.linear(2),
),
);
await tester.pumpAndSettle();
final timestamp = tester.widget<Text>(find.text('12/31/2025'));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
},
);
testWidgets('gives the author unused timestamp width', (tester) async {
_setSurfaceSize(tester, const Size(320, 600));
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120;
const displayName = 'A moderately long forum author name';
await tester.pumpWidget(
_buildPostCard(
post: _makePost(createdAt: createdAt),
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: displayName),
},
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.text(displayName)).width, greaterThan(150));
expect(find.text('2m ago'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('truncates long content', (tester) async {
final longContent = 'A' * 300;
await tester.pumpWidget(
@@ -495,6 +564,97 @@ void main() {
expect(find.text('Bob'), findsOneWidget);
});
testWidgets('constrains post and reply timestamps at large text sizes', (
tester,
) async {
final oldTimestamp =
DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000;
await tester.pumpWidget(
_buildThreadPage(
threadResponse: ForumThreadResponse(
post: _makePost(createdAt: oldTimestamp),
replies: [
ThreadReply(
eventId: 'old-reply',
pubkey: 'bob',
content: 'An older reply',
kind: 45003,
createdAt: oldTimestamp,
channelId: _channelId,
tags: const [
['h', _channelId],
],
depth: 1,
),
],
totalReplies: 1,
),
users: const {
'alice': _aliceProfile,
'bob': UserProfile(
pubkey: 'bob',
displayName: 'A very long reply author name',
),
},
textScaler: const TextScaler.linear(2),
),
);
await tester.pumpAndSettle();
final timestamps = tester.widgetList<Text>(find.text('12/31/2025'));
expect(timestamps, hasLength(2));
for (final timestamp in timestamps) {
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
}
expect(tester.takeException(), isNull);
});
testWidgets('gives thread authors unused timestamp width', (tester) async {
_setSurfaceSize(tester, const Size(320, 800));
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120;
const postAuthor = 'A moderately long original author';
const replyAuthor = 'A moderately long reply author';
await tester.pumpWidget(
_buildThreadPage(
threadResponse: ForumThreadResponse(
post: _makePost(createdAt: createdAt),
replies: [
ThreadReply(
eventId: 'reply',
pubkey: 'bob',
content: 'A reply',
kind: 45003,
createdAt: createdAt,
channelId: _channelId,
tags: const [
['h', _channelId],
],
depth: 1,
),
],
totalReplies: 1,
),
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: postAuthor),
'bob': UserProfile(pubkey: 'bob', displayName: replyAuthor),
},
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.text(postAuthor)).width, greaterThan(150));
expect(tester.getSize(find.text(replyAuthor)).width, greaterThan(140));
expect(find.text('2m ago'), findsNWidgets(2));
expect(tester.takeException(), isNull);
});
testWidgets('shows compose bar for members', (tester) async {
await tester.pumpWidget(
_buildThreadPage(

View File

@@ -24,19 +24,31 @@ void main() {
tags: const [],
);
Widget buildTestable(Widget home) {
Widget buildTestable(
Widget home, {
TextScaler textScaler = TextScaler.noScaling,
String displayName = 'Alice',
}) {
return ProviderScope(
overrides: [
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice_pk': const UserProfile(
'alice_pk': UserProfile(
pubkey: 'alice_pk',
displayName: 'Alice',
displayName: displayName,
),
}),
),
],
child: MaterialApp(theme: AppTheme.light(), home: home),
child: MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: home,
),
),
),
);
}
@@ -52,6 +64,53 @@ void main() {
expect(find.text('Reply'), findsOneWidget); // action button label
});
testWidgets('reply preview constrains its timestamp at large text sizes', (
tester,
) async {
final oldReplyNote = UserNote(
id: 'old-note',
pubkey: 'alice_pk',
createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000,
content: 'An older note',
tags: const [],
);
await tester.pumpWidget(
buildTestable(
ComposeNotePage(replyTo: oldReplyNote),
textScaler: const TextScaler.linear(2),
),
);
await tester.pump();
final timestamp = tester.widget<Text>(find.text('Sep 30'));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
testWidgets('gives the reply author unused timestamp width', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 600);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
const displayName = 'A moderately long Pulse reply author';
await tester.pumpWidget(
buildTestable(
ComposeNotePage(replyTo: replyNote),
displayName: displayName,
),
);
await tester.pump();
expect(tester.getSize(find.text(displayName)).width, greaterThan(150));
expect(find.text('2m'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('new-note mode shows no reply preview', (tester) async {
await tester.pumpWidget(buildTestable(const ComposeNotePage()));
await tester.pump();

View File

@@ -0,0 +1,130 @@
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/pulse/note_card.dart';
import 'package:buzz/features/pulse/pulse_models.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@override
Map<String, UserProfile> build() => _users;
}
void main() {
testWidgets('constrains timestamp with agent and follow metadata', (
tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(280, 600);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final note = UserNote(
id: 'note-1',
pubkey: 'alice',
createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000,
content: 'A note',
tags: const [],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice': const UserProfile(
pubkey: 'alice',
displayName: 'A very long display name',
),
}),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: Scaffold(
body: NoteCard(
note: note,
reaction: const PulseReactionState(
count: 0,
reactedByCurrentUser: false,
),
isAgent: true,
canFollow: true,
),
),
),
),
),
),
);
await tester.pumpAndSettle();
final timestamp = tester.widget<Text>(find.text('Sep 30'));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
testWidgets('gives the author unused timestamp width', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 600);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
const displayName = 'A moderately long Pulse author';
final note = UserNote(
id: 'note-2',
pubkey: 'alice',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
content: 'A note',
tags: const [],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier({
'alice': const UserProfile(
pubkey: 'alice',
displayName: displayName,
),
}),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: NoteCard(
note: note,
reaction: const PulseReactionState(
count: 0,
reactedByCurrentUser: false,
),
canFollow: true,
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.text(displayName)).width, greaterThan(145));
expect(find.text('2m'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,105 @@
import 'package:buzz/features/search/recent_searches_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
final RelayConfig _config;
_FixedRelayConfigNotifier(this._config);
@override
RelayConfig build() => _config;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<ProviderContainer> containerWithPrefs({
required String relayUrl,
required String? pubkey,
}) async {
final prefs = await SharedPreferences.getInstance();
final container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(RelayConfig(baseUrl: relayUrl)),
),
myPubkeyProvider.overrideWithValue(pubkey),
],
);
addTearDown(container.dispose);
return container;
}
test(
'normalizes, deduplicates, caps, and persists submitted queries',
() async {
SharedPreferences.setMockInitialValues({});
final first = await containerWithPrefs(
relayUrl: 'https://relay-a.example',
pubkey: 'pk-a',
);
final notifier = first.read(recentSearchesProvider.notifier);
notifier.record(' Design ');
notifier.record('design');
notifier.record('');
for (var index = 0; index < 6; index++) {
notifier.record('query-$index');
}
expect(first.read(recentSearchesProvider), [
'query-5',
'query-4',
'query-3',
'query-2',
'query-1',
'query-0',
]);
final restarted = await containerWithPrefs(
relayUrl: 'https://relay-a.example',
pubkey: 'pk-a',
);
expect(
restarted.read(recentSearchesProvider),
first.read(recentSearchesProvider),
);
},
);
test('isolates persisted history by community and account', () async {
SharedPreferences.setMockInitialValues({});
final accountA = await containerWithPrefs(
relayUrl: 'https://relay-a.example',
pubkey: 'pk-a',
);
accountA.read(recentSearchesProvider.notifier).record('private query');
final accountB = await containerWithPrefs(
relayUrl: 'https://relay-a.example',
pubkey: 'pk-b',
);
expect(accountB.read(recentSearchesProvider), isEmpty);
accountB.read(recentSearchesProvider.notifier).record('account b query');
final communityB = await containerWithPrefs(
relayUrl: 'https://relay-b.example',
pubkey: 'pk-a',
);
expect(communityB.read(recentSearchesProvider), isEmpty);
communityB
.read(recentSearchesProvider.notifier)
.record('community b query');
final accountAAgain = await containerWithPrefs(
relayUrl: 'https://relay-a.example',
pubkey: 'pk-a',
);
expect(accountAAgain.read(recentSearchesProvider), ['private query']);
});
}

View File

@@ -1,6 +1,12 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/search/recent_searches_provider.dart';
import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
@@ -24,6 +30,9 @@ void main() {
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
@@ -55,10 +64,227 @@ void main() {
tester.getSize(searchField).height,
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
);
final input = tester.widget<TextField>(
find.byKey(const Key('search-field')),
);
expect(input.style?.fontSize, searchInputTextStyle.fontSize);
expect(input.style?.height, searchInputTextStyle.height);
expect(tester.getSize(message).height, greaterThan(32));
expect(tester.takeException(), isNull);
});
testWidgets('focus slides Cancel in beside the search field', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
final unfocusedWidth = tester.getSize(searchFieldContainer).width;
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.widget<TextField>(searchField).decoration?.hintText,
'Search messages, channels, people\u2026',
);
expect(
tester.widget<TextField>(searchField).textInputAction,
TextInputAction.search,
);
await tester.tap(searchField);
await tester.pump();
final cancel = find.byKey(const Key('search-cancel'));
expect(cancel, findsOneWidget);
expect(
tester.getSize(cancel).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'Cancel must keep a 48dp touch target.',
);
expect(tester.widget<TextField>(searchField).decoration?.hintText, isNull);
final enteringSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
expect(enteringSlide.position.value.dx, greaterThan(0));
await tester.pump(const Duration(milliseconds: 160));
final focusedWidth = tester.getSize(searchFieldContainer).width;
expect(focusedWidth, lessThan(unfocusedWidth));
final settledSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
expect(settledSlide.position.value, Offset.zero);
await tester.enterText(searchField, 'design');
await tester.tap(cancel);
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
final exitingWidth = tester.getSize(searchFieldContainer).width;
expect(exitingWidth, greaterThan(focusedWidth));
expect(exitingWidth, lessThan(unfocusedWidth));
await tester.pumpAndSettle();
final input = tester.widget<TextField>(searchField);
expect(input.controller?.text, isEmpty);
expect(input.focusNode?.hasFocus, isFalse);
expect(
input.decoration?.hintText,
'Search messages, channels, people\u2026',
);
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.getSize(searchFieldContainer).width,
closeTo(unfocusedWidth, 0.01),
);
});
testWidgets('only submitted queries are added to recent searches', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.enterText(searchField, 'draft');
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.enterText(searchField, 'design systems');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pump();
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsOneWidget);
expect(find.text('design systems'), findsOneWidget);
});
testWidgets('recent searches can be rerun and cleared', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const [
'design systems',
'launch plan',
]),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsOneWidget);
expect(find.text('Recent searches'), findsOneWidget);
expect(find.text('design systems'), findsOneWidget);
expect(find.text('launch plan'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('recent-search-0'))).height,
greaterThanOrEqualTo(Grid.xl),
);
expect(
tester.getSize(find.byKey(const ValueKey('recent-search-1'))).height,
greaterThanOrEqualTo(Grid.xl),
);
await tester.tap(find.byKey(const ValueKey('recent-search-1')));
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final input = tester.widget<TextField>(searchField);
expect(input.controller?.text, 'launch plan');
expect(input.focusNode?.hasFocus, isTrue);
expect(find.text("No results for 'launch plan'"), findsOneWidget);
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-search-0')), findsOneWidget);
expect(find.text('launch plan'), findsOneWidget);
await tester.tap(find.byKey(const Key('clear-recent-searches')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
expect(find.text('Search messages, channels, and people'), findsOneWidget);
});
testWidgets('keeps recent searches scrollable above the keyboard', (
tester,
) async {
const keyboardInset = 300.0;
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const [
'design systems',
'launch plan',
]),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search-field')));
await tester.pumpAndSettle();
final recentSearches = tester.widget<ListView>(
find.byKey(const Key('recent-searches-list')),
);
final padding = recentSearches.padding! as EdgeInsets;
expect(padding.bottom, Grid.xl + keyboardInset);
});
testWidgets('keeps search results scrollable above the keyboard', (
tester,
) async {
@@ -84,6 +310,9 @@ void main() {
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
@@ -120,6 +349,9 @@ void main() {
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState(query: query)),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
@@ -144,6 +376,219 @@ void main() {
expect(tester.getBottomLeft(message).dy, lessThan(keyboardTop));
expect(tester.takeException(), isNull);
});
testWidgets('uses compact content styles and keeps message time by author', (
tester,
) async {
late _FakeRecentSearchesNotifier recentSearches;
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120;
final state = SearchState(
query: 'design',
channelResults: [
Channel(
id: 'design',
name: 'design',
channelType: 'stream',
visibility: 'open',
description: 'Design discussion',
createdBy: 'test',
createdAt: DateTime(2025),
memberCount: 4,
isMember: true,
),
],
userResults: const [
DirectoryUser(
pubkey: 'maya',
displayName: 'Maya',
nip05Handle: 'maya@example.com',
),
],
messageResults: [
SearchHit(
eventId: 'message-1',
content: 'The latest design is ready',
kind: 9,
pubkey: 'alice',
channelName: 'design',
createdAt: createdAt,
score: 1,
),
],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => recentSearches = _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
userCacheProvider.overrideWith(
() => _FakeUserCacheNotifier(
const UserProfile(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
),
),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final channelTitle = tester.widget<Text>(
find.byKey(const ValueKey('search-channel-title-design')),
);
final personTitle = tester.widget<Text>(
find.byKey(const ValueKey('search-person-title-maya')),
);
for (final title in [channelTitle, personTitle]) {
expect(title.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(title.style?.fontWeight, contentListTitleTextStyle.fontWeight);
expect(title.style?.height, contentListTitleTextStyle.height);
}
for (final label in ['channels', 'people', 'messages']) {
final sectionLabel = tester.widget<Text>(
find.byKey(ValueKey('search-section-$label')),
);
expect(
sectionLabel.data,
'${label[0].toUpperCase()}${label.substring(1)}',
);
expect(sectionLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(
sectionLabel.style?.fontWeight,
activityContextTextStyle.fontWeight,
);
expect(sectionLabel.style?.letterSpacing, 0);
}
for (final rowKey in [
'search-channel-row-design',
'search-person-row-maya',
'search-message-row-message-1',
]) {
final row = tester.widget<ListTile>(find.byKey(ValueKey(rowKey)));
expect(
row.contentPadding,
const EdgeInsets.symmetric(horizontal: Grid.gutter),
);
}
for (final alignment in [
('channels', 'search-channel-leading-design'),
('people', 'search-person-leading-maya'),
('messages', 'search-message-avatar-message-1'),
]) {
expect(
tester
.getTopLeft(find.byKey(ValueKey('search-section-${alignment.$1}')))
.dx,
tester.getTopLeft(find.byKey(ValueKey(alignment.$2))).dx,
);
}
final authorFinder = find.byKey(
const ValueKey('search-message-author-message-1'),
);
final usernameFinder = find.byKey(
const ValueKey('search-message-username-message-1'),
);
final timestampFinder = find.byKey(
const ValueKey('search-message-timestamp-message-1'),
);
final author = tester.widget<Text>(authorFinder);
final username = tester.widget<Text>(usernameFinder);
final timestamp = tester.widget<Text>(timestampFinder);
expect(author.style?.fontSize, messageUsernameTextStyle.fontSize);
expect(author.style?.fontWeight, messageUsernameTextStyle.fontWeight);
expect(author.style?.height, messageUsernameTextStyle.height);
expect(username.data, 'alice@example.com');
expect(username.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(username.style?.fontWeight, FontWeight.w400);
expect(username.style?.height, messageMetadataTextStyle.height);
expect(timestamp.style?.fontSize, messageTimestampTextStyle.fontSize);
expect(timestamp.style?.height, messageTimestampTextStyle.height);
expect(
(tester.getCenter(authorFinder).dy - tester.getCenter(timestampFinder).dy)
.abs(),
lessThan(1),
);
expect(
(tester
.getTopLeft(
find.byKey(
const ValueKey('search-message-avatar-message-1'),
),
)
.dy -
tester.getTopLeft(authorFinder).dy)
.abs(),
lessThan(6),
);
final body = tester.widget<MessageContent>(
find.byKey(const ValueKey('search-message-body-message-1')),
);
expect(body.baseStyle?.fontSize, activityPreviewTextStyle.fontSize);
expect(body.baseStyle?.height, activityPreviewTextStyle.height);
expect(
tester.widget<SmallAvatar>(find.byType(SmallAvatar)).size,
compactMessageAvatarSize,
);
final contextLabel = tester.widget<Text>(find.text('Message in'));
final channelLabel = tester.widget<Text>(
find.byKey(const ValueKey('search-message-channel-message-1')),
);
expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(contextLabel.style?.height, activityContextTextStyle.height);
expect(channelLabel.data, '#design');
expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize);
final channelChip = tester.widget<Container>(
find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.child is Text &&
(widget.child as Text).key ==
const ValueKey('search-message-channel-message-1'),
),
);
expect(
(channelChip.decoration! as BoxDecoration).borderRadius,
BorderRadius.circular(Radii.xs),
);
expect(
tester
.getTopLeft(
find.byKey(const ValueKey('search-message-context-message-1')),
)
.dy,
greaterThan(tester.getTopLeft(authorFinder).dy),
);
expect(
tester
.getTopLeft(
find.byKey(const ValueKey('search-message-body-message-1')),
)
.dy,
greaterThan(
tester
.getBottomLeft(
find.byKey(const ValueKey('search-message-context-message-1')),
)
.dy,
),
);
await tester.tap(
find.byKey(const ValueKey('search-message-row-message-1')),
);
await tester.pump();
expect(recentSearches.searches, const ['design']);
});
}
class _FakeSearchNotifier extends SearchNotifier {
@@ -153,6 +598,41 @@ class _FakeSearchNotifier extends SearchNotifier {
@override
SearchState build() => initialState;
@override
void search(String query) {
state = SearchState(query: query.trim());
}
@override
void clear() {
state = const SearchState.initial();
}
}
class _FakeRecentSearchesNotifier extends RecentSearchesNotifier {
_FakeRecentSearchesNotifier(this.initialSearches);
final List<String> initialSearches;
List<String> get searches => state;
@override
List<String> build() => initialSearches;
@override
void record(String query) {
final trimmed = query.trim();
if (trimmed.isEmpty) return;
state = [
trimmed,
...state.where((item) => item.toLowerCase() != trimmed.toLowerCase()),
];
}
@override
void clear() {
state = const [];
}
}
class _FakeProfileNotifier extends ProfileNotifier {
@@ -160,3 +640,17 @@ class _FakeProfileNotifier extends ProfileNotifier {
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'test', displayName: 'Test');
}
class _FakeChannelsNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => const [];
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
_FakeUserCacheNotifier(this.profile);
final UserProfile profile;
@override
Map<String, UserProfile> build() => {profile.pubkey: profile};
}

View File

@@ -0,0 +1,141 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
void expectStyle(
TextStyle style, {
required double fontSize,
required FontWeight fontWeight,
required double lineHeight,
required double letterSpacing,
}) {
expect(style.fontFamily, 'Inter');
expect(style.fontSize, fontSize);
expect(style.fontWeight, fontWeight);
expect(style.height, closeTo(lineHeight / fontSize, 0.0001));
expect(style.letterSpacing, letterSpacing);
}
test('message typography matches the shared mobile scale', () {
expectStyle(
messageBodyTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
expectStyle(
messageUsernameTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
messageTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expect(messageTimestampTextStyle, messageMetadataTextStyle);
expectStyle(
replyPreviewTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
reactionCountTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w500,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
channelTitleTextStyle,
fontSize: 20,
fontWeight: FontWeight.w700,
lineHeight: 24,
letterSpacing: 0,
);
expectStyle(
systemMessageHeadingTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
systemMessageBodyTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
});
test('activity typography matches the conversation row scale', () {
expectStyle(
activityUsernameTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityContextTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w500,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
activityPreviewTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 20,
letterSpacing: 0,
);
});
test('content list typography matches the compact list scale', () {
expectStyle(
contentListTitleTextStyle,
fontSize: 15,
fontWeight: FontWeight.w600,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
contentListBodyTextStyle,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expectStyle(
contentListTimestampTextStyle,
fontSize: 15,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
});
test('message and activity avatars use their surface sizes', () {
expect(messageAvatarSize, 42);
expect(activityAvatarSize, 42);
expect(compactMessageAvatarSize, 42);
expect(messageAvatarContentGap, Grid.twelve);
});
}

View File

@@ -35,6 +35,14 @@ void main() {
);
final resolved = chipTheme.color?.resolve({WidgetState.selected});
expect(resolved, accent);
final selectedLabel = tester.widget<Text>(find.text('Everyone'));
final unselectedLabel = tester.widget<Text>(find.text('Following'));
expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(selectedLabel.style?.height, filterChipTextStyle.height);
expect(selectedLabel.style?.fontWeight, FontWeight.w500);
expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize);
expect(unselectedLabel.style?.height, filterChipTextStyle.height);
expect(unselectedLabel.style?.fontWeight, FontWeight.w400);
});
testWidgets('expanded chips preserve large accessible text scaling', (

View File

@@ -0,0 +1,82 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/message_author_meta.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('reallocates unused metadata width to the display name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'A display name that needs the available width',
username: 'al',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final row = find.byType(MessageAuthorMeta);
final displayName = find.byKey(displayNameKey);
final timestamp = find.byKey(timestampKey);
expect(
tester.getSize(displayName).width,
greaterThan(tester.getSize(row).width / 2),
);
expect(
tester.getTopRight(timestamp).dx,
closeTo(tester.getTopRight(row).dx, 0.01),
);
expect(tester.takeException(), isNull);
});
testWidgets('constrains long metadata at large accessible text sizes', (
tester,
) async {
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 220,
child: MessageAuthorMeta(
displayName: 'A very long display name',
username: 'a-very-long-username',
timestamp: 'Mar 15, 2025',
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
),
);
await tester.pumpAndSettle();
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
}