mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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  ### Activity  ### Search  ## 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:
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
231
mobile/lib/features/activity/activity_page/popover_menu.dart
Normal file
231
mobile/lib/features/activity/activity_page/popover_menu.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
64
mobile/lib/features/search/recent_searches_provider.dart
Normal file
64
mobile/lib/features/search/recent_searches_provider.dart
Normal 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,
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
131
mobile/lib/shared/theme/message_typography.dart
Normal file
131
mobile/lib/shared/theme/message_typography.dart
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
118
mobile/lib/shared/widgets/message_author_meta.dart
Normal file
118
mobile/lib/shared/widgets/message_author_meta.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
130
mobile/test/features/pulse/note_card_test.dart
Normal file
130
mobile/test/features/pulse/note_card_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
105
mobile/test/features/search/recent_searches_provider_test.dart
Normal file
105
mobile/test/features/search/recent_searches_provider_test.dart
Normal 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']);
|
||||
});
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
141
mobile/test/shared/theme/message_typography_test.dart
Normal file
141
mobile/test/shared/theme/message_typography_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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', (
|
||||
|
||||
82
mobile/test/shared/widgets/message_author_meta_test.dart
Normal file
82
mobile/test/shared/widgets/message_author_meta_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user