{
+ window.localStorage.setItem(key, value);
+ },
+ { key: THEME_STORAGE_KEY, value: theme },
+ );
+ await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ await page.addInitScript(
+ ({ list, active }) => {
+ window.localStorage.setItem("buzz-communities", JSON.stringify(list));
+ window.localStorage.setItem("buzz-active-community-id", active);
+ },
+ { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
+ );
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(page.getByTestId("community-rail")).toBeVisible();
+ await expect(page.getByTestId("app-sidebar")).toBeVisible();
+}
+
+/**
+ * Regression: the app-sidebar layer is overflow-visible (huddle drawer), so
+ * the offcanvas-collapsed sidebar slides out of its container but kept
+ * painting over the community rail — opaquely on flat themes, as ghost
+ * fragments on the transparent Buzz chrome. The collapsed sidebar must be
+ * invisible and non-interactive, leaving the rail clean in every theme.
+ */
+for (const theme of ["buzz", "buzz-dark", "vesper"]) {
+ test(`collapsed sidebar leaves the community rail clean — ${theme}`, async ({
+ page,
+ }) => {
+ await setup(page, theme);
+ await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` });
+
+ await page.locator('[data-sidebar="trigger"]').first().click();
+ const shell = page.locator(
+ '[data-state="collapsed"][data-collapsible="offcanvas"]',
+ );
+ await expect(shell).toHaveCount(1);
+ // Let the 200ms slide finish; visibility flips at the transition's end.
+ await page.waitForTimeout(500);
+
+ // Second direct child = the sliding sidebar container (first is the gap).
+ const offscreenSidebar = shell.locator("> div").nth(1);
+ await expect(offscreenSidebar).toHaveCSS("visibility", "hidden");
+ await expect(offscreenSidebar).toHaveCSS("pointer-events", "none");
+
+ // The community rail stays visible and interactive beneath it.
+ await expect(page.getByTestId("community-rail")).toBeVisible();
+ await expect(
+ page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
+ ).toBeVisible();
+ await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` });
+ });
+}
diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart
index 99d0bbc03..dcae998ed 100644
--- a/mobile/lib/features/activity/activity_page.dart
+++ b/mobile/lib/features/activity/activity_page.dart
@@ -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,
diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart
index 08384b69e..0e40dd6c8 100644
--- a/mobile/lib/features/activity/activity_page/header_actions.dart
+++ b/mobile/lib/features/activity/activity_page/header_actions.dart
@@ -13,8 +13,6 @@ const _filterLabels =
{
class _ActivityActionsPill extends StatelessWidget {
final InboxFilter filter;
- final int dueReminderCount;
- final int draftCount;
final bool unreadOnly;
final int unreadCount;
final ValueChanged 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 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 {
diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart
index 6d7f8f298..86edeea0e 100644
--- a/mobile/lib/features/activity/activity_page/inbox_row.dart
+++ b/mobile/lib/features/activity/activity_page/inbox_row.dart
@@ -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}',
diff --git a/mobile/lib/features/channels/android_ime_lift.dart b/mobile/lib/features/channels/android_ime_lift.dart
new file mode 100644
index 000000000..30904055b
--- /dev/null
+++ b/mobile/lib/features/channels/android_ime_lift.dart
@@ -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;
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 7cf3532ac..8cef1a78f 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -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 >[],
- }) => 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 >[],
+ }) => sendMessage.call(
+ channelId: channel.id,
+ content: content,
+ mentionPubkeys: mentionPubkeys,
+ channel: resolvedChannel,
+ mediaTags: mediaTags,
+ ),
+ ),
+ ],
+ ),
),
),
),
diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart
index b05126f31..57a621fba 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -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(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 scrollToLatest() async {
- if (!itemScrollController.isAttached || isAutoScrolling.value) return;
- followsLatest.value = true;
- hasUserScrolled.value = false;
- isAutoScrolling.value = true;
+ Future 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 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();
-}
diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart
index 7d294c5f1..9c29c67a9 100644
--- a/mobile/lib/features/channels/compose_bar.dart
+++ b/mobile/lib/features/channels/compose_bar.dart
@@ -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 Function(
+ String content,
+ List mentionPubkeys, {
+ List> mediaTags,
+ });
diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
index f922c7c01..c6a26cc59 100644
--- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
+++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
@@ -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 Function(
- String content,
- List mentionPubkeys, {
- List> 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(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,
diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart
index 267aa6cb6..7a25f4cfe 100644
--- a/mobile/lib/features/channels/compose_bar/dock.dart
+++ b/mobile/lib/features/channels/compose_bar/dock.dart
@@ -1,10 +1,13 @@
part of '../compose_bar.dart';
class _ComposerDockFrame extends StatelessWidget {
- final double widthFactor;
+ final Animation 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,
+ );
+ },
),
),
),
diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart
index ff60d7444..09d7a8e84 100644
--- a/mobile/lib/features/channels/compose_bar/helpers.dart
+++ b/mobile/lib/features/channels/compose_bar/helpers.dart
@@ -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('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 isExpanded,
+ required ValueNotifier<_AttachmentSurface> attachmentSurface,
+ required VoidCallback? onFocusRequested,
+ required FocusNode focusNode,
+ required FlutterView view,
+ required ValueNotifier androidImeTransitionStarted,
+ required ObjectRef 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 channelSuggestions,
+ required List mentionSuggestions,
+ required Map userCache,
+ required String? currentPubkey,
+ required bool isDmChannel,
+ required ValueChanged onChannelSelect,
+ required ValueChanged 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 Function(XFile image) onCapture,
+ required Future> Function() onPickAllPhotos,
+ required Future Function(List photos) onChoosePhotos,
+ required Future Function(List 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 = [
'image/jpeg',
'image/jpg',
diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart
index 4d9177663..3400edd54 100644
--- a/mobile/lib/features/channels/compose_bar/layout.dart
+++ b/mobile/lib/features/channels/compose_bar/layout.dart
@@ -14,8 +14,7 @@ class _ComposeBarLayout extends StatelessWidget {
final _AttachmentSurface attachmentSurface;
final ValueChanged onAttachmentTap;
final VoidCallback onExpand;
- final double expansionValue;
- final double expansionProgress;
+ final Animation 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 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
diff --git a/mobile/lib/features/channels/ime_metrics_settle_observer.dart b/mobile/lib/features/channels/ime_metrics_settle_observer.dart
new file mode 100644
index 000000000..5924e6e54
--- /dev/null
+++ b/mobile/lib/features/channels/ime_metrics_settle_observer.dart
@@ -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;
+ }
+}
diff --git a/mobile/lib/features/channels/latest_message_button.dart b/mobile/lib/features/channels/latest_message_button.dart
new file mode 100644
index 000000000..81abc8034
--- /dev/null
+++ b/mobile/lib/features/channels/latest_message_button.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart
index e4d0f76ff..4e13e1f39 100644
--- a/mobile/lib/features/channels/thread_detail_helpers.dart
+++ b/mobile/lib/features/channels/thread_detail_helpers.dart
@@ -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 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,
diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart
index fcd89b767..6ab3ddd9b 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -13,6 +13,7 @@ import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
+import 'android_ime_lift.dart';
import 'channel_link_navigation.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
@@ -23,9 +24,11 @@ import 'compose_bar.dart';
import 'composer_dock_size_reporter.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
-import '../profile/user_profile_sheet.dart';
+import 'ime_metrics_settle_observer.dart';
import 'initial_thread_tail_settle.dart';
import 'laid_out_viewport.dart';
+import 'latest_message_button.dart';
+import '../profile/user_profile_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
import 'message_content.dart';
@@ -36,7 +39,10 @@ import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'timeline_message.dart';
+part 'thread_detail_page/nested_thread_summary_row.dart';
part 'thread_detail_helpers.dart';
+part 'thread_detail_page/tail_alignment.dart';
+part 'thread_detail_page/thread_message.dart';
/// Full-screen thread detail page.
///
@@ -64,14 +70,28 @@ class ThreadDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final appView = View.of(context);
final composerDockHeight = useState(0.0);
+ final settledImeBottomInset = useState(
+ usesFixedAndroidImeViewport
+ ? appView.viewInsets.bottom / appView.devicePixelRatio
+ : 0.0,
+ );
final sendMessage = ref.read(sendMessageProvider);
+ // Relay thread queries are keyed by the outermost root, even when this
+ // page displays a nested branch. Query that root, then select this head's
+ // direct children from the returned subtree below.
final queryRootId = threadHead.rootId ?? threadHead.id;
final repliesState = ref.watch(
threadRepliesWithLocalProvider(
ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
),
);
+ // The thread query is one-shot and asks only for content kinds, so a
+ // reaction, edit, or deletion that lands while the thread is open never
+ // reaches it — a new pill (and its burst) only showed up after leaving and
+ // re-entering, which refetched. The channel socket already receives those
+ // events, so union the two sources and format once.
final liveChannelEvents =
ref.watch(channelMessagesProvider(channelId)).value ??
const [];
@@ -90,12 +110,18 @@ class ThreadDetailPage extends HookConsumerWidget {
final allMsgs = fetchedReplies == null
? allMessages
: [
+ // Only fall back to the pushed-route snapshot when neither source
+ // carries the head, and no live deletion has suppressed it. That
+ // keeps a temporarily unavailable head visible without restoring
+ // a head that was deleted while this page was open.
if (!liveDeletionHidesHead &&
!fetchedReplies.any((message) => message.id == threadHead.id))
threadHead,
...fetchedReplies,
];
+ // Index all messages by parentId so we can find direct children of any
+ // message and compute thread summaries for nested threads.
final childrenByParent = >{};
for (final msg in allMsgs) {
final pid = msg.parentId;
@@ -111,30 +137,101 @@ class ThreadDetailPage extends HookConsumerWidget {
final didJumpToInitialMessage = useRef(false);
final followsThreadTail = useRef(false);
final userOptedOutOfTailFollow = useRef(false);
+ final userDragDetachedTailFollow = useRef(false);
final tailIntent = useMemoized(_ThreadTailIntent.new);
- final pendingTailAlignment = useRef(null);
+ final initialTailSettle = useMemoized(InitialThreadTailSettle.new);
+ final isAtThreadTail = useState(true);
+ final tailCorrectionInProgress = useRef(false);
+ final viewportHeight = useListenable(listViewport.height).value;
+ final previousViewportHeight = useRef(viewportHeight);
+ final settledImeLift = usesFixedAndroidImeViewport
+ ? (settledImeBottomInset.value -
+ MediaQuery.viewPaddingOf(context).bottom)
+ .clamp(0.0, double.infinity)
+ .toDouble()
+ : 0.0;
+ final timelineBottomInset =
+ composerDockHeight.value +
+ (followsThreadTail.value || !initialTailSettle.isComplete
+ ? settledImeLift
+ : 0);
+ final navigationBottomInset = composerDockHeight.value + settledImeLift;
+
+ // Item 0 is the thread head; reply `i` lives at `i + 1`.
const headIndex = 0;
int indexForReply(int chronologicalIndex) => chronologicalIndex + 1;
+ final tailAnchorIndex = replies.length + 1;
+
+ double threadTailAlignment() => _threadTailAlignmentForViewport(
+ // This reporter already reflects Scaffold resize, typing rows, and every
+ // other layout consumer above or below the list.
+ fullHeight: viewportHeight > 0
+ ? viewportHeight
+ : MediaQuery.sizeOf(context).height,
+ imeBottomInset: 0,
+ usesFixedImeViewport: true,
+ bottomInset:
+ Grid.xs +
+ composerDockHeight.value +
+ (followsThreadTail.value ? settledImeLift : 0),
+ );
bool threadTailIsVisible() {
- final lastIndex = _threadTailIndex(replies.length);
- final trailingBoundary = _threadTailTrailingBoundary(
- hasComposerDock: isMember && !isArchived,
- viewportHeight: listViewport.height.value,
- dockHeight: composerDockHeight.value,
- );
+ if (isMember &&
+ !isArchived &&
+ (!viewportHeight.isFinite ||
+ viewportHeight <= 0 ||
+ composerDockHeight.value <= 0)) {
+ return false;
+ }
+ if (!viewportHeight.isFinite || viewportHeight <= 0) return false;
+ final trailingBoundary =
+ 1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001;
+ final lastMessageIndex = _threadTailIndex(replies.length);
return itemPositionsListener.itemPositions.value.any(
(position) =>
- position.index == lastIndex &&
+ position.index == lastMessageIndex &&
position.itemTrailingEdge <= trailingBoundary,
);
}
+ void correctThreadTailInstantly() {
+ if (!itemScrollController.isAttached) return;
+ tailCorrectionInProgress.value = true;
+ isAtThreadTail.value = true;
+ itemScrollController.jumpTo(
+ index: tailAnchorIndex,
+ alignment: threadTailAlignment(),
+ );
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ tailCorrectionInProgress.value = false;
+ if (context.mounted && followsThreadTail.value) {
+ isAtThreadTail.value = true;
+ }
+ });
+ }
+
+ void followThreadTailFromComposer() {
+ if (userDragDetachedTailFollow.value) return;
+ initialTailSettle.abandon();
+ tailIntent.endDrag();
+ tailIntent.detach();
+ userOptedOutOfTailFollow.value = false;
+ followsThreadTail.value = true;
+ isAtThreadTail.value = true;
+ if (!threadTailIsVisible()) correctThreadTailInstantly();
+ }
+
useEffect(() {
void onPositionsChanged() {
- if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) {
+ final tailIsVisible = threadTailIsVisible();
+ if (!userOptedOutOfTailFollow.value && tailIsVisible) {
followsThreadTail.value = true;
}
+ if (tailCorrectionInProgress.value) return;
+ if (isAtThreadTail.value != tailIsVisible) {
+ isAtThreadTail.value = tailIsVisible;
+ }
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
@@ -143,8 +240,38 @@ class ThreadDetailPage extends HookConsumerWidget {
);
}, [itemPositionsListener, replies.length]);
+ Future scrollToThreadLatest() async {
+ if (!itemScrollController.isAttached) return;
+ initialTailSettle.abandon();
+ tailIntent.endDrag();
+ userOptedOutOfTailFollow.value = false;
+ userDragDetachedTailFollow.value = false;
+ followsThreadTail.value = true;
+ isAtThreadTail.value = true;
+ tailIntent.scheduleNextFrame(
+ allowed: true,
+ revalidate: () =>
+ context.mounted &&
+ itemScrollController.isAttached &&
+ !tailIntent.isDragging,
+ action: () async {
+ await itemScrollController.scrollTo(
+ index: tailAnchorIndex,
+ alignment: threadTailAlignment(),
+ duration: const Duration(milliseconds: 220),
+ curve: Curves.easeOutCubic,
+ );
+ if (context.mounted && threadTailIsVisible()) {
+ isAtThreadTail.value = true;
+ }
+ },
+ );
+ }
+
useEffect(() {
final messageId = initialMessageId;
+ // Wait for the authoritative thread query before consuming the one-shot
+ // jump; the fallback main-timeline list can contain only the linked reply.
if (messageId == null || fetchedReplies == null) return null;
final chronologicalIndex = replies.indexWhere(
(reply) => reply.id == messageId,
@@ -156,6 +283,9 @@ class ThreadDetailPage extends HookConsumerWidget {
: indexForReply(chronologicalIndex);
if (targetIndex == null || didJumpToInitialMessage.value) return null;
didJumpToInitialMessage.value = true;
+ initialTailSettle.abandon();
+ userOptedOutOfTailFollow.value = true;
+ userDragDetachedTailFollow.value = false;
tailIntent.schedule(
allowed: true,
revalidate: () =>
@@ -163,127 +293,100 @@ class ThreadDetailPage extends HookConsumerWidget {
itemScrollController.isAttached &&
!tailIntent.isDragging,
action: () {
+ // The provisional route snapshot can make the linked reply look like
+ // the tail. This authoritative deep-link jump intentionally leaves
+ // the user at an older item, so it must opt out of follow-tail first.
tailIntent.detach();
followsThreadTail.value = false;
- pendingTailAlignment.value = null;
+ isAtThreadTail.value = false;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
},
);
return null;
}, [initialMessageId, fetchedReplies, replies.length]);
+ // A top-anchored list doesn't stick to the newest item the way the old
+ // reversed one did, so follow the tail explicitly: when a reply arrives
+ // while the last item is on screen, scroll it into view. If the user has
+ // scrolled up to read, leave them where they are.
final hasFetchedReplies = fetchedReplies != null;
- final initialTailSettle = useMemoized(InitialThreadTailSettle.new);
final previousReplyCount = useRef(replies.length);
- final viewportHeight = useListenable(listViewport.height).value;
- final previousViewportHeight = useRef(viewportHeight);
- final topOverlayFraction = frostedAppBarHeight(context) / viewportHeight;
- final settleGeometry = (composerDockHeight.value, viewportHeight);
- bool currentIntentAllowsTailMutation({bool allowIdleDetached = false}) {
- if (tailIntent.isDragging) return false;
- if (allowIdleDetached) return true;
- return !userOptedOutOfTailFollow.value &&
- (followsThreadTail.value || threadTailIsVisible());
- }
-
- void queueTailRealignment({
- bool allowIdleDetached = false,
- bool restoreFollow = false,
- bool animate = true,
- }) {
- if (!initialTailSettle.isComplete ||
- viewportHeight <= 0 ||
- !currentIntentAllowsTailMutation(
- allowIdleDetached: allowIdleDetached,
- )) {
- return;
- }
- if (!allowIdleDetached) followsThreadTail.value = true;
- tailIntent.schedule(
- allowed: true,
- revalidate: () =>
- context.mounted &&
- itemScrollController.isAttached &&
- currentIntentAllowsTailMutation(
- allowIdleDetached: allowIdleDetached,
- ),
- action: () {
- final lastIndex = _threadTailIndex(replies.length);
- if (restoreFollow) {
- userOptedOutOfTailFollow.value = false;
- followsThreadTail.value = true;
- }
- if (animate) {
- itemScrollController.scrollTo(
- index: lastIndex,
- alignment: topOverlayFraction,
- duration: const Duration(milliseconds: 220),
- curve: Curves.easeOutCubic,
- );
- } else {
- itemScrollController.jumpTo(
- index: lastIndex,
- alignment: topOverlayFraction,
- );
- }
- },
- );
- }
-
+ final topOverlayFraction = viewportHeight > 0
+ ? frostedAppBarHeight(context) / viewportHeight
+ : 0.0;
+ final settleGeometry = (
+ composerDockHeight.value,
+ settledImeLift,
+ viewportHeight,
+ );
useEffect(() {
if (!hasFetchedReplies || viewportHeight <= 0) return null;
+ if (initialMessageId != null) {
+ initialTailSettle.abandon();
+ previousReplyCount.value = replies.length;
+ return null;
+ }
if (isMember && !isArchived && composerDockHeight.value <= 0) {
return null;
}
if (!initialTailSettle.isComplete) {
previousReplyCount.value = replies.length;
- previousViewportHeight.value = viewportHeight;
+ followsThreadTail.value = true;
initialTailSettle.schedule(
context: context,
controller: itemScrollController,
positionsListener: itemPositionsListener,
- targetIndex: initialMessageId == null && replies.isNotEmpty
- ? indexForReply(replies.length - 1)
- : null,
+ targetIndex: replies.isEmpty
+ ? null
+ : indexForReply(replies.length - 1),
hiddenTopFraction: topOverlayFraction,
- hiddenBottomFraction: composerDockHeight.value / viewportHeight,
+ hiddenBottomFraction:
+ (composerDockHeight.value + settledImeLift) / viewportHeight,
);
return null;
}
+
final previous = previousReplyCount.value;
previousReplyCount.value = replies.length;
- final viewportChanged =
- (viewportHeight - previousViewportHeight.value).abs() >= 0.5;
- previousViewportHeight.value = viewportHeight;
- if (replies.length <= previous) {
- // Preserve a short thread's valid top anchor when resize leaves its
- // tail inside the newly measured usable viewport. Long/clipped tails
- // still follow through the shared intent-serialized correction path.
- if (viewportChanged && !threadTailIsVisible()) {
- queueTailRealignment(animate: false);
- }
- return null;
- }
+ if (replies.length <= previous) return null;
final positions = itemPositionsListener.itemPositions.value;
- final previousLastIndex = previous == 0
- ? headIndex
- : indexForReply(previous - 1);
- final wasAtTail = positions.any(
- (position) => position.index == previousLastIndex,
- );
+ // Positions still describe the list as it was *before* these replies, so
+ // compare against the old tail. Measuring against the new one only reads
+ // as "at the tail" when exactly one reply arrived.
+ final previousTailAnchorIndex = previous + 1;
+ final wasAtTail =
+ positions.isEmpty ||
+ positions.any(
+ (position) => position.index >= previousTailAnchorIndex,
+ );
final localPubkey = currentPubkey?.toLowerCase();
final hasNewLocalReply =
localPubkey != null &&
replies
.skip(previous)
.any((reply) => reply.pubkey.toLowerCase() == localPubkey);
+ // A reply the current user just sent must be visible even if they were
+ // reading at the head of a long thread. Remote arrivals still respect
+ // the user's scroll position.
if (tailIntent.isDragging) return null;
if (!hasNewLocalReply && (userOptedOutOfTailFollow.value || !wasAtTail)) {
return null;
}
- queueTailRealignment(
- allowIdleDetached: hasNewLocalReply,
- restoreFollow: hasNewLocalReply,
+ if (hasNewLocalReply) {
+ userOptedOutOfTailFollow.value = false;
+ userDragDetachedTailFollow.value = false;
+ }
+ followsThreadTail.value = true;
+ tailIntent.scheduleNextFrame(
+ allowed: true,
+ revalidate: () =>
+ context.mounted &&
+ itemScrollController.isAttached &&
+ !tailIntent.isDragging &&
+ !userOptedOutOfTailFollow.value,
+ // A reply arrival changes list geometry. Keep this correction instant;
+ // only an explicit tap on Latest should animate navigation.
+ action: correctThreadTailInstantly,
);
return null;
}, [hasFetchedReplies, replies.length, settleGeometry]);
@@ -304,6 +407,7 @@ class ThreadDetailPage extends HookConsumerWidget {
return null;
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
+ // Thread-scoped typing indicators (exclude self).
final allTyping = ref.watch(channelTypingProvider(channelId));
final threadTyping = allTyping
.where((e) => e.threadHeadId == threadHead.id)
@@ -314,11 +418,68 @@ class ThreadDetailPage extends HookConsumerWidget {
)
.toList();
+ // Resolve thread head from live data (reactions/edits may have changed).
final liveHead =
allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead;
+ // The root of the entire thread chain. If the current thread head is
+ // itself a root message its rootId is null, so fall back to its own id.
final effectiveRootId = threadHead.rootId ?? threadHead.id;
+ // Composer size changes and keyboard metrics changes are independent:
+ // the dock grows first, then the Scaffold's viewport shrinks once the
+ // keyboard appears. Re-align after that latter layout pass too, but only
+ // while the user was already following the thread tail.
+ void realignThreadTailAfterMetricsChange() {
+ listViewport.reportAfterLayout();
+ final shouldFollowTail =
+ !tailIntent.isDragging &&
+ !userOptedOutOfTailFollow.value &&
+ (followsThreadTail.value || threadTailIsVisible());
+ if (!shouldFollowTail || !initialTailSettle.isComplete) return;
+ followsThreadTail.value = true;
+ tailIntent.scheduleNextFrame(
+ allowed: true,
+ revalidate: () =>
+ context.mounted &&
+ itemScrollController.isAttached &&
+ !tailIntent.isDragging &&
+ !userOptedOutOfTailFollow.value &&
+ followsThreadTail.value,
+ action: () {
+ final targetAlignment = threadTailAlignment();
+ final positions = itemPositionsListener.itemPositions.value;
+ final anchorPosition = positions
+ .where((position) => position.index == tailAnchorIndex)
+ .firstOrNull;
+ final headIsVisible = positions.any(
+ (position) =>
+ position.index == headIndex && position.itemTrailingEdge > 0,
+ );
+ if (anchorPosition != null &&
+ ((anchorPosition.itemLeadingEdge - targetAlignment).abs() <
+ 0.005 ||
+ (headIsVisible &&
+ anchorPosition.itemLeadingEdge < targetAlignment))) {
+ return;
+ }
+ // This runs once after Android's frame-by-frame IME metrics settle.
+ // Keep the resulting layout correction instant.
+ correctThreadTailInstantly();
+ },
+ );
+ }
+
+ useEffect(() {
+ final previousHeight = previousViewportHeight.value;
+ previousViewportHeight.value = viewportHeight;
+ if ((viewportHeight - previousHeight).abs() >= 0.5 &&
+ initialTailSettle.isComplete) {
+ realignThreadTailAfterMetricsChange();
+ }
+ return null;
+ }, [viewportHeight]);
+
void updateComposerDockHeight(double height) {
listViewport.reportAfterLayout();
final previousHeight = composerDockHeight.value;
@@ -326,54 +487,43 @@ class ThreadDetailPage extends HookConsumerWidget {
if (heightDelta.abs() < 0.5) return;
final shouldFollowTail =
+ !tailIntent.isDragging &&
!userOptedOutOfTailFollow.value &&
(followsThreadTail.value || threadTailIsVisible());
if (shouldFollowTail) followsThreadTail.value = true;
composerDockHeight.value = height;
- if (heightDelta <= 0 ||
- !shouldFollowTail ||
- !viewportHeight.isFinite ||
- viewportHeight <= 0 ||
- !initialTailSettle.isComplete) {
- pendingTailAlignment.value = null;
- return;
- }
- final lastIndex = _threadTailIndex(replies.length);
- final lastPosition = itemPositionsListener.itemPositions.value
- .where((position) => position.index == lastIndex)
- .firstOrNull;
- if (lastPosition == null) return;
- final targetAlignment =
- (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) -
- (heightDelta / viewportHeight);
- pendingTailAlignment.value = targetAlignment;
-
- tailIntent.schedule(
- allowed: true,
- revalidate: () =>
- context.mounted &&
- itemScrollController.isAttached &&
- currentIntentAllowsTailMutation(),
- action: () => itemScrollController.jumpTo(
- index: lastIndex,
- alignment: targetAlignment,
- ),
- );
- }
-
- void realignThreadTailAfterMetricsChange() {
- listViewport.reportAfterLayout();
- queueTailRealignment();
+ if (shouldFollowTail) realignThreadTailAfterMetricsChange();
}
useEffect(() {
- final observer = _ThreadTailMetricsObserver(
- onMetricsChanged: realignThreadTailAfterMetricsChange,
+ final observer = ImeMetricsSettleObserver(
+ onMetricsSettled: () {
+ if (!usesFixedAndroidImeViewport) {
+ realignThreadTailAfterMetricsChange();
+ 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, replies.length]);
+ return () {
+ WidgetsBinding.instance.removeObserver(observer);
+ observer.dispose();
+ };
+ }, [appView, itemScrollController]);
+ useEffect(() {
+ if (usesFixedAndroidImeViewport) {
+ realignThreadTailAfterMetricsChange();
+ }
+ return null;
+ }, [settledImeBottomInset.value]);
+
+ // Channel names for message content rendering.
final channelsAsync = ref.watch(channelsProvider);
final channel = channelsAsync.value
?.where((candidate) => candidate.id == channelId)
@@ -386,6 +536,7 @@ class ThreadDetailPage extends HookConsumerWidget {
});
return FrostedScaffold(
+ resizeToAvoidBottomInset: !usesFixedAndroidImeViewport,
appBar: const FrostedAppBar(
title: Text('Thread'),
titleStyle: channelTitleTextStyle,
@@ -403,8 +554,8 @@ class ThreadDetailPage extends HookConsumerWidget {
initialTailSettle.abandon();
tailIntent.beginDrag();
userOptedOutOfTailFollow.value = true;
+ userDragDetachedTailFollow.value = true;
followsThreadTail.value = false;
- pendingTailAlignment.value = null;
},
onUserScrollEnd: () {
tailIntent.endDrag();
@@ -415,25 +566,44 @@ class ThreadDetailPage extends HookConsumerWidget {
itemScrollController.isAttached &&
!tailIntent.isDragging &&
userOptedOutOfTailFollow.value,
- action: () => _resumeThreadTailFollow(
- isVisible: threadTailIsVisible,
- userOptedOut: userOptedOutOfTailFollow,
- followsTail: followsThreadTail,
- ),
+ action: () {
+ _resumeThreadTailFollow(
+ isVisible: threadTailIsVisible,
+ userOptedOut: userOptedOutOfTailFollow,
+ followsTail: followsThreadTail,
+ );
+ if (!userOptedOutOfTailFollow.value) {
+ userDragDetachedTailFollow.value = false;
+ }
+ },
);
},
child: ScrollablePositionedList.builder(
key: const ValueKey('thread-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
+ // Top-anchored, head first, replies flowing down — matching
+ // desktop's thread panel. The old reversed list bottom-anchored
+ // the content, which jammed the head against the composer
+ // whenever a thread had only a handful of replies.
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
- bottom: Grid.xs + composerDockHeight.value,
+ bottom: Grid.xs + timelineBottomInset,
),
- itemCount: replies.length + 1, // +1 for thread head
+ // Head + replies + a stable zero-content tail target. The
+ // anchor lets Latest align the end directly rather than
+ // asking the final reply's leading edge to overshoot the
+ // viewport and rebound against the scroll extent.
+ itemCount: replies.length + 2,
itemBuilder: (context, index) {
+ if (index == tailAnchorIndex) {
+ return const SizedBox(
+ key: ValueKey('thread-tail-anchor'),
+ height: 1,
+ );
+ }
if (index == headIndex) {
if (liveDeletionHidesHead) {
return const Padding(
@@ -496,6 +666,7 @@ class ThreadDetailPage extends HookConsumerWidget {
);
}
+ // Chronological list: index 1 = oldest reply.
final chronIdx = index - 1;
final reply = replies[chronIdx];
final prevReply = chronIdx > 0
@@ -513,6 +684,7 @@ class ThreadDetailPage extends HookConsumerWidget {
reply.pubkey.toLowerCase() ||
(reply.createdAt - prevReply.createdAt) > 300;
+ // Check if this reply itself has children (nested thread).
final nestedChildren = childrenByParent[reply.id];
final nestedSummary =
nestedChildren != null && nestedChildren.isNotEmpty
@@ -521,6 +693,9 @@ class ThreadDetailPage extends HookConsumerWidget {
return Padding(
key: ValueKey('thread-message-group-${reply.id}'),
+ // Tail spacing comes from the list's own bottom padding now
+ // that the list runs top-down; the reversed list used to
+ // need it here because item 0 sat against the composer.
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -563,36 +738,52 @@ class ThreadDetailPage extends HookConsumerWidget {
],
),
if (isMember && !isArchived)
- Align(
- alignment: Alignment.bottomCenter,
- child: ComposerDockSizeReporter(
- key: const ValueKey('thread-composer-dock'),
- onHeightChanged: updateComposerDockHeight,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- _ThreadTypingIndicator(entries: threadTyping),
- ComposeBar(
- channelId: channelId,
- hintText: 'Reply in thread\u2026',
- threadHeadId: threadHead.id,
- rootId: effectiveRootId,
- onSend:
- (
- content,
- mentionPubkeys, {
- mediaTags = const >[],
- }) => sendMessage.call(
- channelId: channelId,
- content: content,
- mentionPubkeys: mentionPubkeys,
- channel: channel,
- parentEventId: threadHead.id,
- rootEventId: effectiveRootId,
- mediaTags: mediaTags,
- ),
- ),
- ],
+ AndroidImeLift(
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: ComposerDockSizeReporter(
+ key: const ValueKey('thread-composer-dock'),
+ onHeightChanged: updateComposerDockHeight,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _ThreadTypingIndicator(entries: threadTyping),
+ ComposeBar(
+ channelId: channelId,
+ hintText: 'Reply in thread\u2026',
+ threadHeadId: threadHead.id,
+ rootId: effectiveRootId,
+ onFocusRequested: followThreadTailFromComposer,
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) => sendMessage.call(
+ channelId: channelId,
+ content: content,
+ mentionPubkeys: mentionPubkeys,
+ channel: channel,
+ parentEventId: threadHead.id,
+ rootEventId: effectiveRootId,
+ mediaTags: mediaTags,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ if (hasFetchedReplies && !isAtThreadTail.value)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: navigationBottomInset + Grid.xs,
+ child: Center(
+ child: LatestMessageButton(
+ key: const ValueKey('thread-jump-to-latest'),
+ surfaceKey: const ValueKey('thread-jump-to-latest-surface'),
+ onPressed: scrollToThreadLatest,
),
),
),
@@ -601,398 +792,3 @@ class ThreadDetailPage extends HookConsumerWidget {
);
}
}
-
-/// 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 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(
- 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,
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _ThreadMessage extends ConsumerWidget {
- final TimelineMessage message;
- final Map channelNames;
- final String channelId;
- final String? currentPubkey;
- final bool showAuthor;
- final bool isHighlighted;
- final List? 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 = {};
- final agentMentionPubkeys = {};
- 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(
- 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,
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart
new file mode 100644
index 000000000..1dcf40f57
--- /dev/null
+++ b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart
@@ -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 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(
+ 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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart b/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart
new file mode 100644
index 000000000..cbeed7daa
--- /dev/null
+++ b/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart
@@ -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();
+}
diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
new file mode 100644
index 000000000..248ccc968
--- /dev/null
+++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
@@ -0,0 +1,284 @@
+part of '../thread_detail_page.dart';
+
+class _ThreadMessage extends ConsumerWidget {
+ final TimelineMessage message;
+ final Map channelNames;
+ final String channelId;
+ final String? currentPubkey;
+ final bool showAuthor;
+ final bool isHighlighted;
+ final List? 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 = {};
+ final agentMentionPubkeys = {};
+ 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(
+ 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,
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart
index 0615a1963..1001e16cd 100644
--- a/mobile/lib/features/search/search_page.dart
+++ b/mobile/lib/features/search/search_page.dart
@@ -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,
diff --git a/mobile/lib/shared/theme/message_typography.dart b/mobile/lib/shared/theme/message_typography.dart
index 5a5d2c2c6..37c1be0ea 100644
--- a/mobile/lib/shared/theme/message_typography.dart
+++ b/mobile/lib/shared/theme/message_typography.dart
@@ -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(
diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart
index c7aff7df8..ed1e3a6e2 100644
--- a/mobile/lib/shared/widgets/message_author_meta.dart
+++ b/mobile/lib/shared/widgets/message_author_meta.dart
@@ -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,
),
),
],
diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart
index 8619a513d..94d040526 100644
--- a/mobile/test/features/activity/activity_page_test.dart
+++ b/mobile/test/features/activity/activity_page_test.dart
@@ -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? tabReselection,
+ List drafts = const [],
+ List 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(find.byType(AvatarImage));
expect(avatars, isNotEmpty);
@@ -909,3 +978,11 @@ class _FakeRemindersNotifier extends RemindersNotifier {
@override
Future> build() async => _reminders;
}
+
+class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
+ final List _drafts;
+ _FakeComposeDraftsNotifier(this._drafts);
+
+ @override
+ List build() => _drafts;
+}
diff --git a/mobile/test/features/channels/android_ime_lift_test.dart b/mobile/test/features/channels/android_ime_lift_test.dart
new file mode 100644
index 000000000..208ff6f67
--- /dev/null
+++ b/mobile/test/features/channels/android_ime_lift_test.dart
@@ -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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 365befcff..79e385452 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -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(
+ 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(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(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(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(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(
+ 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(
+ 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(
+ 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(
@@ -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(
+ 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(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(
+ 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(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(
+ 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(
+ 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(
+ 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(
+ 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,
+ );
},
);
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index df84bf073..1270bc386 100644
--- a/mobile/test/features/channels/compose_bar_test.dart
+++ b/mobile/test/features/channels/compose_bar_test.dart
@@ -181,6 +181,7 @@ Widget _buildComposeBar({
List customEmoji = const [],
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 >[],
+ }) 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(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 >[],
+ }) 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 >[],
+ }) 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();
diff --git a/mobile/test/features/channels/ime_metrics_settle_observer_test.dart b/mobile/test/features/channels/ime_metrics_settle_observer_test.dart
new file mode 100644
index 000000000..78fa70a90
--- /dev/null
+++ b/mobile/test/features/channels/ime_metrics_settle_observer_test.dart
@@ -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;
+ }
+ });
+}
diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart
index 55645313c..de8cda398 100644
--- a/mobile/test/features/search/search_page_test.dart
+++ b/mobile/test/features/search/search_page_test.dart
@@ -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(
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,
diff --git a/mobile/test/shared/theme/message_typography_test.dart b/mobile/test/shared/theme/message_typography_test.dart
index c5bec5555..6616b7fd2 100644
--- a/mobile/test/shared/theme/message_typography_test.dart
+++ b/mobile/test/shared/theme/message_typography_test.dart
@@ -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,
diff --git a/mobile/test/shared/widgets/message_author_meta_test.dart b/mobile/test/shared/widgets/message_author_meta_test.dart
index 3b55946d4..256685a07 100644
--- a/mobile/test/shared/widgets/message_author_meta_test.dart
+++ b/mobile/test/shared/widgets/message_author_meta_test.dart
@@ -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(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(find.byKey(timestampKey));
expect(timestamp.maxLines, 1);
expect(timestamp.overflow, TextOverflow.ellipsis);
+ final nameRichText = tester.widget(
+ find.descendant(
+ of: find.byKey(displayNameKey),
+ matching: find.byType(RichText),
+ ),
+ );
+ final timestampRichText = tester.widget(
+ 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);
});
}