Polish mobile message threads and composer (#5645)

## Summary

- refine mobile message metadata, search spacing, and Activity filter
semantics
- add channel-parity Latest navigation and stable tail following to
threads
- synchronize Android composer/keyboard geometry and keep Latest spacing
stable across IME transitions

## Validation

- `bin/just mobile-check`
- `bin/just mobile-test` (1,276 tests)
- Pixel 10 install/launch and channel/thread keyboard, Latest, tail, and
back-navigation review
- signed iPhone install/launch workflow

## Snapshots

See the review snapshots below.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-15 09:14:14 +01:00
committed by GitHub
co-authored by Fast Fizz Princess Donut
parent 82f7ed1532
commit 69107dc3bf
29 changed files with 2648 additions and 1073 deletions
@@ -113,7 +113,6 @@ class ActivityPage extends HookConsumerWidget {
final readState = ref.watch(readStateProvider);
final localState = ref.watch(inboxLocalStateProvider);
final drafts = ref.watch(composeDraftsProvider);
final dueReminderCount = ref.watch(dueReminderCountProvider);
final allItems = ref.watch(inboxItemsProvider);
final myPk = ref.watch(myPubkeyProvider);
@@ -380,8 +379,6 @@ class ActivityPage extends HookConsumerWidget {
actions: [
_ActivityActionsPill(
filter: filter.value,
dueReminderCount: dueReminderCount,
draftCount: drafts.length,
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
onFilterChanged: (f) => filter.value = f,
@@ -13,8 +13,6 @@ const _filterLabels = <InboxFilter, String>{
class _ActivityActionsPill extends StatelessWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
final bool unreadOnly;
final int unreadCount;
final ValueChanged<InboxFilter> onFilterChanged;
@@ -23,8 +21,6 @@ class _ActivityActionsPill extends StatelessWidget {
const _ActivityActionsPill({
required this.filter,
required this.dueReminderCount,
required this.draftCount,
required this.unreadOnly,
required this.unreadCount,
required this.onFilterChanged,
@@ -45,12 +41,7 @@ class _ActivityActionsPill extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
),
_FilterMenuButton(filter: filter, onChanged: onFilterChanged),
_InboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
@@ -68,16 +59,9 @@ class _ActivityActionsPill extends StatelessWidget {
/// inbox filter menu (`FILTER_OPTIONS`).
class _FilterMenuButton extends StatelessWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
final ValueChanged<InboxFilter> onChanged;
const _FilterMenuButton({
required this.filter,
required this.dueReminderCount,
required this.draftCount,
required this.onChanged,
});
const _FilterMenuButton({required this.filter, required this.onChanged});
@override
Widget build(BuildContext context) {
@@ -121,12 +105,6 @@ class _FilterMenuButton extends StatelessWidget {
),
),
),
if (entry.key == InboxFilter.reminders &&
dueReminderCount > 0)
_CountBadge(count: dueReminderCount)
else if (entry.key == InboxFilter.drafts &&
draftCount > 0)
_CountBadge(count: draftCount),
],
),
),
@@ -154,17 +132,6 @@ class _FilterMenuButton extends StatelessWidget {
size: 16,
color: navigationPrimaryForeground(context),
),
if (dueReminderCount > 0 || draftCount > 0) ...[
const SizedBox(width: Grid.quarter),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary,
),
),
],
],
),
),
@@ -174,33 +141,6 @@ class _FilterMenuButton extends StatelessWidget {
}
}
class _CountBadge extends StatelessWidget {
final int count;
const _CountBadge({required this.count});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + Grid.quarter,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: navigationPrimaryForeground(context),
borderRadius: BorderRadius.circular(Grid.xxs),
),
child: Text(
'$count',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w600,
),
),
);
}
}
/// Overflow menu with the unread-only toggle and mark-all-read, mirroring
/// desktop's inbox options popover.
class _InboxOptionsButton extends StatelessWidget {
@@ -244,7 +244,7 @@ class _InboxRow extends HookConsumerWidget {
nameColor: context.colors.onSurface,
metadataColor: mutedColor,
nameStyle: activityUsernameTextStyle,
metadataStyle:
timestampStyle:
activityTimestampTextStyle,
displayNameKey: ValueKey(
'activity-author-${item.id}',
@@ -0,0 +1,35 @@
import 'dart:math' show max;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Android keeps the message viewport fixed while the IME animates. Only this
/// small wrapper follows the frame-by-frame inset; timelines apply the final
/// inset once metrics settle.
class AndroidImeLift extends StatelessWidget {
/// The composer subtree that follows the animated Android IME inset.
final Widget child;
/// Creates a wrapper that lifts [child] without resizing its surrounding
/// message viewport on Android.
const AndroidImeLift({super.key, required this.child});
@override
Widget build(BuildContext context) {
if (!usesFixedAndroidImeViewport) return child;
final imeBottom = MediaQuery.viewInsetsOf(context).bottom;
final systemBottom = MediaQuery.viewPaddingOf(context).bottom;
return Padding(
// The composer already reserves [systemBottom]. Android's IME inset
// includes that navigation area, so lifting by the full value leaves a
// second safe-area gap above the keyboard.
padding: EdgeInsets.only(bottom: max(0, imeBottom - systemBottom)),
child: child,
);
}
}
/// Whether channel and thread scaffolds should keep a fixed viewport while
/// Android IME insets animate, with their composer lifted independently.
bool get usesFixedAndroidImeViewport =>
defaultTargetPlatform == TargetPlatform.android;
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:math' show min;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
@@ -27,6 +26,7 @@ import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../forum/forum_posts_view.dart';
import 'android_ime_lift.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';
import 'channel_link_navigation.dart';
@@ -44,6 +44,8 @@ import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'ime_metrics_settle_observer.dart';
import 'latest_message_button.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
@@ -298,6 +300,8 @@ class ChannelDetailPage extends HookConsumerWidget {
}, [channel.id, readState.isReady, readTimestamp]);
return FrostedScaffold(
resizeToAvoidBottomInset:
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
appBar: FrostedAppBar(
iconColor: context.colors.primary,
titleContentHeight: appBarTitleContentHeight,
@@ -492,46 +496,48 @@ class ChannelDetailPage extends HookConsumerWidget {
],
),
if (showsComposer)
Align(
alignment: Alignment.bottomCenter,
child: ComposerDockSizeReporter(
key: const ValueKey('channel-composer-dock'),
onHeightChanged: (height) {
if ((composerDockHeight.value - height).abs() < 0.5) return;
composerDockHeight.value = height;
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: typingEntries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: typingEntries),
),
ComposeBar(
channelId: channel.id,
channelName: resolvedChannel.isDm
? ''
: resolvedChannel.name,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => sendMessage.call(
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
channel: resolvedChannel,
mediaTags: mediaTags,
),
),
],
AndroidImeLift(
child: Align(
alignment: Alignment.bottomCenter,
child: ComposerDockSizeReporter(
key: const ValueKey('channel-composer-dock'),
onHeightChanged: (height) {
if ((composerDockHeight.value - height).abs() < 0.5) return;
composerDockHeight.value = height;
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: typingEntries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: typingEntries),
),
ComposeBar(
channelId: channel.id,
channelName: resolvedChannel.isDm
? ''
: resolvedChannel.name,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => sendMessage.call(
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
channel: resolvedChannel,
mediaTags: mediaTags,
),
),
],
),
),
),
),
@@ -35,16 +35,23 @@ class _MessageList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appView = View.of(context);
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final settledImeBottomInset = useState(
usesFixedAndroidImeViewport
? appView.viewInsets.bottom / appView.devicePixelRatio
: 0.0,
);
final hasUserScrolled = useState(false);
final followsLatest = useRef(
final followsLatest = useState(
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final latestNavigationRequest = useState(0);
final latestRealignmentQueued = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
@@ -58,6 +65,15 @@ class _MessageList extends HookConsumerWidget {
final hasUnreadDeepLink =
initialMessageId != null || initialThreadRootId != null;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
final settledImeLift = usesFixedAndroidImeViewport
? (settledImeBottomInset.value -
MediaQuery.viewPaddingOf(context).bottom)
.clamp(0.0, double.infinity)
.toDouble()
: settledImeBottomInset.value;
final timelineBottomInset =
composerBottomInset + (followsLatest.value ? settledImeLift : 0);
final navigationBottomInset = composerBottomInset + settledImeLift;
useEffect(
() {
@@ -158,15 +174,15 @@ class _MessageList extends HookConsumerWidget {
double latestAlignment() {
final viewportHeight = context.size?.height ?? 0;
return viewportHeight > 0
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
? (timelineBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
: 0.0;
}
Future<void> scrollToLatest() async {
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
followsLatest.value = true;
hasUserScrolled.value = false;
isAutoScrolling.value = true;
Future<void> performLatestNavigation() async {
if (!context.mounted || !itemScrollController.isAttached) {
isAutoScrolling.value = false;
return;
}
try {
await itemScrollController.scrollTo(
index: 0,
@@ -182,6 +198,24 @@ class _MessageList extends HookConsumerWidget {
}
}
void scrollToLatest() {
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
isAutoScrolling.value = true;
followsLatest.value = true;
hasUserScrolled.value = false;
latestNavigationRequest.value += 1;
}
useEffect(() {
if (latestNavigationRequest.value == 0) return null;
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled) return;
unawaited(performLatestNavigation());
});
return () => cancelled = true;
}, [latestNavigationRequest.value]);
Future<void> scrollToOldestUnread() async {
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
if (targetIndex == null ||
@@ -221,6 +255,7 @@ class _MessageList extends HookConsumerWidget {
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
isAutoScrolling.value ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
@@ -230,6 +265,7 @@ class _MessageList extends HookConsumerWidget {
latestRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
isAutoScrolling.value ||
!followsLatest.value ||
hasUserScrolled.value ||
latestIsAtBoundary()) {
@@ -237,7 +273,9 @@ class _MessageList extends HookConsumerWidget {
}
// A dock or keyboard resize is a layout correction, not a navigation
// action. Keeping it instant avoids restarting a smooth scroll for
// every position report while the viewport settles.
// every position report while the viewport settles. The rebuilt list
// padding already owns the composer/IME offset; the default alignment
// also keeps short timelines flush with that padding.
itemScrollController.jumpTo(index: 0);
});
}
@@ -284,15 +322,28 @@ class _MessageList extends HookConsumerWidget {
useEffect(() {
realignLatestAfterLayoutChange();
return null;
}, [composerBottomInset]);
}, [timelineBottomInset]);
useEffect(() {
final observer = _ChannelLatestMetricsObserver(
onMetricsChanged: realignLatestAfterLayoutChange,
final observer = ImeMetricsSettleObserver(
onMetricsSettled: () {
if (!usesFixedAndroidImeViewport) {
realignLatestAfterLayoutChange();
return;
}
final nextInset =
appView.viewInsets.bottom / appView.devicePixelRatio;
if ((settledImeBottomInset.value - nextInset).abs() >= 0.5) {
settledImeBottomInset.value = nextInset;
}
},
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController]);
return () {
WidgetsBinding.instance.removeObserver(observer);
observer.dispose();
};
}, [appView, itemScrollController]);
useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
@@ -428,7 +479,7 @@ class _MessageList extends HookConsumerWidget {
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: composerBottomInset,
bottom: timelineBottomInset,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
@@ -548,10 +599,11 @@ class _MessageList extends HookConsumerWidget {
Positioned(
left: 0,
right: 0,
bottom: composerBottomInset + Grid.xs,
bottom: navigationBottomInset + Grid.xs,
child: Center(
child: _JumpToLatestButton(
child: LatestMessageButton(
key: const ValueKey('channel-jump-to-latest'),
surfaceKey: const ValueKey('channel-jump-to-latest-surface'),
onPressed: scrollToLatest,
),
),
@@ -560,73 +612,3 @@ class _MessageList extends HookConsumerWidget {
);
}
}
class _JumpToLatestButton extends StatelessWidget {
final VoidCallback onPressed;
const _JumpToLatestButton({required this.onPressed, super.key});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(Radii.full);
return Semantics(
button: true,
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: const ValueKey('channel-jump-to-latest-surface'),
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.5),
borderRadius: borderRadius,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: context.colors.onSurface,
),
const SizedBox(width: Grid.half),
Text(
'Latest',
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
),
),
),
),
),
);
}
}
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
@@ -58,3 +58,11 @@ part 'compose_bar/send_button.dart';
part 'compose_bar/layout.dart';
part 'compose_bar/dock.dart';
part 'compose_bar/compose_bar_widget.dart';
/// Callback used by channels and threads to submit composer content.
typedef ComposeBarOnSend =
Future<void> Function(
String content,
List<String> mentionPubkeys, {
List<List<String>> mediaTags,
});
@@ -1,21 +1,15 @@
part of '../compose_bar.dart';
/// Rich compose bar with @mention autocomplete and a markdown formatting
/// toolbar. Used in both channel and thread views — the caller provides an
/// [onSend] callback that handles actual message submission.
typedef ComposeBarOnSend =
Future<void> Function(
String content,
List<String> mentionPubkeys, {
List<List<String>> mediaTags,
});
class ComposeBar extends HookConsumerWidget {
final String channelId;
final String channelName;
final String? hintText;
final ComposeBarOnSend onSend;
/// Runs immediately before the editor requests focus, allowing a parent to
/// prepare focus-dependent layout (for example, following a thread tail).
final VoidCallback? onFocusRequested;
/// Optional thread IDs for thread-scoped typing indicators.
final String? threadHeadId;
final String? rootId;
@@ -26,6 +20,7 @@ class ComposeBar extends HookConsumerWidget {
this.hintText,
this.threadHeadId,
this.rootId,
this.onFocusRequested,
required this.onSend,
});
@override
@@ -50,13 +45,22 @@ class ComposeBar extends HookConsumerWidget {
final draftIdentity =
'${ref.watch(relayConfigProvider).baseUrl}'
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
final isComposerExpanded = useState(false);
final androidImeTransitionStarted = useState(
defaultTargetPlatform != TargetPlatform.android,
);
final androidImeFallbackTimer = useRef<Timer?>(null);
final focusNode = useFocusNode();
useEffect(
() =>
() => androidImeFallbackTimer.value?.cancel(),
[androidImeFallbackTimer],
);
useEffect(
() =>
() => _dismissComposerKeyboard(focusNode),
[focusNode],
);
final isComposerExpanded = useState(false);
final isEmojiPickerOpen = useState(false);
final attachmentSurface = useState(_AttachmentSurface.closed);
final iosAttachmentPopover = useMemoized(
@@ -101,10 +105,6 @@ class ComposeBar extends HookConsumerWidget {
initialValue: 0,
upperBound: 1.05,
);
final composerExpansionValue = useAnimation(composerExpansionController);
final composerExpansionProgress = composerExpansionValue
.clamp(0.0, 1.0)
.toDouble();
void collapseComposer() {
if (!isComposerExpanded.value) return;
@@ -127,7 +127,15 @@ class ComposeBar extends HookConsumerWidget {
useEffect(() {
final observer = _ComposerKeyboardMetricsObserver(
view: appView,
onKeyboardShown: () {
androidImeFallbackTimer.value?.cancel();
androidImeTransitionStarted.value = true;
},
onKeyboardHidden: () {
androidImeFallbackTimer.value?.cancel();
if (defaultTargetPlatform == TargetPlatform.android) {
androidImeTransitionStarted.value = false;
}
collapseComposer();
focusNode.unfocus();
},
@@ -138,26 +146,36 @@ class ComposeBar extends HookConsumerWidget {
final resolvedHint =
hintText ??
(channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026');
useEffect(() {
final target = isComposerExpanded.value ? 1.0 : 0.0;
if (reducedMotion) {
composerExpansionController.value = target;
} else if ((composerExpansionController.value - target).abs() > 0.001) {
composerExpansionController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 220),
bounce: 0.08,
useEffect(
() {
final target =
isComposerExpanded.value && androidImeTransitionStarted.value
? 1.0
: 0.0;
if (reducedMotion) {
composerExpansionController.value = target;
} else if ((composerExpansionController.value - target).abs() > 0.001) {
composerExpansionController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 220),
bounce: 0.08,
),
composerExpansionController.value,
target,
0,
snapToEnd: true,
),
composerExpansionController.value,
target,
0,
snapToEnd: true,
),
);
}
return null;
}, [isComposerExpanded.value, reducedMotion]);
);
}
return null;
},
[
isComposerExpanded.value,
androidImeTransitionStarted.value,
reducedMotion,
],
);
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
@@ -816,15 +834,10 @@ class ComposeBar extends HookConsumerWidget {
attachmentSurface.value = _AttachmentSurface.camera;
}
final motionDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
attachmentSurface.value == _AttachmentSurface.camera ||
attachmentSurface.value == _AttachmentSurface.photos
? 320
: 250,
);
final motionDuration = _composerMotionDuration(
reducedMotion,
attachmentSurface.value,
);
final resizeDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 140);
@@ -839,42 +852,28 @@ class ComposeBar extends HookConsumerWidget {
return null;
}, [suggestionOverlayController]);
void expandComposer() {
if (isComposerExpanded.value) return;
attachmentSurface.value = _AttachmentSurface.closed;
isComposerExpanded.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) focusNode.requestFocus();
});
}
void expandComposer() => _expandComposer(
context: context,
isExpanded: isComposerExpanded,
attachmentSurface: attachmentSurface,
onFocusRequested: onFocusRequested,
focusNode: focusNode,
view: appView,
androidImeTransitionStarted: androidImeTransitionStarted,
androidImeFallbackTimer: androidImeFallbackTimer,
);
final suggestionPanel = channelSuggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('channel-suggestions'),
child: _ChannelSuggestions(
suggestions: channelSuggestions,
onSelect: insertChannel,
),
)
: suggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('mention-suggestions'),
child: _MentionSuggestions(
suggestions: suggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onSelect: insertMention,
),
)
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
final suggestionPanel = _composerSuggestionPanel(
channelSuggestions: channelSuggestions,
mentionSuggestions: suggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onChannelSelect: insertChannel,
onMentionSelect: insertMention,
);
Widget buildOverlayPanel(_AttachmentSurface surface) {
return _AttachmentSurfacePanel(
key: ValueKey(
surface == _AttachmentSurface.closed
? 'composer-suggestions'
: 'attachment-surface',
),
return _composerAttachmentPanel(
surface: surface,
suggestionPanel: suggestionPanel,
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
@@ -913,12 +912,10 @@ class ComposeBar extends HookConsumerWidget {
);
}
// Suggestions and attachments live in the overlay so showing them cannot
// reflow the composer. Both stay anchored just above the capsule.
final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress;
// Suggestions and attachments live in the overlay.
final hasPendingUploads = uploadingCount.value > 0;
return _ComposerDockFrame(
widthFactor: composerWidthFactor,
expansionAnimation: composerExpansionController,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -955,8 +952,7 @@ class ComposeBar extends HookConsumerWidget {
attachmentSurface: attachmentSurface.value,
onAttachmentTap: handleAttachmentTap,
onExpand: expandComposer,
expansionValue: composerExpansionValue,
expansionProgress: composerExpansionProgress,
expansionAnimation: composerExpansionController,
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
@@ -1,10 +1,13 @@
part of '../compose_bar.dart';
class _ComposerDockFrame extends StatelessWidget {
final double widthFactor;
final Animation<double> expansionAnimation;
final Widget child;
const _ComposerDockFrame({required this.widthFactor, required this.child});
const _ComposerDockFrame({
required this.expansionAnimation,
required this.child,
});
@override
Widget build(BuildContext context) {
@@ -30,10 +33,19 @@ class _ComposerDockFrame extends StatelessWidget {
),
child: Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
key: const ValueKey('composer-width-transition'),
widthFactor: widthFactor,
child: AnimatedBuilder(
animation: expansionAnimation,
child: child,
builder: (context, child) {
final progress = expansionAnimation.value
.clamp(0.0, 1.0)
.toDouble();
return FractionallySizedBox(
key: const ValueKey('composer-width-transition'),
widthFactor: 0.85 + 0.15 * progress,
child: child,
);
},
),
),
),
@@ -21,17 +21,20 @@ const _typingThrottleMs = 3000;
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
final FlutterView view;
final VoidCallback onKeyboardShown;
final VoidCallback onKeyboardHidden;
bool _wasVisible;
_ComposerKeyboardMetricsObserver({
required this.view,
required this.onKeyboardShown,
required this.onKeyboardHidden,
}) : _wasVisible = view.viewInsets.bottom > 0;
@override
void didChangeMetrics() {
final isVisible = view.viewInsets.bottom > 0;
if (!_wasVisible && isVisible) onKeyboardShown();
if (_wasVisible && !isVisible) onKeyboardHidden();
_wasVisible = isVisible;
}
@@ -59,6 +62,112 @@ void _dismissComposerKeyboard(FocusNode focusNode) {
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
}
Duration _composerMotionDuration(
bool reducedMotion,
_AttachmentSurface surface,
) => reducedMotion
? Duration.zero
: Duration(
milliseconds:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? 320
: 250,
);
void _expandComposer({
required BuildContext context,
required ValueNotifier<bool> isExpanded,
required ValueNotifier<_AttachmentSurface> attachmentSurface,
required VoidCallback? onFocusRequested,
required FocusNode focusNode,
required FlutterView view,
required ValueNotifier<bool> androidImeTransitionStarted,
required ObjectRef<Timer?> androidImeFallbackTimer,
}) {
if (isExpanded.value) return;
attachmentSurface.value = _AttachmentSurface.closed;
onFocusRequested?.call();
isExpanded.value = true;
// Attach the editor before requesting focus so native restoration cannot
// reopen a composer behind a popped route.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && isExpanded.value) focusNode.requestFocus();
});
if (defaultTargetPlatform != TargetPlatform.android) return;
androidImeFallbackTimer.value?.cancel();
if (view.viewInsets.bottom > 0) {
androidImeTransitionStarted.value = true;
return;
}
androidImeTransitionStarted.value = false;
androidImeFallbackTimer.value = Timer(const Duration(milliseconds: 250), () {
if (context.mounted && isExpanded.value) {
androidImeTransitionStarted.value = true;
}
});
}
Widget _composerSuggestionPanel({
required List<Channel> channelSuggestions,
required List<MentionCandidate> mentionSuggestions,
required Map<String, UserProfile> userCache,
required String? currentPubkey,
required bool isDmChannel,
required ValueChanged<Channel> onChannelSelect,
required ValueChanged<MentionCandidate> onMentionSelect,
}) => channelSuggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('channel-suggestions'),
child: _ChannelSuggestions(
suggestions: channelSuggestions,
onSelect: onChannelSelect,
),
)
: mentionSuggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('mention-suggestions'),
child: _MentionSuggestions(
suggestions: mentionSuggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onSelect: onMentionSelect,
),
)
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
Widget _composerAttachmentPanel({
required _AttachmentSurface surface,
required Widget suggestionPanel,
required VoidCallback onBack,
required VoidCallback onCamera,
required VoidCallback onPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
required Future<void> Function(XFile image) onCapture,
required Future<List<XFile>> Function() onPickAllPhotos,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required Future<void> Function(List<XFile> photos) onChooseAllPhotos,
}) => _AttachmentSurfacePanel(
key: ValueKey(
surface == _AttachmentSurface.closed
? 'composer-suggestions'
: 'attachment-surface',
),
surface: surface,
suggestionPanel: suggestionPanel,
onBack: onBack,
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
onFiles: onFiles,
onCapture: onCapture,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
);
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
@@ -14,8 +14,7 @@ class _ComposeBarLayout extends StatelessWidget {
final _AttachmentSurface attachmentSurface;
final ValueChanged<BuildContext> onAttachmentTap;
final VoidCallback onExpand;
final double expansionValue;
final double expansionProgress;
final Animation<double> expansionAnimation;
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
@@ -43,8 +42,7 @@ class _ComposeBarLayout extends StatelessWidget {
required this.attachmentSurface,
required this.onAttachmentTap,
required this.onExpand,
required this.expansionValue,
required this.expansionProgress,
required this.expansionAnimation,
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
@@ -69,184 +67,173 @@ class _ComposeBarLayout extends StatelessWidget {
final collapsedText = trimmedDraft.isEmpty
? resolvedHint
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
final composerRadius =
Radii.dialog + Grid.quarter * (1 - expansionProgress);
return Container(
key: const ValueKey('composer-surface'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(composerRadius),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (attachments.isNotEmpty) ...[
_AttachmentStrip(
attachments: attachments,
onRemove: onRemoveAttachment,
),
const SizedBox(height: Grid.xxs),
],
if (uploadError case final error?) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (attachments.isNotEmpty) ...[
_AttachmentStrip(
attachments: attachments,
onRemove: onRemoveAttachment,
),
const SizedBox(height: Grid.xxs),
],
if (uploadError case final error?) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
const SizedBox(height: Grid.xxs),
],
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
resizeDuration == Duration.zero
? KeyedSubtree(
key: const ValueKey('composer-text-height-motion'),
child: _buildTextField(context),
)
: AnimatedSize(
key: const ValueKey('composer-text-height-motion'),
alignment: Alignment.topCenter,
duration: resizeDuration,
curve: Curves.easeOutCubic,
child: _buildTextField(context),
)
else
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: false,
onTap: onAttachmentTap,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Semantics(
button: true,
label: resolvedHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _runComposerAction(onExpand),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
collapsedText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: trimmedDraft.isEmpty
? context.colors.onSurfaceVariant
: context.colors.onSurface,
),
),
const SizedBox(height: Grid.xxs),
],
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
resizeDuration == Duration.zero
? KeyedSubtree(
key: const ValueKey('composer-text-height-motion'),
child: _buildTextField(context),
)
: AnimatedSize(
key: const ValueKey('composer-text-height-motion'),
alignment: Alignment.topCenter,
duration: resizeDuration,
curve: Curves.easeOutCubic,
child: _buildTextField(context),
)
else
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: false,
onTap: onAttachmentTap,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Semantics(
button: true,
label: resolvedHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _runComposerAction(onExpand),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
collapsedText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: trimmedDraft.isEmpty
? context.colors.onSurfaceVariant
: context.colors.onSurface,
),
),
),
),
),
),
const SizedBox(width: Grid.xxs),
_SendButton(
isDisabled: !canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: expansionValue,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expansionProgress,
child: Transform.translate(
offset: Offset(0, Grid.xxs * (1 - expansionProgress)),
child: Column(
children: [
const SizedBox(height: Grid.xxs),
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: formattingOpen,
onTap: (triggerContext) {
if (formattingOpen) {
onCloseFormatting();
} else {
onAttachmentTap(triggerContext);
}
},
),
const SizedBox(width: Grid.xxs),
_SendButton(
isDisabled: !canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
_ExpandedComposerActionsMotion(
animation: expansionAnimation,
isExpanded: isExpanded,
child: Column(
children: [
const SizedBox(height: Grid.xxs),
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: formattingOpen,
onTap: (triggerContext) {
if (formattingOpen) {
onCloseFormatting();
} else {
onAttachmentTap(triggerContext);
}
},
),
const SizedBox(width: Grid.half),
Expanded(
child: AnimatedSwitcher(
duration: motionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [...previousChildren, ?currentChild],
),
child: formattingOpen
? _FormattingToolbar(onFormat: onFormat)
: Row(
key: const ValueKey('standard-actions'),
children: [
_ComposeAction(
icon: LucideIcons.atSign,
onTap: onMention,
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: onChannel,
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: onEmoji,
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: onOpenFormatting,
),
const Spacer(),
_SendButton(
isDisabled: !canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
const SizedBox(width: Grid.half),
Expanded(
child: AnimatedSwitcher(
duration: motionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder:
(currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
?currentChild,
],
),
child: formattingOpen
? _FormattingToolbar(onFormat: onFormat)
: Row(
key: const ValueKey('standard-actions'),
children: [
_ComposeAction(
icon: LucideIcons.atSign,
onTap: onMention,
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: onChannel,
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: onEmoji,
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: onOpenFormatting,
),
const Spacer(),
_SendButton(
isDisabled:
!canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
),
),
],
),
],
),
),
),
],
),
],
),
),
],
);
return AnimatedBuilder(
animation: expansionAnimation,
child: content,
builder: (context, child) {
final progress = expansionAnimation.value.clamp(0.0, 1.0).toDouble();
final composerRadius = Radii.dialog + Grid.quarter * (1 - progress);
return Container(
key: const ValueKey('composer-surface'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(composerRadius),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
],
),
padding: const EdgeInsets.all(Grid.xxs),
child: child,
);
},
);
}
@@ -288,6 +275,46 @@ class _ComposeBarLayout extends StatelessWidget {
}
}
class _ExpandedComposerActionsMotion extends StatelessWidget {
final Animation<double> animation;
final bool isExpanded;
final Widget child;
const _ExpandedComposerActionsMotion({
required this.animation,
required this.isExpanded,
required this.child,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final value = animation.value;
final progress = value.clamp(0.0, 1.0).toDouble();
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: value,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: progress,
child: Transform.translate(
offset: Offset(0, Grid.xxs * (1 - progress)),
child: child,
),
),
),
),
);
},
);
}
}
/// Drag the compose bar downward to put the keyboard away.
///
/// Continues the gesture the message list starts: once your finger reaches the
@@ -0,0 +1,44 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// How long Android viewport metrics must stay quiet before layout correction.
const androidImeMetricsSettleDelay = Duration(milliseconds: 120);
/// Coalesces Android's frame-by-frame IME metrics into one settled callback.
///
/// iOS continues to receive callbacks immediately. Android sends viewport
/// metrics throughout the keyboard animation; doing list realignment for each
/// delivery competes with the IME transition and can drop frames.
class ImeMetricsSettleObserver with WidgetsBindingObserver {
/// Runs after Android metrics remain quiet for [androidSettleDelay], or
/// immediately for each metrics change on other platforms.
final VoidCallback onMetricsSettled;
/// The quiet period used to coalesce Android IME animation metrics.
final Duration androidSettleDelay;
Timer? _androidTimer;
/// Creates an observer that coalesces Android IME metric updates.
ImeMetricsSettleObserver({
required this.onMetricsSettled,
this.androidSettleDelay = androidImeMetricsSettleDelay,
});
@override
void didChangeMetrics() {
if (defaultTargetPlatform != TargetPlatform.android) {
onMetricsSettled();
return;
}
_androidTimer?.cancel();
_androidTimer = Timer(androidSettleDelay, onMetricsSettled);
}
/// Cancels any pending Android metrics-settlement callback.
void dispose() {
_androidTimer?.cancel();
_androidTimer = null;
}
}
@@ -0,0 +1,77 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
/// Shared channel/thread control for returning to the newest message.
class LatestMessageButton extends StatelessWidget {
/// Returns the message list to its newest item.
final VoidCallback onPressed;
/// Optional key for inspecting or measuring the decorated glass surface.
final Key? surfaceKey;
/// Creates the shared control for returning to the newest message.
const LatestMessageButton({
required this.onPressed,
this.surfaceKey,
super.key,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(Radii.full);
return Semantics(
button: true,
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: surfaceKey,
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.5),
borderRadius: borderRadius,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: context.colors.onSurface,
),
const SizedBox(width: Grid.half),
Text(
'Latest',
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
),
),
),
),
),
);
}
}
@@ -2,21 +2,6 @@ part of 'thread_detail_page.dart';
int _threadTailIndex(int replyCount) => replyCount;
double _threadTailTrailingBoundary({
required bool hasComposerDock,
required double viewportHeight,
required double dockHeight,
}) {
if (!hasComposerDock) return 1.001;
if (!viewportHeight.isFinite ||
viewportHeight <= 0 ||
!dockHeight.isFinite ||
dockHeight <= 0) {
return double.negativeInfinity;
}
return 1 - (dockHeight / viewportHeight) + 0.001;
}
void _resumeThreadTailFollow({
required bool Function() isVisible,
required ObjectRef<bool> userOptedOut,
@@ -63,15 +48,6 @@ ThreadSummary _buildNestedSummary(
);
}
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ThreadTailMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
/// Serializes deferred tail work behind the latest user scroll intent.
class _ThreadTailIntent {
var _generation = 0;
@@ -86,6 +62,18 @@ class _ThreadTailIntent {
void endDrag() => isDragging = false;
void scheduleNextFrame({
required bool allowed,
required bool Function() revalidate,
required VoidCallback action,
}) {
if (!allowed) return;
final generation = ++_generation;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (generation == _generation && revalidate()) action();
});
}
void schedule({
required bool allowed,
required bool Function() revalidate,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
part of '../thread_detail_page.dart';
/// Tappable summary row shown below a reply that itself has replies.
/// Pushes a new [ThreadDetailPage] for the nested thread.
class _NestedThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage replyMessage;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _NestedThreadSummaryRow({
required this.summary,
required this.replyMessage,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width:
32.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${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,
),
),
],
),
),
);
}
}
@@ -0,0 +1,17 @@
part of '../thread_detail_page.dart';
double _threadTailAlignmentForViewport({
required double fullHeight,
required double imeBottomInset,
required bool usesFixedImeViewport,
required double bottomInset,
}) {
// iOS resizes the Scaffold body around the keyboard and removes that inset
// from the body's MediaQuery. List alignment is relative to that smaller
// viewport, so using fullHeight leaves the final reply behind the composer.
final viewportHeight =
(fullHeight - (usesFixedImeViewport ? 0 : imeBottomInset))
.clamp(1.0, double.infinity)
.toDouble();
return (1 - bottomInset / viewportHeight).clamp(0.0, 1.0).toDouble();
}
@@ -0,0 +1,284 @@
part of '../thread_detail_page.dart';
class _ThreadMessage extends ConsumerWidget {
final TimelineMessage message;
final Map<String, String> channelNames;
final String channelId;
final String? currentPubkey;
final bool showAuthor;
final bool isHighlighted;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
/// Whether this is the message the thread hangs off, which keeps a standing
/// "+" where replies only get one once they carry a reaction.
final bool isThreadHead;
const _ThreadMessage({
required this.message,
required this.channelNames,
required this.channelId,
required this.currentPubkey,
required this.showAuthor,
this.isHighlighted = false,
this.allMessages,
this.isMember = false,
this.isArchived = false,
this.isThreadHead = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final canManageMessage =
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[normalizedPubkey] = p!.displayName!;
}
if (knownAgentPubkeys.contains(normalizedPubkey)) {
agentMentionPubkeys.add(normalizedPubkey);
}
}
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: message.mentionPubkeys,
profileMentionNames: mentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
void openMessageActions(Rect anchorRect) {
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
);
}
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: 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: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
// The media carousel intentionally continues through the list's
// trailing gutter. InkWell still clips its ink to [borderRadius],
// while leaving overflowing message content visible.
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
),
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}',
),
),
),
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: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: 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),
),
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton:
isMember &&
!isArchived &&
(isThreadHead || message.reactions.isNotEmpty),
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _Avatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
const _Avatar({required this.profile, required this.pubkey});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return AvatarImage(
imageUrl: avatarUrl,
radius: messageAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
+9 -9
View File
@@ -39,9 +39,7 @@ const _searchTitleReturnDuration = Duration(milliseconds: 80);
const _searchCancelEnterDuration = Duration(milliseconds: 80);
const _searchCancelExitDuration = Duration(milliseconds: 60);
const _searchIdleFieldTopInset = Grid.half;
const _searchActiveFieldTopOffset = 42.0;
const _searchBottomOverlap =
_searchActiveFieldTopOffset + _searchIdleFieldTopInset;
const _searchControlsToFiltersGap = Grid.xxs;
const _searchFilterChipVerticalPadding = Grid.xxs;
const _searchFilterBarVerticalPadding = Grid.xxs;
const _searchHeaderFiltersMinHeight = Grid.xl;
@@ -130,15 +128,17 @@ class SearchPage extends HookConsumerWidget {
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
final searchBottomOverlap =
_searchIdleFieldTopInset +
compactSearchFieldHeight +
_searchControlsToFiltersGap;
// Cancel remains an accessible target without giving the text action a
// visual button treatment.
final searchControlHeight = compactSearchFieldHeight > Grid.xl
? compactSearchFieldHeight
: Grid.xl;
final searchHeaderBottomHeight = isSearchEditing.value
? _searchIdleFieldTopInset +
compactSearchFieldHeight +
searchHeaderFiltersHeight
? searchHeaderFiltersHeight + _searchControlsToFiltersGap
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
final topSectionHeight = frostedAppBarHeight(
context,
@@ -295,7 +295,7 @@ class SearchPage extends HookConsumerWidget {
),
],
bottomHeight: searchHeaderBottomHeight,
bottomOverlap: _searchBottomOverlap,
bottomOverlap: searchBottomOverlap,
bottom: Stack(
clipBehavior: Clip.none,
children: [
@@ -308,7 +308,7 @@ class SearchPage extends HookConsumerWidget {
: Grid.gutter,
top: isSearchEditing.value
? _searchIdleFieldTopInset
: _searchBottomOverlap + _searchIdleFieldTopInset,
: searchBottomOverlap + _searchIdleFieldTopInset,
height: isSearchEditing.value
? compactSearchFieldHeight
: idleSearchFieldHeight,
@@ -360,7 +360,7 @@ class SearchPage extends HookConsumerWidget {
? Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: _searchBottomOverlap),
padding: EdgeInsets.only(top: searchBottomOverlap),
child: SizedBox(
key: const ValueKey('search-header-filters'),
height: searchHeaderFiltersHeight,
@@ -43,8 +43,14 @@ const messageMetadataTextStyle = TextStyle(
letterSpacing: 0,
);
/// Message timestamps share the secondary author metadata style.
const messageTimestampTextStyle = messageMetadataTextStyle;
/// Message timestamps: 13.1sp regular, one step below 15sp author names.
const messageTimestampTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 13.1,
fontWeight: FontWeight.w400,
height: 17 / 13.1,
letterSpacing: 0,
);
/// Compact reply previews: 13.1sp regular on a 17sp line height.
const replyPreviewTextStyle = TextStyle(
@@ -121,8 +127,8 @@ const systemMessageBodyTextStyle = TextStyle(
/// Activity sender names share the primary author style.
const activityUsernameTextStyle = messageUsernameTextStyle;
/// Activity timestamps share the secondary author metadata style.
const activityTimestampTextStyle = messageMetadataTextStyle;
/// Activity timestamps share the compact message timestamp treatment.
const activityTimestampTextStyle = messageTimestampTextStyle;
/// Activity context labels: 13.1sp medium on a 17sp line height.
const activityContextTextStyle = TextStyle(
@@ -16,7 +16,7 @@ class MessageAuthorMeta extends StatelessWidget {
/// Color applied to [displayName].
final Color nameColor;
/// Color applied to the username, separator, and [timestamp].
/// Color applied to the username and [timestamp].
final Color metadataColor;
/// Optional callback invoked when [displayName] is tapped.
@@ -37,6 +37,9 @@ class MessageAuthorMeta extends StatelessWidget {
/// Base text style for secondary metadata, with [metadataColor] applied.
final TextStyle metadataStyle;
/// Base text style for [timestamp], with [metadataColor] applied.
final TextStyle timestampStyle;
/// Creates an inline author row with optional username and tap handling.
const MessageAuthorMeta({
super.key,
@@ -51,6 +54,7 @@ class MessageAuthorMeta extends StatelessWidget {
this.timestampKey,
this.nameStyle = messageUsernameTextStyle,
this.metadataStyle = messageMetadataTextStyle,
this.timestampStyle = messageTimestampTextStyle,
});
@override
@@ -62,6 +66,9 @@ class MessageAuthorMeta extends StatelessWidget {
normalizedUsername != displayName.trim();
final resolvedNameStyle = nameStyle.copyWith(color: nameColor);
final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor);
final resolvedTimestampStyle = timestampStyle.copyWith(
color: metadataColor,
);
Widget authorName = Text(
displayName,
@@ -97,9 +104,7 @@ class MessageAuthorMeta extends StatelessWidget {
),
),
],
const SizedBox(width: Grid.half),
Text('·', style: resolvedMetadataStyle),
const SizedBox(width: Grid.half),
const SizedBox(width: Grid.xxs),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
child: Text(
@@ -107,7 +112,7 @@ class MessageAuthorMeta extends StatelessWidget {
key: timestampKey,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: resolvedMetadataStyle,
style: resolvedTimestampStyle,
),
),
],
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:buzz/features/activity/activity_page.dart';
import 'package:buzz/features/activity/activity_provider.dart';
import 'package:buzz/features/activity/compose_drafts_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';
@@ -117,6 +118,8 @@ void main() {
TextScaler? textScaler,
EdgeInsets mediaPadding = EdgeInsets.zero,
ValueListenable<int>? tabReselection,
List<ComposeDraft> drafts = const [],
List<Reminder> reminders = const [],
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
@@ -135,7 +138,10 @@ void main() {
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(readContexts),
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
composeDraftsProvider.overrideWith(
() => _FakeComposeDraftsNotifier(drafts),
),
remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)),
],
child: MaterialApp(
theme: AppTheme.light(),
@@ -386,6 +392,65 @@ void main() {
);
});
testWidgets('filter stays indicator-free when drafts and reminders exist', (
tester,
) async {
final dueReminder = Reminder(
id: 'reminder-1',
notBefore: now - 1,
status: 'pending',
target: const ReminderTarget(
eventId: 'm1',
channelId: 'ch1',
preview: 'Follow up',
authorPubkey: 'alice_pk',
),
note: null,
createdAt: now - 60,
eventId: 'reminder-event-1',
);
final draft = ComposeDraft(
key: 'ch1',
channelId: 'ch1',
threadHeadId: null,
text: 'Unsent review note',
updatedAt: now,
);
await tester.pumpWidget(
await buildTestable(drafts: [draft], reminders: [dueReminder]),
);
await tester.pumpAndSettle();
final filterTrigger = find.byKey(const ValueKey('activity-filter-menu'));
expect(
find.descendant(
of: filterTrigger,
matching: find.byWidgetPredicate((widget) {
if (widget is! Container) return false;
final constraints = widget.constraints;
return constraints?.minWidth == 6 && constraints?.minHeight == 6;
}),
),
findsNothing,
);
await tester.tap(filterTrigger);
await tester.pumpAndSettle();
final filterPopover = find.byKey(const ValueKey('activity-filter-popover'));
expect(
find.descendant(of: filterPopover, matching: find.text('1')),
findsNothing,
);
await tester.tap(
find.descendant(of: filterPopover, matching: find.text('Drafts')),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('draft-row-ch1')), findsOneWidget);
expect(find.text('Unsent review note'), findsOneWidget);
});
testWidgets('rows lead with sender, contextual label, and preview', (
tester,
) async {
@@ -434,8 +499,12 @@ void main() {
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?.fontSize, activityTimestampTextStyle.fontSize);
expect(timestampText.style?.fontWeight, FontWeight.w400);
expect(
timestampText.style?.fontSize,
lessThan(usernameText.style!.fontSize!),
);
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
expect(avatars, isNotEmpty);
@@ -909,3 +978,11 @@ class _FakeRemindersNotifier extends RemindersNotifier {
@override
Future<List<Reminder>> build() async => _reminders;
}
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
final List<ComposeDraft> _drafts;
_FakeComposeDraftsNotifier(this._drafts);
@override
List<ComposeDraft> build() => _drafts;
}
@@ -0,0 +1,93 @@
import 'package:buzz/features/channels/android_ime_lift.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('lifts only the composer on Android', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(_testLayout());
expect(
tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy,
280,
);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('leaves the iOS composer on the scaffold resize path', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(_testLayout());
expect(
tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy,
400,
);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('does not double-count Android navigation safe area', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
_testLayout(viewPadding: 24, reservesViewPadding: true),
);
final keyboardTop = 400 - 120;
final composerBottom = tester
.getBottomLeft(find.byKey(const ValueKey('composer')))
.dy;
expect(keyboardTop - composerBottom, Grid.xxs);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
}
Widget _testLayout({double viewPadding = 0, bool reservesViewPadding = false}) {
return MediaQuery(
data: MediaQueryData(
viewInsets: const EdgeInsets.only(bottom: 120),
viewPadding: EdgeInsets.only(bottom: viewPadding),
),
child: Directionality(
textDirection: TextDirection.ltr,
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
height: 400,
child: AndroidImeLift(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: reservesViewPadding ? viewPadding + Grid.xxs : 0,
),
child: const SizedBox(
key: ValueKey('composer'),
width: 100,
height: 40,
),
),
),
),
),
),
),
);
}
@@ -20,6 +20,7 @@ import 'package:buzz/features/channels/composer_dock_size_reporter.dart';
import 'package:buzz/features/channels/date_formatters.dart';
import 'package:buzz/features/channels/day_divider.dart';
import 'package:buzz/features/channels/emoji_picker.dart';
import 'package:buzz/features/channels/ime_metrics_settle_observer.dart';
import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/thread_detail_page.dart';
import 'package:buzz/features/channels/thread_replies_provider.dart';
@@ -36,6 +37,7 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
@@ -762,6 +764,10 @@ void main() {
expect(find.text('Forum threads are not on mobile yet'), findsNothing);
// The compose bar for stream messages should not appear.
expect(find.text('Message…'), findsNothing);
final scaffold = tester.widget<FrostedScaffold>(
find.byType(FrostedScaffold).first,
);
expect(scaffold.resizeToAvoidBottomInset, isTrue);
});
testWidgets('renders video attachments from imeta tags in the timeline', (
@@ -1145,8 +1151,22 @@ void main() {
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?.fontSize,
messageTimestampTextStyle.fontSize,
);
expect(aliceTimestamp.style?.fontWeight, FontWeight.w400);
expect(
aliceTimestamp.style?.fontSize,
lessThan(aliceText.style!.fontSize!),
);
expect(
find.descendant(
of: find.byKey(const ValueKey('message-row-msg1')),
matching: find.text('·'),
),
findsNothing,
);
final helloContent = findRichText('Hello world!');
final helloText = tester.widget<RichText>(helloContent);
expect(
@@ -2285,6 +2305,61 @@ void main() {
},
);
testWidgets(
'seeds an already-visible Android keyboard into the channel tail layout',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
final messages = [
for (var i = 0; i < 20; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: i == 19
? List.filled(8, 'Tall latest message').join('\n')
: 'Message $i',
createdAt: 1000 + i,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final latestMessage = find.byKey(
const ValueKey('channel-message-group-msg19'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
expect(latestMessage, findsOneWidget);
expect(
tester.getBottomLeft(latestMessage).dy,
closeTo(tester.getTopLeft(composerDock).dy, 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
},
);
testWidgets('keeps a short followed tail flush through composer resize', (
tester,
) async {
@@ -2491,6 +2566,182 @@ void main() {
);
});
testWidgets(
'Latest reveals the channel tail while the Android keyboard stays open',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.reset);
final messages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Message #general'));
await tester.pump();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.pump(androidImeMetricsSettleDelay);
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode?.hasFocus, isTrue);
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);
tester
.widget<ScrollablePositionedList>(messageList)
.itemScrollController!
.jumpTo(index: 39);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('channel-jump-to-latest')),
);
await tester.pumpAndSettle();
final latestMessage = find.byKey(
const ValueKey('channel-message-group-msg39'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
expect(latestMessage, findsOneWidget);
expect(textField.focusNode?.hasFocus, isTrue);
expect(
tester.getBottomLeft(latestMessage).dy,
closeTo(tester.getTopLeft(composerDock).dy, 1),
);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
},
);
testWidgets(
'keeps the Latest gap stable above the Android composer and keyboard',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.reset);
final initialMessages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
await tester.pumpWidget(
_buildTestable(
messages: initialMessages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
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);
tester
.widget<ScrollablePositionedList>(messageList)
.itemScrollController!
.jumpTo(index: 39);
await tester.pumpAndSettle();
final latestSurface = find.byKey(
const ValueKey('channel-jump-to-latest-surface'),
);
final composerDock = find.byKey(
const ValueKey('channel-composer-dock'),
);
double latestGap() =>
tester.getTopLeft(composerDock).dy -
tester.getBottomLeft(latestSurface).dy;
final collapsedGap = latestGap();
expect(collapsedGap, closeTo(Grid.xs, 0.5));
await tester.tap(find.text('Message #general'));
await tester.pump();
await tester.pump();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.pump(androidImeMetricsSettleDelay);
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(latestGap(), closeTo(collapsedGap, 0.5));
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
},
);
testWidgets(
'keeps follow mode off while a tall newest message stays visible',
(tester) async {
@@ -4292,6 +4543,20 @@ void main() {
.dy;
expect(headY, lessThan(oldestReplyY));
expect(oldestReplyY, lessThan(newestReplyY));
final threadTimestamp = tester.widget<Text>(
find.byKey(const ValueKey('thread-message-timestamp-thread-root')),
);
expect(
threadTimestamp.style?.fontSize,
messageTimestampTextStyle.fontSize,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('thread-message-row-thread-root')),
matching: find.text('·'),
),
findsNothing,
);
});
testWidgets('thread keeps its tail above a growing composer dock', (
@@ -4376,12 +4641,90 @@ void main() {
// that metrics change too.
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
await tester.pumpAndSettle();
await tester.pump();
await tester.pump(androidImeMetricsSettleDelay);
await tester.pump();
expect(
tester.getBottomLeft(latestReply).dy,
lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy),
);
expect(
tester
.state<ScrollableState>(
find
.descendant(
of: find.byKey(const ValueKey('thread-message-list')),
matching: find.byType(Scrollable),
)
.first,
)
.position
.isScrollingNotifier
.value,
isFalse,
reason: 'Keyboard layout correction must not start a scroll animation.',
);
});
testWidgets('short thread keeps its head stable when the keyboard opens', (
tester,
) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'A short thread',
createdAt: 1000,
);
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: const {'thread-root': []},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
final head = find.byKey(
const ValueKey('thread-message-group-thread-root'),
);
final initialHeadY = tester.getTopLeft(head).dy;
expect(initialHeadY, lessThan(300));
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pumpAndSettle();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.pump(androidImeMetricsSettleDelay);
await tester.pump();
expect(
tester.getTopLeft(head).dy,
closeTo(initialHeadY, 1),
reason: 'A fully visible short thread should remain head-anchored.',
);
});
for (final replyCount in [0, 1]) {
@@ -4898,7 +5241,11 @@ void main() {
final list = find.byKey(const ValueKey('thread-message-list'));
final listHeight = tester.getSize(list).height;
final mediaQueryHeight = MediaQuery.sizeOf(tester.element(list)).height;
expect(listHeight, lessThan(mediaQueryHeight));
expect(
listHeight,
closeTo(mediaQueryHeight, 0.5),
reason: 'Android keeps the thread viewport fixed behind the IME.',
);
completer.complete(replies);
await tester.pumpAndSettle();
@@ -5114,6 +5461,10 @@ void main() {
find.byKey(const ValueKey('thread-message-group-thread-root')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
completer.complete(replies);
await tester.pump();
@@ -5988,8 +6339,9 @@ void main() {
await tester.pumpAndSettle();
}
expect(
find.byKey(const ValueKey('thread-message-group-reply-29')),
findsNothing,
find.byKey(const ValueKey('thread-jump-to-latest')),
findsOneWidget,
reason: 'Browsing away from the tail should offer Latest.',
);
final visibleBeforeResize = tester
.widgetList<Widget>(
@@ -6051,7 +6403,7 @@ void main() {
);
testWidgets(
'deep-linking an older reply does not resume tail following on keyboard resize',
'deep-link stays put through passive resize until composer focus follows tail',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
@@ -6115,13 +6467,427 @@ void main() {
const ValueKey('thread-message-group-reply-5'),
);
expect(target, findsOneWidget);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsOneWidget,
);
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pumpAndSettle();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
expect(target, findsOneWidget);
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-message-group-reply-29')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
},
);
testWidgets('iOS composer focus and Latest fully reveal the final reply', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(bottom: 20);
addTearDown(tester.view.reset);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 30; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'bob',
content: 'Reply $i',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pumpAndSettle();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
final latestReply = find.byKey(
const ValueKey('thread-message-group-reply-29'),
);
final composerSurface = find.byKey(const ValueKey('composer-surface'));
final focusedReplyBottom = tester.getBottomLeft(latestReply).dy;
final composerTop = tester.getTopLeft(composerSurface).dy;
final list = find.byKey(const ValueKey('thread-message-list'));
final listElement = tester.element(list);
ScrollStartNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 100,
pixels: 0,
viewportDimension: 100,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: listElement,
dragDetails: DragStartDetails(),
).dispatch(listElement);
tester
.widget<ScrollablePositionedList>(list)
.itemScrollController!
.jumpTo(index: 5);
await tester.pumpAndSettle();
final latestButton = find.byKey(const ValueKey('thread-jump-to-latest'));
final latestButtonWasVisible = latestButton.evaluate().length == 1;
await tester.tap(latestButton);
await tester.pumpAndSettle();
final latestReplyBottom = tester.getBottomLeft(latestReply).dy;
debugDefaultTargetPlatformOverride = previousPlatform;
expect(
focusedReplyBottom,
lessThanOrEqualTo(composerTop),
reason: 'Focusing the composer must retain the final reply above it.',
);
expect(latestButtonWasVisible, isTrue);
expect(
latestReplyBottom,
lessThanOrEqualTo(composerTop),
reason: 'Latest must reveal the final reply above the iOS composer.',
);
});
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
testWidgets(
'thread composer focus returns to the tail on ${platform.name}',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = platform;
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(bottom: 20);
addTearDown(tester.view.reset);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 30; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'bob',
content: 'Reply $i',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
initialMessageId: 'reply-5',
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsOneWidget,
);
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pump();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
if (platform == TargetPlatform.android) {
await tester.pump(androidImeMetricsSettleDelay);
}
await tester.pumpAndSettle();
final latestReply = find.byKey(
const ValueKey('thread-message-group-reply-29'),
);
final composerSurface = find.byKey(
const ValueKey('composer-surface'),
);
final focusNode = tester
.widget<TextField>(find.byType(TextField))
.focusNode!;
final latestReplyBottom = tester.getBottomLeft(latestReply).dy;
final composerTop = tester.getTopLeft(composerSurface).dy;
final latestButtonIsVisible = find
.byKey(const ValueKey('thread-jump-to-latest'))
.evaluate()
.isNotEmpty;
debugDefaultTargetPlatformOverride = previousPlatform;
expect(focusNode.hasFocus, isTrue);
expect(latestReplyBottom, lessThanOrEqualTo(composerTop));
expect(latestButtonIsVisible, isFalse);
},
);
}
testWidgets(
'thread shows Latest after browsing history and returns to tail',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 30; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'bob',
content: 'Reply $i',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
final list = find.byKey(const ValueKey('thread-message-list'));
expect(
find.byKey(const ValueKey('thread-message-group-reply-29')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
await tester.drag(list, const Offset(0, 500));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsOneWidget,
);
final threadScrollable = tester.state<ScrollableState>(
find.descendant(of: list, matching: find.byType(Scrollable)).first,
);
await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest')));
await tester.pump(const Duration(milliseconds: 50));
expect(
threadScrollable.position.isScrollingNotifier.value,
isTrue,
reason: 'An explicit Latest tap should retain its navigation motion.',
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-message-group-reply-29')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
final settledTailY = tester
.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor')))
.dy;
await tester.pump(const Duration(milliseconds: 250));
expect(
tester
.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor')))
.dy,
closeTo(settledTailY, 0.5),
reason: 'Latest must not be followed by a corrective rebound.',
);
},
);
testWidgets(
'a newly sent thread reply follows the tail without animation',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 20; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'bob',
content: 'Reply $i',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
final messagesNotifier = _FakeMessagesNotifier([rootEvent]);
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
messagesNotifier: messagesNotifier,
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
'self': UserProfile(pubkey: 'self', displayName: 'Me'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
final list = find.byKey(const ValueKey('thread-message-list'));
final threadScrollable = tester.state<ScrollableState>(
find.descendant(of: list, matching: find.byType(Scrollable)).first,
);
final localReply = _textMsg(
id: 'reply-local',
pubkey: 'self',
content: 'My new reply',
createdAt: 2000,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
);
messagesNotifier.setMessages([rootEvent, localReply]);
await tester.pump();
await tester.pump();
expect(
find.byKey(const ValueKey('thread-message-group-reply-local')),
findsOneWidget,
);
expect(
threadScrollable.position.isScrollingNotifier.value,
isFalse,
reason: 'Reply-driven tail correction must be instant.',
);
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
},
);
@@ -181,6 +181,7 @@ Widget _buildComposeBar({
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
RelayConfigNotifier Function()? relayConfig,
PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(),
VoidCallback? onFocusRequested,
}) {
return ProviderScope(
overrides: [
@@ -227,7 +228,11 @@ Widget _buildComposeBar({
body: SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: ComposeBar(channelId: 'channel-1', onSend: onSend),
child: ComposeBar(
channelId: 'channel-1',
onFocusRequested: onFocusRequested,
onSend: onSend,
),
),
),
),
@@ -526,6 +531,110 @@ void main() {
expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget);
});
testWidgets('notifies focus intent before attaching the focused field', (
tester,
) async {
var focusRequested = false;
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onFocusRequested: () => focusRequested = true,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await tester.tap(find.text('Message\u2026'));
expect(focusRequested, isTrue);
await tester.pump();
await tester.pump();
expect(find.byType(TextField), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus,
isTrue,
);
});
testWidgets('starts Android composer motion with the first IME metrics', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
final widthFinder = find.byKey(
const ValueKey('composer-width-transition'),
);
final compactWidth = tester.getSize(widthFinder).width;
await tester.tap(find.text('Message\u2026'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
expect(tester.getSize(widthFinder).width, closeTo(compactWidth, 0.1));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
expect(tester.getSize(widthFinder).width, greaterThan(compactWidth));
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('expands Android composer when the IME is already visible', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(() {
tester.view.reset();
debugDefaultTargetPlatformOverride = previousPlatform;
});
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
final widthFinder = find.byKey(
const ValueKey('composer-width-transition'),
);
final compactWidth = tester.getSize(widthFinder).width;
await tester.tap(find.text('Message\u2026'));
await tester.pumpAndSettle();
expect(tester.getSize(widthFinder).width, greaterThan(compactWidth));
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('returns to the compact capsule when the keyboard drops', (
tester,
) async {
@@ -705,6 +814,9 @@ void main() {
await tester.tap(find.text('Message\u2026'));
await tester.pump();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.reset);
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.tap(find.byTooltip('Add attachment').hitTestable());
await tester.pumpAndSettle();
@@ -0,0 +1,49 @@
import 'package:buzz/features/channels/ime_metrics_settle_observer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('coalesces Android IME metrics until the viewport settles', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
var callbacks = 0;
final observer = ImeMetricsSettleObserver(
onMetricsSettled: () => callbacks += 1,
);
addTearDown(observer.dispose);
observer.didChangeMetrics();
await tester.pump(const Duration(milliseconds: 60));
observer.didChangeMetrics();
await tester.pump(const Duration(milliseconds: 119));
expect(callbacks, 0);
await tester.pump(const Duration(milliseconds: 1));
expect(callbacks, 1);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('keeps non-Android metrics callbacks immediate', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
var callbacks = 0;
final observer = ImeMetricsSettleObserver(
onMetricsSettled: () => callbacks += 1,
);
addTearDown(observer.dispose);
observer.didChangeMetrics();
observer.didChangeMetrics();
expect(callbacks, 2);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
}
@@ -316,6 +316,14 @@ void main() {
reason: 'The active field translates upward into the title row.',
);
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
final filtersRect = tester.getRect(
find.byKey(const Key('search-header-filters')),
);
expect(
filtersRect.top - focusedRect.bottom,
Grid.xxs,
reason: 'Filters keep one compact spacing token below the controls.',
);
final settledSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
@@ -330,6 +338,12 @@ void main() {
expect(iconScale.scale, lessThan(1));
expect(movingField.top, Grid.half);
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
expect(
appBarRect.bottom - filtersRect.bottom,
closeTo(Grid.xxs + 1, 0.01),
reason:
'The filter row keeps the same spacing below it, plus the divider.',
);
expect(
appBarRect.contains(focusedRect.center),
isTrue,
@@ -34,12 +34,15 @@ void main() {
);
expectStyle(
messageTimestampTextStyle,
fontSize: 15,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
);
expect(messageTimestampTextStyle, messageMetadataTextStyle);
expect(
messageTimestampTextStyle.fontSize,
lessThan(messageUsernameTextStyle.fontSize!),
);
expectStyle(
replyPreviewTextStyle,
fontSize: 13.1,
@@ -87,7 +90,7 @@ void main() {
);
expectStyle(
activityTimestampTextStyle,
fontSize: 15,
fontSize: 13.1,
fontWeight: FontWeight.w400,
lineHeight: 17,
letterSpacing: 0,
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('keeps the separator and timestamp next to a short name', (
testWidgets('keeps the timestamp next to a short name without a separator', (
tester,
) async {
const displayNameKey = Key('author-display-name');
@@ -31,11 +31,14 @@ void main() {
await tester.pumpAndSettle();
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
final separatorRect = tester.getRect(find.text('·'));
final timestampRect = tester.getRect(find.byKey(timestampKey));
expect(separatorRect.left - displayNameRect.right, Grid.half);
expect(timestampRect.left - separatorRect.right, Grid.half);
expect(find.text('·'), findsNothing);
expect(timestampRect.left - displayNameRect.right, Grid.xxs);
expect(
tester.widget<Text>(find.byKey(timestampKey)).style?.fontSize,
messageTimestampTextStyle.fontSize,
);
expect(tester.takeException(), isNull);
});
@@ -84,6 +87,7 @@ void main() {
testWidgets('constrains long metadata at large accessible text sizes', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
@@ -98,6 +102,7 @@ void main() {
displayName: 'A very long display name',
username: 'a-very-long-username',
timestamp: 'Mar 15, 2025',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
@@ -112,6 +117,20 @@ void main() {
final timestamp = tester.widget<Text>(find.byKey(timestampKey));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
final nameRichText = tester.widget<RichText>(
find.descendant(
of: find.byKey(displayNameKey),
matching: find.byType(RichText),
),
);
final timestampRichText = tester.widget<RichText>(
find.descendant(
of: find.byKey(timestampKey),
matching: find.byType(RichText),
),
);
expect(nameRichText.textScaler.scale(15), 30);
expect(timestampRichText.textScaler.scale(13.1), 26.2);
expect(tester.takeException(), isNull);
});
}