From 7e8e4d1fd004ea7d8cf47b476ab9bd965b83b9fd Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 31 Jul 2026 13:18:57 +0100 Subject: [PATCH 1/5] Polish mobile composer and messaging UI Signed-off-by: kenny lopez --- mobile/ios/Runner/InlinePhotoPicker.swift | 11 - .../ios/Runner/NativeAttachmentPopover.swift | 89 +++-- .../NativeAttachmentPopoverCoordinator.swift | 68 +++- mobile/ios/RunnerTests/RunnerTests.swift | 35 +- .../lib/features/activity/activity_page.dart | 16 +- .../activity_page/header_actions.dart | 16 - .../activity/activity_page/lists.dart | 4 +- .../activity/activity_page/status_views.dart | 7 +- .../channels/channel_detail_page.dart | 253 +++++++----- .../channel_detail_page/message_list.dart | 71 +++- .../channel_detail_page/system_rows.dart | 22 +- .../channels/channel_typing_indicator.dart | 66 +++- .../channels/channels_page/sections.dart | 66 +++- mobile/lib/features/channels/compose_bar.dart | 112 +++--- .../channels/compose_bar/attachments.dart | 97 +++-- .../channels/compose_bar/camera_preview.dart | 4 +- .../features/channels/compose_bar/dock.dart | 144 +++++++ .../compose_bar/formatting_toolbar.dart | 4 +- .../channels/compose_bar/helpers.dart | 39 ++ .../compose_bar/ios_photo_picker.dart | 9 +- .../features/channels/compose_bar/layout.dart | 32 +- .../compose_bar/photo_gallery_picker.dart | 18 +- .../channels/compose_bar/send_button.dart | 4 +- .../channels/compose_bar/suggestions.dart | 154 ++++---- .../channels/composer_dock_size_reporter.dart | 50 +++ .../lib/features/channels/emoji_picker.dart | 1 + .../channels/emoji_picker/emoji_grid.dart | 5 +- .../features/channels/message_actions.dart | 3 +- .../lib/features/channels/reaction_row.dart | 3 +- .../features/channels/thread_detail_page.dart | 362 ++++++++++-------- .../lib/shared/emoji/native_emoji_glyph.dart | 22 ++ mobile/lib/shared/theme/app_theme.dart | 20 +- .../lib/shared/theme/message_typography.dart | 10 +- .../shared/widgets/anchored_popover_menu.dart | 32 +- .../lib/shared/widgets/filter_chip_bar.dart | 18 +- .../widgets/mobile_tab_footer_backdrop.dart | 32 +- .../features/activity/activity_page_test.dart | 44 ++- .../channels/channel_detail_page_test.dart | 134 ++++++- .../features/channels/channels_page_test.dart | 74 ++++ .../features/channels/compose_bar_test.dart | 347 +++++++++++++++++ .../shared/emoji/native_emoji_glyph_test.dart | 37 ++ mobile/test/shared/theme/app_theme_test.dart | 17 + .../shared/theme/message_typography_test.dart | 7 + .../shared/widgets/filter_chip_bar_test.dart | 52 ++- .../mobile_tab_footer_backdrop_test.dart | 22 ++ 45 files changed, 1985 insertions(+), 648 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/dock.dart create mode 100644 mobile/lib/features/channels/composer_dock_size_reporter.dart create mode 100644 mobile/lib/shared/emoji/native_emoji_glyph.dart create mode 100644 mobile/test/shared/emoji/native_emoji_glyph_test.dart diff --git a/mobile/ios/Runner/InlinePhotoPicker.swift b/mobile/ios/Runner/InlinePhotoPicker.swift index 05a576323f..4126734779 100644 --- a/mobile/ios/Runner/InlinePhotoPicker.swift +++ b/mobile/ios/Runner/InlinePhotoPicker.swift @@ -3,14 +3,6 @@ import PhotosUI import UIKit import UniformTypeIdentifiers -enum EmbeddedPhotoPickerLayout { - static func applyPreferredScale(_ zoomIn: () -> Void) { - UIView.performWithoutAnimation { - zoomIn() - } - } -} - final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory { private let messenger: FlutterBinaryMessenger private weak var parentViewController: UIViewController? @@ -130,9 +122,6 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView { } pickerViewController = picker containerView.layoutIfNeeded() - EmbeddedPhotoPickerLayout.applyPreferredScale { - picker.zoomIn() - } } private func exportPickerResult(_ result: PHPickerResult) async throws -> String { diff --git a/mobile/ios/Runner/NativeAttachmentPopover.swift b/mobile/ios/Runner/NativeAttachmentPopover.swift index f2f6c01df8..cc29de9cb3 100644 --- a/mobile/ios/Runner/NativeAttachmentPopover.swift +++ b/mobile/ios/Runner/NativeAttachmentPopover.swift @@ -17,8 +17,6 @@ final class NativeAttachmentPopoverViewController: case camera } - private typealias ContentPreparation = (@escaping () -> Void) -> Void - private let channel: FlutterMethodChannel private let expandedWidth: CGFloat private let maximumMenuHeight: CGFloat @@ -85,17 +83,29 @@ final class NativeAttachmentPopoverViewController: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear - view.layer.cornerRadius = 22 + view.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius view.layer.cornerCurve = .continuous - view.clipsToBounds = true + view.layer.borderColor = UIColor.black.withAlphaComponent(0.04).cgColor + view.layer.borderWidth = NativeAttachmentPopoverStyle.borderWidth + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = NativeAttachmentPopoverStyle.shadowOpacity + view.layer.shadowRadius = NativeAttachmentPopoverStyle.shadowRadius + view.layer.shadowOffset = NativeAttachmentPopoverStyle.shadowOffset + view.clipsToBounds = false let glassEffect = UIGlassEffect(style: .regular) glassEffect.isInteractive = true let glassView = UIVisualEffectView(effect: glassEffect) glassView.translatesAutoresizingMaskIntoConstraints = false + glassView.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius + glassView.layer.cornerCurve = .continuous + glassView.clipsToBounds = true view.addSubview(glassView) contentHost.translatesAutoresizingMaskIntoConstraints = false + contentHost.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius + contentHost.layer.cornerCurve = .continuous + contentHost.clipsToBounds = true view.addSubview(contentHost) NSLayoutConstraint.activate([ glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -114,6 +124,11 @@ final class NativeAttachmentPopoverViewController: override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + view.layer.shadowPath = + UIBezierPath( + roundedRect: view.bounds, + cornerRadius: NativeAttachmentPopoverStyle.cornerRadius + ).cgPath cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero } @@ -198,21 +213,21 @@ final class NativeAttachmentPopoverViewController: makeNativeAttachmentMenuButton( title: "Camera", symbol: "camera", - action: UIAction { [weak self] _ in self?.showCamera() } + action: { [weak self] in self?.showCamera() } ) ) stack.addArrangedSubview( makeNativeAttachmentMenuButton( title: "Photos", symbol: "photo.on.rectangle.angled", - action: UIAction { [weak self] _ in self?.showPhotos() } + action: { [weak self] in self?.showPhotos() } ) ) stack.addArrangedSubview( makeNativeAttachmentMenuButton( title: "Video", symbol: "video", - action: UIAction { [weak self] _ in + action: { [weak self] in self?.finish(method: "pickVideo") } ) @@ -221,7 +236,7 @@ final class NativeAttachmentPopoverViewController: makeNativeAttachmentMenuButton( title: "Files", symbol: "doc", - action: UIAction { [weak self] _ in + action: { [weak self] in self?.finish(method: "pickFiles") } ) @@ -280,14 +295,14 @@ final class NativeAttachmentPopoverViewController: title: nil, symbol: "chevron.left", accessibilityLabel: "Back to attachment options", - action: UIAction { [weak self] _ in self?.showMenu() } + action: { [weak self] in self?.showMenu() } ) let actionButton = makeGlassControl( title: "All Photos", symbol: nil, accessibilityLabel: "All Photos", prominent: true, - action: UIAction { [weak self] _ in self?.performPhotoAction() } + action: { [weak self] in self?.performPhotoAction() } ) photoActionButton = actionButton addBottomControls( @@ -296,27 +311,7 @@ final class NativeAttachmentPopoverViewController: trailing: actionButton ) - transition( - to: .photos, - content: container, - preparation: { [weak picker] reveal in - guard let picker else { - reveal() - return - } - // PHPicker ignores scale changes while its remote grid is still - // adapting to the compact menu bounds. Give it one main-loop turn at - // the final popover size, apply the scale offscreen, then reveal it. - DispatchQueue.main.async { - picker.view.layoutIfNeeded() - EmbeddedPhotoPickerLayout.applyPreferredScale { - picker.zoomIn() - picker.view.layoutIfNeeded() - } - DispatchQueue.main.async(execute: reveal) - } - } - ) + transition(to: .photos, content: container) } private func showCamera() { @@ -353,7 +348,7 @@ final class NativeAttachmentPopoverViewController: title: nil, symbol: "chevron.left", accessibilityLabel: "Back to attachment options", - action: UIAction { [weak self] _ in self?.showMenu() } + action: { [weak self] in self?.showMenu() } ) let captureButton = makeCameraCaptureButton() cameraCaptureButton = captureButton @@ -415,7 +410,6 @@ final class NativeAttachmentPopoverViewController: private func transition( to nextSurface: Surface, content nextView: UIView, - preparation: ContentPreparation? = nil, completion: (() -> Void)? = nil ) { let previousView = visibleContentView @@ -485,11 +479,7 @@ final class NativeAttachmentPopoverViewController: } } - if let preparation { - preparation(reveal) - } else { - reveal() - } + reveal() } } @@ -498,7 +488,7 @@ final class NativeAttachmentPopoverViewController: symbol: String?, accessibilityLabel: String, prominent: Bool = false, - action: UIAction + action: @escaping () -> Void ) -> UIButton { var configuration = prominent @@ -513,20 +503,37 @@ final class NativeAttachmentPopoverViewController: } configuration.imagePadding = 8 configuration.baseForegroundColor = .white + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { attributes in + var interAttributes = attributes + interAttributes.font = NativeAttachmentMenuTypography.font( + forTextStyle: .body + ) + return interAttributes + } configuration.contentInsets = NSDirectionalEdgeInsets( top: 11, leading: 15, bottom: 11, trailing: 15 ) - let button = UIButton(configuration: configuration, primaryAction: action) + let button = UIButton( + configuration: configuration, + primaryAction: UIAction { _ in + UISelectionFeedbackGenerator().selectionChanged() + action() + } + ) button.accessibilityLabel = accessibilityLabel return button } private func makeCameraCaptureButton() -> UIButton { let button = UIButton( - primaryAction: UIAction { [weak self] _ in self?.capturePhoto() } + primaryAction: UIAction { [weak self] _ in + UISelectionFeedbackGenerator().selectionChanged() + self?.capturePhoto() + } ) button.accessibilityLabel = "Take photo" button.translatesAutoresizingMaskIntoConstraints = false diff --git a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift index 73559d7c2b..f59db1f84c 100644 --- a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift +++ b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift @@ -1,3 +1,4 @@ +import CoreText import Flutter import UIKit @@ -276,7 +277,7 @@ enum NativeAttachmentMenuLayout { static func itemHeight( compatibleWith traitCollection: UITraitCollection ) -> CGFloat { - let labelHeight = UIFont.preferredFont( + let labelHeight = NativeAttachmentMenuTypography.font( forTextStyle: labelTextStyle, compatibleWith: traitCollection ).lineHeight @@ -313,12 +314,71 @@ enum NativeAttachmentMenuLayout { } } +enum NativeAttachmentMenuTypography { + static let interPostScriptName = "InterVariable" + + private static let registeredInter: Bool = { + let fontURL = Bundle.main.bundleURL + .appendingPathComponent("Frameworks") + .appendingPathComponent("App.framework") + .appendingPathComponent("flutter_assets") + .appendingPathComponent("assets") + .appendingPathComponent("fonts") + .appendingPathComponent("InterVariable.ttf") + guard FileManager.default.fileExists(atPath: fontURL.path) else { + return false + } + return CTFontManagerRegisterFontsForURL( + fontURL as CFURL, + .process, + nil + ) + }() + + static func font( + forTextStyle textStyle: UIFont.TextStyle, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> UIFont { + _ = registeredInter + let scaledPointSize = UIFontMetrics(forTextStyle: textStyle).scaledValue( + for: 20, + compatibleWith: traitCollection + ) + let preferredFont = UIFont.preferredFont( + forTextStyle: textStyle, + compatibleWith: traitCollection + ) + guard + let interFont = UIFont( + name: interPostScriptName, + size: scaledPointSize + ) + else { + return preferredFont + } + return interFont + } +} + +enum NativeAttachmentPopoverStyle { + static let cornerRadius: CGFloat = 20 + static let shadowOpacity: Float = 0.18 + static let shadowRadius: CGFloat = 12 + static let shadowOffset = CGSize(width: 0, height: 6) + static let borderWidth: CGFloat = 1 +} + func makeNativeAttachmentMenuButton( title: String, symbol: String, - action: UIAction + action: @escaping () -> Void ) -> UIButton { - let button = UIButton(primaryAction: action) + let button = UIButton( + primaryAction: UIAction { _ in + UISelectionFeedbackGenerator().selectionChanged() + action() + } + ) button.accessibilityLabel = title let symbolConfiguration = UIImage.SymbolConfiguration( @@ -338,7 +398,7 @@ func makeNativeAttachmentMenuButton( let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = .label - titleLabel.font = .preferredFont( + titleLabel.font = NativeAttachmentMenuTypography.font( forTextStyle: NativeAttachmentMenuLayout.labelTextStyle ) titleLabel.adjustsFontForContentSizeCategory = true diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index c5333cfdf2..8374ca77b6 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -127,19 +127,6 @@ class RunnerTests: XCTestCase { ) } - func testEmbeddedPhotoPickerAppliesOneZoomInStepWithoutAnimation() { - var zoomInCalls = 0 - var animationsWereEnabled = true - - EmbeddedPhotoPickerLayout.applyPreferredScale { - zoomInCalls += 1 - animationsWereEnabled = UIView.areAnimationsEnabled - } - - XCTAssertEqual(zoomInCalls, 1) - XCTAssertFalse(animationsWereEnabled) - } - func testNativeAttachmentMenuUsesRoomyRowsAndInsets() { let traits = UITraitCollection(preferredContentSizeCategory: .large) let size = NativeAttachmentMenuLayout.size(compatibleWith: traits) @@ -155,6 +142,28 @@ class RunnerTests: XCTestCase { XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3) } + func testNativeAttachmentMenuUsesInterAndSharedPopoverChrome() { + let font = NativeAttachmentMenuTypography.font( + forTextStyle: NativeAttachmentMenuLayout.labelTextStyle + ) + var didSelect = false + let button = makeNativeAttachmentMenuButton( + title: "Photos", + symbol: "photo", + action: { didSelect = true } + ) + let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first + + XCTAssertTrue(font.fontName.hasPrefix("Inter")) + XCTAssertTrue(titleLabel?.font.fontName.hasPrefix("Inter") == true) + XCTAssertEqual(NativeAttachmentPopoverStyle.cornerRadius, 20) + XCTAssertEqual(NativeAttachmentPopoverStyle.borderWidth, 1) + XCTAssertEqual(NativeAttachmentPopoverStyle.shadowOpacity, 0.18) + + button.sendActions(for: .primaryActionTriggered) + XCTAssertTrue(didSelect) + } + func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() { let traits = UITraitCollection( preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index aecef6329c..5b4cebfa97 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -37,6 +37,18 @@ part 'activity_page/inbox_row.dart'; part 'activity_page/lists.dart'; part 'activity_page/status_views.dart'; +EdgeInsets _activityScrollPadding( + BuildContext context, { + double horizontal = 0, + double top = Grid.xxs, + double bottom = Grid.xxs, +}) => EdgeInsets.fromLTRB( + horizontal, + top, + horizontal, + MediaQuery.paddingOf(context).bottom + bottom, +); + /// Conversation-oriented Activity inbox. /// /// Matches desktop's Home inbox item design and semantics (see @@ -264,7 +276,7 @@ class ActivityPage extends HookConsumerWidget { body = RefreshIndicator( onRefresh: refresh, child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: visibleItems.length, itemBuilder: (context, index) { final item = visibleItems[index]; @@ -318,7 +330,9 @@ class ActivityPage extends HookConsumerWidget { ], ), body: SafeArea( + key: const ValueKey('activity-content-safe-area'), top: false, + bottom: false, child: Padding( padding: EdgeInsets.only( top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 56592e2e69..39bb29e7a3 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -39,15 +39,6 @@ class _FilterMenuButton extends StatelessWidget { alignment: AnchoredPopoverAlignment.start, offset: const Offset(0, Grid.half), menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), - color: context.colors.surface.withValues(alpha: 0.98), - elevation: 8, - shadowColor: context.colors.shadow.withValues(alpha: 0.18), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.card), - side: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.45), - ), - ), surfaceKey: const ValueKey('activity-filter-popover'), items: [ for (final entry in _filterLabels.entries) @@ -183,13 +174,6 @@ class _InboxOptionsButton extends StatelessWidget { context: buttonContext, width: 216, alignment: AnchoredPopoverAlignment.end, - color: context.colors.surface, - elevation: 4, - shadowColor: context.colors.shadow.withValues(alpha: 0.18), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: context.colors.outline), - ), surfaceKey: const ValueKey('activity-options-popover'), items: [ PopupMenuItem( diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 37311c9287..9df193fe96 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -36,7 +36,7 @@ class _RemindersList extends ConsumerWidget { return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: reminders.length, itemBuilder: (context, index) { final reminder = reminders[index]; @@ -97,7 +97,7 @@ class _DraftsList extends StatelessWidget { } return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: drafts.length, itemBuilder: (context, index) { final draft = drafts[index]; diff --git a/mobile/lib/features/activity/activity_page/status_views.dart b/mobile/lib/features/activity/activity_page/status_views.dart index 11115d9dc8..442634849c 100644 --- a/mobile/lib/features/activity/activity_page/status_views.dart +++ b/mobile/lib/features/activity/activity_page/status_views.dart @@ -6,7 +6,12 @@ class _LoadingSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.separated( - padding: const EdgeInsets.all(Grid.gutter), + padding: _activityScrollPadding( + context, + horizontal: Grid.gutter, + top: Grid.gutter, + bottom: Grid.gutter, + ), itemCount: 8, separatorBuilder: (_, _) => const SizedBox(height: Grid.xs), itemBuilder: (context, _) => Row( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a24b6c07ee..121d3c1750 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' show min; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -32,6 +33,7 @@ import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; @@ -125,6 +127,7 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final composerDockHeight = useState(0.0); final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); @@ -293,122 +296,162 @@ class ChannelDetailPage extends HookConsumerWidget { ), ], ), - body: Column( + body: Stack( + fit: StackFit.expand, children: [ - Expanded( - child: resolvedChannel.isForum - ? Stack( - fit: StackFit.expand, - children: [ - ForumPostsView( - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), - if (showConnectionSkeleton.value) - Positioned( - top: - frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, - ) + - Grid.xs, - left: Grid.gutter, - right: Grid.gutter, - child: _ForumConnectionSkeleton( - status: sessionStatus, - ), - ), - ], - ) - : SkeletonReveal( - loading: - showInitialConnectionSkeleton || - showConnectionSkeleton.value || - messagesState.isLoading, - shimmerEnabled: sessionStatus != SessionStatus.disconnected, - skeleton: _MessageTimelineSkeleton( - appBarTitleContentHeight: appBarTitleContentHeight, - status: sessionStatus, - ), - content: messagesState.when( - loading: SizedBox.shrink, - error: (e, _) => Padding( - padding: EdgeInsets.only( - top: frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, + Column( + children: [ + Expanded( + child: resolvedChannel.isForum + ? Stack( + fit: StackFit.expand, + children: [ + ForumPostsView( + channel: resolvedChannel, + currentPubkey: currentPubkey, ), + if (showConnectionSkeleton.value) + Positioned( + top: + frostedAppBarHeight( + context, + titleContentHeight: + appBarTitleContentHeight, + ) + + Grid.xs, + left: Grid.gutter, + right: Grid.gutter, + child: _ForumConnectionSkeleton( + status: sessionStatus, + ), + ), + ], + ) + : SkeletonReveal( + loading: + showInitialConnectionSkeleton || + showConnectionSkeleton.value || + messagesState.isLoading, + shimmerEnabled: + sessionStatus != SessionStatus.disconnected, + skeleton: _MessageTimelineSkeleton( + appBarTitleContentHeight: appBarTitleContentHeight, + status: sessionStatus, ), - child: Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + content: messagesState.when( + loading: SizedBox.shrink, + error: (e, _) => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ), + ), + child: Center( + child: Text( + 'Failed to load messages', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + ), ), ), + data: (events) { + final messages = formatTimeline( + events, + currentPubkey: currentPubkey, + ); + final summaries = ref + .read( + channelMessagesProvider(channel.id).notifier, + ) + .threadSummaries; + final entries = buildMainTimelineEntries( + messages, + relaySummaries: summaries, + ); + return _MessageList( + entries: entries, + allMessages: messages, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + channelId: channel.id, + currentPubkey: currentPubkey, + isMember: resolvedChannel.isMember, + isArchived: resolvedChannel.isArchived, + appBarTitleContentHeight: + appBarTitleContentHeight, + composerBottomInset: composerDockHeight.value, + ); + }, ), ), - data: (events) { - final messages = formatTimeline( - events, - currentPubkey: currentPubkey, - ); - final summaries = ref - .read(channelMessagesProvider(channel.id).notifier) - .threadSummaries; - final entries = buildMainTimelineEntries( - messages, - relaySummaries: summaries, - ); - return _MessageList( - entries: entries, - allMessages: messages, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - channelId: channel.id, - currentPubkey: currentPubkey, - isMember: resolvedChannel.isMember, - isArchived: resolvedChannel.isArchived, - appBarTitleContentHeight: appBarTitleContentHeight, - ); - }, - ), - ), + ), + if (!resolvedChannel.isForum && + (!resolvedChannel.isMember || + resolvedChannel.isArchived)) ...[ + 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), + ), + if (!resolvedChannel.isDm) + _ReadOnlyNotice(channel: resolvedChannel), + ], + ], ), - if (!resolvedChannel.isForum) - 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), - ), if (!resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived) - ComposeBar( - channelId: channel.id, - channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - mediaTags: mediaTags, - ), - ) - else if (!resolvedChannel.isDm && - (!resolvedChannel.isMember || resolvedChannel.isArchived)) - _ReadOnlyNotice(channel: resolvedChannel), + 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 >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + 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 eacba5856e..ac73a5076e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -10,6 +10,7 @@ class _MessageList extends HookConsumerWidget { final bool isMember; final bool isArchived; final double appBarTitleContentHeight; + final double composerBottomInset; const _MessageList({ required this.entries, @@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget { required this.isMember, required this.isArchived, required this.appBarTitleContentHeight, + required this.composerBottomInset, }); @override @@ -259,7 +261,7 @@ class _MessageList extends HookConsumerWidget { context, titleContentHeight: appBarTitleContentHeight, ), - bottom: 0, + bottom: composerBottomInset, ), itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -356,25 +358,76 @@ class _MessageList extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: Grid.xs, + bottom: composerBottomInset + Grid.xs, child: Center( - child: FilledButton.icon( + child: _JumpToLatestButton( key: const ValueKey('channel-jump-to-latest'), onPressed: scrollToLatest, - style: FilledButton.styleFrom( - backgroundColor: context.colors.primaryContainer, - foregroundColor: context.colors.onPrimaryContainer, + ), + ), + ), + ], + ); + } +} + +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, + ), + ), + ], + ), ), - icon: const Icon(LucideIcons.arrowDown, size: 16), - label: const Text('Latest'), ), ), ), - ], + ), + ), ); } } diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 0372690e1e..e22a31684e 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -27,12 +27,18 @@ class _SystemMessageRow extends ConsumerWidget { final userCache = ref.watch(userCacheProvider); final sourceMessages = groupedMessages ?? [message]; final groupedMembership = _membershipDisplayEvent(sourceMessages); - final channelCreator = systemEvent.type == SystemEventType.channelCreated - ? systemEvent.actorPubkey?.trim() - : null; + final messageStyleAction = switch (systemEvent.type) { + SystemEventType.channelCreated => 'created this channel', + SystemEventType.huddleStarted => 'started a huddle', + SystemEventType.huddleEnded => 'ended the huddle', + _ => null, + }; + final messageStyleActor = messageStyleAction == null + ? null + : systemEvent.actorPubkey?.trim(); final usesMessageStyleLayout = groupedMembership != null || - (channelCreator != null && channelCreator.isNotEmpty); + (messageStyleActor != null && messageStyleActor.isNotEmpty); String resolveLabel(String? pubkey) { if (pubkey == null) return 'Someone'; @@ -102,13 +108,15 @@ class _SystemMessageRow extends ConsumerWidget { resolveLabel: resolveLabel, userCache: userCache, ) - else if (channelCreator != null && channelCreator.isNotEmpty) + else if (messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null) _MessageStyleSystemMessageContent( - displayPubkey: channelCreator, + displayPubkey: messageStyleActor, createdAt: message.createdAt, resolveLabel: resolveLabel, userCache: userCache, - actionSpans: const [TextSpan(text: 'created this channel')], + actionSpans: [TextSpan(text: messageStyleAction)], ) else Row( diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index d021b9e287..0543f13b06 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; @@ -72,13 +73,11 @@ class ChannelTypingIndicator extends ConsumerWidget { ), const SizedBox(width: Grid.xxs), Flexible( - child: Text( + child: _TypingTextShimmer( text, style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontStyle: FontStyle.italic, + color: context.colors.onSurfaceVariant, ), - overflow: TextOverflow.ellipsis, ), ), ], @@ -87,3 +86,62 @@ class ChannelTypingIndicator extends ConsumerWidget { ); } } + +class _TypingTextShimmer extends HookWidget { + final String text; + final TextStyle? style; + + const _TypingTextShimmer(this.text, {this.style}); + + @override + Widget build(BuildContext context) { + final animation = useAnimationController( + duration: const Duration(milliseconds: 2600), + ); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final baseColor = style?.color ?? context.colors.onSurfaceVariant; + final highlightColor = + Color.lerp(context.colors.surface, baseColor, 0.4) ?? baseColor; + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + final label = Text(text, style: style, overflow: TextOverflow.ellipsis); + if (reducedMotion) return label; + + return RepaintBoundary( + child: AnimatedBuilder( + animation: animation, + child: label, + builder: (context, child) { + final center = 1.5 - (animation.value * 3); + return ShaderMask( + key: const ValueKey('channel-typing-shimmer'), + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment(center - 1, 0), + end: Alignment(center + 1, 0), + colors: [ + baseColor, + baseColor, + highlightColor, + baseColor, + baseColor, + ], + stops: const [0, 0.34, 0.5, 0.66, 1], + ).createShader(bounds), + child: child, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index f9fe5453d7..febe16e996 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -1,5 +1,7 @@ part of '../channels_page.dart'; +const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0); + class _CustomChannelSection extends StatelessWidget { final ChannelSection section; final List channels; @@ -178,32 +180,42 @@ class _CustomSectionHeader extends ConsumerWidget { context: buttonContext, width: 216, alignment: AnchoredPopoverAlignment.end, - color: context.colors.surface, - elevation: 4, - shadowColor: context.colors.shadow.withValues(alpha: 0.18), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: context.colors.outline), - ), surfaceKey: ValueKey('section-popover-${section.id}'), items: [ const PopupMenuItem( value: 'rename', - child: Text('Rename'), + padding: _sectionMenuItemPadding, + child: _SectionMenuItemContent( + icon: LucideIcons.pencil, + label: 'Rename section', + ), ), PopupMenuItem( value: 'move_up', enabled: !isFirst, - child: const Text('Move Up'), + padding: _sectionMenuItemPadding, + child: const _SectionMenuItemContent( + icon: LucideIcons.arrowUp, + label: 'Move up', + ), ), PopupMenuItem( value: 'move_down', enabled: !isLast, - child: const Text('Move Down'), + padding: _sectionMenuItemPadding, + child: const _SectionMenuItemContent( + icon: LucideIcons.arrowDown, + label: 'Move down', + ), ), - const PopupMenuItem( + PopupMenuItem( value: 'delete', - child: Text('Delete'), + padding: _sectionMenuItemPadding, + child: _SectionMenuItemContent( + icon: LucideIcons.trash2, + label: 'Delete section', + color: context.colors.error, + ), ), ], ); @@ -229,6 +241,36 @@ class _CustomSectionHeader extends ConsumerWidget { } } +class _SectionMenuItemContent extends StatelessWidget { + final IconData icon; + final String label; + final Color? color; + + const _SectionMenuItemContent({ + required this.icon, + required this.label, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: color == null ? null : TextStyle(color: color), + ), + ), + ], + ); + } +} + CustomEmoji? _resolveCustomEmoji(String icon, List palette) { if (!icon.startsWith(':') || !icon.endsWith(':')) return null; final shortcode = normalizeShortcode(icon); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 7560f998f3..d600bc465e 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math' as math; +import 'dart:ui' show FlutterView; import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; @@ -19,8 +20,10 @@ import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; @@ -48,6 +51,7 @@ part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; +part 'compose_bar/dock.dart'; const _maxConcurrentImageUploads = 3; @@ -84,6 +88,7 @@ class ComposeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useMemoized(_MarkdownEditingController.new); + useListenable(controller); useEffect(() => controller.dispose, [controller]); // Restore and persist unsent text as a local draft so the Activity @@ -126,6 +131,11 @@ class ComposeBar extends HookConsumerWidget { return () => controller.removeListener(persistDraft); }, [controller, draftKey, draftIdentity]); final focusNode = useFocusNode(); + useEffect( + () => + () => _dismissComposerKeyboard(focusNode), + [focusNode], + ); final isComposerExpanded = useState(false); final attachmentSurface = useState(_AttachmentSurface.closed); final iosAttachmentPopover = useMemoized( @@ -144,6 +154,7 @@ class ComposeBar extends HookConsumerWidget { final clipboardHasImage = useState(false); final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; + final canSend = controller.text.trim().isNotEmpty || hasAttachments; final customEmoji = ref.watch(customEmojiListProvider); final reducedMotion = MediaQuery.disableAnimationsOf(context); final composerExpansionController = useAnimationController( @@ -155,6 +166,34 @@ class ComposeBar extends HookConsumerWidget { .clamp(0.0, 1.0) .toDouble(); + void collapseComposer() { + if (!isComposerExpanded.value) return; + showFormatting.value = false; + isComposerExpanded.value = false; + } + + // A focus loss covers deliberate dismiss gestures. The metrics observer + // also catches the system back/swipe dismissal path, where the platform can + // hide the keyboard while Flutter keeps the TextField focused. + useEffect(() { + void collapseWhenUnfocused() { + if (!focusNode.hasFocus) collapseComposer(); + } + + focusNode.addListener(collapseWhenUnfocused); + return () => focusNode.removeListener(collapseWhenUnfocused); + }, [focusNode]); + + final appView = View.of(context); + useEffect(() { + final observer = _ComposerKeyboardMetricsObserver( + view: appView, + onKeyboardHidden: collapseComposer, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [appView]); + final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); @@ -167,8 +206,8 @@ class ComposeBar extends HookConsumerWidget { composerExpansionController.animateWith( SpringSimulation( SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 280), - bounce: 0.16, + duration: const Duration(milliseconds: 220), + bounce: 0.08, ), composerExpansionController.value, target, @@ -887,64 +926,16 @@ 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. - return Padding( - padding: EdgeInsets.only( - left: Grid.twelve, - right: Grid.twelve, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: OverlayPortal.overlayChildLayoutBuilder( + final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; + return _ComposerDockFrame( + widthFactor: composerWidthFactor, + child: _ComposerOverlayPortal( controller: suggestionOverlayController, - overlayChildBuilder: (context, layoutInfo) { - final composerOrigin = MatrixUtils.transformPoint( - layoutInfo.childPaintTransform, - Offset.zero, - ); - return ValueListenableBuilder<_AttachmentSurface>( - valueListenable: attachmentSurface, - builder: (context, surface, _) { - final surfaceDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? 320 - : 250, - ); - final expandedSurfaceCoversComposer = - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos; - final overlayAnchorY = - composerOrigin.dy + - (expandedSurfaceCoversComposer - ? layoutInfo.childSize.height + Grid.twelve - : 0); - return AnimatedPositioned( - duration: surfaceDuration, - curve: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? const Cubic(0.34, 1.25, 0.64, 1) - : const Cubic(0.22, 1, 0.36, 1), - left: composerOrigin.dx, - bottom: layoutInfo.overlaySize.height - overlayAnchorY, - width: layoutInfo.childSize.width, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: surface == _AttachmentSurface.closed - ? _SuggestionPanelMotion( - duration: surfaceDuration, - alignment: Alignment.bottomLeft, - child: buildOverlayPanel(surface), - ) - : buildOverlayPanel(surface), - ), - ), - ); - }, - ); + attachmentSurface: attachmentSurface, + reducedMotion: reducedMotion, + buildOverlayPanel: buildOverlayPanel, + onDismissAttachmentSurface: () { + attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( attachments: attachments.value, @@ -977,12 +968,13 @@ class ComposeBar extends HookConsumerWidget { }, onEmoji: () { attachmentSurface.value = _AttachmentSurface.closed; - showEmojiPicker(context: context, onSelect: insertEmoji); + _showComposerEmojiPicker(context, insertEmoji); }, onOpenFormatting: () { attachmentSurface.value = _AttachmentSurface.closed; showFormatting.value = true; }, + canSend: canSend, hasPendingUploads: hasPendingUploads, isSending: isSending.value, ), diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 7c53ae1098..eee4d222ac 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -177,7 +177,7 @@ class _AttachmentSurfacePanel extends HookWidget { final height = menuLayout.height + ((expandedHeight - menuLayout.height) * sizeProgress); - final baseColor = context.colors.surfaceContainerHighest; + final baseColor = appPopoverColor(context); final expandedColor = visibleExpandedSurface == _AttachmentSurface.camera ? Colors.black @@ -189,57 +189,51 @@ class _AttachmentSurfacePanel extends HookWidget { child: SizedBox( width: width, height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: Color.lerp(baseColor, expandedColor, sizeProgress), - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.dialog), - child: Material( - type: MaterialType.transparency, - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - Positioned( - left: 0, - top: 0, - width: _attachmentMenuWidth, - height: menuLayout.height, - child: IgnorePointer( - ignoring: surface != _AttachmentSurface.menu, - child: Opacity( - opacity: menuOpacity, - child: _AttachmentMenu( - layout: menuLayout, - onCamera: onCamera, - onPhotos: onPhotos, - onVideo: onVideo, - onFiles: onFiles, - ), - ), + child: Material( + key: const ValueKey('attachment-surface-popover'), + type: MaterialType.card, + color: Color.lerp(baseColor, expandedColor, sizeProgress), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), + clipBehavior: Clip.antiAlias, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: 0, + top: 0, + width: _attachmentMenuWidth, + height: menuLayout.height, + child: IgnorePointer( + ignoring: surface != _AttachmentSurface.menu, + child: Opacity( + opacity: menuOpacity, + child: _AttachmentMenu( + layout: menuLayout, + onCamera: onCamera, + onPhotos: onPhotos, + onVideo: onVideo, + onFiles: onFiles, ), ), - Positioned( - left: 0, - top: 0, - width: expandedWidth, - height: expandedHeight, - child: IgnorePointer( - ignoring: !isExpanded, - child: Opacity( - opacity: expandedOpacity, - child: expandedContent, - ), - ), + ), + ), + Positioned( + left: 0, + top: 0, + width: expandedWidth, + height: expandedHeight, + child: IgnorePointer( + ignoring: !isExpanded, + child: Opacity( + opacity: expandedOpacity, + child: expandedContent, ), - ], + ), ), - ), + ], ), ), ), @@ -308,7 +302,7 @@ class _AttachmentTrigger extends StatelessWidget { _AttachmentSurface.camera || _AttachmentSurface.photos => 'Back to attachment options', }, - onPressed: () => onTap(context), + onPressed: () => _runComposerAction(() => onTap(context)), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, icon: AnimatedRotation( @@ -417,7 +411,7 @@ class _AttachmentMenuItem extends StatelessWidget { child: Tooltip( message: label, child: InkWell( - onTap: onTap, + onTap: () => _runComposerAction(onTap), child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( @@ -617,7 +611,8 @@ class _AttachmentStrip extends StatelessWidget { width: 24, height: 24, child: IconButton( - onPressed: () => onRemove(attachment.url), + onPressed: () => + _runComposerAction(() => onRemove(attachment.url)), tooltip: 'Remove attachment', visualDensity: VisualDensity.compact, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index e51c2d16aa..1a06534d6e 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -252,7 +252,7 @@ class _CameraCaptureButton extends StatelessWidget { button: true, label: 'Take photo', child: GestureDetector( - onTap: isPressed ? null : onTap, + onTap: isPressed ? null : () => _runComposerAction(onTap), child: AnimatedScale( scale: isPressed ? 0.92 : 1, duration: duration, @@ -290,7 +290,7 @@ class _CameraCloseButton extends StatelessWidget { return SizedBox.square( dimension: emphasized ? _cameraBackSize : 36, child: IconButton( - onPressed: onTap, + onPressed: () => _runComposerAction(onTap), tooltip: 'Back to attachment options', padding: EdgeInsets.zero, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart new file mode 100644 index 0000000000..f19fe19e46 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -0,0 +1,144 @@ +part of '../compose_bar.dart'; + +class _ComposerDockFrame extends StatelessWidget { + final double widthFactor; + final Widget child; + + const _ComposerDockFrame({required this.widthFactor, required this.child}); + + @override + Widget build(BuildContext context) { + final backdropHeight = mobileTabFooterBackdropHeight(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: const ValueKey('composer-footer-gradient'), + left: 0, + right: 0, + bottom: 0, + height: backdropHeight, + child: IgnorePointer( + child: MobileTabFooterBackdrop(height: backdropHeight), + ), + ), + Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + key: const ValueKey('composer-width-transition'), + widthFactor: widthFactor, + child: child, + ), + ), + ), + ], + ); + } +} + +class _ComposerOverlayPortal extends StatelessWidget { + final OverlayPortalController controller; + final ValueListenable<_AttachmentSurface> attachmentSurface; + final bool reducedMotion; + final Widget Function(_AttachmentSurface surface) buildOverlayPanel; + final VoidCallback onDismissAttachmentSurface; + final Widget child; + + const _ComposerOverlayPortal({ + required this.controller, + required this.attachmentSurface, + required this.reducedMotion, + required this.buildOverlayPanel, + required this.onDismissAttachmentSurface, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return OverlayPortal.overlayChildLayoutBuilder( + controller: controller, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return ValueListenableBuilder<_AttachmentSurface>( + valueListenable: attachmentSurface, + builder: (context, surface, _) { + final surfaceDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + final expandedSurfaceCoversComposer = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final surfaceLeft = expandedSurfaceCoversComposer + ? Grid.twelve + : composerOrigin.dx; + final surfaceWidth = expandedSurfaceCoversComposer + ? layoutInfo.overlaySize.width - (Grid.twelve * 2) + : layoutInfo.childSize.width; + final overlayAnchorY = + composerOrigin.dy + + (expandedSurfaceCoversComposer + ? layoutInfo.childSize.height + Grid.twelve + : 0); + return Stack( + children: [ + if (surface != _AttachmentSurface.closed) + Positioned( + left: 0, + top: 0, + right: 0, + height: composerOrigin.dy, + child: ExcludeSemantics( + child: GestureDetector( + key: const ValueKey('attachment-dismiss-barrier'), + behavior: HitTestBehavior.opaque, + onTap: onDismissAttachmentSurface, + ), + ), + ), + AnimatedPositioned( + duration: surfaceDuration, + curve: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? const Cubic(0.34, 1.25, 0.64, 1) + : const Cubic(0.22, 1, 0.36, 1), + left: surfaceLeft, + bottom: layoutInfo.overlaySize.height - overlayAnchorY, + width: surfaceWidth, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: surface == _AttachmentSurface.closed + ? _SuggestionPanelMotion( + duration: surfaceDuration, + alignment: Alignment.bottomLeft, + child: buildOverlayPanel(surface), + ) + : buildOverlayPanel(surface), + ), + ), + ), + ], + ); + }, + ); + }, + child: child, + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index c9089a99cb..c6dc65a8fa 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -58,7 +58,7 @@ class _FormatButton extends StatelessWidget { message: tooltip, child: InkWell( borderRadius: BorderRadius.circular(Radii.sm), - onTap: onTap, + onTap: () => _runComposerAction(onTap), child: Padding( padding: const EdgeInsets.all(Grid.xxs), child: Icon(icon, size: 18, color: context.colors.primary), @@ -80,7 +80,7 @@ class _ComposeAction extends StatelessWidget { width: 36, height: 36, child: IconButton( - onPressed: onTap, + onPressed: () => _runComposerAction(onTap), icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 145d28fb44..d9a22039c7 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,6 +1,45 @@ part of '../compose_bar.dart'; const _typingThrottleMs = 3000; + +class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { + final FlutterView view; + final VoidCallback onKeyboardHidden; + bool _wasVisible; + + _ComposerKeyboardMetricsObserver({ + required this.view, + required this.onKeyboardHidden, + }) : _wasVisible = view.viewInsets.bottom > 0; + + @override + void didChangeMetrics() { + final isVisible = view.viewInsets.bottom > 0; + if (_wasVisible && !isVisible) onKeyboardHidden(); + _wasVisible = isVisible; + } +} + +void _runComposerAction(VoidCallback action) { + unawaited(HapticFeedback.selectionClick()); + action(); +} + +void _showComposerEmojiPicker( + BuildContext context, + ValueChanged onSelect, +) { + showEmojiPicker( + context: context, + onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)), + ); +} + +void _dismissComposerKeyboard(FocusNode focusNode) { + focusNode.unfocus(); + unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); +} + const _pastedImageMimeTypes = [ 'image/jpeg', 'image/jpg', diff --git a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart index 039b5160a3..ade2bec94e 100644 --- a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -146,7 +146,9 @@ class _IOSInlinePhotoPicker extends HookWidget { ), child: IconButton( key: const ValueKey('ios-inline-photo-picker-back'), - onPressed: isProcessing.value ? null : onBack, + onPressed: isProcessing.value + ? null + : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', icon: const Icon( LucideIcons.chevronLeft, @@ -164,11 +166,12 @@ class _IOSInlinePhotoPicker extends HookWidget { child: FilledButton( key: const ValueKey('ios-inline-photo-picker-select'), onPressed: canSelect - ? submitSelection + ? () => + _runComposerAction(() => unawaited(submitSelection())) : selectedCount.value == 0 && !isPreparingSelection.value && !isProcessing.value - ? openAllPhotos + ? () => _runComposerAction(() => unawaited(openAllPhotos())) : null, style: FilledButton.styleFrom( backgroundColor: Colors.black.withValues(alpha: 0.76), diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 31b930e36d..e0adb9621c 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -25,6 +25,7 @@ class _ComposeBarLayout extends StatelessWidget { final VoidCallback onChannel; final VoidCallback onEmoji; final VoidCallback onOpenFormatting; + final bool canSend; final bool hasPendingUploads; final bool isSending; @@ -53,6 +54,7 @@ class _ComposeBarLayout extends StatelessWidget { required this.onChannel, required this.onEmoji, required this.onOpenFormatting, + required this.canSend, required this.hasPendingUploads, required this.isSending, }); @@ -63,10 +65,17 @@ class _ComposeBarLayout extends StatelessWidget { } Widget _buildBar(BuildContext context) { + final trimmedDraft = controller.text.trim(); + 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(Radii.dialog), + borderRadius: BorderRadius.circular(composerRadius), border: Border.all( color: Colors.black.withValues(alpha: 0.04), width: 1, @@ -142,7 +151,7 @@ class _ComposeBarLayout extends StatelessWidget { label: resolvedHint, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onExpand, + onTap: () => _runComposerAction(onExpand), child: Padding( padding: const EdgeInsets.symmetric( vertical: Grid.half, @@ -150,9 +159,13 @@ class _ComposeBarLayout extends StatelessWidget { child: Align( alignment: Alignment.centerLeft, child: Text( - resolvedHint, + collapsedText, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, + color: trimmedDraft.isEmpty + ? context.colors.onSurfaceVariant + : context.colors.onSurface, ), ), ), @@ -160,6 +173,12 @@ class _ComposeBarLayout extends StatelessWidget { ), ), ), + const SizedBox(width: Grid.xxs), + _SendButton( + isDisabled: !canSend || hasPendingUploads, + isSending: isSending, + onTap: onSend, + ), ], ), ClipRect( @@ -167,7 +186,7 @@ class _ComposeBarLayout extends StatelessWidget { alignment: Alignment.topCenter, heightFactor: expansionValue, child: IgnorePointer( - ignoring: expansionValue < 0.98, + ignoring: !isExpanded, child: Opacity( opacity: expansionProgress, child: Transform.translate( @@ -225,7 +244,8 @@ class _ComposeBarLayout extends StatelessWidget { ), const Spacer(), _SendButton( - isDisabled: hasPendingUploads, + isDisabled: + !canSend || hasPendingUploads, isSending: isSending, onTap: onSend, ), diff --git a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 8b19b74aa2..5985874065 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -136,7 +136,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { photo: photo, selectionIndex: selectionIndex, reducedMotion: reducedMotion, - onTap: () => togglePhoto(photo), + onTap: () => _runComposerAction(() => togglePhoto(photo)), ); }, ); @@ -154,7 +154,9 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { children: [ IconButton( key: const ValueKey('photo-gallery-back'), - onPressed: isResolving.value ? null : onBack, + onPressed: isResolving.value + ? null + : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', visualDensity: VisualDensity.compact, icon: const Icon(LucideIcons.arrowLeft, size: 20), @@ -212,7 +214,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { child: selectedCount == 0 ? OutlinedButton.icon( key: const ValueKey('photo-gallery-action'), - onPressed: isResolving.value ? null : choosePhotos, + onPressed: isResolving.value + ? null + : () => _runComposerAction( + () => unawaited(choosePhotos()), + ), icon: isResolving.value ? BuzzLoadingIndicator( size: 22, @@ -224,7 +230,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { ) : FilledButton.icon( key: const ValueKey('photo-gallery-action'), - onPressed: isResolving.value ? null : choosePhotos, + onPressed: isResolving.value + ? null + : () => _runComposerAction( + () => unawaited(choosePhotos()), + ), icon: isResolving.value ? const BuzzLoadingIndicator( size: 22, diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index 54060ae948..bbe9d2aaca 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -17,7 +17,9 @@ class _SendButton extends StatelessWidget { width: 36, height: 36, child: IconButton( - onPressed: (isSending || isDisabled) ? null : onTap, + onPressed: (isSending || isDisabled) + ? null + : () => _runComposerAction(onTap), style: IconButton.styleFrom( backgroundColor: context.colors.primary, disabledBackgroundColor: context.colors.primary.withValues( diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index ed97284d46..7b8e7c175b 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -111,54 +111,55 @@ class _MentionSuggestions extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(maxHeight: 240), + return Material( + key: const ValueKey('mention-suggestions-popover'), + type: MaterialType.card, + color: appPopoverColor(context), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - itemCount: suggestions.length, - separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) { - final candidate = suggestions[index]; - final name = candidate.label; - final avatarUrl = - candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox.shrink(), + itemBuilder: (context, index) { + final candidate = suggestions[index]; + final name = candidate.label; + final avatarUrl = + candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; - return ListTile( - dense: true, - visualDensity: VisualDensity.compact, - leading: AvatarImage( - imageUrl: avatarUrl, - radius: 18, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - name[0].toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: AvatarImage( + imageUrl: avatarUrl, + radius: 18, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + name[0].toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), ), ), - ), - title: Text(name, style: context.textTheme.titleSmall), - subtitle: _MentionSuggestionInfo.build( - context, - candidate: candidate, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - userCache: userCache, - ), - onTap: () => onSelect(candidate), - ); - }, + title: Text(name, style: context.textTheme.titleSmall), + subtitle: _MentionSuggestionInfo.build( + context, + candidate: candidate, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + userCache: userCache, + ), + onTap: () => _runComposerAction(() => onSelect(candidate)), + ); + }, + ), ), ); } @@ -261,40 +262,41 @@ class _ChannelSuggestions extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(maxHeight: 240), + return Material( + key: const ValueKey('channel-suggestions-popover'), + type: MaterialType.card, + color: appPopoverColor(context), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - itemCount: suggestions.length, - separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) { - final channel = suggestions[index]; - return ListTile( - dense: true, - visualDensity: VisualDensity.compact, - horizontalTitleGap: 0, - leading: SizedBox.square( - dimension: 36, - child: Icon( - LucideIcons.hash, - size: 20, - color: context.colors.onSurfaceVariant, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox.shrink(), + itemBuilder: (context, index) { + final channel = suggestions[index]; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + horizontalTitleGap: 0, + leading: SizedBox.square( + dimension: 36, + child: Icon( + LucideIcons.hash, + size: 20, + color: context.colors.onSurfaceVariant, + ), ), - ), - title: Text(channel.name, style: context.textTheme.bodyLarge), - onTap: () => onSelect(channel), - ); - }, + title: Text(channel.name, style: context.textTheme.bodyLarge), + onTap: () => _runComposerAction(() => onSelect(channel)), + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/composer_dock_size_reporter.dart b/mobile/lib/features/channels/composer_dock_size_reporter.dart new file mode 100644 index 0000000000..38730729b3 --- /dev/null +++ b/mobile/lib/features/channels/composer_dock_size_reporter.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Reports the laid-out height of a floating composer dock. +/// +/// Message timelines use that height as scroll padding while still painting +/// beneath the dock, which lets the dock's fade reveal real timeline content. +class ComposerDockSizeReporter extends HookWidget { + final ValueChanged onHeightChanged; + final Widget child; + + const ComposerDockSizeReporter({ + super.key, + required this.onHeightChanged, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final sizeKey = useMemoized(GlobalKey.new); + final lastHeight = useRef(null); + + void reportHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderObject = sizeKey.currentContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final height = renderObject.size.height; + final previous = lastHeight.value; + if (previous != null && (previous - height).abs() < 0.5) return; + lastHeight.value = height; + onHeightChanged(height); + }); + } + + useEffect(() { + reportHeight(); + return null; + }, const []); + + return NotificationListener( + onNotification: (_) { + reportHeight(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: KeyedSubtree(key: sizeKey, child: child), + ), + ); + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 10292cfa28..f7e2d9f0bb 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/theme/theme.dart'; import 'recent_emoji_provider.dart'; diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 2ebe631edd..354a31e44c 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -99,10 +99,7 @@ class _EmojiTile extends StatelessWidget { button: true, label: entry.name, child: Center( - child: Text( - entry.native, - style: const TextStyle(fontSize: _emojiGlyphSize), - ), + child: NativeEmojiGlyph(emoji: entry.native, size: _emojiGlyphSize), ), ), ); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 519d25898c..df04800022 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -17,6 +17,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/widgets/sheet_divider.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; @@ -689,7 +690,7 @@ class _QuickReactionGlyph extends StatelessWidget { ); } } - return Text(value, style: const TextStyle(fontSize: 24)); + return NativeEmojiGlyph(emoji: value, size: 24); } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index a9511c587d..9d01b5c8e7 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -8,6 +8,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/emoji/positive_emoji.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; @@ -303,7 +304,7 @@ class _ReactionEmoji extends StatelessWidget { Widget build(BuildContext context) { final emojiUrl = reaction.emojiUrl; if (emojiUrl == null || emojiUrl.isEmpty) { - return Text(reaction.emoji, style: TextStyle(fontSize: size)); + return NativeEmojiGlyph(emoji: reaction.emoji, size: size); } final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1); return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 810861aa00..db77316de4 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -20,6 +20,7 @@ import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; @@ -58,6 +59,7 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final composerDockHeight = useState(0.0); // 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. @@ -241,176 +243,212 @@ class ThreadDetailPage extends HookConsumerWidget { title: Text('Thread'), titleStyle: channelTitleTextStyle, ), - body: Column( + body: Stack( + fit: StackFit.expand, children: [ - Expanded( - child: KeyboardDismissOnDrag( - 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, - ), - itemCount: replies.length + 1, // +1 for thread head - itemBuilder: (context, index) { - if (index == headIndex) { - if (liveDeletionHidesHead) { - return const Padding( - key: ValueKey('thread-message-deleted'), - padding: EdgeInsets.only(bottom: Grid.xs), - child: Text('This message was deleted'), - ); - } - return Padding( - key: ValueKey('thread-message-group-${liveHead.id}'), - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DayDivider( - label: formatDayHeading(liveHead.createdAt), - ), - _ThreadMessage( - message: liveHead, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: liveHead.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - isThreadHead: true, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.xxs, - ), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), + Column( + children: [ + Expanded( + child: KeyboardDismissOnDrag( + 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, + ), + itemCount: replies.length + 1, // +1 for thread head + itemBuilder: (context, index) { + if (index == headIndex) { + if (liveDeletionHidesHead) { + return const Padding( + key: ValueKey('thread-message-deleted'), + padding: EdgeInsets.only(bottom: Grid.xs), + child: Text('This message was deleted'), + ); + } + return Padding( + key: ValueKey('thread-message-group-${liveHead.id}'), + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayDivider( + label: formatDayHeading(liveHead.createdAt), + ), + _ThreadMessage( + message: liveHead, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + isHighlighted: liveHead.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + isThreadHead: true, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.xxs, ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, - ), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), - ); - } - - // Chronological list: index 1 = oldest reply. - final chronIdx = index - 1; - final reply = replies[chronIdx]; - final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null; - final previousMessage = prevReply ?? liveHead; - final showDayDivider = !isSameDay( - previousMessage.createdAt, - reply.createdAt, - ); - final showAuthor = - prevReply == null || - showDayDivider || - prevReply.pubkey.toLowerCase() != - 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 - ? _buildNestedSummary(reply.id, nestedChildren) - : null; - - 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, - children: [ - if (showDayDivider) - DayDivider(label: formatDayHeading(reply.createdAt)), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, + ); + } + + // Chronological list: index 1 = oldest reply. + final chronIdx = index - 1; + final reply = replies[chronIdx]; + final prevReply = chronIdx > 0 + ? replies[chronIdx - 1] + : null; + final previousMessage = prevReply ?? liveHead; + final showDayDivider = !isSameDay( + previousMessage.createdAt, + reply.createdAt, + ); + final showAuthor = + prevReply == null || + showDayDivider || + prevReply.pubkey.toLowerCase() != + 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 + ? _buildNestedSummary(reply.id, nestedChildren) + : null; + + 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, + children: [ + if (showDayDivider) + DayDivider( + label: formatDayHeading(reply.createdAt), + ), + _ThreadMessage( + message: reply, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + ), + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ], - ), - ); - }, + ); + }, + ), + ), ), - ), - ), - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: threadTyping.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: threadTyping), + if (!isMember || isArchived) + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: threadTyping.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: threadTyping), + ), + ], ), if (isMember && !isArchived) - ComposeBar( - channelId: channelId, - hintText: 'Reply in thread\u2026', - threadHeadId: threadHead.id, - rootId: effectiveRootId, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - mediaTags: mediaTags, - ), + Align( + alignment: Alignment.bottomCenter, + child: ComposerDockSizeReporter( + key: const ValueKey('thread-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: threadTyping.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: threadTyping), + ), + ComposeBar( + channelId: channelId, + hintText: 'Reply in thread\u2026', + threadHeadId: threadHead.id, + rootId: effectiveRootId, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), + ), + ], + ), + ), ), ], ), diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart new file mode 100644 index 0000000000..7831c1b32b --- /dev/null +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A standalone system emoji whose visual centre matches its surrounding UI. +/// +/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the +/// layout box unchanged and lift only the painted glyph on iOS; Android's +/// system emoji metrics are already visually centred. +class NativeEmojiGlyph extends StatelessWidget { + final String emoji; + final double size; + + const NativeEmojiGlyph({super.key, required this.emoji, required this.size}); + + @override + Widget build(BuildContext context) { + final glyph = Text(emoji, style: TextStyle(fontSize: size)); + if (defaultTargetPlatform != TargetPlatform.iOS) return glyph; + + return Transform.translate(offset: const Offset(0, -1), child: glyph); + } +} diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index f8000d79e2..1407357062 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -16,6 +16,7 @@ class Radii { static const double md = 8.0; static const double sm = 6.0; static const double card = 12.0; // grouped settings cards + static const double popover = 20.0; static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs /// Fully rounds pills, circles, and other capsule shapes. @@ -277,13 +278,22 @@ class AppTheme { labelPadding: EdgeInsets.zero, ), - // Popups/menus: desktop uses rounded-md (8px) + // Popups/menus share the elevated 20px mobile popover treatment. popupMenuTheme: PopupMenuThemeData( - color: scheme.surface, - elevation: 4, + color: scheme.surface.withValues(alpha: 0.98), + elevation: 8, + shadowColor: scheme.shadow.withValues(alpha: 0.18), + surfaceTintColor: Colors.transparent, + textStyle: textTheme.labelLarge?.copyWith(color: scheme.onSurface), + labelTextStyle: WidgetStatePropertyAll( + textTheme.labelLarge?.copyWith(color: scheme.onSurface), + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: scheme.outline), + borderRadius: BorderRadius.circular(Radii.popover), + side: BorderSide( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), ), diff --git a/mobile/lib/shared/theme/message_typography.dart b/mobile/lib/shared/theme/message_typography.dart index 1073626390..5a5d2c2c6e 100644 --- a/mobile/lib/shared/theme/message_typography.dart +++ b/mobile/lib/shared/theme/message_typography.dart @@ -88,8 +88,14 @@ const contentListBodyTextStyle = TextStyle( /// Timestamps in compact content lists. const contentListTimestampTextStyle = messageMetadataTextStyle; -/// Filter chip labels use the compact 15sp type ramp. -const filterChipTextStyle = messageMetadataTextStyle; +/// Filter chip labels use a tighter 15sp Inter treatment. +const filterChipTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1, + letterSpacing: 0, +); /// Search fields use the primary 15sp body treatment. const searchInputTextStyle = messageBodyTextStyle; diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 46b50b6b00..7188e021f9 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -9,6 +9,24 @@ const _popoverEnterDuration = Duration(milliseconds: 150); const _popoverExitDuration = Duration(milliseconds: 110); const _popoverStartScale = 0.96; +/// Elevation shared by anchored menus and composer popover surfaces. +const appPopoverElevation = 8.0; + +/// Returns the translucent surface color shared by app popovers. +Color appPopoverColor(BuildContext context) => + context.colors.surface.withValues(alpha: 0.98); + +/// Returns the shadow color shared by app popovers. +Color appPopoverShadowColor(BuildContext context) => + context.colors.shadow.withValues(alpha: 0.18); + +/// Returns the 20px shape and composer-matching hairline shared by popovers. +RoundedRectangleBorder appPopoverShape(BuildContext context) => + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.popover), + side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1), + ); + /// The horizontal edge a popover aligns to on its triggering control. enum AnchoredPopoverAlignment { /// Aligns the popover's leading edge with the trigger's leading edge. @@ -25,10 +43,10 @@ Future showAnchoredPopover({ required List> items, required double width, required AnchoredPopoverAlignment alignment, - required Color color, - required ShapeBorder shape, - required double elevation, - required Color shadowColor, + Color? color, + ShapeBorder? shape, + double elevation = appPopoverElevation, + Color? shadowColor, Offset offset = Offset.zero, EdgeInsetsGeometry menuPadding = EdgeInsets.zero, Clip clipBehavior = Clip.antiAlias, @@ -56,10 +74,10 @@ Future showAnchoredPopover({ width: width, alignment: alignment, offset: offset, - color: color, - shape: shape, + color: color ?? appPopoverColor(context), + shape: shape ?? appPopoverShape(context), elevation: elevation, - shadowColor: shadowColor, + shadowColor: shadowColor ?? appPopoverShadowColor(context), menuPadding: menuPadding, clipBehavior: clipBehavior, surfaceKey: surfaceKey, diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 1fe55712a5..628267c167 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -129,15 +129,23 @@ class FilterChipBar extends StatelessWidget { textAlign: fillWidth ? TextAlign.center : TextAlign.start, style: labelStyle, ); + final centeredLabel = Align( + alignment: Alignment.center, + widthFactor: fillWidth ? null : 1, + heightFactor: 1, + child: label, + ); final chip = FilterChip( selected: isSelected, showCheckmark: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.lg), + ), + side: BorderSide.none, label: fillWidth - ? SizedBox( - width: double.infinity, - child: Center(child: label), - ) - : label, + ? SizedBox(width: double.infinity, child: centeredLabel) + : centeredLabel, + labelPadding: EdgeInsets.zero, onSelected: (_) => onSelected(item.id), padding: EdgeInsets.symmetric( horizontal: fillWidth ? Grid.quarter : Grid.twelve, diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index 687880acb1..d972b8184f 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -16,6 +16,27 @@ double mobileTabFooterBackdropHeight(BuildContext context) => Grid.xl + Grid.gutter; +/// Builds the shared transparent-to-surface footer fade. +/// +/// Kept separate from [MobileTabFooterBackdrop] so floating controls such as +/// the channel composer can paint the exact same fade behind their own content. +LinearGradient mobileTabFooterBackdropGradient( + BuildContext context, { + List stops = const [0, 0.5, 1], + List opacities = const [0, 0.75, 1], +}) { + assert(stops.length == opacities.length); + final surface = context.colors.surface; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: stops, + colors: [ + for (final opacity in opacities) surface.withValues(alpha: opacity), + ], + ); +} + /// Shared fade behind the floating mobile tab bar. class MobileTabFooterBackdrop extends StatelessWidget { /// Vertical extent of the backdrop in logical pixels. @@ -39,20 +60,15 @@ class MobileTabFooterBackdrop extends StatelessWidget { @override Widget build(BuildContext context) { - final surface = context.colors.surface; return SizedBox( height: height, width: double.infinity, child: DecoratedBox( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + gradient: mobileTabFooterBackdropGradient( + context, stops: stops, - colors: [ - for (final opacity in opacities) - surface.withValues(alpha: opacity), - ], + opacities: opacities, ), ), ), diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index a0293455f2..aecb3303fc 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -13,6 +13,7 @@ import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; @@ -111,6 +112,7 @@ void main() { Map readContexts = const {}, List? channels, TextScaler? textScaler, + EdgeInsets mediaPadding = EdgeInsets.zero, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -133,12 +135,12 @@ void main() { ], child: MaterialApp( theme: AppTheme.light(), - builder: textScaler == null - ? null - : (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: child!, - ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, padding: mediaPadding), + child: child!, + ), home: const ActivityPage(), ), ); @@ -182,16 +184,22 @@ void main() { expect(find.byTooltip('Back'), findsNothing); }); - testWidgets('keeps bottom clearance for the floating tab bar', ( + testWidgets('keeps footer clearance inside the scrollable content', ( tester, ) async { - await tester.pumpWidget(await buildTestable()); + await tester.pumpWidget( + await buildTestable(mediaPadding: const EdgeInsets.only(bottom: 88)), + ); await tester.pumpAndSettle(); - final safeAreas = tester.widgetList(find.byType(SafeArea)); - expect(safeAreas, hasLength(1)); - expect(safeAreas.single.top, isFalse); - expect(safeAreas.single.bottom, isTrue); + final safeArea = tester.widget( + find.byKey(const ValueKey('activity-content-safe-area')), + ); + expect(safeArea.top, isFalse); + expect(safeArea.bottom, isFalse); + + final list = tester.widget(find.byType(ListView)); + expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96)); }); testWidgets('shows error view with retry button', (tester) async { @@ -253,7 +261,13 @@ void main() { final material = tester.widget(surface); final shape = material.shape! as RoundedRectangleBorder; - expect(shape.borderRadius, BorderRadius.circular(Radii.card)); + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); expect(material.surfaceTintColor, Colors.transparent); expect(material.clipBehavior, Clip.antiAlias); @@ -275,7 +289,11 @@ void main() { final optionsSurface = find.byKey( const ValueKey('activity-options-popover'), ); + final optionsMaterial = tester.widget(optionsSurface); + final optionsShape = optionsMaterial.shape! as RoundedRectangleBorder; expect(tester.getSize(optionsSurface).width, 216); + expect(optionsShape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(optionsMaterial.elevation, appPopoverElevation); expect( tester .widget( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f95c6fefed..f750756fc4 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -85,6 +85,25 @@ NostrEvent _systemMsg({ sig: '', ); +NostrEvent _huddleMsg({ + required String id, + required int kind, + String pubkey = 'alice', + int createdAt = 1000, +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: [ + ['h', _channelId], + ], + content: jsonEncode({ + 'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c', + }), + sig: '', +); + NostrEvent _reaction({ required String id, required String targetId, @@ -158,6 +177,7 @@ Widget _buildTestable({ Map> threadReplies = const {}, Map>> pendingThreadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, + bool disableAnimations = false, RelaySessionNotifier? relaySessionNotifier, }) { final resolvedChannel = channel ?? _testChannel; @@ -216,7 +236,10 @@ Widget _buildTestable({ child: MaterialApp( theme: AppTheme.light(), builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), + data: MediaQuery.of(context).copyWith( + textScaler: textScaler, + disableAnimations: disableAnimations, + ), child: child!, ), navigatorObservers: navigatorObservers, @@ -886,7 +909,15 @@ void main() { final messageList = tester.widget( find.byKey(const ValueKey('channel-message-list')), ); - expect(messageList.padding!.bottom, 0); + final composerDock = find.byKey(const ValueKey('channel-composer-dock')); + final composerDockHeight = tester.getSize(composerDock).height; + expect(messageList.padding!.bottom, composerDockHeight); + expect( + tester + .getBottomLeft(find.byKey(const ValueKey('channel-message-list'))) + .dy, + greaterThan(tester.getTopLeft(composerDock).dy), + ); final newestMessageGroup = tester.widget( find.byKey(const ValueKey('channel-message-group-msg2')), ); @@ -1115,6 +1146,26 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); + final latestSurface = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-surface')), + ); + final latestDecoration = latestSurface.decoration! as BoxDecoration; + expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full)); + expect( + latestDecoration.color, + AppTheme.light().colorScheme.surface.withValues(alpha: 0.5), + ); + expect( + (latestDecoration.border! as Border).top.color, + Colors.black.withValues(alpha: 0.04), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); messagesNotifier.setMessages([ ...initialMessages, @@ -1446,6 +1497,31 @@ void main() { ); }); + testWidgets('renders a huddle event like a regular message row', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [_huddleMsg(id: 'huddle-1', kind: EventKind.huddleStarted)], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(findRichText('started a huddle'), findsOneWidget); + expect( + tester.getSize(find.byType(CircleAvatar)), + const Size.square(messageAvatarSize), + ); + expect( + find.byKey(const ValueKey('system-message-timestamp-alice')), + findsOneWidget, + ); + }); + testWidgets('renders member_joined (self-join) system event', ( tester, ) async { @@ -2011,7 +2087,8 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice is typing…'), findsOneWidget); @@ -2030,7 +2107,15 @@ void main() { expect(decoration.border, isA()); expect( tester.widget(find.text('Alice is typing…')).style?.color, - AppTheme.light().colorScheme.primary, + AppTheme.light().colorScheme.onSurfaceVariant, + ); + expect( + tester.widget(find.text('Alice is typing…')).style?.fontStyle, + isNot(FontStyle.italic), + ); + expect( + find.byKey(const ValueKey('channel-typing-shimmer')), + findsOneWidget, ); expect(tester.widget(find.byType(SmallAvatar)).size, 24); }); @@ -2055,7 +2140,8 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice and Bob are typing…'), findsOneWidget); }); @@ -2085,10 +2171,39 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice and 2 others are typing…'), findsOneWidget); }); + + testWidgets('keeps typing text static when motion is reduced', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + disableAnimations: true, + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Alice is typing…'), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-typing-shimmer')), + findsNothing, + ); + }); }); group('Compose bar', () { @@ -2099,7 +2214,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); - expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); await tester.tap(find.text('Message #general')); await tester.pumpAndSettle(); @@ -2480,7 +2595,10 @@ void main() { find.byKey(const ValueKey('thread-message-list')), ); expect(threadList.reverse, isFalse); - expect(threadList.padding!.bottom, Grid.xs); + final threadComposerDockHeight = tester + .getSize(find.byKey(const ValueKey('thread-composer-dock'))) + .height; + expect(threadList.padding!.bottom, Grid.xs + threadComposerDockHeight); final newestThreadGroup = tester.widget( find.byKey(const ValueKey('thread-message-group-reply-next-day')), ); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 991db3b5cd..56032a5dbf 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -200,6 +200,80 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('section menu matches desktop labels, icons, and inset', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection(id: 'section-1', name: 'Design', order: 0), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('section-menu-section-1'))); + await tester.pumpAndSettle(); + + final popover = find.byKey(const Key('section-popover-section-1')); + expect(popover, findsOneWidget); + for (final label in [ + 'Rename section', + 'Move up', + 'Move down', + 'Delete section', + ]) { + expect( + find.descendant(of: popover, matching: find.text(label)), + findsOne, + ); + } + for (final icon in [ + LucideIcons.pencil, + LucideIcons.arrowUp, + LucideIcons.arrowDown, + LucideIcons.trash2, + ]) { + expect( + find.descendant(of: popover, matching: find.byIcon(icon)), + findsOne, + ); + } + + final menuItems = tester.widgetList>( + find.descendant( + of: popover, + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + expect(menuItems, hasLength(4)); + for (final item in menuItems) { + expect( + item.padding, + const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0), + ); + } + + final error = Theme.of(tester.element(popover)).colorScheme.error; + final deleteText = tester.widget(find.text('Delete section')); + final deleteIcon = tester.widget( + find.descendant(of: popover, matching: find.byIcon(LucideIcons.trash2)), + ); + expect(deleteText.style?.color, error); + expect(deleteIcon.color, error); + }); + testWidgets('aligns the top, section, row, and skeleton label columns', ( tester, ) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 919d4039eb..c73faf3050 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -23,6 +23,8 @@ import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; 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/anchored_popover_menu.dart'; +import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; final _pngBytes = Uint8List.fromList([ @@ -392,6 +394,139 @@ void main() { }); group('ComposeBar', () { + testWidgets('starts compact and grows to the full-width composer', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + expect(find.byType(TextField), findsNothing); + expect(find.byTooltip('Add attachment').hitTestable(), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); + expect(find.byKey(const ValueKey('composer-footer-gradient')), findsOne); + final composerBackdrop = find.descendant( + of: find.byKey(const ValueKey('composer-footer-gradient')), + matching: find.byType(MobileTabFooterBackdrop), + ); + expect(composerBackdrop, findsOneWidget); + expect( + tester.getSize(composerBackdrop).height, + mobileTabFooterBackdropHeight(tester.element(composerBackdrop)), + ); + final compactDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect( + compactDecoration.borderRadius, + BorderRadius.circular(Radii.dialog + Grid.quarter), + ); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + + await _expandComposer(tester); + + final expandedWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + final expandedDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect(compactWidth, closeTo(expandedWidth * 0.85, 0.5)); + expect( + expandedDecoration.borderRadius, + BorderRadius.circular(Radii.dialog), + ); + expect(find.byType(TextField), findsOneWidget); + expect(find.byIcon(LucideIcons.atSign), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget); + expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget); + }); + + testWidgets('returns to the compact capsule when the keyboard drops', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pump(); + + tester.view.viewInsets = FakeViewPadding.zero; + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + final compactDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect( + compactDecoration.borderRadius, + BorderRadius.circular(Radii.dialog + Grid.quarter), + ); + }); + + testWidgets('attachment control responds while the composer is expanding', ( + 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 {}, + ), + ); + + await tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('mounted composer does not carry draft text across an in-place ' 'identity switch', (tester) async { final keysA = nostr.Keys.generate(); @@ -494,6 +629,92 @@ void main() { expect(textField.controller!.selection.baseOffset, 12); }); + testWidgets('composer controls use selection haptics', (tester) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + hapticCalls.add(call); + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + hapticCalls.clear(); + + await tester.tap(find.byIcon(LucideIcons.atSign)); + tester.widget(find.byType(TextField)).controller!.clear(); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.hash)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.aLargeSmall)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.bold)); + await tester.pump(); + await tester.tap(find.byTooltip('Close formatting')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Add attachment')); + await tester.pumpAndSettle(); + + expect(hapticCalls, hasLength(6)); + expect( + hapticCalls.every( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + isTrue, + ); + }); + + testWidgets('composer suggestions use the shared popover treatment', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: [_makeChannel(name: 'general', channelType: 'stream')], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.hash)); + await tester.pumpAndSettle(); + + final surface = find.byKey(const ValueKey('channel-suggestions-popover')); + final material = tester.widget(surface); + final shape = material.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); + expect( + tester.widget(find.text('general')).style?.fontFamily, + 'Inter', + ); + }); + testWidgets('native All Photos picker failures show an error', ( tester, ) async { @@ -595,6 +816,60 @@ void main() { } }); + testWidgets('leaving a focused composer dismisses the native keyboard', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var dismissCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + case 'present': + return true; + case 'dismiss': + dismissCalls += 1; + return null; + } + return null; + }); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + expect(focusNode.hasFocus, isTrue); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + expect(focusNode.hasFocus, isFalse); + expect(dismissCalls, 1); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets( 'unsupported iOS attachment popover unfocuses before fallback menu', (tester) async { @@ -1191,6 +1466,10 @@ void main() { ), ); + final compactComposerWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); @@ -1201,6 +1480,12 @@ void main() { ); expect(find.byTooltip('Back to attachment options'), findsWidgets); expect(find.text('All photos'), findsOneWidget); + expect( + tester + .getSize(find.byKey(const ValueKey('attachment-surface-popover'))) + .width, + closeTo(compactComposerWidth / 0.85, 0.5), + ); await tester.tap(find.byKey(const ValueKey('recent-photo-two'))); await tester.pumpAndSettle(); @@ -1265,12 +1550,22 @@ void main() { await _openAttachmentMenu(tester); final menu = find.byKey(const ValueKey('attachment-menu')); + final surface = find.byKey(const ValueKey('attachment-surface-popover')); final rows = [ for (final label in ['camera', 'photos', 'video', 'files']) find.byKey(ValueKey('attachment-menu-item-$label')), ]; final menuRect = tester.getRect(menu); + final material = tester.widget(surface); + final shape = material.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); expect(menuRect.size, const Size(216, 264)); for (final row in rows) { expect(tester.getSize(row).height, 52); @@ -1280,6 +1575,7 @@ void main() { for (final label in ['Camera', 'Photos', 'Video', 'Files']) { final text = tester.widget(find.text(label)); expect(text.style?.fontSize, 20); + expect(text.style?.fontFamily, 'Inter'); } final icons = [ for (final label in ['camera', 'photos', 'video', 'files']) @@ -1319,6 +1615,48 @@ void main() { } }); + testWidgets('tapping outside dismisses the Android attachment menu', ( + 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 {}, + ), + ); + + await _openAttachmentMenu(tester); + expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget); + expect( + find.byKey(const ValueKey('attachment-dismiss-barrier')), + findsOneWidget, + ); + + await tester.tapAt(const Offset(24, 24)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('attachment-menu')), findsNothing); + expect( + find.byKey(const ValueKey('attachment-dismiss-barrier')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('attachment-trigger-closed')).hitTestable(), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets( 'attachment menu grows rows and scrolls for accessibility text', (tester) async { @@ -1376,6 +1714,9 @@ void main() { }) async {}, ), ); + final compactComposerWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; await _openAttachmentMenu(tester); await tester.tap(find.text('Camera')); @@ -1397,6 +1738,12 @@ void main() { find.byKey(const ValueKey('camera-initialization-ready')), findsOneWidget, ); + expect( + tester + .getSize(find.byKey(const ValueKey('attachment-surface-popover'))) + .width, + closeTo(compactComposerWidth / 0.85, 0.5), + ); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } diff --git a/mobile/test/shared/emoji/native_emoji_glyph_test.dart b/mobile/test/shared/emoji/native_emoji_glyph_test.dart new file mode 100644 index 0000000000..435423905f --- /dev/null +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -0,0 +1,37 @@ +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('lifts the glyph one logical pixel on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + ), + ); + + final transform = tester.widget(find.byType(Transform)); + expect(transform.transform.getTranslation().y, -1); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keeps the glyph unshifted on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + ), + ); + + expect(find.byType(Transform), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/mobile/test/shared/theme/app_theme_test.dart b/mobile/test/shared/theme/app_theme_test.dart index 6231befc69..f038c6d974 100644 --- a/mobile/test/shared/theme/app_theme_test.dart +++ b/mobile/test/shared/theme/app_theme_test.dart @@ -7,4 +7,21 @@ void main() { expect(AppTheme.light().splashFactory, NoSplash.splashFactory); expect(AppTheme.dark().splashFactory, NoSplash.splashFactory); }); + + test('uses Inter and the shared elevated popover treatment', () { + final theme = AppTheme.light(); + final popupTheme = theme.popupMenuTheme; + final shape = popupTheme.shape! as RoundedRectangleBorder; + final side = shape.side; + + expect(popupTheme.textStyle?.fontFamily, 'Inter'); + expect(popupTheme.elevation, 8); + expect( + popupTheme.shadowColor, + theme.colorScheme.shadow.withValues(alpha: 0.18), + ); + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(side.color, Colors.black.withValues(alpha: 0.04)); + expect(side.width, 1); + }); } diff --git a/mobile/test/shared/theme/message_typography_test.dart b/mobile/test/shared/theme/message_typography_test.dart index 169bcc0200..c5bec55556 100644 --- a/mobile/test/shared/theme/message_typography_test.dart +++ b/mobile/test/shared/theme/message_typography_test.dart @@ -130,6 +130,13 @@ void main() { lineHeight: 17, letterSpacing: 0, ); + expectStyle( + filterChipTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 15, + letterSpacing: 0, + ); }); test('message and activity avatars use their surface sizes', () { diff --git a/mobile/test/shared/widgets/filter_chip_bar_test.dart b/mobile/test/shared/widgets/filter_chip_bar_test.dart index 6f651624b6..72040e5d86 100644 --- a/mobile/test/shared/widgets/filter_chip_bar_test.dart +++ b/mobile/test/shared/widgets/filter_chip_bar_test.dart @@ -39,10 +39,57 @@ void main() { final unselectedLabel = tester.widget(find.text('Following')); expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize); expect(selectedLabel.style?.height, filterChipTextStyle.height); + expect(selectedLabel.style?.fontFamily, 'Inter'); expect(selectedLabel.style?.fontWeight, FontWeight.w500); expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize); expect(unselectedLabel.style?.height, filterChipTextStyle.height); expect(unselectedLabel.style?.fontWeight, FontWeight.w400); + final chip = tester.widget( + find.widgetWithText(FilterChip, 'Everyone'), + ); + final shape = chip.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.lg)); + expect(chip.labelPadding, EdgeInsets.zero); + }); + + testWidgets('search labels are vertically centered in their chips', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + width: 390, + child: FilterChipBar( + expandItems: true, + visualDensity: const VisualDensity(horizontal: -2), + chipVerticalPadding: Grid.xxs, + barVerticalPadding: Grid.twelve, + selected: 0, + onSelected: (_) {}, + items: const [ + FilterChipItem(id: 0, label: 'All'), + FilterChipItem(id: 1, label: 'Messages'), + FilterChipItem(id: 2, label: 'Channels'), + FilterChipItem(id: 3, label: 'People'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final label in ['All', 'Messages', 'Channels', 'People']) { + final text = find.text(label); + final chip = find.ancestor(of: text, matching: find.byType(RawChip)); + expect(chip, findsOneWidget); + expect( + tester.getCenter(text).dy, + closeTo(tester.getCenter(chip).dy, 0.01), + ); + } }); testWidgets('expanded chips preserve large accessible text scaling', ( @@ -81,7 +128,10 @@ void main() { ), findsNothing, ); - expect(tester.getSize(find.text('Messages')).height, greaterThan(32)); + expect( + tester.getSize(find.text('Messages')).height, + greaterThanOrEqualTo(30), + ); expect(tester.takeException(), isNull); }); } diff --git a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart index 9e54177d61..e13d17a65e 100644 --- a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -3,6 +3,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('shared gradient fades from transparent to the page surface', ( + tester, + ) async { + LinearGradient? gradient; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + gradient = mobileTabFooterBackdropGradient(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(gradient?.stops, [0, 0.5, 1]); + expect(gradient?.colors.first.a, 0); + expect(gradient?.colors[1].a, 0.75); + expect(gradient?.colors.last.a, 1); + }); + testWidgets('uses the logical bottom safe-area inset', (tester) async { double? height; From 1bbdaff0028ca0f94c870e1a45146de7f65d2d89 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 31 Jul 2026 17:02:23 +0100 Subject: [PATCH 2/5] Preserve message insets as composer changes Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 12 +- .../features/channels/thread_detail_page.dart | 65 ++++++++- .../widgets/keyboard_dismiss_on_drag.dart | 15 ++- .../channels/channel_detail_page_test.dart | 125 ++++++++++++++++++ .../keyboard_dismiss_on_drag_test.dart | 23 +++- 5 files changed, 228 insertions(+), 12 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 121d3c1750..f1efb1557f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -159,6 +159,10 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final showsComposer = + !resolvedChannel.isForum && + resolvedChannel.isMember && + !resolvedChannel.isArchived; final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); @@ -381,7 +385,9 @@ class ChannelDetailPage extends HookConsumerWidget { isArchived: resolvedChannel.isArchived, appBarTitleContentHeight: appBarTitleContentHeight, - composerBottomInset: composerDockHeight.value, + composerBottomInset: showsComposer + ? composerDockHeight.value + : 0, ); }, ), @@ -405,9 +411,7 @@ class ChannelDetailPage extends HookConsumerWidget { ], ], ), - if (!resolvedChannel.isForum && - resolvedChannel.isMember && - !resolvedChannel.isArchived) + if (showsComposer) Align( alignment: Alignment.bottomCenter, child: ComposerDockSizeReporter( diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index db77316de4..fb8b22425c 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -115,11 +115,34 @@ class ThreadDetailPage extends HookConsumerWidget { final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final didJumpToInitialMessage = useRef(false); + final followsThreadTail = useRef(false); + final pendingTailAlignment = useRef(null); // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; + bool threadTailIsVisible() { + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + return itemPositionsListener.itemPositions.value.any( + (position) => + position.index == lastIndex && position.itemTrailingEdge <= 1.001, + ); + } + + useEffect(() { + void onPositionsChanged() { + if (threadTailIsVisible()) followsThreadTail.value = true; + } + + itemPositionsListener.itemPositions.addListener(onPositionsChanged); + return () => itemPositionsListener.itemPositions.removeListener( + onPositionsChanged, + ); + }, [itemPositionsListener, replies.length]); + useEffect(() { final messageId = initialMessageId; // Wait for the authoritative thread query before consuming the one-shot @@ -229,6 +252,39 @@ class ThreadDetailPage extends HookConsumerWidget { // itself a root message its rootId is null, so fall back to its own id. final effectiveRootId = threadHead.rootId ?? threadHead.id; + void updateComposerDockHeight(double height) { + final previousHeight = composerDockHeight.value; + final heightDelta = height - previousHeight; + if (heightDelta.abs() < 0.5) return; + + final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + if (shouldFollowTail) followsThreadTail.value = true; + composerDockHeight.value = height; + if (heightDelta <= 0 || !shouldFollowTail) { + pendingTailAlignment.value = null; + return; + } + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + final lastPosition = itemPositionsListener.itemPositions.value + .where((position) => position.index == lastIndex) + .firstOrNull; + if (lastPosition == null) return; + final targetAlignment = + (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) - + (heightDelta / MediaQuery.sizeOf(context).height); + pendingTailAlignment.value = targetAlignment; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !itemScrollController.isAttached) return; + itemScrollController.jumpTo( + index: lastIndex, + alignment: targetAlignment, + ); + }); + } + // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; @@ -250,6 +306,10 @@ class ThreadDetailPage extends HookConsumerWidget { children: [ Expanded( child: KeyboardDismissOnDrag( + onUserScrollStart: () { + followsThreadTail.value = false; + pendingTailAlignment.value = null; + }, child: ScrollablePositionedList.builder( key: const ValueKey('thread-message-list'), itemScrollController: itemScrollController, @@ -408,10 +468,7 @@ class ThreadDetailPage extends HookConsumerWidget { alignment: Alignment.bottomCenter, child: ComposerDockSizeReporter( key: const ValueKey('thread-composer-dock'), - onHeightChanged: (height) { - if ((composerDockHeight.value - height).abs() < 0.5) return; - composerDockHeight.value = height; - }, + onHeightChanged: updateComposerDockHeight, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart index f562b9f83a..93371bdd0b 100644 --- a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart +++ b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart @@ -27,8 +27,13 @@ const keyboardDismissDragThreshold = 48.0; /// `WindowInsetsAnimationController`). class KeyboardDismissOnDrag extends HookWidget { final Widget child; + final VoidCallback? onUserScrollStart; - const KeyboardDismissOnDrag({super.key, required this.child}); + const KeyboardDismissOnDrag({ + super.key, + this.onUserScrollStart, + required this.child, + }); @override Widget build(BuildContext context) { @@ -37,8 +42,12 @@ class KeyboardDismissOnDrag extends HookWidget { final downwardTravel = useRef(0.0); bool handle(ScrollNotification notification) { - if (notification is ScrollStartNotification || - notification is ScrollEndNotification) { + if (notification is ScrollStartNotification) { + if (notification.dragDetails != null) onUserScrollStart?.call(); + downwardTravel.value = 0; + return false; + } + if (notification is ScrollEndNotification) { downwardTravel.value = 0; return false; } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f750756fc4..bfadf83292 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -752,6 +752,53 @@ void main() { ); }); + testWidgets('clears the composer inset when membership is revoked', ( + tester, + ) async { + final channelsNotifier = _FakeChannelsNotifier([_testChannel]); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Hello', + createdAt: 1000, + ), + ], + channelsNotifier: channelsNotifier, + ), + ); + await tester.pumpAndSettle(); + + final messageListFinder = find.byKey( + const ValueKey('channel-message-list'), + ); + expect( + tester + .widget(messageListFinder) + .padding! + .bottom, + greaterThan(0), + ); + expect( + find.byKey(const ValueKey('channel-composer-dock')), + findsOneWidget, + ); + + channelsNotifier.setChannels([_testChannel.copyWith(isMember: false)]); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-composer-dock')), findsNothing); + expect( + tester + .widget(messageListFinder) + .padding! + .bottom, + 0, + ); + }); + testWidgets('updates detail page state after joining a channel', ( tester, ) async { @@ -2625,6 +2672,84 @@ void main() { expect(oldestReplyY, lessThan(newestReplyY)); }); + testWidgets('thread keeps its tail above a growing composer dock', ( + 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'], + ], + ), + ]; + + 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-19', + ), + ), + ); + await tester.pumpAndSettle(); + + final dock = find.byKey(const ValueKey('thread-composer-dock')); + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-19'), + ); + final composerSurface = find.byKey(const ValueKey('composer-surface')); + final compactDockHeight = tester.getSize(dock).height; + expect(latestReply, findsOneWidget); + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + + expect(tester.getSize(dock).height, greaterThan(compactDockHeight)); + expect(latestReply, findsOneWidget); + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + }); + testWidgets( 'initial thread hydration keeps the head visible instead of following the tail', (tester) async { diff --git a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart index ae03f55c16..19424b7c82 100644 --- a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart +++ b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart @@ -4,7 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; /// A focused field inside a `Scaffold` body, which is the only arrangement /// either message list ever runs in. -Widget _testable({required FocusNode focusNode}) { +Widget _testable({ + required FocusNode focusNode, + VoidCallback? onUserScrollStart, +}) { return MaterialApp( home: Scaffold( body: Column( @@ -12,6 +15,7 @@ Widget _testable({required FocusNode focusNode}) { TextField(focusNode: focusNode), Expanded( child: KeyboardDismissOnDrag( + onUserScrollStart: onUserScrollStart, child: ListView( children: [ for (var i = 0; i < 40; i++) @@ -104,6 +108,23 @@ void main() { expect(focusNode.hasFocus, isTrue); }); + testWidgets('reports a user-started scroll', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var userScrollStarts = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => userScrollStarts += 1, + ), + ); + + await tester.drag(find.text('row 3'), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(userScrollStarts, 1); + }); + testWidgets('an upward drag never dismisses, however far it goes', ( tester, ) async { From 4f38778e2298588319c0db554c9a0610a67f5b80 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 07:55:13 +0100 Subject: [PATCH 3/5] Fix mobile composer keyboard transitions Signed-off-by: kenny lopez --- mobile/lib/features/channels/compose_bar.dart | 10 ++++- .../features/channels/thread_detail_page.dart | 41 +++++++++++++++++++ .../channels/channel_detail_page_test.dart | 12 ++++++ .../features/channels/compose_bar_test.dart | 13 ++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index d600bc465e..aadf93536b 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -188,11 +188,17 @@ class ComposeBar extends HookConsumerWidget { useEffect(() { final observer = _ComposerKeyboardMetricsObserver( view: appView, - onKeyboardHidden: collapseComposer, + onKeyboardHidden: () { + collapseComposer(); + // Android Back and iOS dismissal gestures can hide the keyboard + // without changing Flutter focus. Clear it as well so reopening the + // compact capsule establishes a new text-input connection. + focusNode.unfocus(); + }, ); WidgetsBinding.instance.addObserver(observer); return () => WidgetsBinding.instance.removeObserver(observer); - }, [appView]); + }, [appView, focusNode]); final resolvedHint = hintText ?? diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fb8b22425c..05491f68b4 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -117,6 +117,7 @@ class ThreadDetailPage extends HookConsumerWidget { final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); final pendingTailAlignment = useRef(null); + final tailRealignmentQueued = useRef(false); // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; @@ -285,6 +286,37 @@ class ThreadDetailPage extends HookConsumerWidget { }); } + // 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() { + final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + if (!shouldFollowTail || tailRealignmentQueued.value) return; + followsThreadTail.value = true; + tailRealignmentQueued.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + tailRealignmentQueued.value = false; + if (!context.mounted || !itemScrollController.isAttached) return; + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + itemScrollController.scrollTo( + index: lastIndex, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + }); + } + + useEffect(() { + final observer = _ThreadTailMetricsObserver( + onMetricsChanged: realignThreadTailAfterMetricsChange, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [itemScrollController, replies.length]); + // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; @@ -661,6 +693,15 @@ class _NestedThreadSummaryRow extends ConsumerWidget { } } +class _ThreadTailMetricsObserver with WidgetsBindingObserver { + final VoidCallback onMetricsChanged; + + _ThreadTailMetricsObserver({required this.onMetricsChanged}); + + @override + void didChangeMetrics() => onMetricsChanged(); +} + class _ThreadMessage extends ConsumerWidget { final TimelineMessage message; final Map channelNames; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index bfadf83292..8f0155203d 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2748,6 +2748,18 @@ void main() { tester.getBottomLeft(latestReply).dy, lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), ); + + // The dock size change above is separate from the later Scaffold + // viewport resize caused by the keyboard. Keep following the tail after + // that metrics change too. + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); }); testWidgets( diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index c73faf3050..4a9d4c82aa 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -476,6 +476,10 @@ void main() { ), ); await _expandComposer(tester); + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + expect(focusNode.hasFocus, isTrue); tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.reset); await tester.pump(); @@ -484,6 +488,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); + expect(focusNode.hasFocus, isFalse); final compactDecoration = tester .widget( @@ -495,6 +500,14 @@ void main() { compactDecoration.borderRadius, BorderRadius.circular(Radii.dialog + Grid.quarter), ); + + await tester.tap(find.text('Message\u2026')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).focusNode!.hasFocus, + isTrue, + ); }); testWidgets('attachment control responds while the composer is expanding', ( From 98f37e5ff560857d4aa9858f66ca9e2f326d593d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 13:20:54 +0100 Subject: [PATCH 4/5] Keep composer open through emoji selection Signed-off-by: kenny lopez --- mobile/lib/features/channels/compose_bar.dart | 12 ++++++++++-- .../lib/features/channels/compose_bar/helpers.dart | 2 ++ mobile/lib/features/channels/emoji_picker.dart | 3 ++- mobile/test/features/channels/compose_bar_test.dart | 2 ++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index aadf93536b..fe0d5cf5d4 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -137,6 +137,7 @@ class ComposeBar extends HookConsumerWidget { [focusNode], ); final isComposerExpanded = useState(false); + final isEmojiPickerOpen = useState(false); final attachmentSurface = useState(_AttachmentSurface.closed); final iosAttachmentPopover = useMemoized( _IOSAttachmentPopoverController.new, @@ -177,7 +178,9 @@ class ComposeBar extends HookConsumerWidget { // hide the keyboard while Flutter keeps the TextField focused. useEffect(() { void collapseWhenUnfocused() { - if (!focusNode.hasFocus) collapseComposer(); + if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { + collapseComposer(); + } } focusNode.addListener(collapseWhenUnfocused); @@ -974,7 +977,12 @@ class ComposeBar extends HookConsumerWidget { }, onEmoji: () { attachmentSurface.value = _AttachmentSurface.closed; - _showComposerEmojiPicker(context, insertEmoji); + isEmojiPickerOpen.value = true; + _showComposerEmojiPicker(context, insertEmoji, () { + if (!context.mounted) return; + isEmojiPickerOpen.value = false; + focusNode.requestFocus(); + }); }, onOpenFormatting: () { attachmentSurface.value = _AttachmentSurface.closed; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index d9a22039c7..c09815538a 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -28,10 +28,12 @@ void _runComposerAction(VoidCallback action) { void _showComposerEmojiPicker( BuildContext context, ValueChanged onSelect, + VoidCallback onDismiss, ) { showEmojiPicker( context: context, onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)), + onDismiss: onDismiss, ); } diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index f7e2d9f0bb..e3cd0f8eab 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -31,6 +31,7 @@ const _sheetHeightFactor = 0.62; void showEmojiPicker({ required BuildContext context, required void Function(String emoji) onSelect, + VoidCallback? onDismiss, }) { showModalBottomSheet( context: context, @@ -43,7 +44,7 @@ void showEmojiPicker({ onSelect(emoji); }, ), - ); + ).whenComplete(onDismiss ?? () {}); } class EmojiPickerSheet extends HookConsumerWidget { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 4a9d4c82aa..7dd2c2b608 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -640,6 +640,8 @@ void main() { expect(textField.controller!.text, 'hello :meow:world'); expect(textField.controller!.selection.baseOffset, 12); + expect(find.byType(TextField), findsOneWidget); + expect(textField.focusNode!.hasFocus, isTrue); }); testWidgets('composer controls use selection haptics', (tester) async { From a13089e6c4e3fb5135724cfb3b0a5c3e510b70ac Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 13:33:00 +0100 Subject: [PATCH 5/5] Preserve deep-link position in threads Signed-off-by: kenny lopez --- .../features/channels/thread_detail_page.dart | 11 ++- .../channels/channel_detail_page_test.dart | 75 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 05491f68b4..53be4488b2 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -160,6 +160,11 @@ class ThreadDetailPage extends HookConsumerWidget { if (targetIndex == null || didJumpToInitialMessage.value) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; + // 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. + followsThreadTail.value = false; + pendingTailAlignment.value = null; itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); didJumpToInitialMessage.value = true; }); @@ -297,7 +302,11 @@ class ThreadDetailPage extends HookConsumerWidget { tailRealignmentQueued.value = true; WidgetsBinding.instance.addPostFrameCallback((_) { tailRealignmentQueued.value = false; - if (!context.mounted || !itemScrollController.isAttached) return; + if (!context.mounted || + !itemScrollController.isAttached || + !followsThreadTail.value) { + return; + } final lastIndex = replies.isEmpty ? headIndex : indexForReply(replies.length - 1); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 8f0155203d..51c06d0283 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2830,6 +2830,81 @@ void main() { }, ); + testWidgets( + 'deep-linking an older reply does not resume tail following on keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + 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'], + ], + ), + ]; + final completer = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': completer.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + final provisionalTarget = formatTimeline([replies[5]]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead, provisionalTarget], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + + completer.complete(replies); + await tester.pumpAndSettle(); + + final target = find.byKey( + const ValueKey('thread-message-group-reply-5'), + ); + expect(target, 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); + }, + ); + testWidgets('a reaction landing while the thread is open shows up there', ( tester, ) async {