From 7dfea2634f7e87f6a42f5fc1f22d9f77c648abfc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 28 Jul 2026 16:48:35 +0100 Subject: [PATCH 01/59] Add mobile message image galleries (#3312) ## What - group uploaded photos into full-width message carousels - add a fullscreen viewer with pinch zoom, double-tap reset, swipe-down dismissal, a centered filmstrip, and image actions - preload nearby display-sized images for smoother swiping and keep each upload as its own avatar-backed message ## Validation - `just mobile-check` - `flutter test test/features/channels/message_content_test.dart` - iOS 26.5 simulator gesture pass --------- Signed-off-by: kenny lopez --- .../android/app/src/main/AndroidManifest.xml | 11 +- .../xyz/block/buzz/mobile/MainActivity.kt | 5 + mobile/ios/Podfile.lock | 13 + mobile/ios/Runner/Info.plist | 2 + .../channel_detail_page/message_bubble.dart | 41 +- .../channel_detail_page/message_list.dart | 3 +- .../features/channels/media_viewer_hero.dart | 72 ++ .../features/channels/media_viewer_page.dart | 710 +++++++++++++----- .../media_viewer_page/image_controls.dart | 300 ++++++++ .../media_viewer_page/route_transition.dart | 22 + .../features/channels/message_actions.dart | 237 +++++- .../features/channels/message_content.dart | 82 +- .../message_content/media_carousel.dart | 293 ++++++++ .../features/channels/thread_detail_page.dart | 42 +- .../features/channels/timeline_message.dart | 5 + mobile/lib/shared/relay/media_upload.dart | 13 + mobile/pubspec.lock | 24 + mobile/pubspec.yaml | 2 + .../channels/message_actions_test.dart | 65 ++ .../channels/message_content_test.dart | 305 +++++++- 20 files changed, 2024 insertions(+), 223 deletions(-) create mode 100644 mobile/lib/features/channels/media_viewer_hero.dart create mode 100644 mobile/lib/features/channels/media_viewer_page/image_controls.dart create mode 100644 mobile/lib/features/channels/media_viewer_page/route_transition.dart create mode 100644 mobile/lib/features/channels/message_content/media_carousel.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 17a16742af..e1eb3e3456 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ - + + + { handleTranscodeVideoToMp4(call.arguments, result) } + REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD -> { + result.success(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) + } else -> result.notImplemented() } } @@ -284,5 +287,7 @@ class MainActivity : FlutterActivity() { private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4" + private const val REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD = + "requiresLegacyMediaStoragePermission" } } diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index ba8c828d25..c1a2b9e13c 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -22,6 +22,11 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter + - photo_manager (3.11.0): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -43,6 +48,8 @@ DEPENDENCIES: - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) @@ -70,6 +77,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/open_filex/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" url_launcher_ios: @@ -89,6 +100,8 @@ SPEC CHECKSUMS: mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + photo_manager: 6ab48c2ce7ec21aa06d59e6cc049f0b6d9ba7f94 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 70ba34a11f..bf0aca8f57 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -47,6 +47,8 @@ Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. NSPhotoLibraryUsageDescription Buzz needs photo library access so you can attach images to messages. + NSPhotoLibraryAddUsageDescription + Buzz needs permission to save images to your photo library. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 453e788271..2a02b51c95 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -29,6 +29,10 @@ class _MessageBubble extends ConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final canManageMessage = + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()); // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); @@ -55,10 +59,7 @@ class _MessageBubble extends ConsumerWidget { ref: ref, message: message, channelId: currentChannelId, - canManageMessage: - currentPubkey?.toLowerCase() == pk || - (profile?.ownerPubkey != null && - profile?.ownerPubkey == currentPubkey?.toLowerCase()), + canManageMessage: canManageMessage, allMessages: allMessages, currentPubkey: currentPubkey, isMember: isMember, @@ -124,6 +125,38 @@ class _MessageBubble extends ConsumerWidget { baseStyle: context.textTheme.bodyLarge?.copyWith( color: context.colors.onSurface, ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), onChannelTap: (channelId) { openChannelLink( context: context, 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 ba5a2e1a71..035cbb3051 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -240,7 +240,8 @@ class _MessageList extends HookConsumerWidget { final showAuthor = !message.isSystem && - (prevMessage == null || + (message.hasAttachments || + prevMessage == null || prevMessage.isSystem || showDayDivider || prevMessage.pubkey.toLowerCase() != diff --git a/mobile/lib/features/channels/media_viewer_hero.dart b/mobile/lib/features/channels/media_viewer_hero.dart new file mode 100644 index 0000000000..00be663a6f --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_hero.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +/// Keeps image-viewer shared-element motion consistent at every source. +class MediaViewerHero extends StatelessWidget { + /// The identity shared by the inline image and full-screen image. + final Object tag; + + /// The image rendered during and after the shared-element transition. + final Widget child; + + /// Creates an image-viewer shared element. + const MediaViewerHero({super.key, required this.tag, required this.child}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: tag, + createRectTween: (begin, end) => RectTween(begin: begin, end: end), + flightShuttleBuilder: + ( + flightContext, + animation, + flightDirection, + fromHeroContext, + toHeroContext, + ) { + final sourceHero = fromHeroContext.widget; + final destinationHero = toHeroContext.widget; + final sourceChild = sourceHero is Hero ? sourceHero.child : child; + final destinationChild = destinationHero is Hero + ? destinationHero.child + : child; + return _MediaViewerHeroFlight( + animation: animation, + sourceChild: sourceChild, + destinationChild: destinationChild, + ); + }, + child: child, + ); + } +} + +class _MediaViewerHeroFlight extends StatelessWidget { + final Animation animation; + final Widget sourceChild; + final Widget destinationChild; + + const _MediaViewerHeroFlight({ + required this.animation, + required this.sourceChild, + required this.destinationChild, + }); + + @override + Widget build(BuildContext context) { + final destinationOpacity = CurvedAnimation( + parent: animation, + curve: const Interval(0.18, 0.82, curve: Curves.easeInOutCubic), + ); + return Stack( + fit: StackFit.expand, + children: [ + FadeTransition( + opacity: ReverseAnimation(destinationOpacity), + child: sourceChild, + ), + FadeTransition(opacity: destinationOpacity, child: destinationChild), + ], + ); + } +} diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index 1c4052e1b8..6ea579374f 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -1,34 +1,105 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:video_player/video_player.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import 'media_viewer_hero.dart'; -const _imageViewerPushDuration = Duration(milliseconds: 280); -const _imageViewerPopDuration = Duration(milliseconds: 220); -const _imageViewerTransitionOffset = Offset(0, 0.08); +export 'media_viewer_hero.dart'; + +part 'media_viewer_page/image_controls.dart'; +part 'media_viewer_page/route_transition.dart'; + +const _imageViewerPushDuration = Duration(milliseconds: 260); +const _imageViewerPopDuration = Duration(milliseconds: 170); const _identityTransformEpsilon = 0.0001; final List _identityTransformStorage = List.unmodifiable( Matrix4.identity().storage, ); +/// Opens message-specific actions for the currently visible image. +typedef MediaViewerMoreAction = + void Function(BuildContext context, String imageUrl); + +/// An image and its source Hero tag in a full-screen media gallery. +@immutable +class MediaViewerImage { + /// The image URL. + final String url; + + /// The shared-element transition tag for the source thumbnail. + final Object heroTag; + + /// The accessible image description. + final String? semanticLabel; + + /// The logical decode width already cached by the source thumbnail. + final double? previewDecodeWidth; + + /// The image's intrinsic width-to-height ratio, when provided by metadata. + final double? aspectRatio; + + /// A display-sized provider that can be warmed before this page is shown. + final ImageProvider? preloadProvider; + + /// Creates a media-viewer image. + const MediaViewerImage({ + required this.url, + required this.heroTag, + this.semanticLabel, + this.previewDecodeWidth, + this.aspectRatio, + this.preloadProvider, + }); +} + PageRoute buildImageViewerRoute({ required String imageUrl, required Object heroTag, String? semanticLabel, + double? previewDecodeWidth, + double? aspectRatio, + List? galleryItems, + int initialIndex = 0, + VoidCallback? onReply, + MediaViewerMoreAction? onMore, + bool disableAnimations = false, }) { + final images = + galleryItems ?? + [ + MediaViewerImage( + url: imageUrl, + heroTag: heroTag, + semanticLabel: semanticLabel, + previewDecodeWidth: previewDecodeWidth, + aspectRatio: aspectRatio, + ), + ]; + final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt(); return PageRouteBuilder( - transitionDuration: _imageViewerPushDuration, - reverseTransitionDuration: _imageViewerPopDuration, + transitionDuration: disableAnimations + ? Duration.zero + : _imageViewerPushDuration, + reverseTransitionDuration: disableAnimations + ? Duration.zero + : _imageViewerPopDuration, pageBuilder: (context, animation, secondaryAnimation) => MediaImageViewerPage( imageUrl: imageUrl, heroTag: heroTag, semanticLabel: semanticLabel, + galleryItems: images, + initialIndex: safeInitialIndex, + onReply: onReply, + onMore: onMore, ), transitionsBuilder: (context, animation, secondaryAnimation, child) => _MediaViewerRouteTransition(animation: animation, child: child), @@ -40,12 +111,25 @@ void openImageViewer( required String imageUrl, required Object heroTag, String? semanticLabel, + double? previewDecodeWidth, + double? aspectRatio, + List? galleryItems, + int initialIndex = 0, + VoidCallback? onReply, + MediaViewerMoreAction? onMore, }) { Navigator.of(context).push( buildImageViewerRoute( imageUrl: imageUrl, heroTag: heroTag, semanticLabel: semanticLabel, + previewDecodeWidth: previewDecodeWidth, + aspectRatio: aspectRatio, + galleryItems: galleryItems, + initialIndex: initialIndex, + onReply: onReply, + onMore: onMore, + disableAnimations: MediaQuery.disableAnimationsOf(context), ), ); } @@ -57,8 +141,12 @@ void openVideoViewer( }) { Navigator.of(context).push( PageRouteBuilder( - transitionDuration: _imageViewerPushDuration, - reverseTransitionDuration: _imageViewerPopDuration, + transitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _imageViewerPushDuration, + reverseTransitionDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _imageViewerPopDuration, pageBuilder: (context, animation, secondaryAnimation) => MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl), transitionsBuilder: (context, animation, secondaryAnimation, child) => @@ -67,189 +155,345 @@ void openVideoViewer( ); } -class _MediaViewerRouteTransition extends StatelessWidget { - final Animation animation; - final Widget child; - - const _MediaViewerRouteTransition({ - required this.animation, - required this.child, - }); - - @override - Widget build(BuildContext context) { - final fade = CurvedAnimation( - parent: animation, - curve: Curves.easeOut, - reverseCurve: Curves.easeIn, - ); - final slide = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - return FadeTransition( - opacity: fade, - child: SlideTransition( - position: Tween( - begin: _imageViewerTransitionOffset, - end: Offset.zero, - ).animate(slide), - child: child, - ), - ); - } -} - -// StatefulWidget retained: imperative gesture/animation controllers with -// listener lifecycle don't map cleanly to hooks (allowed exception). -class MediaImageViewerPage extends StatefulWidget { +class MediaImageViewerPage extends HookConsumerWidget { final String imageUrl; final Object heroTag; final String? semanticLabel; + final List? galleryItems; + final int initialIndex; + final VoidCallback? onReply; + final MediaViewerMoreAction? onMore; const MediaImageViewerPage({ super.key, required this.imageUrl, required this.heroTag, this.semanticLabel, + this.galleryItems, + this.initialIndex = 0, + this.onReply, + this.onMore, }); - @override - State createState() => _MediaImageViewerPageState(); -} - -class _MediaImageViewerPageState extends State - with SingleTickerProviderStateMixin { - late final TransformationController _transformationController; - late final AnimationController _snapBackController; - bool _isTransformed = false; - bool _disableHeroOnDismiss = false; - double _dragOffset = 0; - bool _isDragging = false; - static const _dismissThreshold = 100.0; + static const _dismissVelocity = 700.0; static const _backgroundFadeDivisor = 300.0; + static const _filmstripScrubExtent = 44.0; @override - void initState() { - super.initState(); - _transformationController = TransformationController(); - _transformationController.addListener(_handleTransformChanged); - _snapBackController = AnimationController( - vsync: this, + Widget build(BuildContext context, WidgetRef ref) { + final images = useMemoized( + () => + galleryItems ?? + [ + MediaViewerImage( + url: imageUrl, + heroTag: heroTag, + semanticLabel: semanticLabel, + ), + ], + [galleryItems, imageUrl, heroTag, semanticLabel], + ); + final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt(); + final currentIndex = useState(safeInitialIndex); + final pageController = usePageController(initialPage: safeInitialIndex); + final pagePosition = useState(safeInitialIndex.toDouble()); + final transformationControllers = useMemoized( + () => [ + for (var index = 0; index < images.length; index++) + TransformationController(), + ], + [images], + ); + final snapBackController = useAnimationController( duration: const Duration(milliseconds: 200), ); - } + final zoomResetController = useAnimationController( + duration: const Duration(milliseconds: 180), + ); + final zoomResetListener = useRef(null); + final fullResolutionIndices = useState>({}); + final isTransformed = useState(false); + final disableHeroOnDismiss = useState(false); + final dragOffset = useState(0.0); + final isDragging = useState(false); + + void handlePagePositionChanged() { + if (!pageController.hasClients) return; + final nextPosition = pageController.page; + if (nextPosition == null || + (nextPosition - pagePosition.value).abs() < 0.0001) { + return; + } + pagePosition.value = nextPosition; + } - @override - void dispose() { - _transformationController.removeListener(_handleTransformChanged); - _transformationController.dispose(); - _snapBackController.dispose(); - super.dispose(); - } + void handleTransformChanged(int index) { + if (index != currentIndex.value) return; + final nextIsTransformed = _hasImageTransform( + transformationControllers[index].value, + ); + if (nextIsTransformed == isTransformed.value) { + return; + } - void _handleTransformChanged() { - final isTransformed = _hasImageTransform(_transformationController.value); - if (isTransformed == _isTransformed) { - return; + isTransformed.value = nextIsTransformed; + if (nextIsTransformed && isDragging.value) { + isDragging.value = false; + dragOffset.value = 0; + } } - setState(() { - _isTransformed = isTransformed; - // If the user zooms in while dragging, cancel the drag. - if (_isTransformed && _isDragging) { - _isDragging = false; - _dragOffset = 0; + useEffect(() { + pageController.addListener(handlePagePositionChanged); + return () => pageController.removeListener(handlePagePositionChanged); + }, [pageController]); + + useEffect(() { + final listeners = []; + for (var index = 0; index < transformationControllers.length; index++) { + final controllerIndex = index; + void listener() => handleTransformChanged(controllerIndex); + listeners.add(listener); + transformationControllers[index].addListener(listener); } - }); - } + return () { + for (var index = 0; index < transformationControllers.length; index++) { + transformationControllers[index] + ..removeListener(listeners[index]) + ..dispose(); + } + }; + }, [transformationControllers]); + + useEffect(() { + var cancelled = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!cancelled && context.mounted) { + _precacheViewerImages(context, images, currentIndex.value); + } + }); + return () => cancelled = true; + }, [images]); + + useEffect(() { + return () { + final listener = zoomResetListener.value; + if (listener != null) { + zoomResetController.removeListener(listener); + } + }; + }, [zoomResetController]); + + void onPageChanged(int index) { + _precacheViewerImages(context, images, index); + currentIndex.value = index; + isTransformed.value = _hasImageTransform( + transformationControllers[index].value, + ); + disableHeroOnDismiss.value = index != safeInitialIndex; + dragOffset.value = 0; + isDragging.value = false; + } - void _onInteractionStart(ScaleStartDetails details) { - if (!_isTransformed && details.pointerCount == 1) { - _isDragging = true; + void onFilmstripScrubUpdate(double delta) { + if (isTransformed.value || !pageController.hasClients) return; + final position = pageController.position; + final viewport = position.viewportDimension; + if (viewport <= 0) return; + final target = + (pageController.offset - ((delta / _filmstripScrubExtent) * viewport)) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + pageController.jumpTo(target); } - } - void _onInteractionUpdate(ScaleUpdateDetails details) { - if (_isDragging && !_isTransformed) { - setState(() { - _dragOffset += details.focalPointDelta.dy; - }); + void onFilmstripScrubEnd() { + if (!pageController.hasClients) return; + final targetPage = (pageController.page ?? currentIndex.value.toDouble()) + .round() + .clamp(0, images.length - 1); + if (MediaQuery.disableAnimationsOf(context)) { + pageController.jumpToPage(targetPage); + return; + } + pageController.animateToPage( + targetPage, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + ); } - } - void _onInteractionEnd(ScaleEndDetails details) { - if (!_isDragging) return; - _isDragging = false; + void upgradeToFullResolution(int index) { + if (images[index].previewDecodeWidth == null || + fullResolutionIndices.value.contains(index)) { + return; + } + fullResolutionIndices.value = {...fullResolutionIndices.value, index}; + } - if (_dragOffset.abs() > _dismissThreshold) { - _dismiss(); - } else { - _animateSnapBack(); + void onImageInteractionStart(int index, ScaleStartDetails details) { + if (details.pointerCount > 1) { + upgradeToFullResolution(index); + } + if (details.pointerCount == 1 && !isTransformed.value) { + isDragging.value = true; + } } - } - void _animateSnapBack() { - final startOffset = _dragOffset; - final tween = Tween(begin: startOffset, end: 0); - final curved = CurvedAnimation( - parent: _snapBackController, - curve: Curves.easeOut, - ); + void onImageInteractionUpdate(int index, ScaleUpdateDetails details) { + if (details.pointerCount > 1 || details.scale != 1) { + final needsFullResolution = + images[index].previewDecodeWidth != null && + !fullResolutionIndices.value.contains(index); + if (isDragging.value || needsFullResolution) { + if (needsFullResolution) { + fullResolutionIndices.value = { + ...fullResolutionIndices.value, + index, + }; + } + isDragging.value = false; + dragOffset.value = 0; + } + return; + } - void listener() { - setState(() { - _dragOffset = tween.evaluate(curved); - }); + if (!isDragging.value || isTransformed.value) return; + dragOffset.value = (dragOffset.value + details.focalPointDelta.dy).clamp( + 0.0, + MediaQuery.sizeOf(context).height, + ); } - _snapBackController - ..reset() - ..addListener(listener); - _snapBackController.forward().whenCompleteOrCancel(() { - _snapBackController.removeListener(listener); - }); - } + void resetImageTransform(int index) { + final controller = transformationControllers[index]; + if (!_hasImageTransform(controller.value)) { + return; + } + + final previousListener = zoomResetListener.value; + if (previousListener != null) { + zoomResetController.removeListener(previousListener); + } + zoomResetController.stop(); - bool get _canDismissWithHero => !_isTransformed || _disableHeroOnDismiss; + if (MediaQuery.disableAnimationsOf(context)) { + controller.value = Matrix4.identity(); + zoomResetListener.value = null; + return; + } - Future _prepareHeroFallbackDismiss() async { - if (_canDismissWithHero) { - return; + final animation = + Matrix4Tween( + begin: Matrix4.copy(controller.value), + end: Matrix4.identity(), + ).animate( + CurvedAnimation( + parent: zoomResetController, + curve: Curves.easeOutCubic, + ), + ); + void listener() => controller.value = animation.value; + zoomResetListener.value = listener; + zoomResetController + ..reset() + ..addListener(listener) + ..forward(); } - setState(() { - _disableHeroOnDismiss = true; - }); + bool canDismissWithHero() => + !isTransformed.value || disableHeroOnDismiss.value; - await WidgetsBinding.instance.endOfFrame; - } + Future prepareHeroFallbackDismiss() async { + if (canDismissWithHero()) { + return; + } + disableHeroOnDismiss.value = true; + await WidgetsBinding.instance.endOfFrame; + } - Future _dismiss() async { - await _prepareHeroFallbackDismiss(); - if (!mounted) { - return; + Future dismiss() async { + await prepareHeroFallbackDismiss(); + if (!context.mounted) { + return; + } + Navigator.of(context).maybePop(); } - Navigator.of(context).maybePop(); - } - @override - Widget build(BuildContext context) { + void animateSnapBack() { + final tween = Tween(begin: dragOffset.value, end: 0); + + void listener() { + dragOffset.value = tween.evaluate(snapBackController); + } + + snapBackController + ..stop() + ..reset() + ..addListener(listener); + snapBackController + .animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 260), + bounce: 0.14, + ), + 0, + 1, + 0, + snapToEnd: true, + ), + ) + .whenCompleteOrCancel(() { + snapBackController.removeListener(listener); + }); + } + + void finishVerticalDismiss(double velocity) { + isDragging.value = false; + if (dragOffset.value > _dismissThreshold || velocity > _dismissVelocity) { + unawaited(dismiss()); + } else { + animateSnapBack(); + } + } + + void onImageInteractionEnd(ScaleEndDetails details) { + if (!isDragging.value) return; + finishVerticalDismiss(details.velocity.pixelsPerSecond.dy); + } + + Future replyInThread() async { + final callback = onReply; + if (callback == null) return; + final route = ModalRoute.of(context); + await dismiss(); + await route?.completed; + callback(); + } + + void showMoreActions() { + onMore?.call(context, images[currentIndex.value].url); + } + + final viewportHeight = MediaQuery.sizeOf(context).height; + final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0); + final imageScale = 1 - (dragProgress * 0.1); + final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0); + return PopScope( - canPop: _canDismissWithHero, + canPop: canDismissWithHero(), onPopInvokedWithResult: (didPop, result) { if (didPop) { return; } - unawaited(_dismiss()); + unawaited(dismiss()); }, child: Scaffold( key: const ValueKey('message-media-image-viewer'), backgroundColor: Colors.black.withValues( - alpha: (1 - (_dragOffset.abs() / _backgroundFadeDivisor)).clamp( + alpha: (1 - (dragOffset.value.abs() / _backgroundFadeDivisor)).clamp( 0.3, 1.0, ), @@ -258,52 +502,146 @@ class _MediaImageViewerPageState extends State children: [ Positioned.fill( child: Transform.translate( - offset: Offset(0, _dragOffset), - child: InteractiveViewer( - transformationController: _transformationController, - onInteractionStart: _onInteractionStart, - onInteractionUpdate: _onInteractionUpdate, - onInteractionEnd: _onInteractionEnd, - minScale: 1, - maxScale: 4, - child: Center( - child: HeroMode( - key: const ValueKey( - 'message-media-image-viewer-hero-mode', - ), - enabled: !_disableHeroOnDismiss, - child: Hero( - tag: widget.heroTag, - child: MediaImage( - url: widget.imageUrl, - boundDecodeToLayout: false, - fit: BoxFit.contain, - semanticLabel: widget.semanticLabel, - errorBuilder: (_, _, _) => const _MediaLoadFailure( - message: 'Failed to load image', - icon: LucideIcons.imageOff, - ), + offset: Offset(0, dragOffset.value), + child: Transform.scale( + scale: imageScale, + child: PageView.builder( + key: const ValueKey('message-media-image-viewer-pages'), + controller: pageController, + physics: isTransformed.value + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(), + itemCount: images.length, + onPageChanged: onPageChanged, + itemBuilder: (context, index) { + final image = images[index]; + final viewPadding = MediaQuery.viewPaddingOf(context); + return Padding( + padding: EdgeInsets.only( + top: viewPadding.top + 48 + Grid.xxs, + bottom: viewPadding.bottom + 56 + (Grid.xxs * 2), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final viewerSize = _imageViewerSize( + Size(constraints.maxWidth, constraints.maxHeight), + image.aspectRatio, + ); + return GestureDetector( + key: ValueKey( + 'message-media-image-viewer-gesture:$index', + ), + behavior: HitTestBehavior.opaque, + onDoubleTap: () => resetImageTransform(index), + child: InteractiveViewer( + transformationController: + transformationControllers[index], + onInteractionStart: (details) => + onImageInteractionStart(index, details), + onInteractionUpdate: (details) => + onImageInteractionUpdate(index, details), + onInteractionEnd: onImageInteractionEnd, + panEnabled: isTransformed.value, + scaleEnabled: true, + minScale: 1, + maxScale: 4, + boundaryMargin: const EdgeInsets.all(Grid.xxl), + clipBehavior: Clip.none, + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: viewerSize.width, + height: viewerSize.height, + child: HeroMode( + key: index == safeInitialIndex + ? const ValueKey( + 'message-media-image-viewer-hero-mode', + ) + : ValueKey( + 'message-media-image-viewer-hero-mode-$index', + ), + enabled: + !disableHeroOnDismiss.value && + index == safeInitialIndex, + child: MediaViewerHero( + tag: image.heroTag, + child: MediaImage( + key: ValueKey( + 'message-media-image-viewer-image:$index', + ), + url: image.url, + decodeWidth: + fullResolutionIndices.value + .contains(index) + ? null + : image.previewDecodeWidth, + boundDecodeToLayout: false, + fit: BoxFit.contain, + semanticLabel: image.semanticLabel, + errorBuilder: (_, _, _) => + const _MediaLoadFailure( + message: 'Failed to load image', + icon: LucideIcons.imageOff, + ), + ), + ), + ), + ), + ), + ), + ); + }, ), - ), - ), + ); + }, ), ), ), ), PositionedDirectional( - top: Grid.sm, - end: Grid.sm, - child: SafeArea( - child: DecoratedBox( - decoration: const BoxDecoration( - color: Color.fromRGBO(0, 0, 0, 0.56), - shape: BoxShape.circle, + bottom: 0, + start: 0, + end: 0, + child: Opacity( + opacity: chromeOpacity, + child: SafeArea( + child: _MediaViewerBottomControls( + images: images, + currentIndex: currentIndex.value, + pagePosition: pagePosition, + onScrubUpdate: onFilmstripScrubUpdate, + onScrubEnd: onFilmstripScrubEnd, + onSelect: (index) { + if (index == currentIndex.value) return; + if (MediaQuery.disableAnimationsOf(context)) { + pageController.jumpToPage(index); + return; + } + pageController.animateToPage( + index, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + }, + onReply: onReply == null + ? null + : () => unawaited(replyInThread()), + onMore: onMore == null ? null : showMoreActions, ), - child: IconButton( + ), + ), + ), + PositionedDirectional( + top: 0, + end: Grid.sm, + child: Opacity( + opacity: chromeOpacity, + child: SafeArea( + child: _MediaViewerCircleButton( key: const ValueKey('message-media-image-viewer-close'), - onPressed: _dismiss, + icon: LucideIcons.x, tooltip: 'Close image viewer', - icon: const Icon(LucideIcons.x, color: Colors.white), + onPressed: () => unawaited(dismiss()), ), ), ), @@ -315,17 +653,6 @@ class _MediaImageViewerPageState extends State } } -bool _hasImageTransform(Matrix4 transform) { - final storage = transform.storage; - for (var index = 0; index < storage.length; index++) { - if ((storage[index] - _identityTransformStorage[index]).abs() > - _identityTransformEpsilon) { - return true; - } - } - return false; -} - // StatefulWidget retained: owns a VideoPlayerController with async init and // disposal — kept imperative deliberately (allowed exception). class MediaVideoViewerPage extends StatefulWidget { @@ -462,7 +789,12 @@ class _VideoLoadingPoster extends StatelessWidget { else _videoPlaceholder(context), const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)), - const Center(child: CircularProgressIndicator()), + const Center( + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + ), + ), ], ), ); diff --git a/mobile/lib/features/channels/media_viewer_page/image_controls.dart b/mobile/lib/features/channels/media_viewer_page/image_controls.dart new file mode 100644 index 0000000000..7736167611 --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page/image_controls.dart @@ -0,0 +1,300 @@ +part of '../media_viewer_page.dart'; + +class _MediaViewerBottomControls extends StatelessWidget { + final List images; + final int currentIndex; + final ValueListenable pagePosition; + final ValueChanged onScrubUpdate; + final VoidCallback onScrubEnd; + final ValueChanged onSelect; + final VoidCallback? onReply; + final VoidCallback? onMore; + + const _MediaViewerBottomControls({ + required this.images, + required this.currentIndex, + required this.pagePosition, + required this.onScrubUpdate, + required this.onScrubEnd, + required this.onSelect, + required this.onReply, + required this.onMore, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _MediaViewerCircleButton( + key: const ValueKey('message-media-image-viewer-reply-thread'), + icon: LucideIcons.messageSquareReply, + tooltip: 'Reply in thread', + onPressed: onReply, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: images.length > 1 + ? _MediaViewerFilmstrip( + key: const ValueKey('message-media-image-viewer-filmstrip'), + images: images, + currentIndex: currentIndex, + pagePosition: pagePosition, + onScrubUpdate: onScrubUpdate, + onScrubEnd: onScrubEnd, + onSelect: onSelect, + ) + : const SizedBox(height: 56), + ), + const SizedBox(width: Grid.xxs), + _MediaViewerCircleButton( + key: const ValueKey('message-media-image-viewer-more-actions'), + icon: LucideIcons.ellipsis, + tooltip: 'More image actions', + onPressed: onMore, + ), + ], + ), + ); + } +} + +class _MediaViewerFilmstrip extends StatelessWidget { + final List images; + final int currentIndex; + final ValueListenable pagePosition; + final ValueChanged onScrubUpdate; + final VoidCallback onScrubEnd; + final ValueChanged onSelect; + + const _MediaViewerFilmstrip({ + super.key, + required this.images, + required this.currentIndex, + required this.pagePosition, + required this.onScrubUpdate, + required this.onScrubEnd, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 56, + child: RepaintBoundary( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) => + onScrubUpdate(details.primaryDelta ?? 0), + onHorizontalDragEnd: (_) => onScrubEnd(), + child: ValueListenableBuilder( + valueListenable: pagePosition, + builder: (context, position, _) { + return LayoutBuilder( + builder: (context, constraints) { + const compactWidth = 40.0; + const focusedWidth = 72.0; + const itemHeight = 52.0; + const spacing = Grid.half; + final clampedPosition = position + .clamp(0.0, images.length - 1.0) + .toDouble(); + final widths = []; + final proximities = []; + final centers = []; + var cursor = 0.0; + + for (var index = 0; index < images.length; index++) { + final proximity = (1 - (index - clampedPosition).abs()) + .clamp(0.0, 1.0) + .toDouble(); + final width = + compactWidth + + ((focusedWidth - compactWidth) * proximity); + widths.add(width); + proximities.add(proximity); + centers.add(cursor + (width / 2)); + cursor += width + spacing; + } + + final lowerIndex = clampedPosition.floor(); + final upperIndex = clampedPosition.ceil(); + final fraction = clampedPosition - lowerIndex; + final lowerCenter = centers[lowerIndex]; + final upperCenter = centers[upperIndex]; + final focusCenter = + lowerCenter + ((upperCenter - lowerCenter) * fraction); + final viewportCenter = constraints.maxWidth / 2; + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (var index = 0; index < images.length; index++) + Positioned( + left: + viewportCenter + + centers[index] - + focusCenter - + (widths[index] / 2), + top: Grid.quarter, + width: widths[index], + height: itemHeight, + child: _MediaViewerFilmstripImage( + image: images[index], + index: index, + proximity: proximities[index], + selected: index == currentIndex, + onSelect: onSelect, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ), + ); + } +} + +class _MediaViewerFilmstripImage extends StatelessWidget { + final MediaViewerImage image; + final int index; + final double proximity; + final bool selected; + final ValueChanged onSelect; + + const _MediaViewerFilmstripImage({ + required this.image, + required this.index, + required this.proximity, + required this.selected, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + final borderWidth = 1 + (1.5 * proximity); + return Semantics( + button: true, + selected: selected, + label: selected + ? 'Image ${index + 1}, selected' + : 'Show image ${index + 1}', + child: GestureDetector( + key: ValueKey('message-media-image-viewer-thumbnail:$index'), + onTap: () => onSelect(index), + child: Container( + height: 52, + padding: EdgeInsets.all(borderWidth), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: Colors.white.withValues(alpha: 0.28 + (0.72 * proximity)), + width: borderWidth, + ), + ), + child: ClipRRect( + key: ValueKey('message-media-image-viewer-thumbnail-clip:$index'), + borderRadius: BorderRadius.circular(Radii.sm - borderWidth), + clipBehavior: Clip.antiAlias, + child: Opacity( + opacity: 0.62 + (0.38 * proximity), + child: MediaImage( + url: image.url, + decodeWidth: 72, + fit: BoxFit.cover, + semanticLabel: image.semanticLabel, + errorBuilder: (_, _, _) => const ColoredBox( + color: Color.fromRGBO(255, 255, 255, 0.12), + child: Icon( + LucideIcons.imageOff, + color: Colors.white70, + size: 18, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _MediaViewerCircleButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback? onPressed; + + const _MediaViewerCircleButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 48, + child: onPressed == null + ? const SizedBox.shrink() + : DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.16), + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: onPressed, + tooltip: tooltip, + icon: Icon(icon, color: Colors.white, size: 20), + ), + ), + ); + } +} + +Size _imageViewerSize(Size viewport, double? aspectRatio) { + if (aspectRatio == null || aspectRatio <= 0) { + return viewport; + } + + final safeAspectRatio = aspectRatio.clamp(0.05, 20.0).toDouble(); + if (viewport.aspectRatio > safeAspectRatio) { + return Size(viewport.height * safeAspectRatio, viewport.height); + } + return Size(viewport.width, viewport.width / safeAspectRatio); +} + +void _precacheViewerImages( + BuildContext context, + List images, + int focusedIndex, +) { + for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) { + if (index < 0 || index >= images.length) { + continue; + } + final provider = images[index].preloadProvider; + if (provider == null) { + continue; + } + unawaited(precacheImage(provider, context, onError: (_, _) {})); + } +} + +bool _hasImageTransform(Matrix4 transform) { + final storage = transform.storage; + for (var index = 0; index < storage.length; index++) { + if ((storage[index] - _identityTransformStorage[index]).abs() > + _identityTransformEpsilon) { + return true; + } + } + return false; +} diff --git a/mobile/lib/features/channels/media_viewer_page/route_transition.dart b/mobile/lib/features/channels/media_viewer_page/route_transition.dart new file mode 100644 index 0000000000..5ebed62a95 --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page/route_transition.dart @@ -0,0 +1,22 @@ +part of '../media_viewer_page.dart'; + +class _MediaViewerRouteTransition extends StatelessWidget { + final Animation animation; + final Widget child; + + const _MediaViewerRouteTransition({ + required this.animation, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final fade = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInOutCubic, + ); + + return FadeTransition(opacity: fade, child: child); + } +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 6cbcd843a6..c4cc5e2a0e 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,10 +1,18 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:share_plus/share_plus.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/deeplink/deep_link.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; @@ -167,6 +175,214 @@ void showMessageActions({ ); } +/// Image-focused actions shown from the full-screen viewer. +void showImageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required String imageUrl, + required bool canManageMessage, + VoidCallback? onDeleted, +}) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.download), + title: const Text('Save image'), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited(_saveImage(context, ref, imageUrl)); + }, + ), + ListTile( + leading: const Icon(LucideIcons.share2), + title: const Text('Share image'), + onTap: () { + final renderBox = context.findRenderObject() as RenderBox?; + final shareOrigin = renderBox == null + ? null + : renderBox.localToGlobal(Offset.zero) & renderBox.size; + Navigator.of(sheetContext).pop(); + unawaited( + _shareImage(context, ref, imageUrl, shareOrigin: shareOrigin), + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.link2), + title: const Text('Copy image link'), + onTap: () { + Navigator.of(sheetContext).pop(); + copyToClipboard( + context, + imageUrl, + message: 'Image link copied', + ); + }, + ), + if (canManageMessage) ...[ + const SheetDivider(), + ListTile( + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete message', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + onDeleted: onDeleted, + ); + }, + ), + ], + ], + ), + ), + ), + ); +} + +@immutable +class _DownloadedImage { + final Uint8List bytes; + final String filename; + + const _DownloadedImage({required this.bytes, required this.filename}); +} + +Future<_DownloadedImage> _downloadImage(WidgetRef ref, String imageUrl) async { + final response = await ref + .read(mediaHttpClientProvider) + .get( + Uri.parse(imageUrl), + headers: ref.read(mediaGetAuthServiceProvider).headersFor(imageUrl), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'Image download failed (${response.statusCode})', + uri: Uri.parse(imageUrl), + ); + } + return _DownloadedImage( + bytes: response.bodyBytes, + filename: downloadedImageFilename( + imageUrl, + response.headers['content-type'], + ), + ); +} + +/// Returns a safe image filename while preserving supported image formats. +@visibleForTesting +String downloadedImageFilename(String imageUrl, String? contentType) { + final pathSegments = Uri.tryParse(imageUrl)?.pathSegments; + final rawName = pathSegments == null || pathSegments.isEmpty + ? '' + : pathSegments.last; + final safeName = rawName + .replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-') + .replaceAll(RegExp(r'-+'), '-'); + if (RegExp( + r'\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$', + caseSensitive: false, + ).hasMatch(safeName)) { + return safeName; + } + final extension = switch (contentType?.split(';').first.trim()) { + 'image/avif' => '.avif', + 'image/gif' => '.gif', + 'image/heic' => '.heic', + 'image/heif' => '.heif', + 'image/png' => '.png', + 'image/webp' => '.webp', + _ => '.jpg', + }; + return 'buzz-${DateTime.now().millisecondsSinceEpoch}$extension'; +} + +Future _saveImage( + BuildContext context, + WidgetRef ref, + String imageUrl, +) async { + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final needsPhotoLibraryPermission = + defaultTargetPlatform == TargetPlatform.iOS || + await requiresLegacyMediaStoragePermission(); + if (needsPhotoLibraryPermission) { + final permission = await PhotoManager.requestPermissionExtend( + requestOption: const PermissionRequestOption( + iosAccessLevel: IosAccessLevel.addOnly, + androidPermission: AndroidPermission( + type: RequestType.image, + mediaLocation: false, + ), + ), + ); + if (!permission.isAuth) { + throw const FileSystemException( + 'Photo library permission was not granted.', + ); + } + } + final image = await _downloadImage(ref, imageUrl); + await PhotoManager.editor.saveImage(image.bytes, filename: image.filename); + messenger?.showSnackBar( + const SnackBar(content: Text('Image saved to Photos')), + ); + } catch (_) { + messenger?.showSnackBar( + const SnackBar(content: Text('Could not save image')), + ); + } +} + +Future _shareImage( + BuildContext context, + WidgetRef ref, + String imageUrl, { + Rect? shareOrigin, +}) async { + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final image = await _downloadImage(ref, imageUrl); + final directory = await getTemporaryDirectory(); + final file = File( + '${directory.path}${Platform.pathSeparator}${image.filename}', + ); + await file.writeAsBytes(image.bytes, flush: true); + await SharePlus.instance.share( + ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin), + ); + } catch (_) { + messenger?.showSnackBar( + const SnackBar(content: Text('Could not share image')), + ); + } +} + /// Canonical `buzz://message` link for a timeline message, including thread /// context when the message is a reply. String messageLinkFor({ @@ -496,6 +712,7 @@ void _confirmDelete({ required WidgetRef ref, required String channelId, required String messageId, + VoidCallback? onDeleted, }) { showDialog( context: context, @@ -508,17 +725,19 @@ void _confirmDelete({ child: const Text('Cancel'), ), FilledButton( - onPressed: () { + onPressed: () async { Navigator.of(dialogContext).pop(); final messenger = ScaffoldMessenger.of(context); - ref - .read(channelActionsProvider) - .deleteMessage(channelId: channelId, eventId: messageId) - .catchError((Object error) { - messenger.showSnackBar( - SnackBar(content: Text('Failed to delete message: $error')), - ); - }); + try { + await ref + .read(channelActionsProvider) + .deleteMessage(channelId: channelId, eventId: messageId); + onDeleted?.call(); + } catch (error) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to delete message: $error')), + ); + } }, style: FilledButton.styleFrom( backgroundColor: dialogContext.colors.error, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index cb1af75c68..664594ade7 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -21,6 +22,8 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import 'media_viewer_page.dart'; import 'message_media.dart'; +part 'message_content/media_carousel.dart'; + const _messageMediaMaxInlineWidth = 320.0; const _messageMediaMaxImageHeight = 240.0; @@ -92,10 +95,25 @@ class MessageContent extends HookConsumerWidget { /// mentioned user's pubkey. final void Function(String pubkey)? onMentionTap; + /// Opens the message's thread from the full-screen image viewer. + final VoidCallback? onMediaReply; + + /// Opens message-specific actions for an image in the full-screen viewer. + final MediaViewerMoreAction? onMediaMore; + final TextStyle? baseStyle; final int? maxLines; + /// Allows a multi-image carousel to reclaim leading space reserved by the + /// surrounding message layout, while keeping its image count aligned with + /// the message body. + final double mediaCarouselLeadingOverflow; + + /// Allows a multi-image carousel to continue through the trailing page + /// gutter while keeping its first image and count aligned with the body. + final double mediaCarouselTrailingOverflow; + const MessageContent({ super.key, required this.content, @@ -105,8 +123,12 @@ class MessageContent extends HookConsumerWidget { this.tags = const [], this.onChannelTap, this.onMentionTap, + this.onMediaReply, + this.onMediaMore, this.baseStyle, this.maxLines, + this.mediaCarouselLeadingOverflow = 0, + this.mediaCarouselTrailingOverflow = 0, }); @override @@ -115,6 +137,10 @@ class MessageContent extends HookConsumerWidget { baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final imetaByUrl = parseImetaTags(tags); + final trailingGallery = maxLines == null + ? _extractTrailingImageGallery(content, imetaByUrl) + : null; + final markdownContent = trailingGallery?.content ?? content; final customEmoji = _mergeCustomEmoji( customEmojiFromTags(tags), ref.watch(customEmojiListProvider), @@ -124,7 +150,7 @@ class MessageContent extends HookConsumerWidget { // Convert autolinks and bare URLs to standard markdown links, // but skip content inside backticks (inline code / fenced blocks). final buffer = StringBuffer(); - final parts = content.split('`'); + final parts = markdownContent.split('`'); for (var i = 0; i < parts.length; i++) { if (i.isOdd) { // Inside backticks — preserve as-is. @@ -186,9 +212,9 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [content, mentionNames]); + }, [markdownContent, mentionNames]); - return GptMarkdown( + final markdown = GptMarkdown( finalContent, style: style, followLinkColor: false, @@ -210,6 +236,25 @@ class MessageContent extends HookConsumerWidget { ...MarkdownComponent.inlineComponents, ], ); + if (trailingGallery == null) return markdown; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (trailingGallery.content.trim().isNotEmpty) markdown, + _MessageImageCarousel( + key: ValueKey( + trailingGallery.items.map((item) => item.url).join('\u0000'), + ), + items: trailingGallery.items, + leadingOverflow: mediaCarouselLeadingOverflow, + trailingOverflow: mediaCarouselTrailingOverflow, + onReply: onMediaReply, + onMore: onMediaMore, + ), + ], + ); } Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { @@ -221,6 +266,8 @@ class MessageContent extends HookConsumerWidget { url: imageUrl, imeta: imeta, semanticLabel: imeta?.alt ?? 'Message image', + onReply: onMediaReply, + onMore: onMediaMore, ); } @@ -298,17 +345,22 @@ class _MessageImagePreview extends HookConsumerWidget { final String url; final ImetaEntry? imeta; final String semanticLabel; + final VoidCallback? onReply; + final MediaViewerMoreAction? onMore; const _MessageImagePreview({ required this.url, required this.imeta, required this.semanticLabel, + required this.onReply, + required this.onMore, }); @override Widget build(BuildContext context, WidgetRef ref) { final heroTag = useMemoized(() => Object()); final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio); + final previewDecodeWidth = layout.width ?? _messageMediaMaxWidth(context); return Padding( padding: const EdgeInsets.only(top: Grid.half), @@ -318,6 +370,10 @@ class _MessageImagePreview extends HookConsumerWidget { imageUrl: url, heroTag: heroTag, semanticLabel: semanticLabel, + previewDecodeWidth: previewDecodeWidth, + aspectRatio: imeta?.aspectRatio, + onReply: onReply, + onMore: onMore, ), child: _MessageMediaPreviewFrame( previewKey: ValueKey('message-media-image-preview:$url'), @@ -325,15 +381,19 @@ class _MessageImagePreview extends HookConsumerWidget { width: layout.width, height: layout.height, constraints: layout.constraints, - child: Hero( + child: MediaViewerHero( tag: heroTag, - child: MediaImage( - url: url, - fit: layout.fit, - semanticLabel: semanticLabel, - errorBuilder: (_, _, _) => _MediaPreviewFallback( - icon: LucideIcons.imageOff, - label: 'Image unavailable', + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.md), + child: MediaImage( + url: url, + decodeWidth: previewDecodeWidth, + fit: layout.fit, + semanticLabel: semanticLabel, + errorBuilder: (_, _, _) => _MediaPreviewFallback( + icon: LucideIcons.imageOff, + label: 'Image unavailable', + ), ), ), ), diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart new file mode 100644 index 0000000000..88bfed5b1d --- /dev/null +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -0,0 +1,293 @@ +part of '../message_content.dart'; + +const _messageMediaCarouselHeight = 220.0; + +@immutable +class _MessageGalleryItem { + final String url; + final String semanticLabel; + final double? aspectRatio; + + const _MessageGalleryItem({ + required this.url, + required this.semanticLabel, + required this.aspectRatio, + }); +} + +@immutable +class _TrailingImageGallery { + final String content; + final List<_MessageGalleryItem> items; + + const _TrailingImageGallery({required this.content, required this.items}); +} + +class _MessageGalleryPrecache extends HookWidget { + final List> providers; + final int focusedIndex; + + const _MessageGalleryPrecache({ + required this.providers, + required this.focusedIndex, + }); + + @override + Widget build(BuildContext context) { + final providerSignature = Object.hashAll(providers); + useEffect(() { + var cancelled = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (cancelled || !context.mounted) { + return; + } + for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) { + if (index < 0 || index >= providers.length) { + continue; + } + unawaited( + precacheImage(providers[index], context, onError: (_, _) {}), + ); + } + }); + return () => cancelled = true; + }, [focusedIndex, providerSignature]); + return const SizedBox.shrink(); + } +} + +_TrailingImageGallery? _extractTrailingImageGallery( + String content, + Map imetaByUrl, +) { + final lines = content.split('\n'); + var cursor = lines.length - 1; + while (cursor >= 0 && lines[cursor].trim().isEmpty) { + cursor -= 1; + } + + final items = <_MessageGalleryItem>[]; + final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$'); + while (cursor >= 0) { + final match = imagePattern.firstMatch(lines[cursor].trim()); + if (match == null) break; + final url = match.group(2)!; + final imeta = imetaByUrl[url]; + if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) { + break; + } + final markdownLabel = match.group(1)?.trim(); + items.insert( + 0, + _MessageGalleryItem( + url: url, + semanticLabel: + imeta?.alt ?? + (markdownLabel?.isNotEmpty == true + ? markdownLabel! + : 'Message image'), + aspectRatio: imeta?.aspectRatio, + ), + ); + cursor -= 1; + } + + if (items.length < 2) return null; + return _TrailingImageGallery( + content: lines.take(cursor + 1).join('\n').trimRight(), + items: items, + ); +} + +class _MessageImageCarousel extends HookConsumerWidget { + final List<_MessageGalleryItem> items; + final double leadingOverflow; + final double trailingOverflow; + final VoidCallback? onReply; + final MediaViewerMoreAction? onMore; + + const _MessageImageCarousel({ + super.key, + required this.items, + required this.leadingOverflow, + required this.trailingOverflow, + required this.onReply, + required this.onMore, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final itemSignature = items.map((item) => item.url).join('\u0000'); + final heroTags = useMemoized( + () => [for (var index = 0; index < items.length; index++) Object()], + [itemSignature], + ); + final controller = usePageController(viewportFraction: 0.9); + final currentIndex = useState(0); + final mediaAuth = ref.watch(mediaGetAuthServiceProvider); + final mediaClient = ref.watch(mediaHttpClientProvider); + + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${items.length} images', + key: const ValueKey('message-media-carousel-count'), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: Grid.half), + LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : _messageMediaMaxWidth(context); + final carouselWidth = + contentWidth + leadingOverflow + trailingOverflow; + final leadingExtent = leadingOverflow; + final isLeftToRight = + Directionality.of(context) == TextDirection.ltr; + final previewDecodeWidths = [ + for (var index = 0; index < items.length; index++) + math.max( + 1.0, + carouselWidth * controller.viewportFraction - + (index == items.length - 1 ? 0 : Grid.half), + ), + ]; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final previewProviders = [ + for (var index = 0; index < items.length; index++) + ResizeImage.resizeIfNeeded( + (previewDecodeWidths[index] * devicePixelRatio).ceil(), + null, + MediaImageProvider( + url: items[index].url, + auth: mediaAuth, + client: mediaClient, + ), + ), + ]; + final viewerItems = [ + for (var index = 0; index < items.length; index++) + MediaViewerImage( + url: items[index].url, + heroTag: heroTags[index], + semanticLabel: items[index].semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: items[index].aspectRatio, + preloadProvider: previewProviders[index], + ), + ]; + final carousel = SizedBox( + key: const ValueKey('message-media-carousel'), + width: carouselWidth, + height: _messageMediaCarouselHeight, + child: PageView.builder( + controller: controller, + clipBehavior: Clip.none, + padEnds: false, + itemCount: items.length, + onPageChanged: (index) => currentIndex.value = index, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + padding: EdgeInsetsDirectional.only( + end: index == items.length - 1 ? 0 : Grid.half, + ), + child: Semantics( + button: true, + excludeSemantics: true, + label: 'Open ${item.semanticLabel}', + child: GestureDetector( + key: ValueKey( + 'message-media-carousel-item:${item.url}', + ), + onTap: () => openImageViewer( + context, + imageUrl: item.url, + heroTag: heroTags[index], + semanticLabel: item.semanticLabel, + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: item.aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant, + ), + ), + child: MediaViewerHero( + tag: heroTags[index], + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.md), + child: MediaImage( + url: item.url, + decodeWidth: previewDecodeWidths[index], + fit: BoxFit.cover, + semanticLabel: item.semanticLabel, + errorBuilder: (_, _, _) => + const _MediaPreviewFallback( + icon: LucideIcons.imageOff, + label: 'Image unavailable', + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + + final carouselSurface = Stack( + clipBehavior: Clip.none, + children: [ + carousel, + _MessageGalleryPrecache( + providers: previewProviders, + focusedIndex: currentIndex.value, + ), + ], + ); + + if (leadingExtent <= 0 && trailingOverflow <= 0) { + return carouselSurface; + } + return SizedBox( + width: contentWidth, + height: _messageMediaCarouselHeight, + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + minWidth: carouselWidth, + maxWidth: carouselWidth, + child: Transform.translate( + offset: Offset( + isLeftToRight ? -leadingExtent : leadingExtent, + 0, + ), + child: carouselSurface, + ), + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d00973b4d1..32a1cf156a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -221,6 +221,7 @@ class ThreadDetailPage extends HookConsumerWidget { reply.createdAt, ); final showAuthor = + reply.hasAttachments || prevReply == null || showDayDivider || prevReply.pubkey.toLowerCase() != @@ -455,6 +456,10 @@ class _ThreadMessage extends ConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final canManageMessage = + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()); final userCache = ref.watch(userCacheProvider); final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); @@ -478,10 +483,7 @@ class _ThreadMessage extends ConsumerWidget { ref: ref, message: message, channelId: channelId, - canManageMessage: - currentPubkey?.toLowerCase() == pk || - (profile?.ownerPubkey != null && - profile?.ownerPubkey == currentPubkey?.toLowerCase()), + canManageMessage: canManageMessage, allMessages: allMessages, currentPubkey: currentPubkey, isMember: isMember, @@ -568,6 +570,38 @@ class _ThreadMessage extends ConsumerWidget { baseStyle: context.textTheme.bodyLarge?.copyWith( color: context.colors.onSurface, ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), onChannelTap: (targetChannelId) { openChannelLink( context: context, diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 203be2dbbd..253c949703 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -171,6 +171,11 @@ class TimelineMessage { this.parentId, this.rootId, }); + + /// Attachment messages stay visually distinct from surrounding messages, + /// even when several are sent by the same author in quick succession. + bool get hasAttachments => + tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta'); } @immutable diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 93c53e9743..bd9a1cce43 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -21,6 +21,8 @@ const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; +const _requiresLegacyMediaStoragePermissionMethod = + 'requiresLegacyMediaStoragePermission'; const _readClipboardImageMethod = 'readClipboardImage'; const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; @@ -39,6 +41,17 @@ final _mediaUploadPlatformChannel = MethodChannel( _mediaUploadPlatformChannelName, ); +/// Whether saving media needs Android's pre-scoped-storage runtime permission. +Future requiresLegacyMediaStoragePermission() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return false; + } + return await _mediaUploadPlatformChannel.invokeMethod( + _requiresLegacyMediaStoragePermissionMethod, + ) ?? + false; +} + const _allowedImageMimeTypes = { 'image/jpeg', 'image/png', diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 55185fc9c6..61d7fd58e4 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1000,6 +1000,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_manager: + dependency: "direct main" + description: + name: photo_manager + sha256: "4f7de6c9778993c5c54cf1fb2eaa8d5c27c0771dc24a4dfca341aa81aef13fe1" + url: "https://pub.dev" + source: hosted + version: "3.11.0" platform: dependency: transitive description: @@ -1096,6 +1104,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.8" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b90f50c25a..b525926288 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: file_selector: ^1.1.0 camera: ^0.12.0+2 image_picker: ^1.1.2 + photo_manager: ^3.11.0 video_player: ^2.10.1 package_info_plus: ^10.0.0 app_badge_plus: ^1.2.10 @@ -36,6 +37,7 @@ dependencies: scrollable_positioned_list: ^0.3.8 open_filex: ^4.7.0 path_provider: ^2.1.6 + share_plus: ^13.3.0 dev_dependencies: flutter_test: diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 5f0406bfab..03d7951b05 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -149,6 +149,37 @@ Future _pumpSheet( await tester.pumpAndSettle(); } +Future _pumpImageSheet( + WidgetTester tester, { + required TimelineMessage message, + bool canManageMessage = false, +}) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => showImageActions( + context: context, + ref: ref, + message: message, + channelId: _channelId, + imageUrl: 'https://example.com/photo.png', + canManageMessage: canManageMessage, + ), + child: const Text('open image actions'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open image actions')); + await tester.pumpAndSettle(); +} + void main() { group('showMessageActions', () { testWidgets('shows parity actions for a regular message', (tester) async { @@ -367,6 +398,40 @@ void main() { }); }); + group('showImageActions', () { + testWidgets('labels the destructive action as deleting the message', ( + tester, + ) async { + await _pumpImageSheet( + tester, + message: _message(), + canManageMessage: true, + ); + + expect(find.text('Delete message'), findsOneWidget); + expect(find.text('Delete upload'), findsNothing); + }); + }); + + group('downloadedImageFilename', () { + test('preserves gif file extensions', () { + expect( + downloadedImageFilename('https://example.com/animation.gif', null), + 'animation.gif', + ); + }); + + test('uses gif extension for gif content types', () { + expect( + downloadedImageFilename( + 'https://example.com/download', + 'image/gif; charset=binary', + ), + matches(RegExp(r'^buzz-\d+\.gif$')), + ); + }); + }); + group('messageLinkFor', () { test('builds a canonical link with thread context', () { expect( diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index bcb46f0c37..91e31eee22 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -9,12 +9,23 @@ import 'package:buzz/features/channels/media_viewer_page.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; -Widget _testable(Widget child, {List overrides = const []}) { +Widget _testable( + Widget child, { + List overrides = const [], + bool disableAnimations = false, +}) { return ProviderScope( overrides: overrides, child: MaterialApp( theme: AppTheme.light(), - home: Scaffold(body: child), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: Scaffold(body: child), + ), + ), ), ); } @@ -182,11 +193,22 @@ void main() { ); expect(route, isA>()); - expect(route.transitionDuration, const Duration(milliseconds: 280)); + expect(route.transitionDuration, const Duration(milliseconds: 260)); expect( route.reverseTransitionDuration, - const Duration(milliseconds: 220), + const Duration(milliseconds: 170), + ); + }); + + test('buildImageViewerRoute disables motion when requested', () { + final route = buildImageViewerRoute( + imageUrl: 'https://example.com/media/image.png', + heroTag: Object(), + disableAnimations: true, ); + + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); }); group('plain text', () { @@ -430,6 +452,197 @@ void main() { ); }); + testWidgets( + 'groups uploaded photos into a carousel and opens the full gallery', + (tester) async { + const first = 'https://example.com/media/one.png'; + const second = 'https://example.com/media/two.png'; + const third = 'https://example.com/media/three.png'; + await tester.pumpWidget( + _testable( + const MessageContent( + content: + ''' +Photos +![image]($first) +![image]($second) +![image]($third) +''', + tags: [ + ['imeta', 'url $first', 'm image/png', 'alt First photo'], + ['imeta', 'url $second', 'm image/png', 'alt Second photo'], + ['imeta', 'url $third', 'm image/png', 'alt Third photo'], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final carousel = find.byKey(const ValueKey('message-media-carousel')); + expect(carousel, findsOneWidget); + expect(find.text('3 images'), findsOneWidget); + + await tester.drag(carousel, const Offset(-600, 0)); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('message-media-carousel-item:$second')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-media-image-viewer-filmstrip')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('message-media-image-viewer-thumbnail:1'), + ), + findsOneWidget, + ); + final displayedImage = tester.widget( + find.byKey(const ValueKey('message-media-image-viewer-image:1')), + ); + expect(displayedImage.decodeWidth, isNotNull); + final selectedThumbnailClip = tester.widget( + find.byKey( + const ValueKey('message-media-image-viewer-thumbnail-clip:1'), + ), + ); + final selectedThumbnailRadius = + selectedThumbnailClip.borderRadius as BorderRadius; + expect( + selectedThumbnailRadius.topLeft.x, + closeTo(Radii.sm - 2.5, 0.01), + ); + + await tester.fling( + find.byKey(const ValueKey('message-media-image-viewer-pages')), + const Offset(-700, 0), + 1200, + ); + await tester.pumpAndSettle(); + + final thirdThumbnail = find.byKey( + const ValueKey('message-media-image-viewer-thumbnail:2'), + ); + final thirdSemantics = tester.widget( + find + .ancestor(of: thirdThumbnail, matching: find.byType(Semantics)) + .first, + ); + expect(thirdSemantics.properties.selected, isTrue); + }, + ); + + testWidgets( + 'jumps to a selected gallery thumbnail when motion is disabled', + (tester) async { + const first = 'https://example.com/media/reduced-motion-one.png'; + const second = 'https://example.com/media/reduced-motion-two.png'; + await tester.pumpWidget( + _testable( + const MessageContent( + content: + ''' +![image]($first) +![image]($second) +''', + tags: [ + ['imeta', 'url $first', 'm image/png'], + ['imeta', 'url $second', 'm image/png'], + ], + ), + disableAnimations: true, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('message-media-carousel-item:$first')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey( + const ValueKey('message-media-image-viewer-thumbnail:1'), + ), + ); + await tester.pumpAndSettle(); + + final selectedThumbnail = tester.widget( + find + .ancestor( + of: find.byKey( + const ValueKey('message-media-image-viewer-thumbnail:1'), + ), + matching: find.byType(Semantics), + ) + .first, + ); + expect(selectedThumbnail.properties.selected, isTrue); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('resets carousel paging when gallery images change', ( + tester, + ) async { + const firstGallery = [ + 'https://example.com/media/first-a.png', + 'https://example.com/media/first-b.png', + 'https://example.com/media/first-c.png', + ]; + const secondGallery = [ + 'https://example.com/media/second-a.png', + 'https://example.com/media/second-b.png', + ]; + + Widget gallery(List urls) => _testable( + MessageContent( + content: urls.map((url) => '![image]($url)').join('\n'), + tags: [ + for (final url in urls) ['imeta', 'url $url', 'm image/png'], + ], + ), + ); + + await tester.pumpWidget(gallery(firstGallery)); + await tester.pumpAndSettle(); + final firstCarousel = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('message-media-carousel')), + matching: find.byType(PageView), + ), + ); + + await tester.fling( + find.byKey(const ValueKey('message-media-carousel')), + const Offset(-700, 0), + 1200, + ); + await tester.pumpAndSettle(); + expect(firstCarousel.controller!.page, greaterThan(0)); + + await tester.pumpWidget(gallery(secondGallery)); + await tester.pumpAndSettle(); + final secondCarousel = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('message-media-carousel')), + matching: find.byType(PageView), + ), + ); + + expect( + secondCarousel.controller, + isNot(same(firstCarousel.controller)), + ); + expect(secondCarousel.controller!.page, 0); + }); + testWidgets( 'disables hero on close after the fullscreen image is transformed', (tester) async { @@ -484,6 +697,90 @@ void main() { }, ); + testWidgets('double tap resets the fullscreen image transform', ( + tester, + ) async { + const imageUrl = 'https://example.com/media/double-tap-reset.png'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Look\n![image](https://example.com/media/double-tap-reset.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/double-tap-reset.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final transformationController = await _openImageViewer( + tester, + imageUrl, + ); + _applyImageViewerTransform( + transformationController, + dx: 32, + dy: 24, + scale: 2, + ); + await tester.pump(); + + final gestureSurface = find.byKey( + const ValueKey('message-media-image-viewer-gesture:0'), + ); + await tester.tap(gestureSurface); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(gestureSurface); + await tester.pumpAndSettle(); + + expect( + transformationController.value.storage, + orderedEquals(Matrix4.identity().storage), + ); + expect(_isImageViewerHeroEnabled(tester), isTrue); + }); + + testWidgets('swiping down dismisses the fullscreen gallery', ( + tester, + ) async { + const imageUrl = 'https://example.com/media/swipe-dismiss.png'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Look\n![image](https://example.com/media/swipe-dismiss.png)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/swipe-dismiss.png', + 'm image/png', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + await _openImageViewer(tester, imageUrl); + + await tester.drag( + find.byKey(const ValueKey('message-media-image-viewer-gesture:0')), + const Offset(0, 180), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + }); + testWidgets( 'disables hero on back navigation after the fullscreen image is transformed', (tester) async { From 6da45ac5cf90fa0768a98256e2200708d219ddfc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 28 Jul 2026 17:20:50 +0100 Subject: [PATCH 02/59] Polish mobile message and search layouts (#3121) ## Summary - align message typography, avatars, metadata, and spacing across mobile surfaces - improve message follow behavior, touch feedback, and Activity popover motion - refine Search motion, gutters, and explicit recent-search history ## Snapshots ### Home ![Home](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--01-home.png) ### Activity ![Activity](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--02-activity.png) ### Search ![Search](https://raw.githubusercontent.com/block/buzz/99aaf9719f68a2813e14c484f503af10c4fca04a/pr-3121--03-search.png) ## Testing - `just mobile-check` - `just mobile-test` (749 passed, 1 skipped) --------- Signed-off-by: Taylor Ho Signed-off-by: kenny lopez Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Signed-off-by: Wes Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: Taylor Ho Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Co-authored-by: Wes Co-authored-by: Carl Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh --- .../lib/features/activity/activity_page.dart | 5 + .../activity_page/header_actions.dart | 232 ++++---- .../activity/activity_page/inbox_row.dart | 50 +- .../activity/activity_page/popover_menu.dart | 231 ++++++++ .../channels/channel_detail_page.dart | 3 + .../channels/channel_detail_page/app_bar.dart | 6 +- .../channel_detail_page/message_bubble.dart | 276 +++++----- .../channel_detail_page/message_list.dart | 150 ++++-- .../channel_detail_page/system_rows.dart | 226 ++++---- .../channels/channels_page/channel_tile.dart | 2 +- .../channels/channels_page/sections.dart | 6 +- .../lib/features/channels/reaction_row.dart | 3 +- .../features/channels/thread_detail_page.dart | 475 +++++++++-------- .../lib/features/forum/forum_post_card.dart | 22 +- .../lib/features/forum/forum_thread_page.dart | 65 ++- mobile/lib/features/profile/user_profile.dart | 7 + .../lib/features/pulse/compose_note_page.dart | 23 +- mobile/lib/features/pulse/note_card.dart | 60 ++- .../search/recent_searches_provider.dart | 64 +++ mobile/lib/features/search/search_page.dart | 481 +++++++++++++---- .../lib/shared/theme/message_typography.dart | 131 +++++ mobile/lib/shared/theme/theme.dart | 1 + .../lib/shared/widgets/filter_chip_bar.dart | 2 +- .../lib/shared/widgets/frosted_app_bar.dart | 6 +- .../shared/widgets/message_author_meta.dart | 118 +++++ .../features/activity/activity_page_test.dart | 190 ++++++- .../channels/channel_detail_page_test.dart | 435 +++++++++++++-- .../features/channels/channels_page_test.dart | 9 + .../features/forum/forum_widgets_test.dart | 184 ++++++- .../pulse/compose_note_page_test.dart | 67 ++- .../test/features/pulse/note_card_test.dart | 130 +++++ .../search/recent_searches_provider_test.dart | 105 ++++ .../features/search/search_page_test.dart | 494 ++++++++++++++++++ .../shared/theme/message_typography_test.dart | 141 +++++ .../shared/widgets/filter_chip_bar_test.dart | 8 + .../widgets/message_author_meta_test.dart | 82 +++ 36 files changed, 3634 insertions(+), 856 deletions(-) create mode 100644 mobile/lib/features/activity/activity_page/popover_menu.dart create mode 100644 mobile/lib/features/search/recent_searches_provider.dart create mode 100644 mobile/lib/shared/theme/message_typography.dart create mode 100644 mobile/lib/shared/widgets/message_author_meta.dart create mode 100644 mobile/test/features/pulse/note_card_test.dart create mode 100644 mobile/test/features/search/recent_searches_provider_test.dart create mode 100644 mobile/test/shared/theme/message_typography_test.dart create mode 100644 mobile/test/shared/widgets/message_author_meta_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 6f38681b2e..50d0544cfc 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -11,6 +13,7 @@ import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/message_author_meta.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; @@ -30,6 +33,7 @@ import 'reminders_provider.dart'; part 'activity_page/header_actions.dart'; part 'activity_page/inbox_row.dart'; part 'activity_page/lists.dart'; +part 'activity_page/popover_menu.dart'; part 'activity_page/status_views.dart'; /// Conversation-oriented Activity inbox. @@ -281,6 +285,7 @@ class ActivityPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( gradient: context.appColors.topSectionGradient, + automaticallyImplyLeading: false, title: const Text('Activity'), titleStyle: headerTitleStyle, actions: [ diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 731285caaa..1aab78e622 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -28,64 +28,101 @@ class _FilterMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { - return PopupMenuButton( - key: const ValueKey('activity-filter-menu'), - onSelected: onChanged, - itemBuilder: (context) => [ - for (final entry in _filterLabels.entries) - PopupMenuItem( - value: entry.key, + return Builder( + builder: (buttonContext) => InkWell( + key: const ValueKey('activity-filter-menu'), + borderRadius: BorderRadius.circular(Radii.md), + onTap: () async { + final selected = await _showActivityPopover( + context: buttonContext, + width: 240, + alignment: _ActivityPopoverAlignment.start, + offset: const Offset(0, Grid.half), + menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), + color: context.colors.surface.withValues(alpha: 0.98), + elevation: 8, + shadowColor: context.colors.shadow.withValues(alpha: 0.18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.card), + side: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.45), + ), + ), + surfaceKey: const ValueKey('activity-filter-popover'), + items: [ + for (final entry in _filterLabels.entries) + PopupMenuItem( + value: entry.key, + height: Grid.xl, + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + child: Row( + children: [ + SizedBox( + width: Grid.sm, + child: entry.key == filter + ? Icon( + LucideIcons.check, + size: 16, + color: context.colors.primary, + ) + : null, + ), + Expanded( + child: Text( + entry.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ), + if (entry.key == InboxFilter.reminders && + dueReminderCount > 0) + _CountBadge(count: dueReminderCount) + else if (entry.key == InboxFilter.drafts && + draftCount > 0) + _CountBadge(count: draftCount), + ], + ), + ), + ], + ); + if (buttonContext.mounted && selected != null) onChanged(selected); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: Grid.xl), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: Grid.sm, - child: entry.key == filter - ? Icon( - LucideIcons.check, - size: 16, - color: context.colors.primary, - ) - : null, + Text( + _filterLabels[filter]!, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), - Text(entry.value), - const Spacer(), - if (entry.key == InboxFilter.reminders && dueReminderCount > 0) - _CountBadge(count: dueReminderCount) - else if (entry.key == InboxFilter.drafts && draftCount > 0) - _CountBadge(count: draftCount), + const SizedBox(width: Grid.quarter), + Icon( + LucideIcons.chevronDown, + size: 16, + color: context.colors.onSurfaceVariant, + ), + if (dueReminderCount > 0 || draftCount > 0) ...[ + const SizedBox(width: Grid.quarter), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + ), + ), + ], ], ), ), - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _filterLabels[filter]!, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Grid.quarter), - Icon( - LucideIcons.chevronDown, - size: 16, - color: context.colors.onSurfaceVariant, - ), - if (dueReminderCount > 0 || draftCount > 0) ...[ - const SizedBox(width: Grid.quarter), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.colors.primary, - ), - ), - ], - ], ), ), ); @@ -136,45 +173,64 @@ class _InboxOptionsButton extends StatelessWidget { @override Widget build(BuildContext context) { - return PopupMenuButton( - key: const ValueKey('activity-options-menu'), - icon: const Icon(LucideIcons.ellipsis, size: 20), - onSelected: (value) { - if (value == 'unread-only') onUnreadOnlyChanged(!unreadOnly); - if (value == 'mark-all-read') onMarkAllRead(); - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'unread-only', - child: Row( - children: [ - Expanded(child: Text(unreadOnly ? 'Show all' : 'Show unread')), - if (unreadOnly) - Icon( - LucideIcons.check, - size: 16, - color: context.colors.primary, + return Builder( + builder: (buttonContext) => IconButton( + key: const ValueKey('activity-options-menu'), + tooltip: 'Activity options', + icon: const Icon(LucideIcons.ellipsis, size: 20), + onPressed: () async { + final selected = await _showActivityPopover( + context: buttonContext, + width: 216, + alignment: _ActivityPopoverAlignment.end, + color: context.colors.surface, + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + side: BorderSide(color: context.colors.outline), + ), + surfaceKey: const ValueKey('activity-options-popover'), + items: [ + PopupMenuItem( + value: 'unread-only', + child: Row( + children: [ + Expanded( + child: Text(unreadOnly ? 'Show all' : 'Show unread'), + ), + if (unreadOnly) + Icon( + LucideIcons.check, + size: 16, + color: context.colors.primary, + ), + ], ), - ], - ), - ), - PopupMenuItem( - value: 'mark-all-read', - enabled: unreadCount > 0, - child: Row( - children: [ - const Expanded(child: Text('Mark all as read')), - if (unreadCount > 0) - Text( - '$unreadCount', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), + ), + PopupMenuItem( + value: 'mark-all-read', + enabled: unreadCount > 0, + child: Row( + children: [ + const Expanded(child: Text('Mark all as read')), + if (unreadCount > 0) + Text( + '$unreadCount', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], ), + ), ], - ), - ), - ], + ); + if (!buttonContext.mounted || selected == null) return; + if (selected == 'unread-only') onUnreadOnlyChanged(!unreadOnly); + if (selected == 'mark-all-read') onMarkAllRead(); + }, + ), ); } } diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 83ae55225f..fb267c03be 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -91,7 +91,7 @@ class _InboxRow extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _RowAvatar(pubkey: item.item.pubkey, profile: profile), - const SizedBox(width: Grid.twelve), + const SizedBox(width: messageAvatarContentGap), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -100,19 +100,25 @@ class _InboxRow extends ConsumerWidget { Row( children: [ Expanded( - child: Text( - senderLabel, - // Compact label scale — matches the old - // "@ Mention" headline treatment while staying - // the row's primary label. - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + child: MessageAuthorMeta( + displayName: senderLabel, + username: messageUsernameLabel(profile), + timestamp: _inboxTimestamp(item.latestActivityAt), + nameColor: context.colors.onSurface, + metadataColor: mutedColor, + nameStyle: activityUsernameTextStyle, + metadataStyle: activityTimestampTextStyle, + displayNameKey: ValueKey( + 'activity-author-${item.id}', + ), + usernameKey: ValueKey('activity-username-${item.id}'), + timestampKey: ValueKey( + 'activity-timestamp-${item.id}', ), - overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: Grid.xxs), if (!isDone) ...[ + const SizedBox(width: Grid.xxs), Container( key: ValueKey('inbox-unread-dot-${item.id}'), width: 6, @@ -122,17 +128,7 @@ class _InboxRow extends ConsumerWidget { color: context.colors.primary, ), ), - const SizedBox(width: Grid.half), ], - Text( - _inboxTimestamp(item.latestActivityAt), - style: context.textTheme.labelSmall?.copyWith( - color: mutedColor, - fontWeight: isDone - ? FontWeight.w400 - : FontWeight.w500, - ), - ), ], ), const SizedBox(height: Grid.quarter), @@ -142,9 +138,8 @@ class _InboxRow extends ConsumerWidget { Flexible( child: Text( label.text, - style: context.textTheme.labelSmall?.copyWith( + style: activityContextTextStyle.copyWith( color: labelColor, - fontWeight: FontWeight.w500, ), overflow: TextOverflow.ellipsis, ), @@ -163,7 +158,7 @@ class _InboxRow extends ConsumerWidget { ), child: Text( '#${label.channelLabel}', - style: context.textTheme.labelSmall?.copyWith( + style: activityContextTextStyle.copyWith( color: mutedColor, ), overflow: TextOverflow.ellipsis, @@ -174,14 +169,13 @@ class _InboxRow extends ConsumerWidget { ], ), const SizedBox(height: Grid.half), - // Preview (bold while unread — desktop parity). + // Message preview. MessageContent( content: item.item.displayContent, tags: item.item.tags, maxLines: 2, - baseStyle: context.textTheme.bodySmall?.copyWith( - color: isDone ? mutedColor : context.colors.onSurface, - fontWeight: isDone ? FontWeight.w400 : FontWeight.w600, + baseStyle: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, ), ), ], @@ -238,7 +232,7 @@ class _RowAvatar extends StatelessWidget { profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); return AvatarImage( imageUrl: profile?.avatarUrl, - radius: 18, + radius: activityAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, diff --git a/mobile/lib/features/activity/activity_page/popover_menu.dart b/mobile/lib/features/activity/activity_page/popover_menu.dart new file mode 100644 index 0000000000..56d0a612ec --- /dev/null +++ b/mobile/lib/features/activity/activity_page/popover_menu.dart @@ -0,0 +1,231 @@ +part of '../activity_page.dart'; + +const _activityPopoverEnterDuration = Duration(milliseconds: 150); +const _activityPopoverExitDuration = Duration(milliseconds: 110); +const _activityPopoverStartScale = 0.96; + +enum _ActivityPopoverAlignment { start, end } + +Future _showActivityPopover({ + required BuildContext context, + required List> items, + required double width, + required _ActivityPopoverAlignment alignment, + required Color color, + required ShapeBorder shape, + required double elevation, + required Color shadowColor, + Offset offset = Offset.zero, + EdgeInsetsGeometry menuPadding = EdgeInsets.zero, + Clip clipBehavior = Clip.antiAlias, + Key? surfaceKey, +}) { + final navigator = Navigator.of(context); + final overlay = navigator.overlay; + final triggerRenderObject = context.findRenderObject(); + final overlayRenderObject = overlay?.context.findRenderObject(); + if (triggerRenderObject is! RenderBox || overlayRenderObject is! RenderBox) { + return Future.value(); + } + + final triggerRect = MatrixUtils.transformRect( + triggerRenderObject.getTransformTo(overlayRenderObject), + Offset.zero & triggerRenderObject.size, + ); + final overlayRect = Offset.zero & overlayRenderObject.size; + final mediaQuery = MediaQuery.of(context); + + return navigator.push( + _ActivityPopoverRoute( + position: RelativeRect.fromRect(triggerRect, overlayRect), + items: items, + width: width, + alignment: alignment, + offset: offset, + color: color, + shape: shape, + elevation: elevation, + shadowColor: shadowColor, + menuPadding: menuPadding, + clipBehavior: clipBehavior, + surfaceKey: surfaceKey, + screenPadding: EdgeInsets.fromLTRB( + math.max(Grid.xxs, mediaQuery.padding.left), + math.max(Grid.xxs, mediaQuery.padding.top), + math.max(Grid.xxs, mediaQuery.padding.right), + math.max(Grid.xxs, mediaQuery.padding.bottom), + ), + reducedMotion: mediaQuery.disableAnimations, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + ), + ); +} + +class _ActivityPopoverRoute extends PopupRoute { + final RelativeRect position; + final List> items; + final double width; + final _ActivityPopoverAlignment alignment; + final Offset offset; + final Color color; + final ShapeBorder shape; + final double elevation; + final Color shadowColor; + final EdgeInsetsGeometry menuPadding; + final Clip clipBehavior; + final Key? surfaceKey; + final EdgeInsets screenPadding; + final bool reducedMotion; + final String _barrierLabel; + + _ActivityPopoverRoute({ + required this.position, + required this.items, + required this.width, + required this.alignment, + required this.offset, + required this.color, + required this.shape, + required this.elevation, + required this.shadowColor, + required this.menuPadding, + required this.clipBehavior, + required this.surfaceKey, + required this.screenPadding, + required this.reducedMotion, + required String barrierLabel, + }) : _barrierLabel = barrierLabel; + + @override + Color? get barrierColor => null; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => _barrierLabel; + + @override + Duration get transitionDuration => + reducedMotion ? Duration.zero : _activityPopoverEnterDuration; + + @override + Duration get reverseTransitionDuration => + reducedMotion ? Duration.zero : _activityPopoverExitDuration; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final curvedAnimation = animation.drive( + CurveTween(curve: Curves.easeOutCubic), + ); + final scaleAnimation = Tween( + begin: _activityPopoverStartScale, + end: 1, + ).animate(curvedAnimation); + final transformOrigin = switch (alignment) { + _ActivityPopoverAlignment.start => Alignment.topLeft, + _ActivityPopoverAlignment.end => Alignment.topRight, + }; + + return CustomSingleChildLayout( + delegate: _ActivityPopoverLayoutDelegate( + position: position, + alignment: alignment, + offset: offset, + screenPadding: screenPadding, + ), + child: FadeTransition( + key: const ValueKey('activity-popover-fade'), + opacity: curvedAnimation, + child: ScaleTransition( + key: const ValueKey('activity-popover-scale'), + scale: scaleAnimation, + alignment: transformOrigin, + child: Material( + key: surfaceKey, + type: MaterialType.card, + color: color, + surfaceTintColor: Colors.transparent, + elevation: elevation, + shadowColor: shadowColor, + shape: shape, + clipBehavior: clipBehavior, + child: SizedBox( + width: width, + child: Semantics( + role: SemanticsRole.menu, + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + child: SingleChildScrollView( + padding: menuPadding, + child: ListBody(children: items), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { + final RelativeRect position; + final _ActivityPopoverAlignment alignment; + final Offset offset; + final EdgeInsets screenPadding; + + const _ActivityPopoverLayoutDelegate({ + required this.position, + required this.alignment, + required this.offset, + required this.screenPadding, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints.loose( + Size( + constraints.maxWidth - screenPadding.horizontal, + constraints.maxHeight - screenPadding.vertical, + ), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final anchorBottom = size.height - position.bottom; + final desiredX = switch (alignment) { + _ActivityPopoverAlignment.start => position.left + offset.dx, + _ActivityPopoverAlignment.end => + size.width - position.right - childSize.width + offset.dx, + }; + final minX = screenPadding.left; + final maxX = size.width - screenPadding.right - childSize.width; + final x = desiredX.clamp(minX, maxX).toDouble(); + + final belowY = anchorBottom + offset.dy; + final aboveY = position.top - childSize.height - offset.dy; + final maxY = size.height - screenPadding.bottom - childSize.height; + final desiredY = + belowY + childSize.height <= size.height - screenPadding.bottom + ? belowY + : aboveY; + final y = desiredY.clamp(screenPadding.top, maxY).toDouble(); + + return Offset(x, y); + } + + @override + bool shouldRelayout(_ActivityPopoverLayoutDelegate oldDelegate) { + return position != oldDelegate.position || + alignment != oldDelegate.alignment || + offset != oldDelegate.offset || + screenPadding != oldDelegate.screenPadding; + } +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f94226ac80..3e5140f844 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' show min; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -12,6 +13,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/skeleton.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; @@ -216,6 +218,7 @@ class ChannelDetailPage extends HookConsumerWidget { appBar: FrostedAppBar( iconColor: context.colors.primary, titleContentHeight: appBarTitleContentHeight, + titleStyle: channelTitleTextStyle, title: resolvedChannel.isDm ? _DmAppBarTitle( channel: resolvedChannel, diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 251a6ea0b8..fbf5abfb3b 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -8,9 +8,9 @@ double _scaledTextHeight(BuildContext context, TextStyle style) { } double _dmAppBarTitleContentHeight(BuildContext context) { - final titleStyle = context.textTheme.titleSmall; + const titleStyle = channelTitleTextStyle; final presenceStyle = context.textTheme.bodySmall; - if (titleStyle == null || presenceStyle == null) { + if (presenceStyle == null) { return 30; } final textHeight = @@ -240,7 +240,7 @@ class _DmAppBarTitle extends ConsumerWidget { ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.titleSmall, + style: channelTitleTextStyle, ), ), if (channel.isEphemeral) ...[ diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 2a02b51c95..8e7ac95644 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -52,147 +52,171 @@ class _MessageBubble extends ConsumerWidget { } } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: currentChannelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.half), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _UserAvatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: 36), - const SizedBox(width: Grid.xxs), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: Text( - displayName, - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: currentChannelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.xs : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _UserAvatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Transform.translate( + offset: Offset(0, showAuthor ? -Grid.quarter : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), ), ), - ), - const SizedBox(width: Grid.xxs), - _messageTimestamp(context, message.createdAt), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ); } } -Widget _messageTimestamp(BuildContext context, int createdAt) { - return Text( - formatMessageTime(createdAt), - style: context.textTheme.labelSmall?.copyWith( - fontSize: 14, - height: 22 / 14, - letterSpacing: context.textTheme.titleSmall?.letterSpacing, - color: context.colors.onSurfaceVariant, +Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), + child: Text( + key: key, + formatMessageTime(createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ); } @@ -205,7 +229,7 @@ class _UserAvatar extends StatelessWidget { const _UserAvatar({ required this.profile, required this.pubkey, - this.size = 36, + this.size = messageAvatarSize, }); @override 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 035cbb3051..af1293dd00 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -31,6 +31,11 @@ class _MessageList extends HookConsumerWidget { final isLoadingOlder = useState(false); final isAtLatest = useState(true); final hasUserScrolled = useState(false); + final followsLatest = useRef( + initialMessageId == null && initialThreadRootId == null, + ); + final isAutoScrolling = useRef(false); + final autoScrollScheduled = useRef(false); final latestEntryId = entries.isEmpty ? null : entries.last.message.id; final previousLatestEntryId = useRef(null); final didOpenInitialThread = useRef(false); @@ -47,24 +52,57 @@ class _MessageList extends HookConsumerWidget { } Future scrollToLatest() async { - if (!itemScrollController.isAttached) return; - await itemScrollController.scrollTo( - index: 0, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, + if (!itemScrollController.isAttached || isAutoScrolling.value) return; + followsLatest.value = true; + hasUserScrolled.value = false; + isAutoScrolling.value = true; + try { + await itemScrollController.scrollTo( + index: 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + if (context.mounted && !hasUserScrolled.value) { + isAtLatest.value = true; + } + } finally { + isAutoScrolling.value = false; + } + } + + void scheduleAutoScrollToLatest() { + if (autoScrollScheduled.value || isAutoScrolling.value) return; + autoScrollScheduled.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + autoScrollScheduled.value = false; + if (!context.mounted || !followsLatest.value || hasUserScrolled.value) { + return; + } + scrollToLatest(); + }); + } + + bool latestIsAtBoundary() { + // In this reversed list, item 0's leading edge is the bottom boundary. + return itemPositionsListener.itemPositions.value.any( + (position) => position.index == 0 && position.itemLeadingEdge >= 0, ); - if (context.mounted) isAtLatest.value = true; } useEffect(() { void onPositionsChanged() { final positions = itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; - final nextIsAtLatest = positions.any( - (position) => position.index == 0 && position.itemLeadingEdge < 1, - ); - if (isAtLatest.value != nextIsAtLatest) { - isAtLatest.value = nextIsAtLatest; + final nextIsAtLatest = latestIsAtBoundary(); + if (nextIsAtLatest) { + if (!isAtLatest.value) isAtLatest.value = true; + } else if (followsLatest.value && !hasUserScrolled.value) { + // The viewport can shrink when the composer or keyboard opens. + // Preserve auto-follow until the user scrolls the timeline. + if (!isAtLatest.value) isAtLatest.value = true; + scheduleAutoScrollToLatest(); + } else if (isAtLatest.value) { + isAtLatest.value = false; } final oldestVisible = positions @@ -124,8 +162,11 @@ class _MessageList extends HookConsumerWidget { } WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; - itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); didJumpToInitialMessage.value = true; + followsLatest.value = false; + hasUserScrolled.value = false; + isAtLatest.value = false; + itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); }); return null; }, [initialMessageId, initialThreadRootId, entries.length]); @@ -187,9 +228,18 @@ class _MessageList extends HookConsumerWidget { children: [ NotificationListener( onNotification: (notification) { - if (notification is ScrollStartNotification && - notification.dragDetails != null) { + if (notification is UserScrollNotification && + notification.direction != ScrollDirection.idle) { hasUserScrolled.value = true; + followsLatest.value = false; + } else if (notification is ScrollEndNotification && + hasUserScrolled.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !latestIsAtBoundary()) return; + hasUserScrolled.value = false; + followsLatest.value = true; + if (!isAtLatest.value) isAtLatest.value = true; + }); } return false; }, @@ -205,7 +255,7 @@ class _MessageList extends HookConsumerWidget { context, titleContentHeight: appBarTitleContentHeight, ), - bottom: Grid.xxs, + bottom: 0, ), itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -248,46 +298,50 @@ class _MessageList extends HookConsumerWidget { message.pubkey.toLowerCase() || (message.createdAt - prevMessage.createdAt) > 300); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider(label: formatDayHeading(message.createdAt)), - if (message.isSystem) - _SystemMessageRow( - message: message, - groupedMessages: entryGroup.length > 1 - ? entryGroup.map((entry) => entry.message).toList() - : null, - channelId: channelId, - currentPubkey: currentPubkey, - allMessages: null, - isMember: isMember, - isArchived: isArchived, - ) - else ...[ - _MessageBubble( - message: message, - showAuthor: showAuthor, - channelNames: channelNamesMap, - currentChannelId: channelId, - currentPubkey: currentPubkey, - allMessages: allMessages, - isMember: isMember, - isArchived: isArchived, - ), - if (entry.summary != null) - _ThreadSummaryRow( - summary: entry.summary!, + return Padding( + key: ValueKey('channel-message-group-${message.id}'), + padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDayDivider) + DayDivider(label: formatDayHeading(message.createdAt)), + if (message.isSystem) + _SystemMessageRow( message: message, - allMessages: allMessages, + groupedMessages: entryGroup.length > 1 + ? entryGroup.map((entry) => entry.message).toList() + : null, channelId: channelId, currentPubkey: currentPubkey, + allMessages: null, + isMember: isMember, + isArchived: isArchived, + ) + else ...[ + _MessageBubble( + message: message, + showAuthor: showAuthor, + channelNames: channelNamesMap, + currentChannelId: channelId, + currentPubkey: currentPubkey, + allMessages: allMessages, isMember: isMember, isArchived: isArchived, ), + if (entry.summary != null) + _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ], - ], + ), ); }, ), 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 8ff09b4e5c..f6019febab 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -30,6 +30,9 @@ class _SystemMessageRow extends ConsumerWidget { final channelCreator = systemEvent.type == SystemEventType.channelCreated ? systemEvent.actorPubkey?.trim() : null; + final usesMessageStyleLayout = + groupedMembership != null || + (channelCreator != null && channelCreator.isNotEmpty); String resolveLabel(String? pubkey) { if (pubkey == null) return 'Someone'; @@ -68,66 +71,83 @@ class _SystemMessageRow extends ConsumerWidget { } } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: false, - allMessages: null, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (groupedMembership != null) - _MembershipSystemMessageContent( - event: groupedMembership, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - ) - else if (channelCreator != null && channelCreator.isNotEmpty) - _MessageStyleSystemMessageContent( - displayPubkey: channelCreator, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - actionSpans: const [TextSpan(text: 'created this channel')], - ) - else - Row( - children: [ - _systemEventAvatar(context, systemEvent, userCache), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - systemEvent.describe(resolveLabel), - style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('system-message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: false, + allMessages: null, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (groupedMembership != null) + _MembershipSystemMessageContent( + event: groupedMembership, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + else if (channelCreator != null && channelCreator.isNotEmpty) + _MessageStyleSystemMessageContent( + displayPubkey: channelCreator, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: const [TextSpan(text: 'created this channel')], + ) + else + Row( + children: [ + _systemEventAvatar(context, systemEvent, userCache), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + systemEvent.describe(resolveLabel), + style: systemMessageBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), + _messageTimestamp( + context, + message.createdAt, + key: ValueKey('system-message-timestamp-${message.id}'), + ), + ], + ), + if (reactions.isNotEmpty) + Padding( + padding: EdgeInsets.only( + left: + (usesMessageStyleLayout ? messageAvatarSize : 36) + + (usesMessageStyleLayout + ? messageAvatarContentGap + : Grid.xxs), + ), + child: ReactionRow( + reactions: reactions, + onToggle: groupedMessages == null + ? (emoji) => toggleReaction(ref, message, emoji) + : toggleGroupedReaction, ), - _messageTimestamp(context, message.createdAt), - ], - ), - if (reactions.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 36 + Grid.xxs), - child: ReactionRow( - reactions: reactions, - onToggle: groupedMessages == null - ? (emoji) => toggleReaction(ref, message, emoji) - : toggleGroupedReaction, ), - ), - ], + ], + ), ), ), ); @@ -282,7 +302,7 @@ class _MembershipSystemMessageContent extends StatelessWidget { } TextStyle? _systemActionTextStyle(BuildContext context) { - return context.textTheme.bodyLarge?.copyWith( + return systemMessageBodyTextStyle.copyWith( color: context.colors.onSurfaceVariant, ); } @@ -310,28 +330,33 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { _UserAvatar( profile: userCache[displayPubkey.toLowerCase()], pubkey: displayPubkey, - size: 36, + size: messageAvatarSize, ), - const SizedBox(width: Grid.xxs), + const SizedBox(width: messageAvatarContentGap), Expanded( child: Transform.translate( offset: const Offset(0, -Grid.quarter), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - resolveLabel(displayPubkey), - style: context.textTheme.titleSmall?.copyWith( - color: context.colors.onSurface, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Grid.xxs), - _messageTimestamp(context, createdAt), - ], + MessageAuthorMeta( + displayName: resolveLabel(displayPubkey), + username: messageUsernameLabel( + userCache[displayPubkey.toLowerCase()], + ), + timestamp: formatMessageTime(createdAt), + nameColor: context.colors.onSurface, + metadataColor: context.colors.onSurfaceVariant, + nameStyle: systemMessageHeadingTextStyle, + displayNameKey: ValueKey( + 'system-message-author-$displayPubkey', + ), + usernameKey: ValueKey( + 'system-message-username-$displayPubkey', + ), + timestampKey: ValueKey( + 'system-message-timestamp-$displayPubkey', + ), ), Text.rich( TextSpan( @@ -482,10 +507,11 @@ class _ThreadSummaryRow extends ConsumerWidget { ); }, child: Padding( + key: ValueKey('thread-summary-${message.id}'), padding: const EdgeInsets.only( - left: 36 + Grid.xxs, + left: messageAvatarSize + messageAvatarContentGap, top: Grid.half, - bottom: Grid.half, + bottom: Grid.xs, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -509,36 +535,38 @@ class _ThreadSummaryRow extends ConsumerWidget { ), ), const SizedBox(width: Grid.xxs), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - ), + Flexible( + child: Text.rich( + TextSpan( + children: [ TextSpan( text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w400, + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.primary, ), ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], ], - ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 298383c369..551e3f8dd6 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -66,7 +66,7 @@ class _ChannelTile extends ConsumerWidget { ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( + style: contentListTitleTextStyle.copyWith( color: context.colors.onSurface, fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400, ), diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 394f68f6f7..63bd5db9d9 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -154,7 +154,7 @@ class _CustomSectionHeader extends ConsumerWidget { const SizedBox(width: _kChannelLabelGap), Text( section.name, - style: context.textTheme.bodyLarge?.copyWith( + style: contentListTitleTextStyle.copyWith( color: sectionColor, fontWeight: FontWeight.w600, ), @@ -318,7 +318,7 @@ class _ChannelSection extends StatelessWidget { ), child: Text( emptyLabel, - style: context.textTheme.bodySmall?.copyWith( + style: contentListBodyTextStyle.copyWith( color: context.colors.onSurfaceVariant, ), ), @@ -430,7 +430,7 @@ class _SectionHeader extends StatelessWidget { const SizedBox(width: _kChannelLabelGap), Text( label, - style: context.textTheme.bodyLarge?.copyWith( + style: contentListTitleTextStyle.copyWith( color: sectionColor, fontWeight: FontWeight.w600, ), diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 8814d698fb..2f2404ccf7 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -75,11 +75,10 @@ class ReactionRow extends StatelessWidget { const SizedBox(width: Grid.quarter), Text( '${reaction.count}', - style: context.textTheme.labelSmall?.copyWith( + style: reactionCountTextStyle.copyWith( color: reaction.reactedByCurrentUser ? context.colors.primary : context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, ), ), ], diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 32a1cf156a..a6d75a9f05 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -7,6 +7,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/message_author_meta.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_link_navigation.dart'; @@ -153,11 +154,15 @@ class ThreadDetailPage extends HookConsumerWidget { }); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Thread')), + appBar: const FrostedAppBar( + title: Text('Thread'), + titleStyle: channelTitleTextStyle, + ), body: Column( children: [ Expanded( child: ScrollablePositionedList.builder( + key: const ValueKey('thread-message-list'), itemScrollController: itemScrollController, // Reversed so the list opens pinned to the newest reply, // matching the channel message list. @@ -166,48 +171,54 @@ class ThreadDetailPage extends HookConsumerWidget { left: Grid.gutter, right: Grid.gutter, top: frostedAppBarHeight(context), - bottom: Grid.xxs, + bottom: 0, ), itemCount: replies.length + 1, // +1 for thread head itemBuilder: (context, index) { if (index == replies.length) { // Thread head. - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DayDivider(label: formatDayHeading(liveHead.createdAt)), - _ThreadMessage( - message: liveHead, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: liveHead.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, + return Padding( + key: ValueKey('thread-message-group-${liveHead.id}'), + padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayDivider(label: formatDayHeading(liveHead.createdAt)), + _ThreadMessage( + message: liveHead, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + isHighlighted: liveHead.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.xxs, + ), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ); } @@ -221,7 +232,6 @@ class ThreadDetailPage extends HookConsumerWidget { reply.createdAt, ); final showAuthor = - reply.hasAttachments || prevReply == null || showDayDivider || prevReply.pubkey.toLowerCase() != @@ -235,33 +245,37 @@ class ThreadDetailPage extends HookConsumerWidget { ? _buildNestedSummary(reply.id, nestedChildren) : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider(label: formatDayHeading(reply.createdAt)), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, + return Padding( + key: ValueKey('thread-message-group-${reply.id}'), + padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDayDivider) + DayDivider(label: formatDayHeading(reply.createdAt)), + _ThreadMessage( + message: reply, + channelNames: channelNamesMap, channelId: channelId, currentPubkey: currentPubkey, + showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, + allMessages: allMsgs, isMember: isMember, isArchived: isArchived, ), - ], + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], + ), ); }, ), @@ -358,10 +372,11 @@ class _NestedThreadSummaryRow extends ConsumerWidget { ); }, child: Padding( + key: ValueKey('nested-thread-summary-${replyMessage.id}'), padding: const EdgeInsets.only( - left: 36 + Grid.xxs, + left: messageAvatarSize + messageAvatarContentGap, top: Grid.half, - bottom: Grid.half, + bottom: Grid.xs, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -387,36 +402,38 @@ class _NestedThreadSummaryRow extends ConsumerWidget { ), ), const SizedBox(width: Grid.xxs), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - ), + Flexible( + child: Text.rich( + TextSpan( + children: [ TextSpan( text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w400, + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.primary, ), ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], ], - ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), ], @@ -476,154 +493,166 @@ class _ThreadMessage extends ConsumerWidget { } } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + return DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), ), - child: DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(Grid.half), - ), - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: 36), - const SizedBox(width: Grid.xxs), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - child: Text( - displayName, - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('thread-message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.xs : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Transform.translate( + offset: Offset(0, showAuthor ? -Grid.quarter : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), ), - ), - const SizedBox(width: Grid.xxs), - Text( - formatMessageTime(message.createdAt), - style: context.textTheme.labelSmall?.copyWith( - fontSize: 14, - height: 22 / 14, - letterSpacing: context - .textTheme - .titleSmall - ?.letterSpacing, - color: context.colors.onSurfaceVariant, - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), @@ -709,7 +738,7 @@ class _Avatar extends StatelessWidget { return AvatarImage( imageUrl: avatarUrl, - radius: 18, + radius: messageAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 7c1851ba54..8666919a4a 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -77,17 +77,22 @@ class ForumPostCard extends ConsumerWidget { onTap: () => showUserProfileSheet(context, post.pubkey), child: Text( displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + maxLines: 1, + style: messageUsernameTextStyle, overflow: TextOverflow.ellipsis, ), ), ), - Text( - formatRelativeTime(post.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), + child: Text( + formatRelativeTime(post.createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), const SizedBox(width: Grid.half), @@ -124,6 +129,9 @@ class ForumPostCard extends ConsumerWidget { content: preview, mentionNames: mentionNames, tags: post.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), ), ), ), diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 4a4c0d0ab8..f7da19be12 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -335,22 +335,29 @@ class _OriginalPost extends ConsumerWidget { ), const SizedBox(width: Grid.xxs), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - GestureDetector( - onTap: () => showUserProfileSheet(context, post.pubkey), - child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + Expanded( + child: GestureDetector( + onTap: () => showUserProfileSheet(context, post.pubkey), + child: Text( + displayName, + maxLines: 1, + style: messageUsernameTextStyle, + overflow: TextOverflow.ellipsis, ), ), ), - Text( - formatRelativeTime(post.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), + child: Text( + formatRelativeTime(post.createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), ], @@ -363,6 +370,9 @@ class _OriginalPost extends ConsumerWidget { content: post.content, mentionNames: mentionNames, tags: post.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), ), ], @@ -417,20 +427,28 @@ class _ReplyRow extends ConsumerWidget { Expanded( child: Row( children: [ - GestureDetector( - onTap: () => showUserProfileSheet(context, reply.pubkey), - child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, + Expanded( + child: GestureDetector( + onTap: () => + showUserProfileSheet(context, reply.pubkey), + child: Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageUsernameTextStyle, ), ), ), const SizedBox(width: Grid.xxs), - Text( - formatRelativeTime(reply.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), + child: Text( + formatRelativeTime(reply.createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), ], @@ -458,6 +476,9 @@ class _ReplyRow extends ConsumerWidget { content: reply.content, mentionNames: mentionNames, tags: reply.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), ), ), diff --git a/mobile/lib/features/profile/user_profile.dart b/mobile/lib/features/profile/user_profile.dart index 4e74159016..de58d955e3 100644 --- a/mobile/lib/features/profile/user_profile.dart +++ b/mobile/lib/features/profile/user_profile.dart @@ -39,3 +39,10 @@ class UserProfile { (displayName?.isNotEmpty == true ? displayName! : pubkey)[0] .toUpperCase(); } + +/// Optional profile handle shown beside a message author's display name. +String? messageUsernameLabel(UserProfile? profile) { + final handle = profile?.nip05Handle?.trim(); + if (handle != null && handle.isNotEmpty) return handle; + return null; +} diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index 2c514d6a71..240cd980d7 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -198,20 +198,24 @@ class _ReplyContext extends ConsumerWidget { children: [ Row( children: [ - Flexible( + Expanded( child: Text( displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + maxLines: 1, + style: messageUsernameTextStyle, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: Grid.half), - Text( - formatPulseRelativeTime(note.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: Grid.xxl), + child: Text( + formatPulseRelativeTime(note.createdAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), ], @@ -229,6 +233,9 @@ class _ReplyContext extends ConsumerWidget { child: MessageContent( content: note.content, tags: note.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), ), ), ), diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 6caf1dad6a..d99e3ada4b 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -89,33 +89,47 @@ class NoteCard extends HookConsumerWidget { onTap: () => showUserProfileSheet(context, note.pubkey), child: Row( children: [ - Flexible( + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + displayName, + maxLines: 1, + style: messageUsernameTextStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (isAgent) ...[ + const SizedBox(width: Grid.half), + Icon( + LucideIcons.bot, + size: 13, + color: context.colors.primary, + ), + ], + ], + ), + ), + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: Grid.xl, + ), child: Text( - displayName, - style: context.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + formatPulseRelativeTime(note.createdAt), + maxLines: 1, overflow: TextOverflow.ellipsis, + style: messageTimestampTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), - if (isAgent) ...[ - const SizedBox(width: Grid.half), - Icon( - LucideIcons.bot, - size: 13, - color: context.colors.primary, - ), - ], ], ), ), ), - Text( - formatPulseRelativeTime(note.createdAt), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), if (canFollow) ...[ const SizedBox(width: Grid.half), _FollowButton( @@ -142,7 +156,13 @@ class NoteCard extends HookConsumerWidget { ), ], const SizedBox(height: Grid.half), - MessageContent(content: note.content, tags: note.tags), + MessageContent( + content: note.content, + tags: note.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + ), const SizedBox(height: Grid.xxs), Row( children: [ diff --git a/mobile/lib/features/search/recent_searches_provider.dart b/mobile/lib/features/search/recent_searches_provider.dart new file mode 100644 index 0000000000..813672a9cc --- /dev/null +++ b/mobile/lib/features/search/recent_searches_provider.dart @@ -0,0 +1,64 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme_provider.dart'; + +const _recentSearchesPrefsKey = 'recent_searches_v1'; +const _maxRecentSearches = 6; + +/// Device-local history of explicitly submitted searches, newest first. +/// +/// Queries are scoped by community and account so searches from one identity +/// cannot appear after switching to another. +class RecentSearchesNotifier extends Notifier> { + late String _prefsKey; + + @override + List build() { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider) ?? 'anon'; + _prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey'; + + final stored = + ref.read(savedPrefsProvider).getStringList(_prefsKey) ?? const []; + return List.unmodifiable( + stored + .map((query) => query.trim()) + .where((query) => query.isNotEmpty) + .take(_maxRecentSearches), + ); + } + + /// Records [query] as the most recent search after trimming whitespace. + /// + /// Empty queries are ignored. Existing matches are deduplicated + /// case-insensitively, the newest spelling is retained, the history is capped + /// at six entries, and the result is persisted for the current community and + /// account. + void record(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + + final normalized = trimmed.toLowerCase(); + final next = [ + trimmed, + ...state.where((item) => item.toLowerCase() != normalized), + ].take(_maxRecentSearches).toList(growable: false); + _persist(next); + } + + /// Clears the current community and account's history and persists it empty. + void clear() => _persist(const []); + + void _persist(List searches) { + state = List.unmodifiable(searches); + ref.read(savedPrefsProvider).setStringList(_prefsKey, searches); + } +} + +/// Provides device-local recent searches scoped to the active community and +/// account. +final recentSearchesProvider = + NotifierProvider>( + RecentSearchesNotifier.new, + ); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 635169f0de..1809dc7437 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -8,6 +8,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/message_author_meta.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -19,20 +20,22 @@ import '../forum/forum_thread_page.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'recent_searches_provider.dart'; import 'search_provider.dart'; enum _SearchFilter { all, messages, channels, people } const _searchFieldMinHeight = 36.0; const _searchFieldVerticalPadding = Grid.xxs; +const _searchFieldHint = 'Search messages, channels, people\u2026'; +const _searchCancelEnterDuration = Duration(milliseconds: 160); +const _searchCancelExitDuration = Duration(milliseconds: 120); double _searchFieldHeight(BuildContext context) { - final style = - context.textTheme.bodyMedium ?? - const TextStyle(fontSize: 14, height: 1.3); + const style = searchInputTextStyle; final scaledFontSize = MediaQuery.textScalerOf( context, - ).scale(style.fontSize ?? 14); + ).scale(style.fontSize ?? 15); final contentHeight = scaledFontSize * (style.height ?? 1) + _searchFieldVerticalPadding * 2; return contentHeight > _searchFieldMinHeight @@ -52,10 +55,12 @@ class SearchPage extends HookConsumerWidget { .value; final activeFilter = useState(_SearchFilter.all); final textController = useTextEditingController(); - final hasText = useListenableSelector( - textController, - () => textController.text.isNotEmpty, + final focusNode = useFocusNode(); + final isSearchFocused = useListenableSelector( + focusNode, + () => focusNode.hasFocus, ); + final reduceMotion = MediaQuery.disableAnimationsOf(context); final isBuzzTheme = context.appColors.topSectionGradient != null; final buzzSearchColor = context.theme.brightness == Brightness.dark ? Colors.white @@ -71,7 +76,20 @@ class SearchPage extends HookConsumerWidget { fontWeight: FontWeight.w600, ); final searchFieldHeight = _searchFieldHeight(context); - final searchHeaderBottomHeight = searchFieldHeight + Grid.twelve; + final searchControlHeight = searchFieldHeight > Grid.xl + ? searchFieldHeight + : Grid.xl; + final searchHeaderBottomHeight = searchControlHeight + Grid.twelve; + + void runRecentSearch(String query) { + textController.value = TextEditingValue( + text: query, + selection: TextSelection.collapsed(offset: query.length), + ); + focusNode.requestFocus(); + ref.read(recentSearchesProvider.notifier).record(query); + ref.read(searchProvider.notifier).search(query); + } return FrostedScaffold( // Keep the empty state centered in the page rather than the portion left @@ -89,51 +107,120 @@ class SearchPage extends HookConsumerWidget { Grid.gutter, Grid.twelve, ), - child: Container( - key: const Key('search-field-container'), - height: searchFieldHeight, - padding: const EdgeInsets.symmetric(horizontal: Grid.half), - decoration: BoxDecoration( - color: searchSurfaceColor, - borderRadius: BorderRadius.circular(Radii.lg), - ), - child: TextField( - controller: textController, - decoration: InputDecoration( - hintText: 'Search messages, channels, people\u2026', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: searchMutedColor, - ), - prefixIcon: Icon( - LucideIcons.search, - size: 16, - color: searchMutedColor, - ), - prefixIconConstraints: const BoxConstraints(minWidth: 32), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: _searchFieldVerticalPadding, + child: Row( + children: [ + Expanded( + child: Container( + key: const Key('search-field-container'), + height: searchFieldHeight, + padding: const EdgeInsets.symmetric(horizontal: Grid.half), + decoration: BoxDecoration( + color: searchSurfaceColor, + borderRadius: BorderRadius.circular(Radii.lg), + ), + child: TextField( + key: const Key('search-field'), + controller: textController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: isSearchFocused ? null : _searchFieldHint, + hintStyle: searchInputTextStyle.copyWith( + color: searchMutedColor, + ), + prefixIcon: Icon( + LucideIcons.search, + size: 16, + color: searchMutedColor, + ), + prefixIconConstraints: const BoxConstraints(minWidth: 32), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: _searchFieldVerticalPadding, + ), + ), + style: searchInputTextStyle.copyWith( + color: context.colors.onSurface, + ), + textInputAction: TextInputAction.search, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + onSubmitted: (value) { + final query = value.trim(); + if (query.isEmpty) return; + ref.read(recentSearchesProvider.notifier).record(query); + }, + ), ), ), - style: context.textTheme.bodyMedium, - onChanged: (value) => - ref.read(searchProvider.notifier).search(value), - ), + AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : _searchCancelEnterDuration, + reverseDuration: reduceMotion + ? Duration.zero + : _searchCancelExitDuration, + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SizeTransition( + sizeFactor: curvedAnimation, + axis: Axis.horizontal, + axisAlignment: 1, + child: FadeTransition( + opacity: curvedAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.35, 0), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, + ), + ), + ); + }, + child: isSearchFocused + ? Padding( + key: const ValueKey('search-cancel-visible'), + padding: const EdgeInsets.only(left: Grid.xxs), + child: TextButton( + key: const Key('search-cancel'), + onPressed: () { + textController.clear(); + ref.read(searchProvider.notifier).clear(); + focusNode.unfocus(); + }, + style: TextButton.styleFrom( + foregroundColor: context.colors.primary, + minimumSize: Size(0, searchControlHeight), + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.xxs, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Cancel', + style: filterChipTextStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('search-cancel-hidden'), + ), + ), + ], ), ), - actions: [ - if (hasText) - IconButton( - icon: const Icon(LucideIcons.x, size: 20), - onPressed: () { - textController.clear(); - ref.read(searchProvider.notifier).clear(); - }, - ), - ], + actions: const [], ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -162,6 +249,7 @@ class SearchPage extends HookConsumerWidget { state: searchState, filter: activeFilter.value, currentPubkey: currentPubkey, + onRecentSearchSelected: runRecentSearch, ), ), ], @@ -174,16 +262,27 @@ class _SearchBody extends ConsumerWidget { final SearchState state; final _SearchFilter filter; final String? currentPubkey; + final ValueChanged onRecentSearchSelected; const _SearchBody({ required this.state, required this.filter, required this.currentPubkey, + required this.onRecentSearchSelected, }); @override Widget build(BuildContext context, WidgetRef ref) { if (state.query.isEmpty) { + final recentSearches = ref.watch(recentSearchesProvider); + if (recentSearches.isNotEmpty) { + return _RecentSearches( + searches: recentSearches, + onSelected: onRecentSearchSelected, + onClear: ref.read(recentSearchesProvider.notifier).clear, + ); + } + return Center( child: Padding( key: const Key('search-empty-state'), @@ -221,6 +320,8 @@ class _SearchBody extends ConsumerWidget { state.channelResults.isNotEmpty || state.userResults.isNotEmpty || state.messageResults.isNotEmpty; + void recordResultSelection() => + ref.read(recentSearchesProvider.notifier).record(state.query); if (!state.isLoading && !hasAnyResults) { return Padding( @@ -246,13 +347,20 @@ class _SearchBody extends ConsumerWidget { ), children: [ if (showChannels && state.channelResults.isNotEmpty) - _ChannelsSection(channels: state.channelResults), + _ChannelsSection( + channels: state.channelResults, + onResultSelected: recordResultSelection, + ), if (showPeople && state.userResults.isNotEmpty) - _PeopleSection(users: state.userResults), + _PeopleSection( + users: state.userResults, + onResultSelected: recordResultSelection, + ), if (showMessages && state.messageResults.isNotEmpty) _MessagesSection( hits: state.messageResults, currentPubkey: currentPubkey, + onResultSelected: recordResultSelection, ), if (state.isLoading) const Padding( @@ -264,10 +372,106 @@ class _SearchBody extends ConsumerWidget { } } +class _RecentSearches extends StatelessWidget { + final List searches; + final ValueChanged onSelected; + final VoidCallback onClear; + + const _RecentSearches({ + required this.searches, + required this.onSelected, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + return ListView( + key: const Key('recent-searches-list'), + padding: EdgeInsets.only( + bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom, + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xs, + Grid.xxs, + Grid.half, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Recent searches', + key: const Key('recent-searches-heading'), + style: activityContextTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + TextButton( + key: const Key('clear-recent-searches'), + onPressed: onClear, + child: Text( + 'Clear', + style: activityContextTextStyle.copyWith( + color: context.colors.primary, + ), + ), + ), + ], + ), + ), + for (var index = 0; index < searches.length; index++) + InkWell( + key: ValueKey('recent-search-$index'), + onTap: () => onSelected(searches[index]), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: Grid.xl), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.twelve, + ), + child: Row( + children: [ + Icon( + LucideIcons.clock, + size: 18, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Text( + searches[index], + style: contentListTitleTextStyle.copyWith( + color: context.colors.onSurface, + ), + ), + ), + Icon( + LucideIcons.chevronRight, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ], + ); + } +} + class _ChannelsSection extends StatelessWidget { final List channels; + final VoidCallback onResultSelected; - const _ChannelsSection({required this.channels}); + const _ChannelsSection({ + required this.channels, + required this.onResultSelected, + }); @override Widget build(BuildContext context) { @@ -277,11 +481,21 @@ class _ChannelsSection extends StatelessWidget { _SectionLabel(label: 'Channels'), for (final channel in channels) ListTile( - leading: Icon(channelIcon(channel), size: 20), - title: Text(channel.name), + key: ValueKey('search-channel-row-${channel.id}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + leading: Icon( + channelIcon(channel), + key: ValueKey('search-channel-leading-${channel.id}'), + size: 20, + ), + title: Text( + channel.name, + key: ValueKey('search-channel-title-${channel.id}'), + style: contentListTitleTextStyle, + ), subtitle: Text( '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: context.textTheme.bodySmall?.copyWith( + style: contentListBodyTextStyle.copyWith( color: context.colors.onSurfaceVariant, ), ), @@ -304,11 +518,14 @@ class _ChannelsSection extends StatelessWidget { ), ) : null, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ), + onTap: () { + onResultSelected(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, ), ], ); @@ -317,8 +534,9 @@ class _ChannelsSection extends StatelessWidget { class _PeopleSection extends ConsumerWidget { final List users; + final VoidCallback onResultSelected; - const _PeopleSection({required this.users}); + const _PeopleSection({required this.users, required this.onResultSelected}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -328,19 +546,27 @@ class _PeopleSection extends ConsumerWidget { _SectionLabel(label: 'People'), for (final user in users) ListTile( + key: ValueKey('search-person-row-${user.pubkey}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), leading: AvatarImage( + key: ValueKey('search-person-leading-${user.pubkey}'), imageUrl: user.avatarUrl, radius: 20, fallback: Text(user.label.substring(0, 1).toUpperCase()), ), - title: Text(user.label), + title: Text( + user.label, + key: ValueKey('search-person-title-${user.pubkey}'), + style: contentListTitleTextStyle, + ), subtitle: Text( user.secondaryLabel, - style: context.textTheme.bodySmall?.copyWith( + style: contentListBodyTextStyle.copyWith( color: context.colors.onSurfaceVariant, ), ), onTap: () async { + onResultSelected(); final channel = await ref .read(channelActionsProvider) .openDm(pubkeys: [user.pubkey]); @@ -360,8 +586,13 @@ class _PeopleSection extends ConsumerWidget { class _MessagesSection extends ConsumerWidget { final List hits; final String? currentPubkey; + final VoidCallback onResultSelected; - const _MessagesSection({required this.hits, required this.currentPubkey}); + const _MessagesSection({ + required this.hits, + required this.currentPubkey, + required this.onResultSelected, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -383,6 +614,7 @@ class _MessagesSection extends ConsumerWidget { userCache: profiles, channel: channels.where((c) => c.id == hit.channelId).firstOrNull, currentPubkey: currentPubkey, + onResultSelected: onResultSelected, ), ], ); @@ -395,6 +627,7 @@ class _MessageTile extends StatelessWidget { final Map userCache; final Channel? channel; final String? currentPubkey; + final VoidCallback onResultSelected; const _MessageTile({ required this.hit, @@ -402,68 +635,99 @@ class _MessageTile extends StatelessWidget { required this.userCache, required this.channel, required this.currentPubkey, + required this.onResultSelected, }); @override Widget build(BuildContext context) { final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); final timeAgo = relativeTime(hit.createdAt); + final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); + final hasChannelName = channelName != null && channelName.isNotEmpty; + final isDm = channel?.isDm ?? false; return ListTile( - leading: SmallAvatar(pubkey: hit.pubkey, userCache: userCache), - title: Row( - children: [ - Expanded( - child: Text( - authorName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - if (hit.channelName != null) ...[ - const SizedBox(width: Grid.half), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: 2, - ), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - hit.channelName!, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ], + key: ValueKey('search-message-row-${hit.eventId}'), + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + titleAlignment: ListTileTitleAlignment.top, + horizontalTitleGap: messageAvatarContentGap, + leading: SmallAvatar( + key: ValueKey('search-message-avatar-${hit.eventId}'), + pubkey: hit.pubkey, + userCache: userCache, + size: compactMessageAvatarSize, + ), + title: MessageAuthorMeta( + displayName: authorName, + username: messageUsernameLabel(authorProfile), + timestamp: timeAgo, + nameColor: context.colors.onSurface, + metadataColor: context.colors.onSurfaceVariant, + displayNameKey: ValueKey('search-message-author-${hit.eventId}'), + usernameKey: ValueKey('search-message-username-${hit.eventId}'), + timestampKey: ValueKey('search-message-timestamp-${hit.eventId}'), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 2), + Row( + key: ValueKey('search-message-context-${hit.eventId}'), + children: [ + Flexible( + child: Text( + isDm + ? 'Direct message' + : hasChannelName + ? 'Message in' + : 'Message', + style: activityContextTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (!isDm && hasChannelName) ...[ + const SizedBox(width: Grid.half), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + Grid.quarter, + vertical: Grid.quarter / 2, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.xs), + ), + child: Text( + '#$channelName', + key: ValueKey('search-message-channel-${hit.eventId}'), + style: activityContextTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ), + const SizedBox(height: Grid.half), MessageContent( + key: ValueKey('search-message-body-${hit.eventId}'), content: hit.content, tags: hit.tags, maxLines: 2, - baseStyle: context.textTheme.bodyMedium, - ), - const SizedBox(height: 2), - Text( - timeAgo, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + baseStyle: activityPreviewTextStyle.copyWith( + color: context.colors.onSurface, ), ), ], ), - onTap: () => _navigateToHit(context, hit, channel), + onTap: () { + onResultSelected(); + _navigateToHit(context, hit, channel); + }, ); } @@ -507,11 +771,10 @@ class _SectionLabel extends StatelessWidget { Grid.half, ), child: Text( - label.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( + label, + key: ValueKey('search-section-${label.toLowerCase()}'), + style: activityContextTextStyle.copyWith( color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.8, ), ), ); diff --git a/mobile/lib/shared/theme/message_typography.dart b/mobile/lib/shared/theme/message_typography.dart new file mode 100644 index 0000000000..1073626390 --- /dev/null +++ b/mobile/lib/shared/theme/message_typography.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; + +import 'grid.dart'; + +const _fontFamily = 'Inter'; + +/// Avatar size for full channel and thread messages. +const messageAvatarSize = 42.0; + +/// Avatar size for conversation-oriented Activity rows. +const activityAvatarSize = messageAvatarSize; + +/// Avatar size for compact message-result rows. +const compactMessageAvatarSize = messageAvatarSize; + +/// Horizontal space between a message avatar and its content. +const messageAvatarContentGap = Grid.twelve; + +/// Primary message copy: 15sp regular on a 20sp line height. +const messageBodyTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 20 / 15, + letterSpacing: 0, +); + +/// Message author names: 15sp semibold on a 17sp line height. +const messageUsernameTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 17 / 15, + letterSpacing: 0, +); + +/// Secondary author metadata: 15sp regular on a tight 17sp line height. +const messageMetadataTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 17 / 15, + letterSpacing: 0, +); + +/// Message timestamps share the secondary author metadata style. +const messageTimestampTextStyle = messageMetadataTextStyle; + +/// Compact reply previews: 13.1sp regular on a 17sp line height. +const replyPreviewTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 13.1, + fontWeight: FontWeight.w400, + height: 17 / 13.1, + letterSpacing: 0, +); + +/// Reaction counts: 13.1sp medium on a 17sp line height. +const reactionCountTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 13.1, + fontWeight: FontWeight.w500, + height: 17 / 13.1, + letterSpacing: 0, +); + +/// Channel and thread titles: 20sp bold on a 24sp line height. +const channelTitleTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 20, + fontWeight: FontWeight.w700, + height: 24 / 20, + letterSpacing: 0, +); + +/// Primary labels in compact content lists. +const contentListTitleTextStyle = messageUsernameTextStyle; + +/// Secondary copy in compact content lists. +const contentListBodyTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 13.1, + fontWeight: FontWeight.w400, + height: 17 / 13.1, + letterSpacing: 0, +); + +/// Timestamps in compact content lists. +const contentListTimestampTextStyle = messageMetadataTextStyle; + +/// Filter chip labels use the compact 15sp type ramp. +const filterChipTextStyle = messageMetadataTextStyle; + +/// Search fields use the primary 15sp body treatment. +const searchInputTextStyle = messageBodyTextStyle; + +/// System message actor names: 15sp semibold on a 17sp line height. +const systemMessageHeadingTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 17 / 15, + letterSpacing: 0, +); + +/// System message copy: 15sp regular on a 20sp line height. +const systemMessageBodyTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 20 / 15, + letterSpacing: 0, +); + +/// Activity sender names share the primary author style. +const activityUsernameTextStyle = messageUsernameTextStyle; + +/// Activity timestamps share the secondary author metadata style. +const activityTimestampTextStyle = messageMetadataTextStyle; + +/// Activity context labels: 13.1sp medium on a 17sp line height. +const activityContextTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 13.1, + fontWeight: FontWeight.w500, + height: 17 / 13.1, + letterSpacing: 0, +); + +/// Activity message previews use the primary message copy style. +const activityPreviewTextStyle = messageBodyTextStyle; diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 91cdf4dcc1..862534b4f7 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -5,6 +5,7 @@ export 'app_theme.dart'; export 'buzz_theme.dart'; export 'color_scheme.dart'; export 'grid.dart'; +export 'message_typography.dart'; export 'theme_catalog.dart'; export 'theme_extensions.dart'; export 'theme_pairs.dart'; diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 294d7b9a5d..1fe55712a5 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -101,7 +101,7 @@ class FilterChipBar extends StatelessWidget { final fg = isSelected ? context.colors.onPrimary : context.colors.onSurfaceVariant; - final labelStyle = context.textTheme.bodyLarge?.copyWith( + final labelStyle = filterChipTextStyle.copyWith( color: fg, fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, ); diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index d8fb533026..6a0bd2729d 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -61,6 +61,9 @@ class FrostedAppBar extends StatelessWidget { /// can pop, a back button is shown automatically. final Widget? leading; + /// Whether to infer a back button from the current navigator. + final bool automaticallyImplyLeading; + /// Widget displayed in the center/title area. final Widget? title; @@ -95,6 +98,7 @@ class FrostedAppBar extends StatelessWidget { const FrostedAppBar({ super.key, this.leading, + this.automaticallyImplyLeading = true, this.title, this.titleStyle, this.titleContentHeight = 0, @@ -119,7 +123,7 @@ class FrostedAppBar extends StatelessWidget { final effectiveLeading = leading ?? - (canPop + (automaticallyImplyLeading && canPop ? SizedBox( width: 48, height: 48, diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart new file mode 100644 index 0000000000..bc69506cc9 --- /dev/null +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// A consistent inline author row for message-oriented surfaces. +class MessageAuthorMeta extends StatelessWidget { + /// Primary author name shown at the start of the row. + final String displayName; + + /// Optional secondary username, hidden when blank or equal to [displayName]. + final String? username; + + /// Timestamp label shown after the author metadata. + final String timestamp; + + /// Color applied to [displayName]. + final Color nameColor; + + /// Color applied to the username, separator, and [timestamp]. + final Color metadataColor; + + /// Optional callback invoked when [displayName] is tapped. + final VoidCallback? onAuthorTap; + + /// Optional key assigned to the display-name text. + final Key? displayNameKey; + + /// Optional key assigned to the username text. + final Key? usernameKey; + + /// Optional key assigned to the timestamp text. + final Key? timestampKey; + + /// Base text style for [displayName], with [nameColor] applied. + final TextStyle nameStyle; + + /// Base text style for secondary metadata, with [metadataColor] applied. + final TextStyle metadataStyle; + + /// Creates an inline author row with optional username and tap handling. + const MessageAuthorMeta({ + super.key, + required this.displayName, + required this.timestamp, + required this.nameColor, + required this.metadataColor, + this.username, + this.onAuthorTap, + this.displayNameKey, + this.usernameKey, + this.timestampKey, + this.nameStyle = messageUsernameTextStyle, + this.metadataStyle = messageMetadataTextStyle, + }); + + @override + Widget build(BuildContext context) { + final normalizedUsername = username?.trim(); + final showUsername = + normalizedUsername != null && + normalizedUsername.isNotEmpty && + normalizedUsername != displayName.trim(); + final resolvedNameStyle = nameStyle.copyWith(color: nameColor); + final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor); + + Widget authorName = Text( + displayName, + key: displayNameKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedNameStyle, + ); + if (onAuthorTap != null) { + authorName = GestureDetector(onTap: onAuthorTap, child: authorName); + } + + return LayoutBuilder( + builder: (context, constraints) { + final metadataMaxWidth = constraints.hasBoundedWidth + ? constraints.maxWidth / (showUsername ? 3 : 2) + : double.infinity; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: authorName), + if (showUsername) ...[ + const SizedBox(width: Grid.half), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + normalizedUsername, + key: usernameKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedMetadataStyle, + ), + ), + ], + const SizedBox(width: Grid.half), + Text('·', style: resolvedMetadataStyle), + const SizedBox(width: Grid.half), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + timestamp, + key: timestampKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedMetadataStyle, + ), + ), + ], + ); + }, + ); + } +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index fba440cc96..19b170b52a 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -3,14 +3,18 @@ import 'dart:async'; import 'package:buzz/features/activity/activity_page.dart'; import 'package:buzz/features/activity/activity_provider.dart'; import 'package:buzz/features/activity/feed_item.dart'; +import 'package:buzz/features/activity/inbox_item.dart'; import 'package:buzz/features/activity/reminders_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; +import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -91,7 +95,11 @@ void main() { ]; final testUsers = { - 'alice_pk': const UserProfile(pubkey: 'alice_pk', displayName: 'Alice'), + 'alice_pk': const UserProfile( + pubkey: 'alice_pk', + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), 'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'), 'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'), }; @@ -102,6 +110,7 @@ void main() { Map? users, Map readContexts = const {}, List? channels, + TextScaler? textScaler, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -122,7 +131,16 @@ void main() { ), remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])), ], - child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()), + child: MaterialApp( + theme: AppTheme.light(), + builder: textScaler == null + ? null + : (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: child!, + ), + home: const ActivityPage(), + ), ); } @@ -153,6 +171,17 @@ void main() { expect(find.text('No activity yet'), findsOneWidget); }); + testWidgets('does not imply a back button for the top-level Activity tab', ( + tester, + ) async { + await tester.pumpWidget(await buildTestable()); + await tester.pumpAndSettle(); + + final appBar = tester.widget(find.byType(FrostedAppBar)); + expect(appBar.automaticallyImplyLeading, isFalse); + expect(find.byTooltip('Back'), findsNothing); + }); + testWidgets('shows error view with retry button', (tester) async { await tester.pumpWidget( await buildTestable(activityNotifier: _ErrorActivityNotifier.new), @@ -163,6 +192,88 @@ void main() { expect(find.text('Retry'), findsOneWidget); }); + testWidgets('activity popovers use fixed-layout scale and opacity motion', ( + tester, + ) async { + await tester.pumpWidget(await buildTestable()); + await tester.pumpAndSettle(); + + final filterTrigger = find.byKey(const ValueKey('activity-filter-menu')); + expect( + tester.getSize(filterTrigger).height, + greaterThanOrEqualTo(Grid.xl), + reason: 'The Activity filter trigger must keep a 48dp touch target.', + ); + + await tester.tap(filterTrigger); + await tester.pump(); + + final surface = find.byKey(const ValueKey('activity-filter-popover')); + final fade = find.byKey(const ValueKey('activity-popover-fade')); + final scale = find.byKey(const ValueKey('activity-popover-scale')); + expect(surface, findsOneWidget); + expect(fade, findsOneWidget); + expect(scale, findsOneWidget); + + final initialSize = tester.getSize(surface); + final initialFade = tester.widget(fade); + final initialScale = tester.widget(scale); + expect(initialSize.width, 240); + expect(initialFade.opacity.value, lessThan(1)); + expect(initialScale.scale.value, greaterThanOrEqualTo(0.96)); + expect(initialScale.scale.value, lessThan(1)); + expect(initialScale.alignment, Alignment.topLeft); + + await tester.pump(const Duration(milliseconds: 75)); + + final movingFade = tester.widget(fade); + final movingScale = tester.widget(scale); + expect(movingFade.opacity.value, greaterThan(0)); + expect(movingFade.opacity.value, lessThan(1)); + expect(movingScale.scale.value, greaterThan(0.96)); + expect(movingScale.scale.value, lessThan(1)); + expect(tester.getSize(surface), initialSize); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.widget(fade).opacity.value, 1); + expect(tester.widget(scale).scale.value, 1); + expect(tester.getSize(surface), initialSize); + + final material = tester.widget(surface); + final shape = material.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.card)); + expect(material.surfaceTintColor, Colors.transparent); + expect(material.clipBehavior, Clip.antiAlias); + + final items = tester.widgetList>( + find.byType(PopupMenuItem), + ); + expect(items, hasLength(InboxFilter.values.length)); + expect( + items.every((item) => item.height >= Grid.xl), + isTrue, + reason: 'Activity filter choices must keep 48dp touch targets.', + ); + + await tester.tap(find.descendant(of: surface, matching: find.text('All'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('activity-options-menu'))); + await tester.pump(); + + final optionsSurface = find.byKey( + const ValueKey('activity-options-popover'), + ); + expect(tester.getSize(optionsSurface).width, 216); + expect( + tester + .widget( + find.byKey(const ValueKey('activity-popover-scale')), + ) + .alignment, + Alignment.topRight, + ); + }); + testWidgets('rows lead with sender, contextual label, and preview', ( tester, ) async { @@ -171,6 +282,7 @@ void main() { // Sender names resolved from the user cache. expect(find.text('Alice'), findsOneWidget); + expect(find.text('alice@example.com'), findsOneWidget); expect(find.text('Bob'), findsOneWidget); expect(find.text('Scout'), findsOneWidget); @@ -180,18 +292,62 @@ void main() { expect(find.text('#general'), findsNWidgets(2)); // mention + agent expect(find.text('#engineering'), findsOneWidget); + // Context labels and channel pills share the compact Activity style. + final contextLabel = tester.widget(find.text('Mentioned in')); + final channelLabel = tester.widgetList(find.text('#general')).first; + expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize); + expect(contextLabel.style?.fontWeight, activityContextTextStyle.fontWeight); + expect(contextLabel.style?.height, activityContextTextStyle.height); + expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize); + expect(channelLabel.style?.fontWeight, activityContextTextStyle.fontWeight); + expect(channelLabel.style?.height, activityContextTextStyle.height); + // Message previews. expect(find.textContaining('Hey check this out'), findsOneWidget); expect(find.textContaining('Deployed the fix'), findsOneWidget); - // Sender uses the compact label scale (labelMedium), not a - // headline-like title scale. + // Activity rows use their conversation-oriented scale. final senderText = tester.widget(find.text('Alice')); - final textTheme = Theme.of(tester.element(find.text('Alice'))).textTheme; - expect(senderText.style?.fontSize, textTheme.labelMedium?.fontSize); + final theme = Theme.of(tester.element(find.text('Alice'))); + expect(senderText.style?.fontSize, activityUsernameTextStyle.fontSize); + expect(senderText.style?.fontWeight, activityUsernameTextStyle.fontWeight); + expect(senderText.style?.height, activityUsernameTextStyle.height); + expect(senderText.style?.color, theme.colorScheme.onSurface); + final usernameText = tester.widget( + find.byKey(const ValueKey('activity-username-m1')), + ); + final timestampText = tester.widget( + find.byKey(const ValueKey('activity-timestamp-m1')), + ); + expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(usernameText.style?.fontWeight, FontWeight.w400); + expect(usernameText.style?.height, messageMetadataTextStyle.height); + expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(timestampText.style?.fontWeight, FontWeight.w400); + + final avatars = tester.widgetList(find.byType(AvatarImage)); + expect(avatars, isNotEmpty); expect( - senderText.style!.fontSize!, - lessThan(textTheme.titleSmall!.fontSize!), + avatars.every((avatar) => avatar.radius == activityAvatarSize / 2), + isTrue, + ); + + final previews = tester.widgetList( + find.byType(MessageContent), + ); + expect(previews, isNotEmpty); + expect( + previews.every( + (preview) => + preview.baseStyle?.fontSize == activityPreviewTextStyle.fontSize && + preview.baseStyle?.fontWeight == + activityPreviewTextStyle.fontWeight && + preview.baseStyle?.height == activityPreviewTextStyle.height && + preview.baseStyle?.letterSpacing == + activityPreviewTextStyle.letterSpacing && + preview.baseStyle?.color == theme.colorScheme.onSurface, + ), + isTrue, ); }); @@ -293,6 +449,24 @@ void main() { expect(find.text('Nothing needs your action'), findsOneWidget); }); + testWidgets('filter menu supports accessibility text scaling', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable(textScaler: const TextScaler.linear(3)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('activity-filter-menu'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('activity-filter-popover')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('opens a thread mention at the referenced message', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index af39eb103a..2dc01a9a0b 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -203,16 +204,15 @@ Widget _buildTestable({ ], child: MaterialApp( theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: child!, + ), navigatorObservers: navigatorObservers, - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: ChannelDetailPage( - channel: resolvedChannel, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - ), - ), + home: ChannelDetailPage( + channel: resolvedChannel, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, ), ), ); @@ -680,7 +680,11 @@ void main() { _buildTestable( messages: messages, users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'alice': const UserProfile( + pubkey: 'alice', + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), }, ), @@ -690,29 +694,58 @@ void main() { expect(findRichText('Hello world!'), findsOneWidget); expect(findRichText('Hey Alice!'), findsOneWidget); expect(find.text('Alice'), findsOneWidget); + expect(find.text('alice@example.com'), findsOneWidget); expect(find.text('Bob'), findsOneWidget); final messageAvatars = find.byType(CircleAvatar); expect(messageAvatars, findsNWidgets(2)); for (final avatar in messageAvatars.evaluate()) { expect( tester.getSize(find.byWidget(avatar.widget)), - const Size.square(36), + const Size.square(messageAvatarSize), ); } final aliceName = find.text('Alice'); final aliceText = tester.widget(aliceName); - final titleStyle = Theme.of( - tester.element(aliceName), - ).textTheme.titleSmall; - expect(aliceText.style?.fontSize, titleStyle?.fontSize); + expect(aliceText.style?.fontSize, messageUsernameTextStyle.fontSize); + expect(aliceText.style?.fontWeight, messageUsernameTextStyle.fontWeight); + expect(aliceText.style?.height, messageUsernameTextStyle.height); + final aliceUsername = tester.widget( + find.byKey(const ValueKey('message-username-msg1')), + ); + final aliceTimestamp = tester.widget( + find.byKey(const ValueKey('message-timestamp-msg1')), + ); + expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(aliceUsername.style?.fontWeight, FontWeight.w400); + expect(aliceUsername.style?.height, messageMetadataTextStyle.height); + expect(aliceTimestamp.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(aliceTimestamp.style?.fontWeight, FontWeight.w400); final helloContent = findRichText('Hello world!'); final helloText = tester.widget(helloContent); - final bodyStyle = Theme.of( - tester.element(helloContent), - ).textTheme.bodyLarge; expect( effectiveFontSizeForText(helloText.text, 'Hello world!'), - bodyStyle?.fontSize, + messageBodyTextStyle.fontSize, + ); + final messageList = tester.widget( + find.byKey(const ValueKey('channel-message-list')), + ); + expect(messageList.padding!.bottom, 0); + final newestMessageGroup = tester.widget( + find.byKey(const ValueKey('channel-message-group-msg2')), + ); + expect( + newestMessageGroup.padding, + const EdgeInsets.only(bottom: Grid.xs), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, ); }); @@ -767,9 +800,62 @@ void main() { const Size.square(32), ); } + final summaryPadding = tester.widget( + find.byKey(const ValueKey('thread-summary-root')), + ); + expect( + summaryPadding.padding, + const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + top: Grid.half, + bottom: Grid.xs, + ), + ); }); - testWidgets('can jump back to latest when newer messages are offscreen', ( + testWidgets('constrains reply summaries at accessibility text sizes', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final lastReplyAt = + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 59 * 60; + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread head', + createdAt: lastReplyAt - 300, + ), + for (var i = 0; i < 3; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'participant-$i', + content: 'Reply $i', + createdAt: lastReplyAt - 2 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ], + channel: _testChannel.copyWith(archivedAt: DateTime.now()), + textScaler: const TextScaler.linear(2), + ), + ); + await tester.pumpAndSettle(); + + final summaryText = tester.widget(findRichText('3 replies')); + expect(summaryText.maxLines, 2); + expect(summaryText.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); + + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { final initialMessages = [ @@ -794,9 +880,21 @@ void main() { ); await tester.pumpAndSettle(); - final listView = tester.widget( - find.byKey(const ValueKey('channel-message-list')), - ); + final messageList = find.byKey(const ValueKey('channel-message-list')); + final messageListElement = tester.element(messageList); + UserScrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 100, + pixels: 0, + viewportDimension: 100, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: messageListElement, + direction: ScrollDirection.reverse, + ).dispatch(messageListElement); + final listView = tester.widget(messageList); listView.itemScrollController!.jumpTo(index: 39); await tester.pumpAndSettle(); expect( @@ -822,6 +920,185 @@ void main() { expect(findRichText('Newest live update'), findsOneWidget); }); + testWidgets( + 'keeps follow mode off while a tall newest message stays visible', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final tallMessage = List.generate( + 12, + (index) => 'Newest message line $index', + ).join('\n'); + final initialMessages = [ + for (var i = 0; i < 12; i++) + _textMsg( + id: 'msg$i', + pubkey: i.isEven ? 'alice' : 'bob', + content: 'Message $i', + createdAt: 1000 + i * 1000, + ), + _textMsg( + id: 'tall-newest', + pubkey: 'alice', + content: tallMessage, + createdAt: 20_000, + ), + ]; + final messagesNotifier = _FakeMessagesNotifier(initialMessages); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + await tester.drag(messageList, const Offset(0, 120)); + await tester.pumpAndSettle(); + + expect(findRichText('Newest message line 0'), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + messagesNotifier.setMessages([ + ...initialMessages, + _textMsg( + id: 'newest-live', + pubkey: 'alice', + content: 'Newest live update', + createdAt: 30_000, + ), + ]); + await tester.pumpAndSettle(); + + expect(findRichText('Newest live update'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + }, + ); + + testWidgets('preserves an initial message deep-link position', ( + tester, + ) async { + final initialMessages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final messagesNotifier = _FakeMessagesNotifier(initialMessages); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + initialMessageId: 'msg5', + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(findRichText('Message 5'), findsOneWidget); + expect(findRichText('Message 39'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + messagesNotifier.setMessages([ + ...initialMessages, + _textMsg( + id: 'newest', + pubkey: 'alice', + content: 'Newest live update', + createdAt: 2000, + ), + ]); + await tester.pumpAndSettle(); + + expect(findRichText('Message 5'), findsOneWidget); + expect(findRichText('Newest live update'), findsNothing); + }); + + testWidgets( + 'keeps a deep-linked message in view when its page arrives after a ' + 'small scroll near the latest message', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // The deep-link target lives in an older page that has not loaded yet. + final messagesNotifier = _FakeMessagesNotifier([ + for (var i = 30; i < 60; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i * 1000, + ), + ]); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + initialMessageId: 'msg3', + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + // Small scrolls that keep the newest message visible, so isAtLatest + // stays true while the scroll offset becomes non-zero. This lets a + // later programmatic jumpTo dispatch ScrollEndNotification. + for (final dy in const [10.0, 20.0, 30.0]) { + await tester.drag( + find.byKey(const ValueKey('channel-message-list')), + Offset(0, dy), + ); + await tester.pumpAndSettle(); + } + + // The older page containing the deep-link target arrives. + messagesNotifier.setMessages([ + for (var i = 0; i < 60; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i * 1000, + ), + ]); + await tester.pumpAndSettle(); + + // The deep-link jump must stick rather than snapping back to newest. + expect(findRichText('Message 3'), findsOneWidget); + expect(findRichText('Message 59'), findsNothing); + }, + ); + testWidgets('groups consecutive messages from same author', (tester) async { final messages = [ _textMsg( @@ -925,22 +1202,29 @@ void main() { expect(find.text('Alice'), findsOneWidget); final createdAction = findRichText('created this channel'); expect(createdAction, findsOneWidget); - expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); + expect( + tester.getSize(find.byType(CircleAvatar)), + const Size.square(messageAvatarSize), + ); final nameRect = tester.getRect(find.text('Alice')); final nameText = tester.widget(find.text('Alice')); - final nameStyle = Theme.of( - tester.element(find.text('Alice')), - ).textTheme.titleSmall; - expect(nameText.style?.fontSize, nameStyle?.fontSize); - final timestampRect = tester.getRect(find.text(formatMessageTime(1000))); - expect(timestampRect.left - nameRect.right, Grid.xxs); + expect(nameText.style?.fontSize, systemMessageHeadingTextStyle.fontSize); + expect( + nameText.style?.fontWeight, + systemMessageHeadingTextStyle.fontWeight, + ); + expect( + find.byKey(const ValueKey('system-message-username-alice')), + findsNothing, + ); + final timestampRect = tester.getRect( + find.byKey(const ValueKey('system-message-timestamp-alice')), + ); + expect(timestampRect.left, greaterThan(nameRect.right)); final createdText = tester.widget(createdAction); - final bodyStyle = Theme.of( - tester.element(createdAction), - ).textTheme.bodyLarge; expect( effectiveFontSizeForText(createdText.text, 'created this channel'), - bodyStyle?.fontSize, + systemMessageBodyTextStyle.fontSize, ); }); @@ -964,7 +1248,10 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect(findRichText('joined the channel'), findsOneWidget); - expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); + expect( + tester.getSize(find.byType(CircleAvatar)), + const Size.square(messageAvatarSize), + ); }); testWidgets('renders member_joined (added by other) system event', ( @@ -992,17 +1279,23 @@ void main() { final addedAction = findRichText('was added by Alice'); expect(addedAction, findsOneWidget); expect(find.text('Alice added Bob to the channel'), findsNothing); - expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36)); + expect( + tester.getSize(find.byType(CircleAvatar)), + const Size.square(messageAvatarSize), + ); final nameRect = tester.getRect(find.text('Bob')); - final timestampRect = tester.getRect(find.text(formatMessageTime(1000))); - expect(timestampRect.left - nameRect.right, Grid.xxs); + expect( + find.byKey(const ValueKey('system-message-username-bob')), + findsNothing, + ); + final timestampRect = tester.getRect( + find.byKey(const ValueKey('system-message-timestamp-bob')), + ); + expect(timestampRect.left, greaterThan(nameRect.right)); final addedText = tester.widget(addedAction); - final bodyStyle = Theme.of( - tester.element(addedAction), - ).textTheme.bodyLarge; expect( effectiveFontSizeForText(addedText.text, 'was added by Alice'), - bodyStyle?.fontSize, + systemMessageBodyTextStyle.fontSize, ); }); @@ -1113,7 +1406,10 @@ void main() { final avatarRect = tester.getRect(find.byType(CircleAvatar)); final reactionRect = tester.getRect(find.byType(ReactionRow)); - expect(reactionRect.left, avatarRect.left + 36 + Grid.xxs); + expect( + reactionRect.left, + avatarRect.left + messageAvatarSize + messageAvatarContentGap, + ); }); testWidgets('renders member_left system event', (tester) async { @@ -1135,6 +1431,51 @@ void main() { expect(find.text('Bob left the channel'), findsOneWidget); }); + testWidgets( + 'constrains generic system timestamps at accessibility text sizes', + (tester) async { + tester.view.physicalSize = const Size(240, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'sys-accessible', + payload: { + 'type': 'topic_changed', + 'actor': 'alice', + 'topic': 'Release planning', + }, + createdAt: + DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ + 1000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + textScaler: const TextScaler.linear(3), + ), + ); + await tester.pumpAndSettle(); + + final timestampFinder = find.byKey( + const ValueKey('system-message-timestamp-sys-accessible'), + ); + final timestamp = tester.widget(timestampFinder); + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + expect( + tester.getSize(timestampFinder).width, + lessThanOrEqualTo(Grid.xxl), + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('renders member_removed system event', (tester) async { final messages = [ _systemMsg( @@ -1851,6 +2192,14 @@ void main() { expect(find.byType(DayDivider), findsNWidgets(2)); expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget); expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget); + final threadList = tester.widget( + find.byKey(const ValueKey('thread-message-list')), + ); + expect(threadList.padding!.bottom, 0); + final newestThreadGroup = tester.widget( + find.byKey(const ValueKey('thread-message-group-reply-next-day')), + ); + expect(newestThreadGroup.padding, const EdgeInsets.only(bottom: Grid.xs)); }); }); } diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 086db18d08..2a624cbde7 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -131,6 +131,15 @@ void main() { expect(find.text('DMs'), findsOneWidget); expect(find.text('Community'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); + + for (final label in ['general', 'Alice']) { + final text = tester.widget(find.text(label)); + expect(text.style?.fontSize, contentListTitleTextStyle.fontSize); + expect(text.style?.height, contentListTitleTextStyle.height); + } + final sectionTitle = tester.widget(find.text('Channels')); + expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize); + expect(sectionTitle.style?.fontWeight, FontWeight.w600); }); testWidgets('aligns the top, section, row, and skeleton label columns', ( diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index b350cbbb29..7f83a33c8d 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -61,6 +61,7 @@ Widget _buildPostCard({ Map users = const {}, VoidCallback? onTap, void Function(String)? onDelete, + TextScaler textScaler = TextScaler.noScaling, }) { return ProviderScope( overrides: [ @@ -68,12 +69,17 @@ Widget _buildPostCard({ ], child: MaterialApp( theme: AppTheme.light(), - home: Scaffold( - body: ForumPostCard( - post: post, - currentPubkey: currentPubkey, - onTap: onTap ?? () {}, - onDelete: onDelete, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: Scaffold( + body: ForumPostCard( + post: post, + currentPubkey: currentPubkey, + onTap: onTap ?? () {}, + onDelete: onDelete, + ), + ), ), ), ), @@ -115,6 +121,7 @@ Widget _buildThreadPage({ bool isMember = true, bool isArchived = false, Map users = const {}, + TextScaler textScaler = TextScaler.noScaling, }) { return ProviderScope( overrides: [ @@ -131,12 +138,17 @@ Widget _buildThreadPage({ ], child: MaterialApp( theme: AppTheme.light(), - home: ForumThreadPage( - channelId: _channelId, - postEventId: postEventId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: ForumThreadPage( + channelId: _channelId, + postEventId: postEventId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), ), ), ); @@ -171,6 +183,63 @@ void main() { expect(find.text('abcdef12\u2026'), findsOneWidget); }); + testWidgets( + 'constrains an older timestamp at large accessible text sizes', + (tester) async { + _setSurfaceSize(tester, const Size(240, 600)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + _buildPostCard( + post: _makePost( + createdAt: + DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000, + ), + users: const { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'A very long display name', + ), + }, + textScaler: const TextScaler.linear(2), + ), + ); + await tester.pumpAndSettle(); + + final timestamp = tester.widget(find.text('12/31/2025')); + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('gives the author unused timestamp width', (tester) async { + _setSurfaceSize(tester, const Size(320, 600)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120; + const displayName = 'A moderately long forum author name'; + + await tester.pumpWidget( + _buildPostCard( + post: _makePost(createdAt: createdAt), + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: displayName), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.text(displayName)).width, greaterThan(150)); + expect(find.text('2m ago'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('truncates long content', (tester) async { final longContent = 'A' * 300; await tester.pumpWidget( @@ -495,6 +564,97 @@ void main() { expect(find.text('Bob'), findsOneWidget); }); + testWidgets('constrains post and reply timestamps at large text sizes', ( + tester, + ) async { + final oldTimestamp = + DateTime.utc(2025, 12, 31, 12).millisecondsSinceEpoch ~/ 1000; + + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(createdAt: oldTimestamp), + replies: [ + ThreadReply( + eventId: 'old-reply', + pubkey: 'bob', + content: 'An older reply', + kind: 45003, + createdAt: oldTimestamp, + channelId: _channelId, + tags: const [ + ['h', _channelId], + ], + depth: 1, + ), + ], + totalReplies: 1, + ), + users: const { + 'alice': _aliceProfile, + 'bob': UserProfile( + pubkey: 'bob', + displayName: 'A very long reply author name', + ), + }, + textScaler: const TextScaler.linear(2), + ), + ); + await tester.pumpAndSettle(); + + final timestamps = tester.widgetList(find.text('12/31/2025')); + expect(timestamps, hasLength(2)); + for (final timestamp in timestamps) { + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('gives thread authors unused timestamp width', (tester) async { + _setSurfaceSize(tester, const Size(320, 800)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120; + const postAuthor = 'A moderately long original author'; + const replyAuthor = 'A moderately long reply author'; + + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(createdAt: createdAt), + replies: [ + ThreadReply( + eventId: 'reply', + pubkey: 'bob', + content: 'A reply', + kind: 45003, + createdAt: createdAt, + channelId: _channelId, + tags: const [ + ['h', _channelId], + ], + depth: 1, + ), + ], + totalReplies: 1, + ), + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: postAuthor), + 'bob': UserProfile(pubkey: 'bob', displayName: replyAuthor), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.text(postAuthor)).width, greaterThan(150)); + expect(tester.getSize(find.text(replyAuthor)).width, greaterThan(140)); + expect(find.text('2m ago'), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }); + testWidgets('shows compose bar for members', (tester) async { await tester.pumpWidget( _buildThreadPage( diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart index 5ac84bcc8c..e32225fe1a 100644 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -24,19 +24,31 @@ void main() { tags: const [], ); - Widget buildTestable(Widget home) { + Widget buildTestable( + Widget home, { + TextScaler textScaler = TextScaler.noScaling, + String displayName = 'Alice', + }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith( () => _FakeUserCacheNotifier({ - 'alice_pk': const UserProfile( + 'alice_pk': UserProfile( pubkey: 'alice_pk', - displayName: 'Alice', + displayName: displayName, ), }), ), ], - child: MaterialApp(theme: AppTheme.light(), home: home), + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: home, + ), + ), + ), ); } @@ -52,6 +64,53 @@ void main() { expect(find.text('Reply'), findsOneWidget); // action button label }); + testWidgets('reply preview constrains its timestamp at large text sizes', ( + tester, + ) async { + final oldReplyNote = UserNote( + id: 'old-note', + pubkey: 'alice_pk', + createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, + content: 'An older note', + tags: const [], + ); + + await tester.pumpWidget( + buildTestable( + ComposeNotePage(replyTo: oldReplyNote), + textScaler: const TextScaler.linear(2), + ), + ); + await tester.pump(); + + final timestamp = tester.widget(find.text('Sep 30')); + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); + + testWidgets('gives the reply author unused timestamp width', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + const displayName = 'A moderately long Pulse reply author'; + + await tester.pumpWidget( + buildTestable( + ComposeNotePage(replyTo: replyNote), + displayName: displayName, + ), + ); + await tester.pump(); + + expect(tester.getSize(find.text(displayName)).width, greaterThan(150)); + expect(find.text('2m'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('new-note mode shows no reply preview', (tester) async { await tester.pumpWidget(buildTestable(const ComposeNotePage())); await tester.pump(); diff --git a/mobile/test/features/pulse/note_card_test.dart b/mobile/test/features/pulse/note_card_test.dart new file mode 100644 index 0000000000..73e6e8af02 --- /dev/null +++ b/mobile/test/features/pulse/note_card_test.dart @@ -0,0 +1,130 @@ +import 'package:buzz/features/profile/user_cache_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/pulse/note_card.dart'; +import 'package:buzz/features/pulse/pulse_models.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _FakeUserCacheNotifier extends UserCacheNotifier { + final Map _users; + + _FakeUserCacheNotifier(this._users); + + @override + Map build() => _users; +} + +void main() { + testWidgets('constrains timestamp with agent and follow metadata', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(280, 600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final note = UserNote( + id: 'note-1', + pubkey: 'alice', + createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, + content: 'A note', + tags: const [], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier({ + 'alice': const UserProfile( + pubkey: 'alice', + displayName: 'A very long display name', + ), + }), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: Scaffold( + body: NoteCard( + note: note, + reaction: const PulseReactionState( + count: 0, + reactedByCurrentUser: false, + ), + isAgent: true, + canFollow: true, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final timestamp = tester.widget(find.text('Sep 30')); + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); + + testWidgets('gives the author unused timestamp width', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 600); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + const displayName = 'A moderately long Pulse author'; + final note = UserNote( + id: 'note-2', + pubkey: 'alice', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, + content: 'A note', + tags: const [], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier({ + 'alice': const UserProfile( + pubkey: 'alice', + displayName: displayName, + ), + }), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: NoteCard( + note: note, + reaction: const PulseReactionState( + count: 0, + reactedByCurrentUser: false, + ), + canFollow: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.text(displayName)).width, greaterThan(145)); + expect(find.text('2m'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/mobile/test/features/search/recent_searches_provider_test.dart b/mobile/test/features/search/recent_searches_provider_test.dart new file mode 100644 index 0000000000..30f13c329c --- /dev/null +++ b/mobile/test/features/search/recent_searches_provider_test.dart @@ -0,0 +1,105 @@ +import 'package:buzz/features/search/recent_searches_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + final RelayConfig _config; + + _FixedRelayConfigNotifier(this._config); + + @override + RelayConfig build() => _config; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future containerWithPrefs({ + required String relayUrl, + required String? pubkey, + }) async { + final prefs = await SharedPreferences.getInstance(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(RelayConfig(baseUrl: relayUrl)), + ), + myPubkeyProvider.overrideWithValue(pubkey), + ], + ); + addTearDown(container.dispose); + return container; + } + + test( + 'normalizes, deduplicates, caps, and persists submitted queries', + () async { + SharedPreferences.setMockInitialValues({}); + final first = await containerWithPrefs( + relayUrl: 'https://relay-a.example', + pubkey: 'pk-a', + ); + final notifier = first.read(recentSearchesProvider.notifier); + + notifier.record(' Design '); + notifier.record('design'); + notifier.record(''); + for (var index = 0; index < 6; index++) { + notifier.record('query-$index'); + } + + expect(first.read(recentSearchesProvider), [ + 'query-5', + 'query-4', + 'query-3', + 'query-2', + 'query-1', + 'query-0', + ]); + + final restarted = await containerWithPrefs( + relayUrl: 'https://relay-a.example', + pubkey: 'pk-a', + ); + expect( + restarted.read(recentSearchesProvider), + first.read(recentSearchesProvider), + ); + }, + ); + + test('isolates persisted history by community and account', () async { + SharedPreferences.setMockInitialValues({}); + final accountA = await containerWithPrefs( + relayUrl: 'https://relay-a.example', + pubkey: 'pk-a', + ); + accountA.read(recentSearchesProvider.notifier).record('private query'); + + final accountB = await containerWithPrefs( + relayUrl: 'https://relay-a.example', + pubkey: 'pk-b', + ); + expect(accountB.read(recentSearchesProvider), isEmpty); + accountB.read(recentSearchesProvider.notifier).record('account b query'); + + final communityB = await containerWithPrefs( + relayUrl: 'https://relay-b.example', + pubkey: 'pk-a', + ); + expect(communityB.read(recentSearchesProvider), isEmpty); + communityB + .read(recentSearchesProvider.notifier) + .record('community b query'); + + final accountAAgain = await containerWithPrefs( + relayUrl: 'https://relay-a.example', + pubkey: 'pk-a', + ); + expect(accountAAgain.read(recentSearchesProvider), ['private query']); + }); +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index dfde69e5ec..52c2fb23ae 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -1,6 +1,12 @@ import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/search/recent_searches_provider.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -24,6 +30,9 @@ void main() { searchProvider.overrideWith( () => _FakeSearchNotifier(const SearchState.initial()), ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), profileProvider.overrideWith(() => _FakeProfileNotifier()), ], child: Builder( @@ -55,10 +64,227 @@ void main() { tester.getSize(searchField).height, greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2), ); + final input = tester.widget( + find.byKey(const Key('search-field')), + ); + expect(input.style?.fontSize, searchInputTextStyle.fontSize); + expect(input.style?.height, searchInputTextStyle.height); expect(tester.getSize(message).height, greaterThan(32)); expect(tester.takeException(), isNull); }); + testWidgets('focus slides Cancel in beside the search field', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final searchField = find.byKey(const Key('search-field')); + final searchFieldContainer = find.byKey( + const Key('search-field-container'), + ); + final unfocusedWidth = tester.getSize(searchFieldContainer).width; + expect(find.byKey(const Key('search-cancel')), findsNothing); + expect( + tester.widget(searchField).decoration?.hintText, + 'Search messages, channels, people\u2026', + ); + expect( + tester.widget(searchField).textInputAction, + TextInputAction.search, + ); + + await tester.tap(searchField); + await tester.pump(); + + final cancel = find.byKey(const Key('search-cancel')); + expect(cancel, findsOneWidget); + expect( + tester.getSize(cancel).height, + greaterThanOrEqualTo(Grid.xl), + reason: 'Cancel must keep a 48dp touch target.', + ); + expect(tester.widget(searchField).decoration?.hintText, isNull); + final enteringSlide = tester.widget( + find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, + ); + expect(enteringSlide.position.value.dx, greaterThan(0)); + + await tester.pump(const Duration(milliseconds: 160)); + final focusedWidth = tester.getSize(searchFieldContainer).width; + expect(focusedWidth, lessThan(unfocusedWidth)); + final settledSlide = tester.widget( + find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, + ); + expect(settledSlide.position.value, Offset.zero); + + await tester.enterText(searchField, 'design'); + await tester.tap(cancel); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 60)); + final exitingWidth = tester.getSize(searchFieldContainer).width; + expect(exitingWidth, greaterThan(focusedWidth)); + expect(exitingWidth, lessThan(unfocusedWidth)); + await tester.pumpAndSettle(); + + final input = tester.widget(searchField); + expect(input.controller?.text, isEmpty); + expect(input.focusNode?.hasFocus, isFalse); + expect( + input.decoration?.hintText, + 'Search messages, channels, people\u2026', + ); + expect(find.byKey(const Key('search-cancel')), findsNothing); + expect( + tester.getSize(searchFieldContainer).width, + closeTo(unfocusedWidth, 0.01), + ); + }); + + testWidgets('only submitted queries are added to recent searches', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final searchField = find.byKey(const Key('search-field')); + await tester.tap(searchField); + await tester.pumpAndSettle(); + await tester.enterText(searchField, 'draft'); + await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recent-searches-list')), findsNothing); + + await tester.tap(searchField); + await tester.pumpAndSettle(); + await tester.enterText(searchField, 'design systems'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('recent-searches-list')), findsOneWidget); + expect(find.text('design systems'), findsOneWidget); + }); + + testWidgets('recent searches can be rerun and cleared', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const [ + 'design systems', + 'launch plan', + ]), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('recent-searches-list')), findsOneWidget); + expect(find.text('Recent searches'), findsOneWidget); + expect(find.text('design systems'), findsOneWidget); + expect(find.text('launch plan'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('recent-search-0'))).height, + greaterThanOrEqualTo(Grid.xl), + ); + expect( + tester.getSize(find.byKey(const ValueKey('recent-search-1'))).height, + greaterThanOrEqualTo(Grid.xl), + ); + + await tester.tap(find.byKey(const ValueKey('recent-search-1'))); + await tester.pumpAndSettle(); + + final searchField = find.byKey(const Key('search-field')); + final input = tester.widget(searchField); + expect(input.controller?.text, 'launch plan'); + expect(input.focusNode?.hasFocus, isTrue); + expect(find.text("No results for 'launch plan'"), findsOneWidget); + + await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recent-search-0')), findsOneWidget); + expect(find.text('launch plan'), findsOneWidget); + + await tester.tap(find.byKey(const Key('clear-recent-searches'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recent-searches-list')), findsNothing); + expect(find.text('Search messages, channels, and people'), findsOneWidget); + }); + + testWidgets('keeps recent searches scrollable above the keyboard', ( + tester, + ) async { + const keyboardInset = 300.0; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const [ + 'design systems', + 'launch plan', + ]), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + viewInsets: const EdgeInsets.only(bottom: keyboardInset), + ), + child: const SearchPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('search-field'))); + await tester.pumpAndSettle(); + + final recentSearches = tester.widget( + find.byKey(const Key('recent-searches-list')), + ); + final padding = recentSearches.padding! as EdgeInsets; + + expect(padding.bottom, Grid.xl + keyboardInset); + }); + testWidgets('keeps search results scrollable above the keyboard', ( tester, ) async { @@ -84,6 +310,9 @@ void main() { WidgetHelpers.testable( overrides: [ searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), profileProvider.overrideWith(() => _FakeProfileNotifier()), ], child: Builder( @@ -120,6 +349,9 @@ void main() { searchProvider.overrideWith( () => _FakeSearchNotifier(const SearchState(query: query)), ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), profileProvider.overrideWith(() => _FakeProfileNotifier()), ], child: Builder( @@ -144,6 +376,219 @@ void main() { expect(tester.getBottomLeft(message).dy, lessThan(keyboardTop)); expect(tester.takeException(), isNull); }); + + testWidgets('uses compact content styles and keeps message time by author', ( + tester, + ) async { + late _FakeRecentSearchesNotifier recentSearches; + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120; + final state = SearchState( + query: 'design', + channelResults: [ + Channel( + id: 'design', + name: 'design', + channelType: 'stream', + visibility: 'open', + description: 'Design discussion', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 4, + isMember: true, + ), + ], + userResults: const [ + DirectoryUser( + pubkey: 'maya', + displayName: 'Maya', + nip05Handle: 'maya@example.com', + ), + ], + messageResults: [ + SearchHit( + eventId: 'message-1', + content: 'The latest design is ready', + kind: 9, + pubkey: 'alice', + channelName: 'design', + createdAt: createdAt, + score: 1, + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => recentSearches = _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier( + const UserProfile( + pubkey: 'alice', + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), + ), + ), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final channelTitle = tester.widget( + find.byKey(const ValueKey('search-channel-title-design')), + ); + final personTitle = tester.widget( + find.byKey(const ValueKey('search-person-title-maya')), + ); + for (final title in [channelTitle, personTitle]) { + expect(title.style?.fontSize, contentListTitleTextStyle.fontSize); + expect(title.style?.fontWeight, contentListTitleTextStyle.fontWeight); + expect(title.style?.height, contentListTitleTextStyle.height); + } + for (final label in ['channels', 'people', 'messages']) { + final sectionLabel = tester.widget( + find.byKey(ValueKey('search-section-$label')), + ); + expect( + sectionLabel.data, + '${label[0].toUpperCase()}${label.substring(1)}', + ); + expect(sectionLabel.style?.fontSize, activityContextTextStyle.fontSize); + expect( + sectionLabel.style?.fontWeight, + activityContextTextStyle.fontWeight, + ); + expect(sectionLabel.style?.letterSpacing, 0); + } + for (final rowKey in [ + 'search-channel-row-design', + 'search-person-row-maya', + 'search-message-row-message-1', + ]) { + final row = tester.widget(find.byKey(ValueKey(rowKey))); + expect( + row.contentPadding, + const EdgeInsets.symmetric(horizontal: Grid.gutter), + ); + } + for (final alignment in [ + ('channels', 'search-channel-leading-design'), + ('people', 'search-person-leading-maya'), + ('messages', 'search-message-avatar-message-1'), + ]) { + expect( + tester + .getTopLeft(find.byKey(ValueKey('search-section-${alignment.$1}'))) + .dx, + tester.getTopLeft(find.byKey(ValueKey(alignment.$2))).dx, + ); + } + + final authorFinder = find.byKey( + const ValueKey('search-message-author-message-1'), + ); + final usernameFinder = find.byKey( + const ValueKey('search-message-username-message-1'), + ); + final timestampFinder = find.byKey( + const ValueKey('search-message-timestamp-message-1'), + ); + final author = tester.widget(authorFinder); + final username = tester.widget(usernameFinder); + final timestamp = tester.widget(timestampFinder); + expect(author.style?.fontSize, messageUsernameTextStyle.fontSize); + expect(author.style?.fontWeight, messageUsernameTextStyle.fontWeight); + expect(author.style?.height, messageUsernameTextStyle.height); + expect(username.data, 'alice@example.com'); + expect(username.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(username.style?.fontWeight, FontWeight.w400); + expect(username.style?.height, messageMetadataTextStyle.height); + expect(timestamp.style?.fontSize, messageTimestampTextStyle.fontSize); + expect(timestamp.style?.height, messageTimestampTextStyle.height); + expect( + (tester.getCenter(authorFinder).dy - tester.getCenter(timestampFinder).dy) + .abs(), + lessThan(1), + ); + expect( + (tester + .getTopLeft( + find.byKey( + const ValueKey('search-message-avatar-message-1'), + ), + ) + .dy - + tester.getTopLeft(authorFinder).dy) + .abs(), + lessThan(6), + ); + + final body = tester.widget( + find.byKey(const ValueKey('search-message-body-message-1')), + ); + expect(body.baseStyle?.fontSize, activityPreviewTextStyle.fontSize); + expect(body.baseStyle?.height, activityPreviewTextStyle.height); + expect( + tester.widget(find.byType(SmallAvatar)).size, + compactMessageAvatarSize, + ); + final contextLabel = tester.widget(find.text('Message in')); + final channelLabel = tester.widget( + find.byKey(const ValueKey('search-message-channel-message-1')), + ); + expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize); + expect(contextLabel.style?.height, activityContextTextStyle.height); + expect(channelLabel.data, '#design'); + expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize); + final channelChip = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.child is Text && + (widget.child as Text).key == + const ValueKey('search-message-channel-message-1'), + ), + ); + expect( + (channelChip.decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(Radii.xs), + ); + expect( + tester + .getTopLeft( + find.byKey(const ValueKey('search-message-context-message-1')), + ) + .dy, + greaterThan(tester.getTopLeft(authorFinder).dy), + ); + expect( + tester + .getTopLeft( + find.byKey(const ValueKey('search-message-body-message-1')), + ) + .dy, + greaterThan( + tester + .getBottomLeft( + find.byKey(const ValueKey('search-message-context-message-1')), + ) + .dy, + ), + ); + + await tester.tap( + find.byKey(const ValueKey('search-message-row-message-1')), + ); + await tester.pump(); + expect(recentSearches.searches, const ['design']); + }); } class _FakeSearchNotifier extends SearchNotifier { @@ -153,6 +598,41 @@ class _FakeSearchNotifier extends SearchNotifier { @override SearchState build() => initialState; + + @override + void search(String query) { + state = SearchState(query: query.trim()); + } + + @override + void clear() { + state = const SearchState.initial(); + } +} + +class _FakeRecentSearchesNotifier extends RecentSearchesNotifier { + _FakeRecentSearchesNotifier(this.initialSearches); + + final List initialSearches; + List get searches => state; + + @override + List build() => initialSearches; + + @override + void record(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + state = [ + trimmed, + ...state.where((item) => item.toLowerCase() != trimmed.toLowerCase()), + ]; + } + + @override + void clear() { + state = const []; + } } class _FakeProfileNotifier extends ProfileNotifier { @@ -160,3 +640,17 @@ class _FakeProfileNotifier extends ProfileNotifier { Future build() async => const UserProfile(pubkey: 'test', displayName: 'Test'); } + +class _FakeChannelsNotifier extends ChannelsNotifier { + @override + Future> build() async => const []; +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + _FakeUserCacheNotifier(this.profile); + + final UserProfile profile; + + @override + Map build() => {profile.pubkey: profile}; +} diff --git a/mobile/test/shared/theme/message_typography_test.dart b/mobile/test/shared/theme/message_typography_test.dart new file mode 100644 index 0000000000..169bcc0200 --- /dev/null +++ b/mobile/test/shared/theme/message_typography_test.dart @@ -0,0 +1,141 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + void expectStyle( + TextStyle style, { + required double fontSize, + required FontWeight fontWeight, + required double lineHeight, + required double letterSpacing, + }) { + expect(style.fontFamily, 'Inter'); + expect(style.fontSize, fontSize); + expect(style.fontWeight, fontWeight); + expect(style.height, closeTo(lineHeight / fontSize, 0.0001)); + expect(style.letterSpacing, letterSpacing); + } + + test('message typography matches the shared mobile scale', () { + expectStyle( + messageBodyTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 20, + letterSpacing: 0, + ); + expectStyle( + messageUsernameTextStyle, + fontSize: 15, + fontWeight: FontWeight.w600, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + messageTimestampTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 17, + letterSpacing: 0, + ); + expect(messageTimestampTextStyle, messageMetadataTextStyle); + expectStyle( + replyPreviewTextStyle, + fontSize: 13.1, + fontWeight: FontWeight.w400, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + reactionCountTextStyle, + fontSize: 13.1, + fontWeight: FontWeight.w500, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + channelTitleTextStyle, + fontSize: 20, + fontWeight: FontWeight.w700, + lineHeight: 24, + letterSpacing: 0, + ); + expectStyle( + systemMessageHeadingTextStyle, + fontSize: 15, + fontWeight: FontWeight.w600, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + systemMessageBodyTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 20, + letterSpacing: 0, + ); + }); + + test('activity typography matches the conversation row scale', () { + expectStyle( + activityUsernameTextStyle, + fontSize: 15, + fontWeight: FontWeight.w600, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + activityTimestampTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + activityContextTextStyle, + fontSize: 13.1, + fontWeight: FontWeight.w500, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + activityPreviewTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 20, + letterSpacing: 0, + ); + }); + + test('content list typography matches the compact list scale', () { + expectStyle( + contentListTitleTextStyle, + fontSize: 15, + fontWeight: FontWeight.w600, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + contentListBodyTextStyle, + fontSize: 13.1, + fontWeight: FontWeight.w400, + lineHeight: 17, + letterSpacing: 0, + ); + expectStyle( + contentListTimestampTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 17, + letterSpacing: 0, + ); + }); + + test('message and activity avatars use their surface sizes', () { + expect(messageAvatarSize, 42); + expect(activityAvatarSize, 42); + expect(compactMessageAvatarSize, 42); + expect(messageAvatarContentGap, Grid.twelve); + }); +} diff --git a/mobile/test/shared/widgets/filter_chip_bar_test.dart b/mobile/test/shared/widgets/filter_chip_bar_test.dart index 09a2f04531..6f651624b6 100644 --- a/mobile/test/shared/widgets/filter_chip_bar_test.dart +++ b/mobile/test/shared/widgets/filter_chip_bar_test.dart @@ -35,6 +35,14 @@ void main() { ); final resolved = chipTheme.color?.resolve({WidgetState.selected}); expect(resolved, accent); + final selectedLabel = tester.widget(find.text('Everyone')); + final unselectedLabel = tester.widget(find.text('Following')); + expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize); + expect(selectedLabel.style?.height, filterChipTextStyle.height); + expect(selectedLabel.style?.fontWeight, FontWeight.w500); + expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize); + expect(unselectedLabel.style?.height, filterChipTextStyle.height); + expect(unselectedLabel.style?.fontWeight, FontWeight.w400); }); testWidgets('expanded chips preserve large accessible text scaling', ( diff --git a/mobile/test/shared/widgets/message_author_meta_test.dart b/mobile/test/shared/widgets/message_author_meta_test.dart new file mode 100644 index 0000000000..a97af86130 --- /dev/null +++ b/mobile/test/shared/widgets/message_author_meta_test.dart @@ -0,0 +1,82 @@ +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/message_author_meta.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('reallocates unused metadata width to the display name', ( + tester, + ) async { + const displayNameKey = Key('author-display-name'); + const timestampKey = Key('author-timestamp'); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: SizedBox( + width: 300, + child: MessageAuthorMeta( + displayName: 'A display name that needs the available width', + username: 'al', + timestamp: '2m', + displayNameKey: displayNameKey, + timestampKey: timestampKey, + nameColor: Colors.black, + metadataColor: Colors.grey, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final row = find.byType(MessageAuthorMeta); + final displayName = find.byKey(displayNameKey); + final timestamp = find.byKey(timestampKey); + + expect( + tester.getSize(displayName).width, + greaterThan(tester.getSize(row).width / 2), + ); + expect( + tester.getTopRight(timestamp).dx, + closeTo(tester.getTopRight(row).dx, 0.01), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('constrains long metadata at large accessible text sizes', ( + tester, + ) async { + const timestampKey = Key('author-timestamp'); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: Scaffold( + body: SizedBox( + width: 220, + child: MessageAuthorMeta( + displayName: 'A very long display name', + username: 'a-very-long-username', + timestamp: 'Mar 15, 2025', + timestampKey: timestampKey, + nameColor: Colors.black, + metadataColor: Colors.grey, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final timestamp = tester.widget(find.byKey(timestampKey)); + expect(timestamp.maxLines, 1); + expect(timestamp.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); +} From a3b097745a3fc22872d05bbd558d231ead4e661d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 28 Jul 2026 18:05:43 +0100 Subject: [PATCH 03/59] Refine mobile attachment picking (#3313) ## What - morph the composer plus button into the attachment menu, camera, and photo surfaces - add ordered multi-select with inline recent photos and system picker fallback - add native iOS attachment/photo popovers and align the Android camera treatment ## Stack - follows #3312 ## Validation - `just mobile-check` - `flutter test test/features/channels/compose_bar_test.dart` - full mobile pre-push suite --------- Signed-off-by: kenny lopez --- .../android/app/src/main/AndroidManifest.xml | 5 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 + mobile/ios/Runner/AppDelegate.swift | 55 +- mobile/ios/Runner/Info.plist | 2 + mobile/ios/Runner/InlinePhotoPicker.swift | 235 +++++ .../ios/Runner/NativeAttachmentPopover.swift | 969 ++++++++++++++++++ .../NativeAttachmentPopoverCoordinator.swift | 228 +++++ .../channels/camera_capture_cleanup.dart | 24 +- mobile/lib/features/channels/compose_bar.dart | 568 +++++----- .../channels/compose_bar/attachments.dart | 353 +++++-- .../channels/compose_bar/camera_preview.dart | 98 +- .../channels/compose_bar/helpers.dart | 6 + .../compose_bar/ios_attachment_popover.dart | 174 ++++ .../compose_bar/ios_photo_picker.dart | 228 +++++ .../features/channels/compose_bar/layout.dart | 245 +++++ .../compose_bar/photo_gallery_picker.dart | 381 +++++++ .../channels/compose_bar/suggestions.dart | 111 +- .../lib/features/channels/photo_library.dart | 104 ++ mobile/lib/shared/relay/media_upload.dart | 44 +- .../channels/camera_capture_cleanup_test.dart | 19 + .../features/channels/compose_bar_test.dart | 535 +++++++++- 21 files changed, 3932 insertions(+), 464 deletions(-) create mode 100644 mobile/ios/Runner/InlinePhotoPicker.swift create mode 100644 mobile/ios/Runner/NativeAttachmentPopover.swift create mode 100644 mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift create mode 100644 mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart create mode 100644 mobile/lib/features/channels/compose_bar/ios_photo_picker.dart create mode 100644 mobile/lib/features/channels/compose_bar/layout.dart create mode 100644 mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart create mode 100644 mobile/lib/features/channels/photo_library.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e1eb3e3456..5e607ad2ea 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -5,11 +5,14 @@ + + + Buzz needs photo library access so you can attach images to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. + PHPhotoLibraryPreventAutomaticLimitedAccessAlert + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/mobile/ios/Runner/InlinePhotoPicker.swift b/mobile/ios/Runner/InlinePhotoPicker.swift new file mode 100644 index 0000000000..4b8d1365df --- /dev/null +++ b/mobile/ios/Runner/InlinePhotoPicker.swift @@ -0,0 +1,235 @@ +import Flutter +import PhotosUI +import UIKit +import UniformTypeIdentifiers + +final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + private weak var parentViewController: UIViewController? + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + self.messenger = messenger + self.parentViewController = parentViewController + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + InlinePhotoPickerPlatformView( + frame: frame, + viewIdentifier: viewId, + messenger: messenger, + parentViewController: parentViewController + ) + } +} + +final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private weak var parentViewController: UIViewController? + private var pickerViewController: PHPickerViewController? + private var selectionGeneration = 0 + private var selectionTask: Task? + private var selectedTemporaryPaths: [String] = [] + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/inline_photo_picker/\(viewId)", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "claimSelection" else { + result(FlutterMethodNotImplemented) + return + } + let paths = call.arguments as? [String] ?? [] + guard let self, paths == self.selectedTemporaryPaths else { + result(false) + return + } + self.selectedTemporaryPaths = [] + result(true) + } + + containerView.backgroundColor = .clear + if #available(iOS 17.0, *) { + installPicker() + } + } + + deinit { + selectionTask?.cancel() + Self.removeTemporaryFiles(selectedTemporaryPaths) + channel.setMethodCallHandler(nil) + pickerViewController?.willMove(toParent: nil) + pickerViewController?.view.removeFromSuperview() + pickerViewController?.removeFromParent() + } + + func view() -> UIView { + containerView + } + + @available(iOS 17.0, *) + private func installPicker() { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 0 + configuration.selection = .continuousAndOrdered + configuration.preferredAssetRepresentationMode = .compatible + configuration.disabledCapabilities = [ + .search, + .stagingArea, + .collectionNavigation, + .selectionActions, + ] + configuration.edgesWithoutContentMargins = .all + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + picker.view.backgroundColor = .clear + picker.view.translatesAutoresizingMaskIntoConstraints = false + + if let parentViewController { + parentViewController.addChild(picker) + } + containerView.addSubview(picker.view) + NSLayoutConstraint.activate([ + picker.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + picker.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + picker.view.topAnchor.constraint(equalTo: containerView.topAnchor), + picker.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + if parentViewController != nil { + picker.didMove(toParent: parentViewController) + } + pickerViewController = picker + } + + private func exportPickerResult(_ result: PHPickerResult) async throws -> String { + let provider = result.itemProvider + guard + let typeIdentifier = provider.registeredTypeIdentifiers.first(where: { + guard let type = UTType($0) else { return false } + return type.conforms(to: .image) + }) + else { + throw InlinePhotoPickerError.unsupportedImage + } + + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { + sourceURL, + error in + if let error { + continuation.resume(throwing: error) + return + } + guard let sourceURL else { + continuation.resume(throwing: InlinePhotoPickerError.missingFile) + return + } + + do { + let fileExtension = + sourceURL.pathExtension.isEmpty + ? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg") + : sourceURL.pathExtension + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + try FileManager.default.copyItem( + at: sourceURL, + to: destinationURL + ) + continuation.resume(returning: destinationURL.path) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func removeTemporaryFiles(_ paths: [String]) { + for path in paths where !path.isEmpty { + try? FileManager.default.removeItem(atPath: path) + } + } +} + +extension InlinePhotoPickerPlatformView: PHPickerViewControllerDelegate { + func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + selectionGeneration += 1 + let generation = selectionGeneration + selectionTask?.cancel() + selectionTask = nil + Self.removeTemporaryFiles(selectedTemporaryPaths) + selectedTemporaryPaths = [] + channel.invokeMethod( + "selectionCountChanged", + arguments: results.count + ) + + guard !results.isEmpty else { + channel.invokeMethod("selectionDidChange", arguments: [String]()) + return + } + + selectionTask = Task { [weak self] in + guard let self else { return } + var paths: [String] = [] + do { + for result in results { + try Task.checkCancellation() + paths.append(try await self.exportPickerResult(result)) + } + try Task.checkCancellation() + await MainActor.run { + guard generation == self.selectionGeneration else { + Self.removeTemporaryFiles(paths) + return + } + self.selectedTemporaryPaths = paths + self.selectionTask = nil + self.channel.invokeMethod("selectionDidChange", arguments: paths) + } + } catch is CancellationError { + Self.removeTemporaryFiles(paths) + } catch { + Self.removeTemporaryFiles(paths) + await MainActor.run { + guard generation == self.selectionGeneration else { return } + self.selectionTask = nil + self.channel.invokeMethod( + "didFail", + arguments: "Unable to prepare the selected photos." + ) + } + } + } + } +} + +private enum InlinePhotoPickerError: Error { + case missingFile + case unsupportedImage +} diff --git a/mobile/ios/Runner/NativeAttachmentPopover.swift b/mobile/ios/Runner/NativeAttachmentPopover.swift new file mode 100644 index 0000000000..bb49ca6e46 --- /dev/null +++ b/mobile/ios/Runner/NativeAttachmentPopover.swift @@ -0,0 +1,969 @@ +import AVFoundation +import Flutter +import PhotosUI +import UIKit +import UniformTypeIdentifiers + +@available(iOS 26.0, *) +final class NativeAttachmentPopoverViewController: + UIViewController, + PHPickerViewControllerDelegate, + UIPopoverPresentationControllerDelegate, + AVCapturePhotoCaptureDelegate +{ + private enum Surface { + case menu + case photos + case camera + } + + private let channel: FlutterMethodChannel + private let expandedWidth: CGFloat + private let menuSize = CGSize(width: 176, height: 208) + private let expandedHeight: CGFloat = 372 + private let expandedHorizontalOffset: CGFloat = 10 + private let expandedVerticalOffset: CGFloat = 40 + private let contentHost = UIView() + private let cameraSession = AVCaptureSession() + private let cameraOutput = AVCapturePhotoOutput() + private let cameraQueue = DispatchQueue( + label: "buzz.native-attachment-camera" + ) + + private var surface = Surface.menu + private var visibleContentView: UIView? + private var photoPickerViewController: PHPickerViewController? + private var cameraPreviewLayer: AVCaptureVideoPreviewLayer? + private weak var cameraPreviewView: UIView? + private weak var photoActionButton: UIButton? + private weak var cameraCaptureButton: UIButton? + private var cameraDevice: AVCaptureDevice? + private var cameraRotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var cameraRotationObservation: NSKeyValueObservation? + private var selectionGeneration = 0 + private var selectionTask: Task? + private var selectedPhotoPaths: [String] = [] + private var cameraConfigured = false + private var cameraIsStarting = false + private var cameraStartupGeneration = 0 + private var cameraIsCapturing = false + private var activeCameraCaptureID: Int64? + private var isFinishing = false + private var didNotifyDismissal = false + + var onDismiss: (() -> Void)? + + init(channel: FlutterMethodChannel, expandedWidth: CGFloat) { + self.channel = channel + self.expandedWidth = expandedWidth + super.init(nibName: nil, bundle: nil) + preferredContentSize = menuSize + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.layer.cornerRadius = 22 + view.layer.cornerCurve = .continuous + view.clipsToBounds = true + + let glassEffect = UIGlassEffect(style: .regular) + glassEffect.isInteractive = true + let glassView = UIVisualEffectView(effect: glassEffect) + glassView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(glassView) + + contentHost.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentHost) + NSLayoutConstraint.activate([ + glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + glassView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + glassView.topAnchor.constraint(equalTo: view.topAnchor), + glassView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentHost.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentHost.trailingAnchor.constraint(equalTo: view.trailingAnchor), + contentHost.topAnchor.constraint(equalTo: view.topAnchor), + contentHost.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let menu = makeMenuView() + installContent(menu) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + invalidateCameraCapture() + stopCamera() + } + + func adaptivePresentationStyle( + for controller: UIPresentationController + ) -> UIModalPresentationStyle { + .none + } + + func presentationControllerDidDismiss( + _ presentationController: UIPresentationController + ) { + notifyDismissalIfNeeded() + } + + private func makeMenuView() -> UIView { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let stack = UIStackView() + stack.axis = .vertical + stack.distribution = .fillEqually + stack.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor), + stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + ]) + + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Camera", + symbol: "camera", + action: UIAction { [weak self] _ in self?.showCamera() } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Photos", + symbol: "photo.on.rectangle.angled", + action: UIAction { [weak self] _ in self?.showPhotos() } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Video", + symbol: "video", + action: UIAction { [weak self] _ in + self?.finish(method: "pickVideo") + } + ) + ) + stack.addArrangedSubview( + makeNativeAttachmentMenuButton( + title: "Files", + symbol: "doc", + action: UIAction { [weak self] _ in + self?.finish(method: "pickFiles") + } + ) + ) + return container + } + + private func showPhotos() { + guard surface != .photos else { return } + stopCamera() + + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 0 + configuration.selection = .continuousAndOrdered + configuration.preferredAssetRepresentationMode = .compatible + configuration.disabledCapabilities = [ + .search, + .stagingArea, + .collectionNavigation, + .selectionActions, + ] + configuration.edgesWithoutContentMargins = .all + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + picker.view.backgroundColor = .clear + + let container = UIView() + container.backgroundColor = .clear + addChild(picker) + picker.view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(picker.view) + NSLayoutConstraint.activate([ + picker.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + picker.view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + picker.view.topAnchor.constraint( + equalTo: container.topAnchor, + constant: -8 + ), + picker.view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + picker.didMove(toParent: self) + photoPickerViewController = picker + + let backButton = makeGlassControl( + title: nil, + symbol: "chevron.left", + accessibilityLabel: "Back to attachment options", + action: UIAction { [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() } + ) + photoActionButton = actionButton + addBottomControls( + to: container, + leading: backButton, + trailing: actionButton + ) + + transition(to: .photos, content: container) + } + + private func showCamera() { + guard surface != .camera else { return } + removePhotoPicker() + + let container = UIView() + container.backgroundColor = .black + let preview = UIView() + preview.backgroundColor = .black + preview.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(preview) + NSLayoutConstraint.activate([ + preview.leadingAnchor.constraint(equalTo: container.leadingAnchor), + preview.trailingAnchor.constraint(equalTo: container.trailingAnchor), + preview.topAnchor.constraint(equalTo: container.topAnchor), + preview.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + cameraPreviewView = preview + + let placeholder = UIActivityIndicatorView(style: .large) + placeholder.color = .white + placeholder.startAnimating() + placeholder.translatesAutoresizingMaskIntoConstraints = false + preview.addSubview(placeholder) + NSLayoutConstraint.activate([ + placeholder.centerXAnchor.constraint(equalTo: preview.centerXAnchor), + placeholder.centerYAnchor.constraint(equalTo: preview.centerYAnchor), + ]) + placeholder.tag = 7001 + + let backButton = makeGlassControl( + title: nil, + symbol: "chevron.left", + accessibilityLabel: "Back to attachment options", + action: UIAction { [weak self] _ in self?.showMenu() } + ) + let captureButton = makeCameraCaptureButton() + cameraCaptureButton = captureButton + addBottomControls( + to: container, + leading: backButton, + center: captureButton + ) + + transition(to: .camera, content: container) + startCamera() + } + + private func showMenu() { + guard surface != .menu else { return } + invalidateCameraCapture() + selectionGeneration += 1 + selectionTask?.cancel() + selectionTask = nil + Self.removeTemporaryFiles(selectedPhotoPaths) + selectedPhotoPaths = [] + stopCamera() + + let menu = makeMenuView() + transition(to: .menu, content: menu) { [weak self] in + self?.removePhotoPicker() + } + } + + private func installContent(_ content: UIView) { + content.translatesAutoresizingMaskIntoConstraints = false + contentHost.addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor), + content.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor), + content.topAnchor.constraint(equalTo: contentHost.topAnchor), + content.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor), + ]) + visibleContentView = content + } + + private func transition( + to nextSurface: Surface, + content nextView: UIView, + completion: (() -> Void)? = nil + ) { + let previousView = visibleContentView + let isExpanding = nextSurface != .menu + let targetSize = + isExpanding + ? CGSize(width: expandedWidth, height: expandedHeight) + : menuSize + + nextView.translatesAutoresizingMaskIntoConstraints = false + contentHost.addSubview(nextView) + NSLayoutConstraint.activate([ + nextView.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor), + nextView.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor), + nextView.topAnchor.constraint(equalTo: contentHost.topAnchor), + nextView.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor), + ]) + contentHost.layoutIfNeeded() + + let direction: CGFloat = isExpanding ? 34 : -34 + nextView.alpha = UIAccessibility.isReduceMotionEnabled ? 0 : 0.01 + nextView.transform = + UIAccessibility.isReduceMotionEnabled + ? .identity + : CGAffineTransform(translationX: direction, y: 0).scaledBy( + x: 0.97, + y: 0.97 + ) + visibleContentView = nextView + surface = nextSurface + + let duration = UIAccessibility.isReduceMotionEnabled ? 0.16 : 0.36 + UIView.animate( + withDuration: duration, + delay: 0, + usingSpringWithDamping: 0.86, + initialSpringVelocity: 0.18, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.preferredContentSize = targetSize + if let popover = self.popoverPresentationController, + let sourceView = popover.sourceView + { + popover.sourceRect = sourceView.bounds.offsetBy( + dx: isExpanding ? self.expandedHorizontalOffset : 0, + dy: isExpanding ? self.expandedVerticalOffset : 0 + ) + } + previousView?.alpha = 0 + previousView?.transform = + UIAccessibility.isReduceMotionEnabled + ? .identity + : CGAffineTransform(translationX: -direction, y: 0).scaledBy( + x: 0.97, + y: 0.97 + ) + nextView.alpha = 1 + nextView.transform = .identity + self.view.layoutIfNeeded() + } completion: { _ in + previousView?.removeFromSuperview() + previousView?.transform = .identity + completion?() + } + } + + private func makeGlassControl( + title: String?, + symbol: String?, + accessibilityLabel: String, + prominent: Bool = false, + action: UIAction + ) -> UIButton { + var configuration = + prominent + ? UIButton.Configuration.prominentGlass() + : UIButton.Configuration.glass() + configuration.title = title + if prominent { + configuration.baseBackgroundColor = .black + } + if let symbol { + configuration.image = UIImage(systemName: symbol) + } + configuration.imagePadding = 8 + configuration.baseForegroundColor = .white + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 11, + leading: 15, + bottom: 11, + trailing: 15 + ) + let button = UIButton(configuration: configuration, primaryAction: action) + button.accessibilityLabel = accessibilityLabel + return button + } + + private func makeCameraCaptureButton() -> UIButton { + let button = UIButton( + primaryAction: UIAction { [weak self] _ in self?.capturePhoto() } + ) + button.accessibilityLabel = "Take photo" + button.translatesAutoresizingMaskIntoConstraints = false + button.backgroundColor = UIColor.white.withAlphaComponent(0.22) + button.layer.cornerRadius = 34 + button.layer.borderColor = UIColor.white.cgColor + button.layer.borderWidth = 3 + let inner = UIView() + inner.isUserInteractionEnabled = false + inner.translatesAutoresizingMaskIntoConstraints = false + inner.backgroundColor = .white + inner.layer.cornerRadius = 25 + button.addSubview(inner) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 68), + button.heightAnchor.constraint(equalToConstant: 68), + inner.widthAnchor.constraint(equalToConstant: 50), + inner.heightAnchor.constraint(equalToConstant: 50), + inner.centerXAnchor.constraint(equalTo: button.centerXAnchor), + inner.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) + return button + } + + private func addBottomControls( + to container: UIView, + leading: UIButton, + center: UIButton? = nil, + trailing: UIButton? = nil + ) { + leading.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(leading) + var constraints = [ + leading.leadingAnchor.constraint( + equalTo: container.leadingAnchor, + constant: 12 + ), + leading.bottomAnchor.constraint( + equalTo: container.bottomAnchor, + constant: -12 + ), + leading.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + ] + + if let center { + center.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(center) + constraints.append( + center.centerXAnchor.constraint(equalTo: container.centerXAnchor) + ) + constraints.append( + center.bottomAnchor.constraint( + equalTo: container.bottomAnchor, + constant: -12 + ) + ) + } + if let trailing { + trailing.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(trailing) + constraints.append( + trailing.trailingAnchor.constraint( + equalTo: container.trailingAnchor, + constant: -12 + ) + ) + constraints.append( + trailing.bottomAnchor.constraint( + equalTo: container.bottomAnchor, + constant: -12 + ) + ) + constraints.append( + trailing.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + ) + } + NSLayoutConstraint.activate(constraints) + } + + func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + selectionGeneration += 1 + let generation = selectionGeneration + selectionTask?.cancel() + selectionTask = nil + Self.removeTemporaryFiles(selectedPhotoPaths) + selectedPhotoPaths = [] + updatePhotoAction(count: results.count, preparing: !results.isEmpty) + + guard !results.isEmpty else { return } + selectionTask = Task { [weak self] in + guard let self else { return } + var paths: [String] = [] + do { + for result in results { + try Task.checkCancellation() + paths.append(try await self.exportPickerResult(result)) + } + try Task.checkCancellation() + await MainActor.run { + guard generation == self.selectionGeneration else { + Self.removeTemporaryFiles(paths) + return + } + self.selectedPhotoPaths = paths + self.selectionTask = nil + self.updatePhotoAction(count: paths.count, preparing: false) + } + } catch is CancellationError { + Self.removeTemporaryFiles(paths) + } catch { + Self.removeTemporaryFiles(paths) + await MainActor.run { + guard generation == self.selectionGeneration else { return } + self.selectionTask = nil + self.selectedPhotoPaths = [] + self.updatePhotoAction(count: 0, preparing: false) + self.showError("Unable to prepare the selected photos.") + } + } + } + } + + private func updatePhotoAction(count: Int, preparing: Bool) { + let title: String + if preparing { + title = "Preparing…" + } else if count == 0 { + title = "All Photos" + } else { + title = "Add \(count) \(count == 1 ? "photo" : "photos")" + } + guard let button = photoActionButton else { return } + let canInteract = !preparing + button.configuration?.title = title + button.accessibilityLabel = title + button.isUserInteractionEnabled = canInteract + if canInteract { + button.accessibilityTraits.remove(.notEnabled) + } else { + button.accessibilityTraits.insert(.notEnabled) + } + } + + private func performPhotoAction() { + if selectedPhotoPaths.isEmpty { + finish(method: "pickAllPhotos") + } else { + let paths = selectedPhotoPaths + selectedPhotoPaths = [] + finish( + method: "photosSelected", + arguments: paths, + temporaryPaths: paths + ) + } + } + + private func exportPickerResult(_ result: PHPickerResult) async throws + -> String + { + let provider = result.itemProvider + guard + let typeIdentifier = provider.registeredTypeIdentifiers.first(where: { + guard let type = UTType($0) else { return false } + return type.conforms(to: .image) + }) + else { + throw NativeAttachmentPopoverError.unsupportedImage + } + + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { + sourceURL, + error in + if let error { + continuation.resume(throwing: error) + return + } + guard let sourceURL else { + continuation.resume( + throwing: NativeAttachmentPopoverError.missingFile + ) + return + } + + do { + let fileExtension = + sourceURL.pathExtension.isEmpty + ? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg") + : sourceURL.pathExtension + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + try FileManager.default.copyItem( + at: sourceURL, + to: destinationURL + ) + continuation.resume(returning: destinationURL.path) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func removeTemporaryFiles(_ paths: [String]) { + for path in paths where !path.isEmpty { + try? FileManager.default.removeItem(atPath: path) + } + } + + private func startCamera() { + guard !cameraIsStarting else { return } + cameraIsStarting = true + cameraStartupGeneration += 1 + let startupGeneration = cameraStartupGeneration + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureAndStartCamera(startupGeneration: startupGeneration) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard + let self, + self.cameraStartupIsCurrent(startupGeneration) + else { return } + if granted { + self.configureAndStartCamera( + startupGeneration: startupGeneration + ) + } else { + self.cameraIsStarting = false + self.showCameraUnavailable( + "Camera access is needed to take a photo." + ) + } + } + } + default: + cameraIsStarting = false + showCameraUnavailable("Camera access is needed to take a photo.") + } + } + + private func configureAndStartCamera(startupGeneration: Int) { + cameraQueue.async { [weak self] in + guard let self else { return } + do { + if !self.cameraConfigured { + self.cameraSession.beginConfiguration() + defer { self.cameraSession.commitConfiguration() } + self.cameraSession.sessionPreset = .photo + guard + let device = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .back + ) + else { + throw NativeAttachmentPopoverError.cameraUnavailable + } + let input = try AVCaptureDeviceInput(device: device) + guard self.cameraSession.canAddInput(input) else { + throw NativeAttachmentPopoverError.cameraUnavailable + } + self.cameraSession.addInput(input) + self.cameraDevice = device + guard self.cameraSession.canAddOutput(self.cameraOutput) else { + throw NativeAttachmentPopoverError.cameraUnavailable + } + self.cameraSession.addOutput(self.cameraOutput) + self.cameraConfigured = true + } + + if !self.cameraSession.isRunning { + self.cameraSession.startRunning() + } + DispatchQueue.main.async { [weak self] in + guard + let self, + self.cameraStartupIsCurrent(startupGeneration) + else { return } + self.cameraIsStarting = false + self.installCameraPreview() + } + } catch { + if self.cameraSession.isRunning { + self.cameraSession.stopRunning() + } + DispatchQueue.main.async { [weak self] in + guard + let self, + self.cameraStartupIsCurrent(startupGeneration) + else { return } + self.cameraIsStarting = false + self.showCameraUnavailable("Camera isn’t available here.") + } + } + } + } + + private func cameraStartupIsCurrent(_ generation: Int) -> Bool { + generation == cameraStartupGeneration && surface == .camera && !isFinishing + } + + private func installCameraPreview() { + guard surface == .camera, let previewView = cameraPreviewView else { return } + previewView.viewWithTag(7001)?.removeFromSuperview() + cameraPreviewLayer?.removeFromSuperlayer() + + let layer = AVCaptureVideoPreviewLayer(session: cameraSession) + layer.videoGravity = .resizeAspectFill + layer.frame = previewView.bounds + previewView.layer.insertSublayer(layer, at: 0) + cameraPreviewLayer = layer + + if let cameraDevice { + let coordinator = AVCaptureDevice.RotationCoordinator( + device: cameraDevice, + previewLayer: layer + ) + cameraRotationCoordinator = coordinator + cameraRotationObservation = coordinator.observe( + \.videoRotationAngleForHorizonLevelPreview, + options: [.initial, .new] + ) { [weak layer] coordinator, _ in + guard let connection = layer?.connection else { return } + let angle = coordinator.videoRotationAngleForHorizonLevelPreview + guard connection.isVideoRotationAngleSupported(angle) else { return } + connection.videoRotationAngle = angle + } + } + } + + private func stopCamera() { + cameraStartupGeneration += 1 + cameraIsStarting = false + cameraRotationObservation?.invalidate() + cameraRotationObservation = nil + cameraRotationCoordinator = nil + cameraPreviewLayer?.removeFromSuperlayer() + cameraPreviewLayer = nil + cameraQueue.async { [weak self] in + guard let self, self.cameraSession.isRunning else { return } + self.cameraSession.stopRunning() + } + } + + private func capturePhoto() { + guard !cameraIsCapturing, cameraSession.isRunning else { return } + cameraIsCapturing = true + cameraCaptureButton?.isEnabled = false + cameraCaptureButton?.transform = CGAffineTransform( + scaleX: 0.92, + y: 0.92 + ) + UIView.animate( + withDuration: 0.12, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.cameraCaptureButton?.transform = .identity + } + if let connection = cameraOutput.connection(with: .video), + let cameraRotationCoordinator + { + let angle = + cameraRotationCoordinator.videoRotationAngleForHorizonLevelCapture + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } + let settings = AVCapturePhotoSettings() + activeCameraCaptureID = settings.uniqueID + cameraOutput.capturePhoto(with: settings, delegate: self) + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error? + ) { + guard error == nil, let data = photo.fileDataRepresentation() else { + DispatchQueue.main.async { [weak self] in + self?.completeCameraCapture( + captureID: photo.resolvedSettings.uniqueID, + path: nil, + errorMessage: "Unable to capture the photo." + ) + } + return + } + + DispatchQueue.global(qos: .userInitiated).async { + do { + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + try data.write(to: destinationURL, options: .atomic) + DispatchQueue.main.async { [weak self] in + guard let self else { + Self.removeTemporaryFiles([destinationURL.path]) + return + } + self.completeCameraCapture( + captureID: photo.resolvedSettings.uniqueID, + path: destinationURL.path, + errorMessage: nil + ) + } + } catch { + DispatchQueue.main.async { [weak self] in + self?.completeCameraCapture( + captureID: photo.resolvedSettings.uniqueID, + path: nil, + errorMessage: "Unable to prepare the captured photo." + ) + } + } + } + } + + @MainActor + private func completeCameraCapture( + captureID: Int64, + path: String?, + errorMessage: String? + ) { + guard + captureID == activeCameraCaptureID, surface == .camera, !isFinishing + else { + if let path { Self.removeTemporaryFiles([path]) } + return + } + activeCameraCaptureID = nil + cameraIsCapturing = false + cameraCaptureButton?.isEnabled = true + if let path { + finish( + method: "cameraCaptured", + arguments: path, + temporaryPaths: [path] + ) + } else if !isFinishing, let errorMessage { + showError(errorMessage) + } + } + + private func invalidateCameraCapture() { + activeCameraCaptureID = nil + cameraIsCapturing = false + cameraCaptureButton?.isEnabled = true + } + + private func showCameraUnavailable(_ message: String) { + guard let previewView = cameraPreviewView else { return } + previewView.viewWithTag(7001)?.removeFromSuperview() + let label = UILabel() + label.text = message + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + previewView.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: previewView.centerXAnchor), + label.centerYAnchor.constraint(equalTo: previewView.centerYAnchor), + label.leadingAnchor.constraint( + greaterThanOrEqualTo: previewView.leadingAnchor, + constant: 28 + ), + label.trailingAnchor.constraint( + lessThanOrEqualTo: previewView.trailingAnchor, + constant: -28 + ), + ]) + } + + private func showError(_ message: String) { + let alert = UIAlertController( + title: nil, + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + private func removePhotoPicker() { + guard let picker = photoPickerViewController else { return } + picker.willMove(toParent: nil) + picker.view.removeFromSuperview() + picker.removeFromParent() + photoPickerViewController = nil + } + + private func finish( + method: String, + arguments: Any? = nil, + temporaryPaths: [String] = [] + ) { + guard !isFinishing else { + Self.removeTemporaryFiles(temporaryPaths) + return + } + isFinishing = true + view.isUserInteractionEnabled = false + selectionGeneration += 1 + selectionTask?.cancel() + selectionTask = nil + stopCamera() + dismiss(animated: true) { [weak self] in + guard let self else { + Self.removeTemporaryFiles(temporaryPaths) + return + } + self.channel.invokeMethod(method, arguments: arguments) { _ in + Self.removeTemporaryFiles(temporaryPaths) + } + self.notifyDismissalIfNeeded() + } + } + + func dismissAndNotify() { + guard !isFinishing else { return } + isFinishing = true + view.isUserInteractionEnabled = false + selectionGeneration += 1 + selectionTask?.cancel() + selectionTask = nil + Self.removeTemporaryFiles(selectedPhotoPaths) + selectedPhotoPaths = [] + stopCamera() + dismiss(animated: true) { [weak self] in + self?.notifyDismissalIfNeeded() + } + } + + private func notifyDismissalIfNeeded() { + selectionGeneration += 1 + selectionTask?.cancel() + selectionTask = nil + Self.removeTemporaryFiles(selectedPhotoPaths) + selectedPhotoPaths = [] + guard !didNotifyDismissal else { return } + didNotifyDismissal = true + onDismiss?() + } +} + +private enum NativeAttachmentPopoverError: Error { + case cameraUnavailable + case missingFile + case unsupportedImage +} diff --git a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift new file mode 100644 index 0000000000..4b9c32ffcd --- /dev/null +++ b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift @@ -0,0 +1,228 @@ +import Flutter +import UIKit + +final class NativeAttachmentPopoverCoordinator: NSObject { + private let channel: FlutterMethodChannel + private weak var parentViewController: UIViewController? + private weak var presentedController: UIViewController? + private weak var sourceAnchorView: UIView? + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + channel = FlutterMethodChannel( + name: "buzz/native_attachment_popover", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + private func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "isSupported": + if #available(iOS 26.0, *) { + result(true) + } else { + result(false) + } + case "present": + guard + let arguments = call.arguments as? [String: Any], + let x = arguments["x"] as? NSNumber, + let y = arguments["y"] as? NSNumber, + let width = arguments["width"] as? NSNumber, + let height = arguments["height"] as? NSNumber + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected the attachment trigger bounds.", + details: nil + ) + ) + return + } + + let sourceRect = CGRect( + x: CGFloat(truncating: x), + y: CGFloat(truncating: y), + width: CGFloat(truncating: width), + height: CGFloat(truncating: height) + ) + DispatchQueue.main.async { [weak self] in + result(self?.presentPopover(sourceRect: sourceRect) ?? false) + } + case "dismiss": + DispatchQueue.main.async { [weak self] in + if #available(iOS 26.0, *), + let controller = + self?.presentedController + as? NativeAttachmentPopoverViewController + { + controller.dismissAndNotify() + } else { + self?.presentedController?.dismiss(animated: true) + } + result(nil) + } + default: + result(FlutterMethodNotImplemented) + } + } + + @MainActor + private func presentPopover(sourceRect: CGRect) -> Bool { + guard #available(iOS 26.0, *) else { return false } + guard presentedController == nil else { return true } + let rootViewController = + parentViewController ?? activeWindowRootViewController() + guard let presenter = topViewController(from: rootViewController) else { + return false + } + + let sourceView = presenter.view + let convertedRect: CGRect + if let window = sourceView?.window { + convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect + } else { + convertedRect = sourceRect + } + + let anchorView = makeSourceAnchor(frame: convertedRect) + sourceView?.addSubview(anchorView) + sourceAnchorView = anchorView + + let availableWidth = max( + 320, + min( + (sourceView?.bounds.width ?? UIScreen.main.bounds.width) - 24, + 430 + ) + ) + let controller = NativeAttachmentPopoverViewController( + channel: channel, + expandedWidth: availableWidth + ) + controller.modalPresentationStyle = .popover + controller.preferredTransition = .zoom { [weak anchorView] _ in + anchorView + } + controller.onDismiss = { [weak self] in + self?.presentedController = nil + self?.sourceAnchorView?.removeFromSuperview() + self?.channel.invokeMethod("dismissed", arguments: nil) + } + + guard let popover = controller.popoverPresentationController else { + anchorView.removeFromSuperview() + return false + } + popover.sourceView = anchorView + popover.sourceRect = anchorView.bounds + popover.permittedArrowDirections = [.down] + popover.backgroundColor = .clear + popover.delegate = controller + + presentedController = controller + presenter.present(controller, animated: true) + return true + } + + @MainActor + private func makeSourceAnchor(frame: CGRect) -> UIView { + let anchor = UIView(frame: frame) + anchor.isUserInteractionEnabled = false + anchor.accessibilityElementsHidden = true + anchor.backgroundColor = .clear + anchor.layer.cornerRadius = min(frame.width, frame.height) / 2 + anchor.layer.cornerCurve = .continuous + return anchor + } + + @MainActor + private func activeWindowRootViewController() -> UIViewController? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + } + + @MainActor + private func topViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let presented = viewController?.presentedViewController { + return topViewController(from: presented) + } + if let navigation = viewController as? UINavigationController { + return topViewController(from: navigation.visibleViewController) + } + if let tab = viewController as? UITabBarController { + return topViewController(from: tab.selectedViewController) + } + return viewController + } +} + +func makeNativeAttachmentMenuButton( + title: String, + symbol: String, + action: UIAction +) -> UIButton { + let button = UIButton(primaryAction: action) + button.accessibilityLabel = title + + let symbolConfiguration = UIImage.SymbolConfiguration( + pointSize: 18, + weight: .regular + ) + let iconView = UIImageView( + image: UIImage( + systemName: symbol, + withConfiguration: symbolConfiguration + ) + ) + iconView.tintColor = .label + iconView.contentMode = .center + iconView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.textColor = .label + titleLabel.font = .preferredFont(forTextStyle: .body) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .left + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + button.addSubview(iconView) + button.addSubview(titleLabel) + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14), + iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 26), + titleLabel.leadingAnchor.constraint( + equalTo: iconView.trailingAnchor, + constant: 12 + ), + titleLabel.trailingAnchor.constraint( + equalTo: button.trailingAnchor, + constant: -14 + ), + titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) + button.configurationUpdateHandler = { button in + button.alpha = button.isHighlighted ? 0.62 : 1 + } + return button +} diff --git a/mobile/lib/features/channels/camera_capture_cleanup.dart b/mobile/lib/features/channels/camera_capture_cleanup.dart index 6937218c9e..469a150e13 100644 --- a/mobile/lib/features/channels/camera_capture_cleanup.dart +++ b/mobile/lib/features/channels/camera_capture_cleanup.dart @@ -5,16 +5,26 @@ import 'package:image_picker/image_picker.dart'; Future processCapturedImage( XFile image, Future Function(XFile image) onCapture, +) async { + await processTemporaryImages([image], (images) => onCapture(images.single)); +} + +/// Processes native-owned [images], then removes their temporary files. +Future processTemporaryImages( + List images, + Future Function(List images) process, ) async { try { - await onCapture(image); + await process(images); } finally { - final path = image.path; - if (path.isNotEmpty) { - try { - await File(path).delete(); - } on FileSystemException { - // The camera plugin may already have removed its temporary file. + for (final image in images) { + final path = image.path; + if (path.isNotEmpty) { + try { + await File(path).delete(); + } on FileSystemException { + // The native picker may already have removed its temporary file. + } } } } diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index b172f6a885..90a19d8d3d 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:math' as math; import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -28,21 +30,21 @@ import 'emoji_picker.dart'; import 'mentions/mention_candidates.dart'; import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; +import 'photo_library.dart'; part 'compose_bar/helpers.dart'; part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; +part 'compose_bar/photo_gallery_picker.dart'; +part 'compose_bar/ios_photo_picker.dart'; +part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; +part 'compose_bar/layout.dart'; -const _pastedImageMimeTypes = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/webp', -]; +const _maxConcurrentImageUploads = 3; /// Rich compose bar with @mention autocomplete and a markdown formatting /// toolbar. Used in both channel and thread views — the caller provides an @@ -120,8 +122,15 @@ class ComposeBar extends HookConsumerWidget { }, [controller, draftKey, draftIdentity]); final focusNode = useFocusNode(); final isComposerExpanded = useState(false); - final showAttachments = useState(false); - final showCamera = useState(false); + final attachmentSurface = useState(_AttachmentSurface.closed); + final iosAttachmentPopover = useMemoized( + _IOSAttachmentPopoverController.new, + ); + useEffect( + () => + () => unawaited(iosAttachmentPopover.dispose()), + [iosAttachmentPopover], + ); final isSending = useState(false); final showFormatting = useState(false); final attachments = useState>([]); @@ -389,8 +398,7 @@ class ComposeBar extends HookConsumerWidget { mentionMap.value.clear(); mentionQuery.value = null; channelQuery.value = null; - showAttachments.value = false; - showCamera.value = false; + attachmentSurface.value = _AttachmentSurface.closed; showFormatting.value = false; uploadError.value = null; focusNode.requestFocus(); @@ -533,6 +541,80 @@ class ComposeBar extends HookConsumerWidget { } } + Future pickThenUpload({ + required Future Function() pick, + required Future Function(XFile file) upload, + }) async { + uploadError.value = null; + try { + final picked = await pick(); + if (picked == null || !context.mounted) return; + await pickAndUpload(() => upload(picked)); + } catch (error) { + if (context.mounted) { + uploadError.value = _formatUploadError(error); + } + } + } + + Future uploadImages(List images) async { + if (images.isEmpty) return; + uploadError.value = null; + uploadingCount.value += images.length; + try { + Future<({BlobDescriptor? uploaded, Object? error})> uploadImage( + XFile image, + ) async { + try { + final uploaded = await ref + .read(mediaUploadServiceProvider) + .uploadImage(image); + return (uploaded: uploaded, error: null); + } catch (error) { + return (uploaded: null, error: error); + } + } + + final results = <({BlobDescriptor? uploaded, Object? error})>[]; + for ( + var start = 0; + start < images.length; + start += _maxConcurrentImageUploads + ) { + final end = math.min( + start + _maxConcurrentImageUploads, + images.length, + ); + results.addAll( + await Future.wait([ + for (final image in images.sublist(start, end)) + uploadImage(image), + ]), + ); + } + if (!context.mounted) return; + + final uploaded = [for (final result in results) ?result.uploaded]; + if (uploaded.isNotEmpty) { + attachments.value = [...attachments.value, ...uploaded]; + } + final firstError = results + .map((result) => result.error) + .whereType() + .firstOrNull; + if (firstError != null) { + uploadError.value = _formatUploadError(firstError); + } + } finally { + if (context.mounted) { + uploadingCount.value = math.max( + 0, + uploadingCount.value - images.length, + ); + } + } + } + Widget buildContextMenu( BuildContext context, EditableTextState editableTextState, @@ -621,31 +703,88 @@ class ComposeBar extends HookConsumerWidget { // ----- Widget tree ---------------------------------------------------- - void chooseAttachment(Future Function() pick) { - showAttachments.value = false; - showCamera.value = false; - pickAndUpload(pick); + void chooseAttachment( + Future Function() choose, { + String? errorMessage, + }) { + attachmentSurface.value = _AttachmentSurface.closed; + unawaited(() async { + try { + await choose(); + } catch (error) { + if (context.mounted) { + uploadError.value = errorMessage ?? _formatUploadError(error); + } + } + }()); } void toggleAttachments() { - if (showCamera.value) { - showCamera.value = false; - showAttachments.value = false; + attachmentSurface.value = switch (attachmentSurface.value) { + _AttachmentSurface.closed => _AttachmentSurface.menu, + _AttachmentSurface.menu => _AttachmentSurface.closed, + _AttachmentSurface.camera || + _AttachmentSurface.photos => _AttachmentSurface.menu, + }; + } + + void handleAttachmentTap(BuildContext triggerContext) { + if (defaultTargetPlatform != TargetPlatform.iOS || + attachmentSurface.value != _AttachmentSurface.closed) { + toggleAttachments(); return; } - showCamera.value = false; - showAttachments.value = !showAttachments.value; + + focusNode.unfocus(); + unawaited( + iosAttachmentPopover + .present( + sourceContext: triggerContext, + onCapture: (image) => pickAndUpload( + () => ref.read(mediaUploadServiceProvider).uploadImage(image), + ), + onChoosePhotos: uploadImages, + onAllPhotos: () => chooseAttachment(() async { + final photos = await ref + .read(mediaUploadServiceProvider) + .pickGalleryImages(); + await uploadImages(photos); + }, errorMessage: 'Unable to open your photo library.'), + onVideo: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenUpload( + pick: service.pickGalleryVideo, + upload: service.uploadVideo, + ); + }), + onFiles: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenUpload( + pick: service.pickAttachmentFile, + upload: service.uploadFile, + ); + }), + ) + .then((didPresent) { + if (!didPresent && context.mounted) toggleAttachments(); + }), + ); } void openCamera() { focusNode.unfocus(); - showAttachments.value = false; - showCamera.value = true; + attachmentSurface.value = _AttachmentSurface.camera; } final motionDuration = reducedMotion ? Duration.zero - : const Duration(milliseconds: 180); + : Duration( + milliseconds: + attachmentSurface.value == _AttachmentSurface.camera || + attachmentSurface.value == _AttachmentSurface.photos + ? 320 + : 250, + ); final suggestionOverlayController = useMemoized( OverlayPortalController.new, ); @@ -659,8 +798,7 @@ class ComposeBar extends HookConsumerWidget { void expandComposer() { if (isComposerExpanded.value) return; - showAttachments.value = false; - showCamera.value = false; + attachmentSurface.value = _AttachmentSurface.closed; isComposerExpanded.value = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) focusNode.requestFocus(); @@ -687,40 +825,48 @@ class ComposeBar extends HookConsumerWidget { ), ) : const SizedBox.shrink(key: ValueKey('no-suggestions')); - final overlayPanel = showCamera.value - ? KeyedSubtree( - key: const ValueKey('camera-preview'), - child: _InlineCameraPreview( - onClose: () => showCamera.value = false, - onCapture: (image) async { - showCamera.value = false; - await pickAndUpload( - () => ref.read(mediaUploadServiceProvider).uploadImage(image), - ); - }, - ), - ) - : showAttachments.value - ? KeyedSubtree( - key: const ValueKey('attachment-menu'), - child: Align( - alignment: Alignment.bottomLeft, - heightFactor: 1, - child: _AttachmentMenu( - onCamera: openCamera, - onPhotos: () => chooseAttachment( - ref.read(mediaUploadServiceProvider).pickAndUploadImage, - ), - onVideo: () => chooseAttachment( - ref.read(mediaUploadServiceProvider).pickAndUploadVideo, - ), - onFiles: () => chooseAttachment( - ref.read(mediaUploadServiceProvider).pickAndUploadFile, - ), - ), - ), - ) - : suggestionPanel; + Widget buildOverlayPanel(_AttachmentSurface surface) { + return _AttachmentSurfacePanel( + key: ValueKey( + surface == _AttachmentSurface.closed + ? 'composer-suggestions' + : 'attachment-surface', + ), + surface: surface, + suggestionPanel: suggestionPanel, + onBack: () => attachmentSurface.value = _AttachmentSurface.menu, + onCamera: openCamera, + onPhotos: () { + focusNode.unfocus(); + attachmentSurface.value = _AttachmentSurface.photos; + }, + onVideo: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenUpload( + pick: service.pickGalleryVideo, + upload: service.uploadVideo, + ); + }), + onFiles: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenUpload( + pick: service.pickAttachmentFile, + upload: service.uploadFile, + ); + }), + onCapture: (image) async { + attachmentSurface.value = _AttachmentSurface.closed; + await pickAndUpload( + () => ref.read(mediaUploadServiceProvider).uploadImage(image), + ); + }, + onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages, + onChoosePhotos: (photos) async { + attachmentSurface.value = _AttachmentSurface.closed; + await uploadImages(photos); + }, + ); + } // Suggestions and attachments live in the overlay so showing them cannot // reflow the composer. Both stay anchored just above the capsule. @@ -737,233 +883,91 @@ class ComposeBar extends HookConsumerWidget { layoutInfo.childPaintTransform, Offset.zero, ); - return Positioned( - left: composerOrigin.dx, - bottom: layoutInfo.overlaySize.height - composerOrigin.dy, - width: layoutInfo.childSize.width, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: _SuggestionPanelMotion( - duration: motionDuration, - child: overlayPanel, + 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), + ), ), - ), - ), + ); + }, ); }, - child: Container( - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - padding: const EdgeInsets.all(Grid.xxs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasAttachments || hasPendingUploads) ...[ - _AttachmentStrip( - attachments: attachments.value, - uploadingCount: uploadingCount.value, - onRemove: removeAttachment, - ), - const SizedBox(height: Grid.xxs), - ], - - if (uploadError.value case final error?) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - const SizedBox(height: Grid.xxs), - ], - - // Keep the default state out of the focus system entirely so - // restored native focus cannot expand a newly opened channel. - if (isComposerExpanded.value) - TextField( - controller: controller, - focusNode: focusNode, - textInputAction: TextInputAction.send, - contextMenuBuilder: buildContextMenu, - contentInsertionConfiguration: ContentInsertionConfiguration( - allowedMimeTypes: _pastedImageMimeTypes, - onContentInserted: uploadPastedImage, - ), - onSubmitted: (_) => send(), - minLines: 1, - maxLines: 5, - style: context.textTheme.bodyLarge, - decoration: InputDecoration( - hintText: resolvedHint, - hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: Grid.half, - ), - isDense: true, - ), - ) - else - Row( - children: [ - _AttachmentTrigger( - open: showAttachments.value || showCamera.value, - onTap: toggleAttachments, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Semantics( - button: true, - label: resolvedHint, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: expandComposer, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.half, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - resolvedHint, - style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ), - ), - ), - ), - ], - ), - - ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: composerExpansionValue, - child: IgnorePointer( - ignoring: composerExpansionValue < 0.98, - child: Opacity( - opacity: composerExpansionProgress, - child: Transform.translate( - offset: Offset( - 0, - Grid.xxs * (1 - composerExpansionProgress), - ), - child: Column( - children: [ - const SizedBox(height: Grid.xxs), - Row( - children: [ - _AttachmentTrigger( - open: - showAttachments.value || - showCamera.value || - showFormatting.value, - onTap: () { - if (showFormatting.value) { - showFormatting.value = false; - } else { - toggleAttachments(); - } - }, - ), - const SizedBox(width: Grid.half), - Expanded( - child: AnimatedSwitcher( - duration: motionDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: - (currentChild, previousChildren) => - Stack( - alignment: Alignment.centerLeft, - children: [ - ...previousChildren, - ?currentChild, - ], - ), - child: showFormatting.value - ? _FormattingToolbar( - onFormat: applyFormat, - ) - : Row( - key: const ValueKey( - 'standard-actions', - ), - children: [ - _ComposeAction( - icon: LucideIcons.atSign, - onTap: () { - showAttachments.value = false; - showCamera.value = false; - triggerMention(); - }, - ), - _ComposeAction( - icon: LucideIcons.hash, - onTap: () { - showAttachments.value = false; - showCamera.value = false; - triggerChannel(); - }, - ), - _ComposeAction( - icon: LucideIcons.smilePlus, - onTap: () { - showAttachments.value = false; - showCamera.value = false; - showEmojiPicker( - context: context, - onSelect: insertEmoji, - ); - }, - ), - _ComposeAction( - icon: LucideIcons.aLargeSmall, - onTap: () { - showAttachments.value = false; - showCamera.value = false; - showFormatting.value = true; - }, - ), - const Spacer(), - _SendButton( - isDisabled: hasPendingUploads, - isSending: isSending.value, - onTap: send, - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ), - ], - ), + child: _ComposeBarLayout( + attachments: attachments.value, + uploadingCount: uploadingCount.value, + onRemoveAttachment: removeAttachment, + uploadError: uploadError.value, + isExpanded: isComposerExpanded.value, + controller: controller, + focusNode: focusNode, + contextMenuBuilder: buildContextMenu, + onContentInserted: uploadPastedImage, + onSend: () => unawaited(send()), + resolvedHint: resolvedHint, + attachmentSurface: attachmentSurface.value, + onAttachmentTap: handleAttachmentTap, + onExpand: expandComposer, + expansionValue: composerExpansionValue, + expansionProgress: composerExpansionProgress, + formattingOpen: showFormatting.value, + onCloseFormatting: () => showFormatting.value = false, + motionDuration: motionDuration, + onFormat: applyFormat, + onMention: () { + attachmentSurface.value = _AttachmentSurface.closed; + triggerMention(); + }, + onChannel: () { + attachmentSurface.value = _AttachmentSurface.closed; + triggerChannel(); + }, + onEmoji: () { + attachmentSurface.value = _AttachmentSurface.closed; + showEmojiPicker(context: context, onSelect: insertEmoji); + }, + onOpenFormatting: () { + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = true; + }, + 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 1d342cd15c..cfb541fc85 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -1,5 +1,195 @@ part of '../compose_bar.dart'; +enum _AttachmentSurface { closed, menu, camera, photos } + +const _attachmentMenuWidth = 176.0; +const _attachmentMenuHeight = 208.0; +const _attachmentExpandedHeight = 372.0; + +class _AttachmentSurfacePanel extends HookWidget { + final _AttachmentSurface surface; + final Widget suggestionPanel; + final VoidCallback onBack; + final VoidCallback onCamera; + final VoidCallback onPhotos; + final VoidCallback onVideo; + final VoidCallback onFiles; + final Future Function(XFile image) onCapture; + final Future> Function() onPickAllPhotos; + final Future Function(List photos) onChoosePhotos; + + const _AttachmentSurfacePanel({ + super.key, + required this.surface, + required this.suggestionPanel, + required this.onBack, + required this.onCamera, + required this.onPhotos, + required this.onVideo, + required this.onFiles, + required this.onCapture, + required this.onPickAllPhotos, + required this.onChoosePhotos, + }); + + @override + Widget build(BuildContext context) { + if (surface == _AttachmentSurface.closed) return suggestionPanel; + + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final isExpanded = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final morphController = useAnimationController( + initialValue: isExpanded ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 320), + reverseDuration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 250), + ); + final rawProgress = useAnimation(morphController); + final renderedExpandedSurface = useState<_AttachmentSurface?>( + isExpanded ? surface : null, + ); + final latestSurface = useRef(surface); + latestSurface.value = surface; + + useEffect(() { + void disposeCollapsedContent(AnimationStatus status) { + if (status == AnimationStatus.dismissed && + latestSurface.value == _AttachmentSurface.menu) { + renderedExpandedSurface.value = null; + } + } + + morphController.addStatusListener(disposeCollapsedContent); + return () => + morphController.removeStatusListener(disposeCollapsedContent); + }, [morphController]); + + useEffect(() { + if (isExpanded) { + renderedExpandedSurface.value = surface; + morphController.forward(); + } else { + morphController.reverse(); + } + return null; + }, [isExpanded, morphController, surface]); + + final visibleExpandedSurface = + renderedExpandedSurface.value ?? (isExpanded ? surface : null); + final expandedContent = switch (visibleExpandedSurface) { + _AttachmentSurface.camera => KeyedSubtree( + key: const ValueKey('camera-preview'), + child: _InlineCameraPreview(onClose: onBack, onCapture: onCapture), + ), + _AttachmentSurface.photos => KeyedSubtree( + key: const ValueKey('photo-gallery'), + child: _PhotoGalleryPicker( + onBack: onBack, + onPickAllPhotos: onPickAllPhotos, + onChoosePhotos: onChoosePhotos, + ), + ), + _AttachmentSurface.closed || + _AttachmentSurface.menu || + null => const SizedBox.shrink(), + }; + + double interval(double value, double begin, double end) { + return ((value - begin) / (end - begin)).clamp(0.0, 1.0); + } + + final menuOpacity = 1 - interval(rawProgress, 0.12, 0.38); + final expandedOpacity = interval(rawProgress, 0.28, 0.65); + final sizeProgress = morphController.status == AnimationStatus.reverse + ? const Cubic(0.22, 1, 0.36, 1).transform(rawProgress) + : Curves.easeInOutCubic.transform(rawProgress); + + return LayoutBuilder( + builder: (context, constraints) { + final expandedWidth = constraints.maxWidth; + const expandedHeight = _attachmentExpandedHeight; + final width = + _attachmentMenuWidth + + ((expandedWidth - _attachmentMenuWidth) * sizeProgress); + final height = + _attachmentMenuHeight + + ((expandedHeight - _attachmentMenuHeight) * sizeProgress); + final baseColor = context.colors.surfaceContainerHighest; + final expandedColor = + visibleExpandedSurface == _AttachmentSurface.camera + ? Colors.black + : baseColor; + + return Align( + alignment: Alignment.topLeft, + heightFactor: 1, + 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: _attachmentMenuHeight, + child: IgnorePointer( + ignoring: surface != _AttachmentSurface.menu, + child: Opacity( + opacity: menuOpacity, + child: _AttachmentMenu( + 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, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + @immutable class _ComposeDraftPayload { final String content; @@ -24,16 +214,21 @@ class _ComposeDraftPayload { } class _AttachmentTrigger extends StatelessWidget { - final bool open; - final VoidCallback onTap; + final _AttachmentSurface surface; + final bool formattingOpen; + final ValueChanged onTap; - const _AttachmentTrigger({required this.open, required this.onTap}); + const _AttachmentTrigger({ + required this.surface, + required this.formattingOpen, + required this.onTap, + }); @override Widget build(BuildContext context) { final duration = MediaQuery.disableAnimationsOf(context) ? Duration.zero - : const Duration(milliseconds: 180); + : const Duration(milliseconds: 240); return SizedBox.square( dimension: 36, @@ -47,18 +242,44 @@ class _AttachmentTrigger extends StatelessWidget { ), ), child: IconButton( - tooltip: open ? 'Close attachments' : 'Add attachment', - onPressed: onTap, + tooltip: switch (surface) { + _AttachmentSurface.closed => + formattingOpen ? 'Close formatting' : 'Add attachment', + _AttachmentSurface.menu => 'Close attachments', + _AttachmentSurface.camera || + _AttachmentSurface.photos => 'Back to attachment options', + }, + onPressed: () => onTap(context), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, icon: AnimatedRotation( duration: duration, - curve: Curves.easeInOutCubic, - turns: open ? 0.125 : 0, - child: Icon( - LucideIcons.plus, - size: 20, - color: context.colors.onSurfaceVariant, + curve: Curves.easeOutBack, + turns: surface == _AttachmentSurface.menu || formattingOpen + ? 0.125 + : 0, + child: AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1).animate(animation), + child: child, + ), + ), + child: Icon( + switch (surface) { + _AttachmentSurface.camera => LucideIcons.camera, + _AttachmentSurface.photos => LucideIcons.images, + _AttachmentSurface.closed || + _AttachmentSurface.menu => LucideIcons.plus, + }, + key: ValueKey('attachment-trigger-${surface.name}'), + size: 20, + color: context.colors.onSurfaceVariant, + ), ), ), ), @@ -82,43 +303,37 @@ class _AttachmentMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: 176, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, + return SizedBox( + width: _attachmentMenuWidth, + height: _attachmentMenuHeight, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AttachmentMenuItem( + icon: LucideIcons.camera, + label: 'Camera', + onTap: onCamera, + ), + _AttachmentMenuItem( + icon: LucideIcons.images, + label: 'Photos', + onTap: onPhotos, + ), + _AttachmentMenuItem( + icon: LucideIcons.video, + label: 'Video', + onTap: onVideo, + ), + _AttachmentMenuItem( + icon: LucideIcons.file, + label: 'Files', + onTap: onFiles, + ), + ], ), ), - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _AttachmentMenuItem( - icon: LucideIcons.camera, - label: 'Camera', - onTap: onCamera, - ), - _AttachmentMenuItem( - icon: LucideIcons.images, - label: 'Photos', - onTap: onPhotos, - ), - _AttachmentMenuItem( - icon: LucideIcons.video, - label: 'Video', - onTap: onVideo, - ), - _AttachmentMenuItem( - icon: LucideIcons.file, - label: 'Files', - onTap: onFiles, - ), - ], - ), ); } } @@ -201,38 +416,52 @@ class _AttachmentStrip extends StatelessWidget { ? 'Uploading attachment…' : 'Uploading $uploadingCount attachments…'; return Semantics( + excludeSemantics: true, liveRegion: true, label: label, child: Container( key: const ValueKey('compose-upload-progress'), - width: 128, + width: thumbWidth, decoration: BoxDecoration( color: context.colors.surface, borderRadius: BorderRadius.circular(Radii.md), border: Border.all(color: context.colors.outlineVariant), ), - padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Stack( + alignment: Alignment.center, children: [ SizedBox.square( - dimension: 18, + dimension: 34, child: CircularProgressIndicator( - strokeWidth: 2, + strokeWidth: 3, color: context.colors.primary, ), ), - const SizedBox(width: Grid.half), - Flexible( - child: Text( - label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, + if (uploadingCount > 1) + PositionedDirectional( + top: Grid.quarter, + end: Grid.quarter, + child: Container( + key: const ValueKey('compose-upload-count'), + constraints: const BoxConstraints( + minWidth: 22, + minHeight: 22, + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colors.primary, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(3), + child: Text( + '$uploadingCount', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimary, + fontWeight: FontWeight.w700, + ), + ), ), ), - ), ], ), ), diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index df16827ba5..b4a0d220a4 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -115,50 +115,44 @@ class _InlineCameraPreview extends HookConsumerWidget { } final activeController = controller.value; - return Container( - width: double.infinity, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(Radii.dialog), - ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: AspectRatio( - aspectRatio: 4 / 3, - child: Stack( - fit: StackFit.expand, - children: [ - if (activeController case final initialized?) - _CameraFeed(controller: initialized) - else - _CameraPlaceholder( - isInitializing: isInitializing.value, - message: error.value, - ), - if (activeController != null) - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.all(Grid.twelve), - child: _CameraCaptureButton( - isPressed: isCapturing.value, - onTap: capture, - ), + final usesAndroidCameraLayout = + defaultTargetPlatform == TargetPlatform.android; + return ColoredBox( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + if (activeController case final initialized?) + _CameraFeed(controller: initialized) + else + _CameraPlaceholder( + isInitializing: isInitializing.value, + message: error.value, + ), + if (activeController != null) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(Grid.twelve), + child: _CameraCaptureButton( + isPressed: isCapturing.value, + onTap: capture, ), ), - Positioned( - top: Grid.xxs, - right: Grid.xxs, - child: _CameraCloseButton(onTap: onClose), ), - ], - ), + Positioned( + top: usesAndroidCameraLayout ? null : Grid.xxs, + left: usesAndroidCameraLayout ? Grid.twelve : null, + right: usesAndroidCameraLayout ? null : Grid.xxs, + bottom: usesAndroidCameraLayout + ? Grid.twelve + ((_cameraCaptureSize - _cameraBackSize) / 2) + : null, + child: _CameraCloseButton( + onTap: onClose, + emphasized: usesAndroidCameraLayout, + ), + ), + ], ), ); } @@ -206,7 +200,7 @@ class _CameraPlaceholder extends StatelessWidget { child: isInitializing ? const CircularProgressIndicator( color: Colors.white, - strokeWidth: 2, + strokeWidth: 3, ) : Column( mainAxisSize: MainAxisSize.min, @@ -254,8 +248,8 @@ class _CameraCaptureButton extends StatelessWidget { duration: duration, curve: Curves.easeOutCubic, child: Container( - width: 64, - height: 64, + width: _cameraCaptureSize, + height: _cameraCaptureSize, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.24), shape: BoxShape.circle, @@ -277,27 +271,33 @@ class _CameraCaptureButton extends StatelessWidget { class _CameraCloseButton extends StatelessWidget { final VoidCallback onTap; + final bool emphasized; - const _CameraCloseButton({required this.onTap}); + const _CameraCloseButton({required this.onTap, required this.emphasized}); @override Widget build(BuildContext context) { return SizedBox.square( - dimension: 36, + dimension: emphasized ? _cameraBackSize : 36, child: IconButton( onPressed: onTap, - tooltip: 'Close camera', + tooltip: 'Back to attachment options', padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.56), + backgroundColor: Colors.black.withValues( + alpha: emphasized ? 0.68 : 0.56, + ), foregroundColor: Colors.white, ), - icon: const Icon(LucideIcons.x, size: 18), + icon: Icon(LucideIcons.arrowLeft, size: emphasized ? 24 : 18), ), ); } } +const _cameraCaptureSize = 64.0; +const _cameraBackSize = 44.0; + String _cameraErrorMessage(Object error) { if (error is camera.CameraException) { return switch (error.code) { diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 983aa27066..145d28fb44 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,6 +1,12 @@ part of '../compose_bar.dart'; const _typingThrottleMs = 3000; +const _pastedImageMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', +]; /// Cap on ranked mention suggestions shown — matches desktop's /// `MENTION_SUGGESTION_LIMIT`. diff --git a/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart new file mode 100644 index 0000000000..d56db5998c --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart @@ -0,0 +1,174 @@ +part of '../compose_bar.dart'; + +const _nativeAttachmentPopoverChannel = MethodChannel( + 'buzz/native_attachment_popover', +); + +final _iosAttachmentPopoverCoordinator = _IOSAttachmentPopoverCoordinator( + _nativeAttachmentPopoverChannel, +); + +class _IOSAttachmentPopoverCallbacks { + final Future Function(XFile image) onCapture; + final Future Function(List photos) onChoosePhotos; + final VoidCallback onAllPhotos; + final VoidCallback onVideo; + final VoidCallback onFiles; + + const _IOSAttachmentPopoverCallbacks({ + required this.onCapture, + required this.onChoosePhotos, + required this.onAllPhotos, + required this.onVideo, + required this.onFiles, + }); +} + +class _IOSAttachmentPopoverCoordinator { + final MethodChannel _channel; + + Object? _activeOwner; + _IOSAttachmentPopoverCallbacks? _callbacks; + bool _didPresent = false; + bool _handlerInstalled = false; + + _IOSAttachmentPopoverCoordinator(this._channel); + + Future present({ + required Object owner, + required BuildContext sourceContext, + required Future Function(XFile image) onCapture, + required Future Function(List photos) onChoosePhotos, + required VoidCallback onAllPhotos, + required VoidCallback onVideo, + required VoidCallback onFiles, + }) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + if (_activeOwner != null) return true; + + _activeOwner = owner; + _callbacks = _IOSAttachmentPopoverCallbacks( + onCapture: onCapture, + onChoosePhotos: onChoosePhotos, + onAllPhotos: onAllPhotos, + onVideo: onVideo, + onFiles: onFiles, + ); + _ensureHandler(); + + try { + final supported = + await _channel.invokeMethod('isSupported') ?? false; + if (!identical(_activeOwner, owner)) return false; + if (!supported || !sourceContext.mounted) { + _clearOwner(owner); + return false; + } + + final renderObject = sourceContext.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + _clearOwner(owner); + return false; + } + final origin = renderObject.localToGlobal(Offset.zero); + + _didPresent = true; + final didPresent = + await _channel.invokeMethod('present', { + 'x': origin.dx, + 'y': origin.dy, + 'width': renderObject.size.width, + 'height': renderObject.size.height, + }) ?? + false; + if (!identical(_activeOwner, owner)) return false; + if (!didPresent) _clearOwner(owner); + return didPresent; + } on PlatformException { + _clearOwner(owner); + return false; + } + } + + Future disposeOwner(Object owner) async { + if (!identical(_activeOwner, owner)) return; + + _callbacks = null; + if (!_didPresent) { + _clearOwner(owner); + return; + } + + try { + await _channel.invokeMethod('dismiss'); + } on PlatformException { + _clearOwner(owner); + } + } + + void _ensureHandler() { + if (_handlerInstalled) return; + _handlerInstalled = true; + _channel.setMethodCallHandler(_handleMethodCall); + } + + Future _handleMethodCall(MethodCall call) async { + final callbacks = _callbacks; + switch (call.method) { + case 'cameraCaptured': + if (call.arguments case final String path) { + final activeCallbacks = callbacks; + if (activeCallbacks != null) { + await processCapturedImage(XFile(path), activeCallbacks.onCapture); + } + } + case 'photosSelected': + final paths = (call.arguments as List? ?? const []) + .whereType() + .toList(); + if (callbacks != null && paths.isNotEmpty) { + await processTemporaryImages([ + for (final path in paths) XFile(path), + ], callbacks.onChoosePhotos); + } + case 'pickAllPhotos': + callbacks?.onAllPhotos(); + case 'pickVideo': + callbacks?.onVideo(); + case 'pickFiles': + callbacks?.onFiles(); + case 'dismissed': + _activeOwner = null; + _callbacks = null; + _didPresent = false; + } + } + + void _clearOwner(Object owner) { + if (!identical(_activeOwner, owner)) return; + _activeOwner = null; + _callbacks = null; + _didPresent = false; + } +} + +class _IOSAttachmentPopoverController { + Future present({ + required BuildContext sourceContext, + required Future Function(XFile image) onCapture, + required Future Function(List photos) onChoosePhotos, + required VoidCallback onAllPhotos, + required VoidCallback onVideo, + required VoidCallback onFiles, + }) => _iosAttachmentPopoverCoordinator.present( + owner: this, + sourceContext: sourceContext, + onCapture: onCapture, + onChoosePhotos: onChoosePhotos, + onAllPhotos: onAllPhotos, + onVideo: onVideo, + onFiles: onFiles, + ); + + Future dispose() => _iosAttachmentPopoverCoordinator.disposeOwner(this); +} diff --git a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart new file mode 100644 index 0000000000..b8cc83cbe9 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -0,0 +1,228 @@ +part of '../compose_bar.dart'; + +const _inlinePhotoPickerViewType = 'buzz/inline_photo_picker'; +const _inlinePhotoPickerSupportChannel = MethodChannel( + 'buzz/inline_photo_picker', +); + +class _IOSInlinePhotoPicker extends HookWidget { + final VoidCallback onBack; + final Future> Function() onPickAllPhotos; + final Future Function(List photos) onChoosePhotos; + final Widget fallback; + + const _IOSInlinePhotoPicker({ + required this.onBack, + required this.onPickAllPhotos, + required this.onChoosePhotos, + required this.fallback, + }); + + @override + Widget build(BuildContext context) { + final supportFuture = useMemoized( + () async => + await _inlinePhotoPickerSupportChannel.invokeMethod( + 'isSupported', + ) ?? + false, + ); + final support = useFuture(supportFuture); + final pickerChannel = useState(null); + final selectedCount = useState(0); + final selectedPaths = useState>(const []); + final isPreparingSelection = useState(false); + final isProcessing = useState(false); + + useEffect(() { + final channel = pickerChannel.value; + if (channel == null) return null; + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'selectionCountChanged': + selectedCount.value = call.arguments as int? ?? 0; + selectedPaths.value = const []; + isPreparingSelection.value = selectedCount.value > 0; + case 'selectionDidChange': + final paths = (call.arguments as List? ?? const []) + .whereType() + .toList(); + selectedPaths.value = paths; + isPreparingSelection.value = false; + case 'didFail': + isPreparingSelection.value = false; + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text( + call.arguments as String? ?? + 'Unable to prepare the selected photos.', + ), + ), + ); + } + }); + return () => channel.setMethodCallHandler(null); + }, [pickerChannel.value]); + + final canSelect = + selectedCount.value > 0 && + selectedPaths.value.length == selectedCount.value && + !isPreparingSelection.value && + !isProcessing.value; + + Future submitSelection() async { + if (!canSelect) return; + isProcessing.value = true; + try { + final paths = List.from(selectedPaths.value); + final claimed = + await pickerChannel.value?.invokeMethod( + 'claimSelection', + paths, + ) ?? + false; + if (!claimed) return; + final photos = [for (final path in paths) XFile(path)]; + await processTemporaryImages(photos, onChoosePhotos); + } finally { + if (context.mounted) isProcessing.value = false; + } + } + + Future openAllPhotos() async { + if (isPreparingSelection.value || isProcessing.value) return; + isProcessing.value = true; + try { + final photos = await onPickAllPhotos(); + if (photos.isNotEmpty) { + await onChoosePhotos(photos); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Unable to open your photo library.')), + ); + } + } finally { + if (context.mounted) isProcessing.value = false; + } + } + + if (support.connectionState != ConnectionState.done) { + return const _NativePhotoPickerLoading(); + } + if (support.hasError || support.data != true) return fallback; + + return SizedBox( + key: const ValueKey('ios-inline-photo-picker'), + height: _attachmentExpandedHeight, + width: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + UiKitView( + viewType: _inlinePhotoPickerViewType, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + pickerChannel.value = MethodChannel( + 'buzz/inline_photo_picker/$viewId', + ); + }, + ), + PositionedDirectional( + start: Grid.twelve, + bottom: Grid.twelve, + child: SafeArea( + top: false, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.62), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.28), + ), + ), + child: IconButton( + key: const ValueKey('ios-inline-photo-picker-back'), + onPressed: isProcessing.value ? null : onBack, + tooltip: 'Back to attachment options', + icon: const Icon( + LucideIcons.chevronLeft, + color: Colors.white, + ), + ), + ), + ), + ), + PositionedDirectional( + end: Grid.twelve, + bottom: Grid.twelve, + child: SafeArea( + top: false, + child: FilledButton( + key: const ValueKey('ios-inline-photo-picker-select'), + onPressed: canSelect + ? submitSelection + : selectedCount.value == 0 && + !isPreparingSelection.value && + !isProcessing.value + ? openAllPhotos + : null, + style: FilledButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.76), + disabledBackgroundColor: Colors.black.withValues(alpha: 0.32), + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white70, + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + shape: const StadiumBorder(), + side: BorderSide(color: Colors.white.withValues(alpha: 0.28)), + ), + child: isPreparingSelection.value + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + selectedCount.value == 0 + ? 'All Photos' + : 'Add ${selectedCount.value} ' + '${selectedCount.value == 1 ? 'photo' : 'photos'}', + ), + ), + ), + ), + if (isProcessing.value) + const ColoredBox( + color: Color.fromRGBO(0, 0, 0, 0.28), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } +} + +class _NativePhotoPickerLoading extends StatelessWidget { + const _NativePhotoPickerLoading(); + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('ios-inline-photo-picker-loading'), + height: _attachmentExpandedHeight, + width: double.infinity, + child: const Center(child: CircularProgressIndicator(strokeWidth: 3)), + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart new file mode 100644 index 0000000000..2d62db836d --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -0,0 +1,245 @@ +part of '../compose_bar.dart'; + +class _ComposeBarLayout extends StatelessWidget { + final List attachments; + final int uploadingCount; + final ValueChanged onRemoveAttachment; + final String? uploadError; + final bool isExpanded; + final TextEditingController controller; + final FocusNode focusNode; + final EditableTextContextMenuBuilder contextMenuBuilder; + final ValueChanged onContentInserted; + final VoidCallback onSend; + final String resolvedHint; + final _AttachmentSurface attachmentSurface; + final ValueChanged onAttachmentTap; + final VoidCallback onExpand; + final double expansionValue; + final double expansionProgress; + final bool formattingOpen; + final VoidCallback onCloseFormatting; + final Duration motionDuration; + final void Function(String prefix, [String? suffix]) onFormat; + final VoidCallback onMention; + final VoidCallback onChannel; + final VoidCallback onEmoji; + final VoidCallback onOpenFormatting; + final bool hasPendingUploads; + final bool isSending; + + const _ComposeBarLayout({ + required this.attachments, + required this.uploadingCount, + required this.onRemoveAttachment, + required this.uploadError, + required this.isExpanded, + required this.controller, + required this.focusNode, + required this.contextMenuBuilder, + required this.onContentInserted, + required this.onSend, + required this.resolvedHint, + required this.attachmentSurface, + required this.onAttachmentTap, + required this.onExpand, + required this.expansionValue, + required this.expansionProgress, + required this.formattingOpen, + required this.onCloseFormatting, + required this.motionDuration, + required this.onFormat, + required this.onMention, + required this.onChannel, + required this.onEmoji, + required this.onOpenFormatting, + required this.hasPendingUploads, + required this.isSending, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + padding: const EdgeInsets.all(Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (attachments.isNotEmpty || hasPendingUploads) ...[ + _AttachmentStrip( + attachments: attachments, + uploadingCount: uploadingCount, + onRemove: onRemoveAttachment, + ), + const SizedBox(height: Grid.xxs), + ], + if (uploadError case final error?) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + const SizedBox(height: Grid.xxs), + ], + // Keep the default state out of the focus system entirely so + // restored native focus cannot expand a newly opened channel. + if (isExpanded) + TextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.send, + contextMenuBuilder: contextMenuBuilder, + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: _pastedImageMimeTypes, + onContentInserted: onContentInserted, + ), + onSubmitted: (_) => onSend(), + minLines: 1, + maxLines: 5, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: resolvedHint, + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.half, + ), + isDense: true, + ), + ) + else + Row( + children: [ + _AttachmentTrigger( + surface: attachmentSurface, + formattingOpen: false, + onTap: onAttachmentTap, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Semantics( + button: true, + label: resolvedHint, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onExpand, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + resolvedHint, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: expansionValue, + child: IgnorePointer( + ignoring: expansionValue < 0.98, + child: Opacity( + opacity: expansionProgress, + child: Transform.translate( + offset: Offset(0, Grid.xxs * (1 - expansionProgress)), + child: Column( + children: [ + const SizedBox(height: Grid.xxs), + Row( + children: [ + _AttachmentTrigger( + surface: attachmentSurface, + formattingOpen: formattingOpen, + onTap: (triggerContext) { + if (formattingOpen) { + onCloseFormatting(); + } else { + onAttachmentTap(triggerContext); + } + }, + ), + const SizedBox(width: Grid.half), + Expanded( + child: AnimatedSwitcher( + duration: motionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: + (currentChild, previousChildren) => Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + ?currentChild, + ], + ), + child: formattingOpen + ? _FormattingToolbar(onFormat: onFormat) + : Row( + key: const ValueKey('standard-actions'), + children: [ + _ComposeAction( + icon: LucideIcons.atSign, + onTap: onMention, + ), + _ComposeAction( + icon: LucideIcons.hash, + onTap: onChannel, + ), + _ComposeAction( + icon: LucideIcons.smilePlus, + onTap: onEmoji, + ), + _ComposeAction( + icon: LucideIcons.aLargeSmall, + onTap: onOpenFormatting, + ), + const Spacer(), + _SendButton( + isDisabled: 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 new file mode 100644 index 0000000000..2b35cc53c6 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -0,0 +1,381 @@ +part of '../compose_bar.dart'; + +class _PhotoGalleryPicker extends StatelessWidget { + final VoidCallback onBack; + final Future> Function() onPickAllPhotos; + final Future Function(List photos) onChoosePhotos; + + const _PhotoGalleryPicker({ + required this.onBack, + required this.onPickAllPhotos, + required this.onChoosePhotos, + }); + + @override + Widget build(BuildContext context) { + final fallback = _RecentPhotoGalleryPicker( + onBack: onBack, + onPickAllPhotos: onPickAllPhotos, + onChoosePhotos: onChoosePhotos, + ); + if (defaultTargetPlatform != TargetPlatform.iOS) return fallback; + return _IOSInlinePhotoPicker( + onBack: onBack, + onPickAllPhotos: onPickAllPhotos, + onChoosePhotos: onChoosePhotos, + fallback: fallback, + ); + } +} + +class _RecentPhotoGalleryPicker extends HookConsumerWidget { + final VoidCallback onBack; + final Future> Function() onPickAllPhotos; + final Future Function(List photos) onChoosePhotos; + + const _RecentPhotoGalleryPicker({ + required this.onBack, + required this.onPickAllPhotos, + required this.onChoosePhotos, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recentPhotos = useMemoized( + ref.read(photoLibraryProvider).loadRecentPhotos, + ); + final recentSnapshot = useFuture(recentPhotos); + final selection = useState>([]); + final isResolving = useState(false); + final actionError = useState(null); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + + void togglePhoto(RecentPhoto photo) { + if (isResolving.value) return; + final current = selection.value; + final existingIndex = current.indexWhere((item) => item.id == photo.id); + selection.value = existingIndex < 0 + ? [...current, photo] + : [ + ...current.take(existingIndex), + ...current.skip(existingIndex + 1), + ]; + } + + Future choosePhotos() async { + if (isResolving.value) return; + isResolving.value = true; + actionError.value = null; + try { + final photos = selection.value.isEmpty + ? await onPickAllPhotos() + : await ref + .read(photoLibraryProvider) + .resolveSelectedPhotos(selection.value); + if (photos.isNotEmpty && context.mounted) { + await onChoosePhotos(photos); + } + } catch (_) { + if (context.mounted) { + actionError.value = selection.value.isEmpty + ? 'Unable to open your photo library.' + : 'Unable to prepare the selected photos.'; + } + } finally { + if (context.mounted) isResolving.value = false; + } + } + + final selectedCount = selection.value.length; + final actionLabel = selectedCount == 0 + ? 'All photos' + : selectedCount == 1 + ? 'Add 1 photo' + : 'Add $selectedCount photos'; + + Widget buildGalleryBody() { + if (recentSnapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator(strokeWidth: 3)); + } + if (recentSnapshot.hasError) { + return const _PhotoGalleryMessage( + icon: LucideIcons.images, + title: 'Recent photos aren’t available', + message: 'Use All photos to choose with the system photo picker.', + ); + } + + final photos = recentSnapshot.data ?? const []; + if (photos.isEmpty) { + return const _PhotoGalleryMessage( + icon: LucideIcons.images, + title: 'No recent photos', + message: 'Use All photos to browse your photo library.', + ); + } + return GridView.builder( + key: const ValueKey('recent-photo-grid'), + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: Grid.quarter, + mainAxisSpacing: Grid.quarter, + ), + itemCount: photos.length, + itemBuilder: (context, index) { + final photo = photos[index]; + final selectionIndex = selection.value.indexWhere( + (item) => item.id == photo.id, + ); + return _RecentPhotoTile( + photo: photo, + selectionIndex: selectionIndex, + reducedMotion: reducedMotion, + onTap: () => togglePhoto(photo), + ); + }, + ); + } + + return Padding( + key: const ValueKey('photo-gallery-picker'), + padding: const EdgeInsets.all(Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 40, + child: Row( + children: [ + IconButton( + key: const ValueKey('photo-gallery-back'), + onPressed: isResolving.value ? null : onBack, + tooltip: 'Back to attachment options', + visualDensity: VisualDensity.compact, + icon: const Icon(LucideIcons.arrowLeft, size: 20), + ), + const SizedBox(width: Grid.quarter), + Expanded( + child: Text( + 'Recent photos', + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + if (selectedCount > 0) + Padding( + padding: const EdgeInsets.only(right: Grid.half), + child: Text( + '$selectedCount selected', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + const SizedBox(height: Grid.half), + Expanded( + child: Column( + children: [ + Expanded(child: buildGalleryBody()), + if (actionError.value case final error?) ...[ + const SizedBox(height: Grid.half), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 72), + child: SingleChildScrollView( + child: Text( + error, + key: const ValueKey('photo-gallery-error'), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: Grid.xxs), + SizedBox( + width: double.infinity, + child: selectedCount == 0 + ? OutlinedButton.icon( + key: const ValueKey('photo-gallery-action'), + onPressed: isResolving.value ? null : choosePhotos, + icon: isResolving.value + ? SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.primary, + ), + ) + : const Icon(LucideIcons.images, size: 18), + label: Text(actionLabel), + ) + : FilledButton.icon( + key: const ValueKey('photo-gallery-action'), + onPressed: isResolving.value ? null : choosePhotos, + icon: isResolving.value + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(LucideIcons.plus, size: 18), + label: Text(actionLabel), + ), + ), + ], + ), + ); + } +} + +class _RecentPhotoTile extends StatelessWidget { + final RecentPhoto photo; + final int selectionIndex; + final bool reducedMotion; + final VoidCallback onTap; + + const _RecentPhotoTile({ + required this.photo, + required this.selectionIndex, + required this.reducedMotion, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isSelected = selectionIndex >= 0; + return Semantics( + button: true, + selected: isSelected, + label: isSelected + ? 'Photo ${selectionIndex + 1} selected' + : 'Select photo', + child: GestureDetector( + key: ValueKey('recent-photo-${photo.id}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(Radii.sm), + child: Image.memory( + photo.thumbnailBytes, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + AnimatedContainer( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: isSelected + ? context.colors.primary + : Colors.transparent, + width: isSelected ? 3 : 0, + ), + color: isSelected + ? Colors.black.withValues(alpha: 0.08) + : Colors.transparent, + ), + ), + PositionedDirectional( + top: Grid.half, + end: Grid.half, + child: AnimatedScale( + scale: isSelected ? 1 : 0.8, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: isSelected ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 100), + child: Container( + key: ValueKey('photo-selection-index-${photo.id}'), + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colors.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Text( + '${selectionIndex + 1}', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PhotoGalleryMessage extends StatelessWidget { + final IconData icon; + final String title; + final String message; + + const _PhotoGalleryMessage({ + required this.icon, + required this.title, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 32, color: context.colors.onSurfaceVariant), + const SizedBox(height: Grid.xxs), + Text( + title, + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Grid.quarter), + Text( + message, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index f1deefcdaf..ed97284d46 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -1,44 +1,95 @@ part of '../compose_bar.dart'; -class _SuggestionPanelMotion extends StatelessWidget { +class _SuggestionPanelMotion extends HookWidget { final Duration duration; + final Alignment alignment; final Widget child; - const _SuggestionPanelMotion({required this.duration, required this.child}); + const _SuggestionPanelMotion({ + required this.duration, + required this.alignment, + required this.child, + }); @override Widget build(BuildContext context) { - return AnimatedSwitcher( - duration: duration, - reverseDuration: duration, - layoutBuilder: (currentChild, previousChildren) => Stack( - alignment: Alignment.bottomLeft, - clipBehavior: Clip.none, - children: [...previousChildren, ?currentChild], - ), - transitionBuilder: (child, animation) { - final curvedAnimation = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - return AnimatedBuilder( - animation: curvedAnimation, - child: child, - builder: (context, child) => IgnorePointer( - ignoring: animation.status == AnimationStatus.reverse, - child: Opacity( - opacity: curvedAnimation.value, - child: Transform.translate( - offset: Offset(0, Grid.xs * (1 - curvedAnimation.value)), - child: child, + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final springController = useAnimationController( + initialValue: 1, + upperBound: 1.08, + ); + final springValue = useAnimation(springController); + final previousChildKey = useRef(child.key); + + useEffect(() { + if (previousChildKey.value == child.key) return null; + previousChildKey.value = child.key; + if (reducedMotion) { + springController.value = 1; + } else { + springController + ..stop() + ..value = 0.9 + ..animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 320), + bounce: 0.18, ), + 0.9, + 1, + 0, + snapToEnd: true, ), + ); + } + return null; + }, [child.key, reducedMotion]); + + return Transform.scale( + scale: springValue, + alignment: alignment, + child: AnimatedSize( + duration: duration, + curve: Curves.easeInOutCubic, + alignment: alignment, + child: AnimatedSwitcher( + duration: duration, + reverseDuration: duration, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: alignment, + clipBehavior: Clip.none, + children: [...previousChildren, ?currentChild], ), - ); - }, - child: child, + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutBack, + reverseCurve: Curves.easeInOutCubic, + ); + + return AnimatedBuilder( + animation: curvedAnimation, + child: child, + builder: (context, child) => IgnorePointer( + ignoring: animation.status == AnimationStatus.reverse, + child: Opacity( + opacity: animation.value.clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, Grid.xs * (1 - animation.value)), + child: Transform.scale( + scale: 0.92 + (0.08 * curvedAnimation.value), + alignment: alignment, + child: child, + ), + ), + ), + ), + ); + }, + child: child, + ), + ), ); } } diff --git a/mobile/lib/features/channels/photo_library.dart b/mobile/lib/features/channels/photo_library.dart new file mode 100644 index 0000000000..8a01de01e0 --- /dev/null +++ b/mobile/lib/features/channels/photo_library.dart @@ -0,0 +1,104 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:photo_manager/photo_manager.dart'; + +const _recentPhotoCount = 30; +const _photoPermissionRequest = PermissionRequestOption( + androidPermission: AndroidPermission( + type: RequestType.image, + mediaLocation: false, + ), +); + +/// A recent photo exposed to Buzz's compact in-composer gallery. +@immutable +class RecentPhoto { + /// The platform photo-library identifier. + final String id; + + /// Thumbnail bytes sized for the compact picker grid. + final Uint8List thumbnailBytes; + + /// Creates a recent photo value. + const RecentPhoto({required this.id, required this.thumbnailBytes}); +} + +/// Reads recent photos and resolves selected library assets to uploadable files. +abstract interface class PhotoLibrary { + /// Requests access when needed and returns the newest visible photos. + Future> loadRecentPhotos(); + + /// Resolves selected photos in the supplied selection order. + Future> resolveSelectedPhotos(List photos); +} + +/// Indicates that the compact gallery cannot read the device photo library. +class PhotoLibraryAccessException implements Exception { + /// Creates a photo-library access error. + const PhotoLibraryAccessException(); + + @override + String toString() => 'Photo access is turned off for Buzz.'; +} + +/// Provides the device photo library. Tests can override this with fixtures. +final photoLibraryProvider = Provider( + (ref) => const DevicePhotoLibrary(), +); + +/// The device-backed implementation of [PhotoLibrary]. +class DevicePhotoLibrary implements PhotoLibrary { + /// Creates the device photo library. + const DevicePhotoLibrary(); + + @override + Future> loadRecentPhotos() async { + final permission = await PhotoManager.requestPermissionExtend( + requestOption: _photoPermissionRequest, + ); + if (!permission.hasAccess) { + throw const PhotoLibraryAccessException(); + } + + final assets = await PhotoManager.getAssetListPaged( + page: 0, + pageCount: _recentPhotoCount, + type: RequestType.image, + filterOption: FilterOptionGroup( + imageOption: const FilterOption(needTitle: true), + orders: const [ + OrderOption(type: OrderOptionType.createDate, asc: false), + ], + ), + ); + + final loaded = await Future.wait([ + for (final asset in assets) _loadRecentPhoto(asset), + ]); + return [for (final photo in loaded) ?photo]; + } + + Future _loadRecentPhoto(AssetEntity asset) async { + final bytes = await asset.thumbnailDataWithSize( + const ThumbnailSize.square(256), + quality: 84, + ); + if (bytes == null || bytes.isEmpty) return null; + return RecentPhoto(id: asset.id, thumbnailBytes: bytes); + } + + @override + Future> resolveSelectedPhotos(List photos) async { + final resolved = []; + for (final photo in photos) { + final asset = await AssetEntity.fromId(photo.id); + final file = await asset?.file; + if (file == null) { + throw const PhotoLibraryAccessException(); + } + resolved.add(XFile(file.path, name: asset?.title)); + } + return resolved; + } +} diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index bd9a1cce43..58c93979d7 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -64,6 +64,9 @@ const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); + +/// Selects multiple gallery images for upload in picker order. +typedef PickGalleryImages = Future> Function(); typedef PickGalleryVideo = Future Function(); typedef PickAttachmentFile = Future Function(); typedef SanitizeImageBytes = @@ -172,6 +175,7 @@ class MediaUploadService { final String _baseUrl; final String? _nsec; final PickGalleryImage _pickGalleryImage; + final PickGalleryImages _pickGalleryImages; final PickGalleryVideo _pickGalleryVideo; final PickAttachmentFile? _pickAttachmentFile; final SanitizeImageBytes _sanitizeImageBytes; @@ -186,6 +190,7 @@ class MediaUploadService { required String baseUrl, required String? nsec, required PickGalleryImage pickGalleryImage, + PickGalleryImages? pickGalleryImages, required PickGalleryVideo pickGalleryVideo, PickAttachmentFile? pickAttachmentFile, SanitizeImageBytes? sanitizeImageBytes, @@ -197,6 +202,12 @@ class MediaUploadService { }) : _baseUrl = baseUrl, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickGalleryImages = + pickGalleryImages ?? + (() async { + final image = await pickGalleryImage(); + return image == null ? const [] : [image]; + }), _pickGalleryVideo = pickGalleryVideo, _pickAttachmentFile = pickAttachmentFile, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, @@ -220,6 +231,9 @@ class MediaUploadService { return uploadImage(pickedImage); } + /// Opens the system picker with multi-selection enabled. + Future> pickGalleryImages() => _pickGalleryImages(); + Future uploadImage(XFile image) async { final preparedImage = await _prepareUploadImage(image); return _uploadPreparedBytes( @@ -243,9 +257,11 @@ class MediaUploadService { return uploadImage(XFile.fromData(bytes)); } - Future pickAndUploadVideo() async { - final pickedVideo = await _pickGalleryVideo(); - if (pickedVideo == null) return null; + /// Opens the system gallery video picker. + Future pickGalleryVideo() => _pickGalleryVideo(); + + /// Sanitizes and uploads [pickedVideo] as an MP4 attachment. + Future uploadVideo(XFile pickedVideo) async { final length = await pickedVideo.length(); if (length > _maxVideoSizeBytes) { throw Exception( @@ -278,14 +294,23 @@ class MediaUploadService { } } - Future pickAndUploadFile() async { + Future pickAndUploadVideo() async { + final pickedVideo = await pickGalleryVideo(); + if (pickedVideo == null) return null; + return uploadVideo(pickedVideo); + } + + /// Opens the system document picker for a generic file attachment. + Future pickAttachmentFile() async { final pickAttachmentFile = _pickAttachmentFile; if (pickAttachmentFile == null) { throw Exception("File attachments aren't available on this device."); } - final pickedFile = await pickAttachmentFile(); - if (pickedFile == null) return null; + return pickAttachmentFile(); + } + /// Uploads [pickedFile] as a size-limited generic attachment. + Future uploadFile(XFile pickedFile) async { final length = await pickedFile.length(); if (length == 0) { throw Exception('File is empty.'); @@ -304,6 +329,12 @@ class MediaUploadService { return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); } + Future pickAndUploadFile() async { + final pickedFile = await pickAttachmentFile(); + if (pickedFile == null) return null; + return uploadFile(pickedFile); + } + Future uploadBytes( Uint8List bytes, { required String mimeType, @@ -781,6 +812,7 @@ final mediaUploadServiceProvider = Provider((ref) { source: ImageSource.gallery, requestFullMetadata: false, ), + pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false), pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), pickAttachmentFile: file_selector.openFile, ); diff --git a/mobile/test/features/channels/camera_capture_cleanup_test.dart b/mobile/test/features/channels/camera_capture_cleanup_test.dart index 2b5360035d..17d96b0d5b 100644 --- a/mobile/test/features/channels/camera_capture_cleanup_test.dart +++ b/mobile/test/features/channels/camera_capture_cleanup_test.dart @@ -32,4 +32,23 @@ void main() { expect(await file.exists(), isFalse); }); + + test('deletes every native picker file after processing', () async { + final suffix = DateTime.now().microsecondsSinceEpoch; + final files = [ + File('${Directory.systemTemp.path}/buzz-photo-$suffix-1.jpg'), + File('${Directory.systemTemp.path}/buzz-photo-$suffix-2.jpg'), + ]; + for (final file in files) { + await file.writeAsBytes([1, 2, 3]); + } + + await processTemporaryImages([ + for (final file in files) XFile(file.path), + ], (_) async {}); + + for (final file in files) { + expect(await file.exists(), isFalse); + } + }); } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index c77cd26a12..b851bf0a6e 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -18,6 +19,7 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -127,6 +129,9 @@ List _testPngChunk(String type, List payload) { } const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload'); +const _nativeAttachmentPopoverChannel = MethodChannel( + 'buzz/native_attachment_popover', +); void _setMockMediaUploadPlatformHandler( Future Function(MethodCall call)? handler, @@ -135,6 +140,27 @@ void _setMockMediaUploadPlatformHandler( .setMockMethodCallHandler(_mediaUploadPlatformChannel, handler); } +void _setMockNativeAttachmentPopoverHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeAttachmentPopoverChannel, handler); +} + +Future _sendNativeAttachmentPopoverCall( + WidgetTester tester, + String method, [ + Object? arguments, +]) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _nativeAttachmentPopoverChannel.name, + _nativeAttachmentPopoverChannel.codec.encodeMethodCall( + MethodCall(method, arguments), + ), + null, + ); +} + /// Shared mock prefs for the compose bar's draft store. Initialized in /// [main]. late SharedPreferences _testPrefs; @@ -148,13 +174,16 @@ Widget _buildComposeBar({ List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, + TextScaler? textScaler, List customEmoji = const [], RelayConfigNotifier Function()? relayConfig, + PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(), }) { return ProviderScope( overrides: [ customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), + photoLibraryProvider.overrideWithValue(photoLibrary), currentPubkeyProvider.overrideWith((ref) => currentPubkey), channelMembersProvider( 'channel-1', @@ -172,12 +201,14 @@ Widget _buildComposeBar({ ], child: MaterialApp( theme: AppTheme.light(), - builder: supportsShowingSystemContextMenu == null + builder: supportsShowingSystemContextMenu == null && textScaler == null ? null : (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( supportsShowingSystemContextMenu: - supportsShowingSystemContextMenu, + supportsShowingSystemContextMenu ?? + MediaQuery.of(context).supportsShowingSystemContextMenu, + textScaler: textScaler ?? MediaQuery.textScalerOf(context), ), child: child!, ), @@ -193,6 +224,54 @@ Widget _buildComposeBar({ ); } +Widget _buildNativePopoverOwnershipHarness({ + required MediaUploadService uploadService, + required bool includeFirstComposer, +}) { + return ProviderScope( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + mediaUploadServiceProvider.overrideWithValue(uploadService), + photoLibraryProvider.overrideWithValue(const _EmptyPhotoLibrary()), + currentPubkeyProvider.overrideWith((ref) => null), + channelMembersProvider( + 'channel-1', + ).overrideWith((ref) => Future.value(const [])), + agentDirectoryProvider.overrideWith( + (ref) async => const [], + ), + agentOwnersProvider.overrideWith((ref) async => const {}), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + relayConfigProvider.overrideWith(_FakeRelayConfigNotifier.new), + savedPrefsProvider.overrideWithValue(_testPrefs), + channelsProvider.overrideWith(() => _FakeChannelsNotifier(const [])), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (includeFirstComposer) + ComposeBar( + key: const ValueKey('first-composer'), + channelId: 'channel-1', + onSend: (_, _, {mediaTags = const []}) async {}, + ), + ComposeBar( + key: const ValueKey('second-composer'), + channelId: 'channel-1', + onSend: (_, _, {mediaTags = const []}) async {}, + ), + ], + ), + ), + ), + ); +} + class _FakeRelayConfigNotifier extends RelayConfigNotifier { @override RelayConfig build() => RelayConfig( @@ -201,6 +280,32 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier { ); } +class _EmptyPhotoLibrary implements PhotoLibrary { + const _EmptyPhotoLibrary(); + + @override + Future> loadRecentPhotos() async => const []; + + @override + Future> resolveSelectedPhotos(List photos) async => + const []; +} + +class _FakePhotoLibrary implements PhotoLibrary { + final List photos; + + const _FakePhotoLibrary(this.photos); + + @override + Future> loadRecentPhotos() async => photos; + + @override + Future> resolveSelectedPhotos(List photos) async => [ + for (final photo in photos) + XFile.fromData(_pngBytes, name: '${photo.id}.png'), + ]; +} + /// Relay config that starts from a fixed identity and can be switched /// in place via [RelayConfigNotifier.update] — simulates a community or /// account switch while widgets stay mounted. @@ -390,6 +495,121 @@ void main() { expect(textField.controller!.selection.baseOffset, 12); }); + testWidgets('native All Photos picker failures show an error', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + _setMockNativeAttachmentPopoverHandler((call) async { + return switch (call.method) { + 'isSupported' || 'present' => true, + 'dismiss' => null, + _ => null, + }; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => + throw PlatformException(code: 'photo_picker_failed'), + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + await _sendNativeAttachmentPopoverCall(tester, 'pickAllPhotos'); + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpAndSettle(); + + expect(find.text('Unable to open your photo library.'), findsOneWidget); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('disposing a non-owner keeps native popover callbacks active', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var presentCalls = 0; + var dismissCalls = 0; + var pickAllPhotosCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + return true; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + dismissCalls += 1; + return null; + } + return null; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async { + pickAllPhotosCalls += 1; + return const []; + }, + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: true, + ), + ); + + await tester.tap(find.byTooltip('Add attachment').hitTestable().at(1)); + await tester.pumpAndSettle(); + expect(presentCalls, 1); + + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: false, + ), + ); + await tester.pumpAndSettle(); + expect(dismissCalls, 0); + + await _sendNativeAttachmentPopoverCall(tester, 'pickAllPhotos'); + await tester.pumpAndSettle(); + expect(pickAllPhotosCalls, 1); + + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('uploads an image and sends markdown plus imeta tags', ( tester, ) async { @@ -434,8 +654,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); @@ -455,15 +674,115 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); - testWidgets('keeps upload progress visible after the picker closes', ( + testWidgets('uploads multiple system-selected photos in picker order', ( tester, ) async { - final pickedImage = Completer(); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + final mimeType = request.headers.entries + .firstWhere((entry) => entry.key.toLowerCase() == 'content-type') + .value; + final isGif = mimeType == 'image/gif'; + return http.Response( + jsonEncode({ + 'url': isGif + ? 'https://relay.example/media/two.gif' + : 'https://relay.example/media/one.png', + 'sha256': isGif + ? '2222222222222222222222222222222222222222222222222222222222222222' + : '1111111111111111111111111111111111111111111111111111111111111111', + 'size': request.bodyBytes.length, + 'type': mimeType, + 'uploaded': 1, + }), + 200, + ); + }), + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + XFile.fromData(_pngBytes, name: 'one.png'), + XFile.fromData(_gifBytes, name: 'two.gif'), + ], + pickGalleryVideo: () async => null, + ); + + String? sentContent; + List> sentMediaTags = const []; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMediaTags = mediaTags; + }, + ), + ); + + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Remove attachment'), findsNWidgets(2)); + + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + + expect( + sentContent, + '\n![image](https://relay.example/media/one.png)' + '\n![image](https://relay.example/media/two.gif)', + ); + expect(sentMediaTags, hasLength(2)); + expect(sentMediaTags.map((tag) => tag[1]), [ + 'url https://relay.example/media/one.png', + 'url https://relay.example/media/two.gif', + ]); + }); + + testWidgets('bounds concurrent system-selected photo uploads', ( + tester, + ) async { + final releaseFirstBatch = Completer(); + var requestsStarted = 0; + var activeRequests = 0; + var peakActiveRequests = 0; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + requestsStarted += 1; + final requestNumber = requestsStarted; + activeRequests += 1; + peakActiveRequests = math.max(peakActiveRequests, activeRequests); + if (requestNumber <= 3) { + await releaseFirstBatch.future; + } + activeRequests -= 1; + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/photo-$requestNumber.png', + 'sha256': + '1111111111111111111111111111111111111111111111111111111111111111', + 'size': request.bodyBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ); + }), + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + for (var index = 0; index < 5; index += 1) + XFile.fromData(_pngBytes, name: 'photo-$index.png'), + ], pickGalleryVideo: () async => null, - pickGalleryImage: () => pickedImage.future, ); await tester.pumpWidget( @@ -478,17 +797,191 @@ void main() { ), ); + await _openSystemPhotoPicker(tester); + for (var frame = 0; frame < 20 && requestsStarted < 3; frame += 1) { + await tester.pump(const Duration(milliseconds: 20)); + } + + expect(requestsStarted, 3); + expect(peakActiveRequests, 3); + + releaseFirstBatch.complete(); + await tester.pumpAndSettle(); + + expect(requestsStarted, 5); + expect(peakActiveRequests, 3); + expect(find.byTooltip('Remove attachment'), findsNWidgets(5)); + }); + + testWidgets('numbers recent photo selection and returns to the menu', ( + tester, + ) async { + final photoLibrary = _FakePhotoLibrary([ + RecentPhoto(id: 'one', thumbnailBytes: _gifBytes), + RecentPhoto(id: 'two', thumbnailBytes: _gifBytes), + RecentPhoto(id: 'three', thumbnailBytes: _gifBytes), + ]); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + photoLibrary: photoLibrary, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('photo-gallery-picker')), + findsOneWidget, + ); + expect(find.byTooltip('Back to attachment options'), findsWidgets); + expect(find.text('All photos'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('recent-photo-two'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('recent-photo-one'))); + await tester.pumpAndSettle(); + + expect(find.text('Add 2 photos'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('photo-selection-index-two')), + matching: find.text('1'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('photo-selection-index-one')), + matching: find.text('2'), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('recent-photo-two'))); + await tester.pumpAndSettle(); + + expect(find.text('Add 1 photo'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('photo-selection-index-one')), + matching: find.text('1'), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('photo-gallery-back'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('photo-gallery-picker')), findsNothing); + expect( + find.byKey(const ValueKey('attachment-trigger-menu')).hitTestable(), + findsOneWidget, + ); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Photos'), findsOneWidget); + }); + + testWidgets('photo picker errors keep the action visible at large text', ( + tester, + ) async { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => + throw PlatformException(code: 'photo_picker_failed'), + pickGalleryVideo: () async => null, + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + textScaler: const TextScaler.linear(1.2), + photoLibrary: _FakePhotoLibrary([ + RecentPhoto(id: 'one', thumbnailBytes: _gifBytes), + RecentPhoto(id: 'two', thumbnailBytes: _gifBytes), + RecentPhoto(id: 'three', thumbnailBytes: _gifBytes), + RecentPhoto(id: 'four', thumbnailBytes: _gifBytes), + ]), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('photo-gallery-error')), findsOneWidget); + expect( + find.byKey(const ValueKey('photo-gallery-action')).hitTestable(), + findsOneWidget, + ); + }); + + testWidgets('keeps upload progress visible after the picker closes', ( + tester, + ) async { + final uploadResponse = Completer(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) => uploadResponse.future), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + XFile.fromData(_pngBytes, name: 'tiny.png'), + ], + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openSystemPhotoPicker(tester); await tester.pump(); expect( find.byKey(const ValueKey('compose-upload-progress')), findsOneWidget, ); - expect(find.text('Uploading attachment…'), findsOneWidget); - - pickedImage.complete(null); + expect(find.bySemanticsLabel('Uploading attachment…'), findsOneWidget); + + uploadResponse.complete( + http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ); await tester.pumpAndSettle(); expect( @@ -1053,8 +1546,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( @@ -1109,8 +1601,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); @@ -1150,8 +1641,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); expect( @@ -1199,8 +1689,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); expect( @@ -1426,8 +1915,7 @@ void main() { ), ); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Photos')); + await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); expect( @@ -1747,6 +2235,13 @@ Future _openAttachmentMenu(WidgetTester tester) async { await tester.pumpAndSettle(); } +Future _openSystemPhotoPicker(WidgetTester tester) async { + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); + await tester.pumpAndSettle(); + await tester.tap(find.text('All photos')); +} + Future _selectAndSendAgentMention(WidgetTester tester) async { await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); From af4d8615165b9bdbe1190d4ba71ff32b1df75a8a Mon Sep 17 00:00:00 2001 From: Dave Grochowski Date: Tue, 28 Jul 2026 13:07:04 -0400 Subject: [PATCH 04/59] feat(chart): add relay pod extension points (#3322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Allow operators to install wrapper binaries and override the relay entrypoint without maintaining a duplicated Deployment outside the OSS chart. `extraManifests` can create independent resources but cannot extend the chart-managed relay Pod. ## What - Add opt-in init-container, volume, volume-mount, command, and args extension points - Preserve image defaults when extensions are empty and compose generic init containers with the MinIO readiness gate - Document the distinction from `extraManifests`, add schema coverage, and release chart 0.1.7 ## Risk Assessment Low — all new values are opt-in, and default rendered manifests are unchanged apart from version-derived metadata. Merge publishes a new chart version without modifying existing installations. ## References - [OpenTelemetry Collector Pod extensions](https://github.com/open-telemetry/opentelemetry-helm-charts/blob/main/charts/opentelemetry-collector/templates/_pod.tpl) alongside [extraManifests](https://github.com/open-telemetry/opentelemetry-helm-charts/blob/main/charts/opentelemetry-collector/templates/extraManifests.yaml) - [Argo CD extraObjects](https://github.com/argoproj/argo-helm/blob/main/charts/argo-cd/templates/extra-manifests.yaml) alongside component-scoped Pod extension hooks - `helm unittest` 0.8.2: 43/43 tests passed - Helm lint, schema validation, fixture renders, and chart packaging passed - Oracle review found no functional issues; its literal no-`tpl` regression test recommendation is included Generated with Amp --------- Signed-off-by: David Grochowski Co-authored-by: Amp --- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/README.md | 51 ++++++++ deploy/charts/buzz/templates/deployment.yaml | 21 +++- deploy/charts/buzz/tests/render_test.yaml | 115 +++++++++++++++++++ deploy/charts/buzz/values.schema.json | 27 ++++- deploy/charts/buzz/values.yaml | 15 +++ 6 files changed, 229 insertions(+), 4 deletions(-) diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 956e085749..9309074895 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.6 +version: 0.1.7 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Optional READ_DATABASE_URL env (secretKeyRef) enabling relay read-replica routing; absent key preserves prior behavior. + description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 4cf4b22b24..7e75d81a2e 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -52,6 +52,57 @@ See: The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). +## Relay Pod extensions + +The chart exposes narrow extension points for init containers, volumes, relay +volume mounts, and image command/argument overrides. `extraManifests` creates +independent Kubernetes resources but cannot modify the chart-managed relay +Deployment. These extension values insert fields into that Deployment, avoiding +duplication of its environment, probes, security context, secrets, and +chart-owned volumes. + +For example, an init container can copy a wrapper binary into a shared volume +and make that wrapper the relay entrypoint: + +```yaml +extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + args: [/opt/wrapper/wrapper] + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + resources: + requests: + cpu: 10m + memory: 16Mi + volumeMounts: + - name: wrapper + mountPath: /opt/wrapper + +extraVolumes: + - name: wrapper + emptyDir: {} + +relay: + command: [/opt/wrapper/wrapper] + args: [/usr/local/bin/buzz-relay] + extraVolumeMounts: + - name: wrapper + mountPath: /opt/wrapper +``` + +These values are raw Kubernetes fragments rendered with `toYaml`, not `tpl`. +The chart does not validate cross-field relationships: extension names must not +collide with chart-owned containers or volumes, mounts must reference existing +volumes, and each init container must define an appropriate security context +and resources. Empty `relay.command` and `relay.args` arrays preserve the image +defaults; non-empty values override its entrypoint and arguments respectively. + ## Device pairing relay The chart can run Buzz's stateless pairing WebSocket relay as an independent diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index f8d67de31d..bf2df4c2c8 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -55,13 +55,14 @@ spec: topologySpreadConstraints: {{- toYaml . | nindent 8 }} {{- end }} + {{- if or .Values.minio.enabled .Values.extraInitContainers }} + initContainers: {{- if .Values.minio.enabled }} # Quickstart only: the bundled MinIO bucket is created by a concurrent # init Job (templates/quickstart-minio-init.yaml). The relay's A3 S3 # conformance probe is startup-fatal, so without this gate the relay Pods # CrashLoopBackOff (with growing backoff) until the bucket appears. Block # relay start until the bucket exists — deterministic, no crash-loops. - initContainers: - name: wait-for-bucket image: {{ .Values.minio.mcImage | quote }} securityContext: @@ -90,12 +91,24 @@ spec: done echo "bucket {{ .Values.s3.bucket }} present" {{- end }} + {{- with .Values.extraInitContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} containers: - name: relay image: {{ include "buzz.image" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} securityContext: {{- toYaml .Values.relay.containerSecurityContext | nindent 12 }} + {{- with .Values.relay.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.relay.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: app, containerPort: 3000, protocol: TCP } - { name: health, containerPort: {{ .Values.service.healthPort }}, protocol: TCP } @@ -225,6 +238,9 @@ spec: volumeMounts: - { name: git-repos, mountPath: {{ .Values.persistence.git.mountPath | quote }} } - { name: git-pack-cache, mountPath: {{ .Values.git.packCachePath | quote }} } + {{- with .Values.relay.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} volumes: - name: git-repos @@ -238,3 +254,6 @@ spec: - name: git-pack-cache emptyDir: sizeLimit: {{ .Values.git.packCacheVolumeSize | quote }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index c50a960d26..3e044f5d7c 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -165,3 +165,118 @@ tests: - hasDocuments: count: 0 template: templates/pvc-git.yaml + + - it: preserves image defaults when Pod extensions are empty + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + asserts: + - notExists: + path: spec.template.spec.initContainers + template: templates/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].command + template: templates/deployment.yaml + - notExists: + path: spec.template.spec.containers[0].args + template: templates/deployment.yaml + + - it: appends generic Pod extensions and overrides the relay command + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + relay.command: + - /opt/wrapper/wrapper + relay.args: + - /usr/local/bin/buzz-relay + relay.extraVolumeMounts: + - name: wrapper + mountPath: /opt/wrapper + extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + args: + - /opt/wrapper/wrapper + env: + - name: LITERAL_TEMPLATE + value: '{{ .Release.Name }}' + securityContext: + runAsNonRoot: true + resources: + requests: + cpu: 10m + memory: 16Mi + volumeMounts: + - name: wrapper + mountPath: /opt/wrapper + extraVolumes: + - name: wrapper + emptyDir: {} + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: install-wrapper + template: templates/deployment.yaml + - equal: + path: spec.template.spec.initContainers[0].securityContext.runAsNonRoot + value: true + template: templates/deployment.yaml + # Extension fragments are deliberately rendered with toYaml, not tpl. + - equal: + path: spec.template.spec.initContainers[0].env[0].value + value: '{{ .Release.Name }}' + template: templates/deployment.yaml + - equal: + path: spec.template.spec.containers[0].command + value: + - /opt/wrapper/wrapper + template: templates/deployment.yaml + - equal: + path: spec.template.spec.containers[0].args + value: + - /usr/local/bin/buzz-relay + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: wrapper + mountPath: /opt/wrapper + template: templates/deployment.yaml + - contains: + path: spec.template.spec.volumes + content: + name: wrapper + emptyDir: {} + template: templates/deployment.yaml + + - it: appends generic init containers after the bundled MinIO readiness gate + release: + name: rel + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + postgresql.enabled: true + redis.enabled: true + minio.enabled: true + extraInitContainers: + - name: install-wrapper + image: example.com/wrapper-init:v1 + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: wait-for-bucket + template: templates/deployment.yaml + - equal: + path: spec.template.spec.initContainers[1].name + value: install-wrapper + template: templates/deployment.yaml diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 203fd9b69b..53bb29bb60 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -72,9 +72,34 @@ "type": "array", "items": { "type": "string" } }, - "ephemeralTtlOverride": { "type": "integer", "minimum": 0 } + "ephemeralTtlOverride": { "type": "integer", "minimum": 0 }, + "command": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional relay container entrypoint override. Empty preserves the image default." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional relay container arguments override. Empty preserves the image default." + }, + "extraVolumeMounts": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes volumeMount fragments appended to the relay container." + } } }, + "extraInitContainers": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes init-container fragments appended to the relay Pod." + }, + "extraVolumes": { + "type": "array", + "items": { "type": "object" }, + "description": "Raw Kubernetes volume fragments appended to the relay Pod." + }, "service": { "type": "object", "additionalProperties": true, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 21548f3651..8ac5086e27 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -185,9 +185,24 @@ relay: readOnlyRootFilesystem: false # git writes need a writable repo path terminationGracePeriodSeconds: 60 + # Optional image entrypoint/arguments overrides. Empty arrays preserve the + # relay image's defaults. Consumers own compatibility with the selected image. + command: [] + args: [] + # Appended to the chart-owned relay mounts. Names must match extraVolumes (or + # another volume supplied by the platform) and must not collide with built-ins. + extraVolumeMounts: [] + extraEnv: [] extraEnvFrom: [] +# ── Pod extensions ────────────────────────────────────────────────────────── +# Raw Kubernetes fragments appended to the relay Pod. They are rendered with +# toYaml, not tpl. Init containers must define their own securityContext and +# resources; names must not collide with chart-owned containers or volumes. +extraInitContainers: [] +extraVolumes: [] + # ── Device pairing relay ───────────────────────────────────────────────────── # Optional, stateless NIP-AB relay. When enabled, the main relay advertises # pairingRelay.url in NIP-11 and Buzz clients use it instead of the legacy From 5457c947a74f5ba4b979f9c6411aa7626a858387 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 10:31:27 -0700 Subject: [PATCH 05/59] fix(composer): scope multiline block formatting (#3246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Composer block formatting now applies to the intended line or selection without collapsing multiline content. **Problem:** Block formatting from a Shift+Enter line could convert the entire draft, selected visual lines could collapse into one list item, and code conversion could lose line breaks. **Solution:** Scope caret formatting to its hard-break-delimited line and normalize explicit selections for the destination block type while preserving neighboring content and visual line boundaries.
File changes **desktop/src/features/messages/lib/selectionBlockFormatting.ts** Scopes collapsed-caret block actions to the active visual line and normalizes multiline selections for lists and code blocks. **desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs** Adds unit coverage for caret-line isolation across line positions and selection directions. **desktop/src/features/messages/ui/FormattingToolbar.tsx** Routes list, quote, and code-block actions through the selection-aware formatting transaction. **desktop/tests/e2e/composer-selection-formatting.spec.ts** Covers caret-only formatting, multiline list conversion, list-to-code conversion, preserved hard breaks, Markdown output, and backward selections.
## Reproduction steps 1. In the desktop composer, enter several lines using Shift+Enter and place the caret on one line. 2. Apply a bullet list, ordered list, quote, or code block; only the caret line should change. 3. Select several Shift+Enter lines and apply a list; each visual line should become its own item. 4. Select several list items and apply Code block; they should become one multiline code block while unselected neighbors remain intact. 5. Select several Shift+Enter lines and apply Code block; each line break should remain visible. ## Screenshots/Demos Screen Recording 2026-07-27 at 5 29
19 PM Expected multiline code-block result: https://buzz.block.builderlab.xyz/media/d2e2668093af3b67d896a32e9799daccd236da9fc9e24ec56ddb4ebf7d01dd96.png --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../lib/selectionBlockFormatting.test.mjs | 230 +++++++++++++ .../messages/lib/selectionBlockFormatting.ts | 267 ++++++++++++++- .../messages/ui/FormattingToolbar.tsx | 53 ++- .../e2e/composer-selection-formatting.spec.ts | 307 +++++++++++++++++- 4 files changed, 842 insertions(+), 15 deletions(-) create mode 100644 desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs diff --git a/desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs b/desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs new file mode 100644 index 0000000000..75d04cdde3 --- /dev/null +++ b/desktop/src/features/messages/lib/selectionBlockFormatting.test.mjs @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getSchema, Node } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; + +import { CustomEmojiNode } from "./customEmojiNode.ts"; + +import { + isolateSelectionForBlockFormatting, + mergeSelectedTextblocksIntoCodeBlock, + splitSelectedLinesForListFormatting, +} from "./selectionBlockFormatting.ts"; + +// Matching useRichTextEditor's StarterKit configuration (minus things +// irrelevant to block isolation). +const MentionNode = Node.create({ + name: "mention", + group: "inline", + inline: true, + atom: true, + addAttributes: () => ({ label: { default: "" } }), +}); +const UnknownLeaf = Node.create({ + name: "unknownLeaf", + group: "inline", + inline: true, + atom: true, + addAttributes: () => ({ internalId: { default: "secret" } }), +}); + +const schema = getSchema([ + StarterKit.configure({ + hardBreak: { keepMarks: true }, + heading: false, + trailingNode: false, + link: false, + }), + MentionNode, + CustomEmojiNode.configure({ + resolveUrl: () => undefined, + shortcodes: () => [], + }), + UnknownLeaf, +]); + +const para = (...content) => schema.nodes.paragraph.create(null, content); +const br = () => schema.nodes.hardBreak.create(); +const t = (text) => schema.text(text); + +function doc(...content) { + return schema.nodes.doc.create(null, content); +} + +function stateWithCaret(documentNode, caret) { + return EditorState.create({ + doc: documentNode, + selection: TextSelection.create(documentNode, caret), + }); +} + +function paragraphTexts(documentNode) { + const texts = []; + documentNode.forEach((node) => { + texts.push(node.textContent); + }); + return texts; +} + +test("caret between hard breaks isolates only its line", () => { + //

before␍target␍after

with the caret inside "target". + const state = stateWithCaret( + doc(para(t("before"), br(), t("target"), br(), t("after"))), + 10, + ); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), true); + + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["before", "target", "after"]); + assert.equal(next.selection.empty, true); + assert.equal(next.selection.$from.parent.textContent, "target"); +}); + +test("caret on an empty trailing line isolates an empty paragraph", () => { + // "before" + Shift+Enter, caret at the end — the reported bug shape. + const state = stateWithCaret(doc(para(t("before"), br())), 8); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), true); + + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["before", ""]); + assert.equal(next.selection.empty, true); + assert.equal(next.selection.$from.parent.textContent, ""); +}); + +test("caret on the first line splits only after that line", () => { + const state = stateWithCaret(doc(para(t("first"), br(), t("rest"))), 3); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), true); + + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["first", "rest"]); + assert.equal(next.selection.$from.parent.textContent, "first"); +}); + +test("caret on the last line splits only before that line", () => { + const state = stateWithCaret(doc(para(t("rest"), br(), t("last"))), 8); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), true); + + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["rest", "last"]); + assert.equal(next.selection.$from.parent.textContent, "last"); +}); + +test("caret in a single-line paragraph is a no-op", () => { + const state = stateWithCaret(doc(para(t("only line"))), 4); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), false); + assert.equal(transaction.steps.length, 0); +}); + +test("exact block-boundary selection excludes endpoint paragraphs", () => { + const documentNode = doc(para(t("alpha")), para(t("beta")), para(t("gamma"))); + for (const backward of [false, true]) { + const state = EditorState.create({ + doc: documentNode, + selection: TextSelection.create( + documentNode, + backward ? 14 : 6, + backward ? 6 : 14, + ), + }); + const transaction = state.tr; + isolateSelectionForBlockFormatting(transaction); + assert.equal(mergeSelectedTextblocksIntoCodeBlock(transaction), true); + const next = state.apply(transaction); + assert.deepEqual( + next.doc.toJSON(), + doc( + para(t("alpha")), + schema.nodes.codeBlock.create(null, t("beta")), + para(t("gamma")), + ).toJSON(), + ); + } +}); + +test("selection isolation still splits around the selected text", () => { + const documentNode = doc(para(t("before selected after"))); + const state = EditorState.create({ + doc: documentNode, + // "selected" spans positions 8..16. + selection: TextSelection.create(documentNode, 8, 16), + }); + + const transaction = state.tr; + assert.equal(isolateSelectionForBlockFormatting(transaction), true); + + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["before ", "selected", " after"]); + assert.equal( + next.doc.textBetween(next.selection.from, next.selection.to), + "selected", + ); +}); + +test("list splitting turns selected hard breaks into separate textblocks", () => { + const documentNode = doc(para(t("one"), br(), t("two"), br(), t("three"))); + const state = EditorState.create({ + doc: documentNode, + selection: TextSelection.create(documentNode, 1, 14), + }); + + const transaction = state.tr; + assert.equal(splitSelectedLinesForListFormatting(transaction), true); + const next = state.apply(transaction); + assert.deepEqual(paragraphTexts(next.doc), ["one", "two", "three"]); +}); + +test("code merge preserves hard breaks", () => { + const documentNode = doc(para(t("one"), br(), t("two")), para(t("three"))); + const state = EditorState.create({ + doc: documentNode, + selection: TextSelection.create( + documentNode, + 1, + documentNode.content.size - 1, + ), + }); + + const transaction = state.tr; + assert.equal(mergeSelectedTextblocksIntoCodeBlock(transaction), true); + const next = state.apply(transaction); + assert.equal(next.doc.firstChild.type.name, "codeBlock"); + assert.equal(next.doc.firstChild.textContent, "one\ntwo\nthree"); +}); + +test("code merge preserves meaningful inline atoms and drops unknown leaves", () => { + const documentNode = doc( + para( + t("hello "), + schema.nodes.mention.create({ label: "@Taylor Ho" }), + t(" "), + schema.nodes.customEmoji.create({ shortcode: "party" }), + schema.nodes.unknownLeaf.create(), + ), + ); + const state = EditorState.create({ + doc: documentNode, + selection: TextSelection.create( + documentNode, + 1, + documentNode.content.size - 1, + ), + }); + + const transaction = state.tr; + assert.equal(mergeSelectedTextblocksIntoCodeBlock(transaction), true); + const next = state.apply(transaction); + assert.equal(next.doc.firstChild.textContent, "hello @Taylor Ho :party:"); + assert.equal(next.doc.firstChild.textContent.includes("secret"), false); +}); diff --git a/desktop/src/features/messages/lib/selectionBlockFormatting.ts b/desktop/src/features/messages/lib/selectionBlockFormatting.ts index ef38ed7e0c..9d9792ad04 100644 --- a/desktop/src/features/messages/lib/selectionBlockFormatting.ts +++ b/desktop/src/features/messages/lib/selectionBlockFormatting.ts @@ -1,3 +1,4 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { TextSelection, type Transaction } from "@tiptap/pm/state"; import { canSplit } from "@tiptap/pm/transform"; @@ -29,13 +30,170 @@ function mapRangeThroughLatestStep( } /** - * Isolate a non-empty text selection at exact block boundaries. + * Isolate the hard-break-delimited line under a collapsed caret. + * + * The composer represents Shift+Enter lines as `hardBreak` nodes inside one + * paragraph, so a block toggle at a collapsed caret otherwise reformats every + * line of the draft. Replacing the line's bordering hard breaks with block + * splits gives the caret's line its own textblock, which scopes the following + * block toggle to just that line. + */ +function isolateCaretLineForBlockFormatting(transaction: Transaction): boolean { + const { $from } = transaction.selection; + if (!$from.parent.isTextblock || !$from.parent.inlineContent) return false; + + const blockStart = $from.start(); + const blockEnd = $from.end(); + let caret = transaction.selection.from; + + let lineFrom = blockStart; + let lineTo = blockEnd; + $from.parent.forEach((child, offset) => { + if (child.type.name !== "hardBreak") return; + const breakFrom = blockStart + offset; + const breakTo = breakFrom + child.nodeSize; + if (breakTo <= caret) lineFrom = breakTo; + if (breakFrom >= caret) lineTo = Math.min(lineTo, breakFrom); + }); + + // No hard breaks around the caret — the line already is the whole + // textblock, so the block toggle is correctly scoped as-is. + if (lineFrom === blockStart && lineTo === blockEnd) return false; + + const nodeAfterLine = transaction.doc.resolve(lineTo).nodeAfter; + if (nodeAfterLine?.type.name === "hardBreak") { + transaction.delete(lineTo, lineTo + nodeAfterLine.nodeSize); + if (canSplit(transaction.doc, lineTo)) { + transaction.split(lineTo); + const stepMap = transaction.steps.at(-1)?.getMap(); + if (stepMap) { + caret = stepMap.map(caret, -1); + lineFrom = stepMap.map(lineFrom, -1); + } + } + } + + const nodeBeforeLine = transaction.doc.resolve(lineFrom).nodeBefore; + if (nodeBeforeLine?.type.name === "hardBreak") { + transaction.delete(lineFrom - nodeBeforeLine.nodeSize, lineFrom); + let stepMap = transaction.steps.at(-1)?.getMap(); + if (stepMap) { + caret = stepMap.map(caret, 1); + lineFrom = stepMap.map(lineFrom, -1); + } + if (canSplit(transaction.doc, lineFrom)) { + transaction.split(lineFrom); + stepMap = transaction.steps.at(-1)?.getMap(); + if (stepMap) caret = stepMap.map(caret, 1); + } + } + + transaction.setSelection(TextSelection.create(transaction.doc, caret)); + return true; +} + +function listItemTextRange( + $position: Transaction["selection"]["$from"], +): { from: number; to: number } | null { + let itemDepth = -1; + for (let depth = $position.depth; depth > 0; depth -= 1) { + if ($position.node(depth).type.name === "listItem") { + itemDepth = depth; + break; + } + } + if (itemDepth < 0) return null; + + const item = $position.node(itemDepth); + const itemPosition = $position.before(itemDepth); + let from: number | null = null; + let to: number | null = null; + item.descendants((node, relativePosition) => { + if (!node.isTextblock) return true; + const position = itemPosition + 1 + relativePosition; + from ??= position + 1; + to = position + node.nodeSize - 1; + return false; + }); + return from === null || to === null ? null : { from, to }; +} + +/** Expand partial list endpoint selections to whole list-item textblocks. */ +function expandSelectionToListItems(transaction: Transaction): boolean { + const selection = transaction.selection; + if (!(selection instanceof TextSelection) || selection.empty) return false; + + const startItem = listItemTextRange(selection.$from); + const endItem = listItemTextRange(selection.$to); + if (!(startItem || endItem)) return false; + + const isBackward = selection.anchor > selection.head; + const from = startItem?.from ?? selection.from; + const to = endItem?.to ?? selection.to; + transaction.setSelection( + TextSelection.create( + transaction.doc, + isBackward ? to : from, + isBackward ? from : to, + ), + ); + return true; +} + +export function selectionIncludesList(transaction: Transaction): boolean { + const { from, to } = transaction.selection; + let includesList = false; + transaction.doc.nodesBetween(from, to, (node) => { + if (node.type.name === "listItem") { + includesList = true; + return false; + } + return !includesList; + }); + return includesList; +} + +function normalizeSelectionBlockBoundaries(transaction: Transaction): boolean { + const selection = transaction.selection; + if (!(selection instanceof TextSelection) || selection.empty) return false; + + const isBackward = selection.anchor > selection.head; + let { from, to } = selection; + if ( + selection.$from.parent.isTextblock && + selection.$from.parentOffset === selection.$from.parent.content.size && + selection.$from.depth > 0 + ) { + from = selection.$from.after(); + } + if ( + selection.$to.parent.isTextblock && + selection.$to.parentOffset === 0 && + selection.$to.depth > 0 + ) { + to = selection.$to.before(); + } + if (from >= to) return false; + + transaction.setSelection( + TextSelection.create( + transaction.doc, + isBackward ? to : from, + isBackward ? from : to, + ), + ); + return from !== selection.from || to !== selection.to; +} + +/** + * Isolate the current text selection at exact block boundaries. * * ProseMirror's block commands operate on whole textblocks. The composer can * hold an entire draft in one paragraph, so toggling a list or code block for * a substring otherwise formats the whole draft. Splitting at the selection * end and start first gives the selected text its own block while preserving - * the surrounding content as sibling paragraphs. + * the surrounding content as sibling paragraphs. A collapsed caret isolates + * its hard-break-delimited line so the block format starts at that line. * * This mutates the transaction supplied by a Tiptap command chain so the * isolation and the following block toggle remain one undoable edit. @@ -43,13 +201,16 @@ function mapRangeThroughLatestStep( export function isolateSelectionForBlockFormatting( transaction: Transaction, ): boolean { - if ( - !(transaction.selection instanceof TextSelection) || - transaction.selection.empty - ) { + if (!(transaction.selection instanceof TextSelection)) { return false; } + if (transaction.selection.empty) { + return isolateCaretLineForBlockFormatting(transaction); + } + + expandSelectionToListItems(transaction); + normalizeSelectionBlockBoundaries(transaction); const isBackward = transaction.selection.anchor > transaction.selection.head; let { from, to } = transaction.selection; @@ -84,3 +245,97 @@ export function isolateSelectionForBlockFormatting( ); return true; } + +/** Split each selected hard-break line into a textblock before list wrapping. */ +export function splitSelectedLinesForListFormatting( + transaction: Transaction, +): boolean { + if (!(transaction.selection instanceof TextSelection)) return false; + if (transaction.selection.empty) { + return isolateCaretLineForBlockFormatting(transaction); + } + + const isBackward = transaction.selection.anchor > transaction.selection.head; + isolateSelectionForBlockFormatting(transaction); + let { from, to } = transaction.selection; + const breakPositions: number[] = []; + + transaction.doc.nodesBetween(from, to, (node, position) => { + if (node.type.name === "hardBreak") breakPositions.push(position); + }); + + for (const position of breakPositions.reverse()) { + transaction.delete(position, position + 1); + ({ from, to } = mapRangeThroughLatestStep(transaction, from, to)); + if (!canSplit(transaction.doc, position)) continue; + transaction.split(position); + ({ from, to } = mapRangeThroughLatestStep(transaction, from, to)); + } + + transaction.setSelection( + TextSelection.create( + transaction.doc, + isBackward ? to : from, + isBackward ? from : to, + ), + ); + return true; +} + +function selectedTextblocks( + transaction: Transaction, +): Array<{ node: ProseMirrorNode; position: number }> { + const blocks: Array<{ node: ProseMirrorNode; position: number }> = []; + const { from, to } = transaction.selection; + transaction.doc.nodesBetween(from, to, (node, position) => { + if (node.isTextblock) { + blocks.push({ node, position }); + return false; + } + return true; + }); + return blocks; +} + +function leafTextForCode(leaf: ProseMirrorNode): string { + if (leaf.type.name === "hardBreak") return "\n"; + + const schemaText = leaf.type.spec.leafText?.(leaf); + if (schemaText !== undefined) return schemaText; + + // Inline atoms should survive conversion whenever they expose a meaningful + // textual identity. Unknown leaves intentionally fall back to an empty + // string rather than leaking implementation attributes into user content. + const attrs = leaf.attrs as Record; + if (typeof attrs.label === "string") return attrs.label; + if (typeof attrs.shortcode === "string") return `:${attrs.shortcode}:`; + return ""; +} + +function textblockTextForCode(node: ProseMirrorNode): string { + return node.textBetween(0, node.content.size, "\n", leafTextForCode); +} + +/** Replace selected textblocks with one newline-joined code block. */ +export function mergeSelectedTextblocksIntoCodeBlock( + transaction: Transaction, +): boolean { + if (!(transaction.selection instanceof TextSelection)) return false; + if (transaction.selection.empty) return false; + + const blocks = selectedTextblocks(transaction); + const codeBlock = transaction.doc.type.schema.nodes.codeBlock; + const first = blocks[0]; + const last = blocks.at(-1); + if (!(codeBlock && first && last)) return false; + + const text = blocks.map(({ node }) => textblockTextForCode(node)).join("\n"); + const from = first.position; + const to = last.position + last.node.nodeSize; + const content = text ? transaction.doc.type.schema.text(text) : undefined; + transaction.replaceWith(from, to, codeBlock.create(null, content)); + transaction.setSelection( + TextSelection.create(transaction.doc, from + 1, from + 1 + text.length), + ); + return true; +} diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index 9dcc45b63c..afe2cb22a5 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -16,7 +16,12 @@ import { import { cn } from "@/shared/lib/cn"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { isolateSelectionForBlockFormatting } from "@/features/messages/lib/selectionBlockFormatting"; +import { + isolateSelectionForBlockFormatting, + mergeSelectedTextblocksIntoCodeBlock, + selectionIncludesList, + splitSelectedLinesForListFormatting, +} from "@/features/messages/lib/selectionBlockFormatting"; import { getEditorSpoilerRangeState } from "@/features/messages/lib/spoilerFormatting"; import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark"; @@ -196,12 +201,26 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ }, [formattingChain]); const toggleCodeBlock = React.useCallback(() => { - formattingChain() - ?.command(({ tr }) => { + const chain = formattingChain(); + if (!chain) return; + chain + .command(({ tr, chain: currentChain }) => { + if (tr.selection.empty) { + isolateSelectionForBlockFormatting(tr); + return currentChain().toggleCodeBlock().run(); + } + isolateSelectionForBlockFormatting(tr); - return true; + if (selectionIncludesList(tr)) { + return currentChain() + .liftListItem("listItem") + .command(({ tr: currentTransaction }) => + mergeSelectedTextblocksIntoCodeBlock(currentTransaction), + ) + .run(); + } + return mergeSelectedTextblocksIntoCodeBlock(tr); }) - .toggleCodeBlock() .run(); }, [formattingChain]); @@ -251,9 +270,13 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ const toggleBulletList = React.useCallback(() => { formattingChain() ?.command(({ tr }) => { - isolateSelectionForBlockFormatting(tr); + splitSelectedLinesForListFormatting(tr); return true; }) + .command(({ tr, chain: currentChain }) => { + if (!selectionIncludesList(tr)) return true; + return currentChain().liftListItem("listItem").run(); + }) .toggleBulletList() .run(); }, [formattingChain]); @@ -261,15 +284,29 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ const toggleOrderedList = React.useCallback(() => { formattingChain() ?.command(({ tr }) => { - isolateSelectionForBlockFormatting(tr); + splitSelectedLinesForListFormatting(tr); return true; }) + .command(({ tr, chain: currentChain }) => { + if (!selectionIncludesList(tr)) return true; + return currentChain().liftListItem("listItem").run(); + }) .toggleOrderedList() .run(); }, [formattingChain]); const toggleBlockquote = React.useCallback(() => { - formattingChain()?.toggleBlockquote().run(); + formattingChain() + ?.command(({ tr }) => { + isolateSelectionForBlockFormatting(tr); + return true; + }) + .command(({ tr, chain: currentChain }) => { + if (!selectionIncludesList(tr)) return true; + return currentChain().liftListItem("listItem").run(); + }) + .toggleBlockquote() + .run(); }, [formattingChain]); const toggleSpoiler = React.useCallback(() => { diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 0da13afe3e..b8ab46503d 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -38,6 +38,39 @@ async function selectText(input: Locator, selectedText: string) { }, selectedText); } +async function selectTextRange( + input: Locator, + firstText: string, + lastText: string, +) { + await input.evaluate( + (element, texts) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let first: Text | null = null; + let last: Text | null = null; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (!first && node.data.includes(texts.firstText)) first = node; + if (node.data.includes(texts.lastText)) last = node; + } + if (!(first && last)) + throw new Error("Could not find selection endpoints"); + const range = document.createRange(); + range.setStart(first, first.data.indexOf(texts.firstText)); + range.setEnd( + last, + last.data.indexOf(texts.lastText) + texts.lastText.length, + ); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + (element as HTMLElement).focus(); + document.dispatchEvent(new Event("selectionchange")); + }, + { firstText, lastText }, + ); +} + async function dragSelectText( page: Page, input: Locator, @@ -93,7 +126,7 @@ async function dragSelectText( async function applySelectionFormat( page: Page, input: Locator, - label: "Bullet list" | "Code block" | "Ordered list", + label: "Bullet list" | "Code block" | "Ordered list" | "Quote", collapseAfterMouseDown = false, useMouseSelection = false, ) { @@ -136,10 +169,19 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +async function applyCaretFormat( + page: Page, + label: "Bullet list" | "Code block" | "Ordered list" | "Quote", +) { + await page.getByRole("button", { name: "Toggle formatting" }).first().click(); + await page.getByRole("button", { name: label, exact: true }).click(); +} + for (const format of [ { label: "Code block", selector: "pre" }, { label: "Bullet list", selector: "ul" }, { label: "Ordered list", selector: "ol" }, + { label: "Quote", selector: "blockquote" }, ] as const) { test(`${format.label} applies only to the selected composer text`, async ({ page, @@ -157,8 +199,271 @@ for (const format of [ await expect(input.locator(":scope > p").last()).toHaveText(" after"); await expect(input).toHaveText("before selected after"); }); + + test(`${format.label} starts at a collapsed caret on a new line`, async ({ + page, + }) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await applyCaretFormat(page, format.label); + await input.pressSequentially("inside"); + + await expect(input.locator(":scope > p").first()).toHaveText("before"); + await expect(input.locator(`:scope > ${format.selector}`)).toHaveText( + "inside", + ); + }); + + test(`${format.label} at a collapsed caret formats only the caret's line`, async ({ + page, + }) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await input.pressSequentially("target"); + await input.press("Shift+Enter"); + await input.pressSequentially("after"); + // Collapse the caret into the middle line. + await selectText(input, "target"); + await input.press("ArrowRight"); + await applyCaretFormat(page, format.label); + + await expect(input.locator(":scope > p").first()).toHaveText("before"); + await expect(input.locator(`:scope > ${format.selector}`)).toHaveText( + "target", + ); + await expect(input.locator(":scope > p").last()).toHaveText("after"); + }); +} + +test("Code block uses the restored multiline selection after mouseup collapse", async ({ + page, +}) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await input.pressSequentially("selected"); + await input.press("Shift+Enter"); + await input.pressSequentially("after"); + await applySelectionFormat(page, input, "Code block", true); + + await expect(input.locator(":scope > p").first()).toHaveText("before"); + await expect(input.locator(":scope > pre")).toHaveText("selected"); + await expect(input.locator(":scope > p").last()).toHaveText("after"); +}); + +for (const list of [ + { label: "Bullet list", selector: "ul" }, + { label: "Ordered list", selector: "ol" }, +] as const) { + test(`selected hard-break lines become separate ${list.label.toLowerCase()} items`, async ({ + page, + }) => { + await openGeneral(page); + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("one"); + await input.press("Shift+Enter"); + await input.pressSequentially("two"); + await input.press("Shift+Enter"); + await input.pressSequentially("three"); + await selectTextRange(input, "one", "three"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: list.label }) + .click(); + + const items = input.locator(`:scope > ${list.selector} > li`); + await expect(items).toHaveCount(3); + await expect(items).toHaveText(["one", "two", "three"]); + }); } +test("partial list-item selections snap to whole items for block formats", async ({ + page, +}) => { + for (const format of [ + "Code block", + "Bullet list", + "Ordered list", + "Quote", + ] as const) { + await openGeneral(page); + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await input.pressSequentially("first"); + await input.press("Shift+Enter"); + await input.pressSequentially("second"); + await input.press("Shift+Enter"); + await input.pressSequentially("after"); + await selectTextRange(input, "before", "after"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: "Bullet list" }) + .click(); + + await selectTextRange(input, "irst", "seco"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: format }) + .click(); + + const structure = await input.locator(":scope > *").evaluateAll((nodes) => + nodes.map((node) => ({ + tag: node.tagName.toLowerCase(), + text: node.textContent, + items: Array.from( + node.querySelectorAll(":scope > li"), + (item) => item.textContent, + ), + })), + ); + const expected = { + "Code block": [ + { tag: "ul", text: "before", items: ["before"] }, + { tag: "pre", text: "first\nsecond", items: [] }, + { tag: "ul", text: "after", items: ["after"] }, + ], + "Bullet list": [ + { + tag: "ul", + text: "beforefirstsecondafter", + items: ["before", "first", "second", "after"], + }, + ], + "Ordered list": [ + { tag: "ul", text: "before", items: ["before"] }, + { tag: "ol", text: "firstsecond", items: ["first", "second"] }, + { tag: "ul", text: "after", items: ["after"] }, + ], + Quote: [ + { tag: "ul", text: "before", items: ["before"] }, + { tag: "blockquote", text: "firstsecond", items: [] }, + { tag: "ul", text: "after", items: ["after"] }, + ], + }[format]; + expect(structure).toEqual(expected); + await page.reload(); + } +}); + +test("selected hard-break lines stay newline-separated in one code block", async ({ + page, +}) => { + await openGeneral(page); + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("one"); + await input.press("Shift+Enter"); + await input.pressSequentially("two"); + await input.press("Shift+Enter"); + await input.pressSequentially("three"); + await selectTextRange(input, "one", "three"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: "Code block" }) + .click(); + + await expect(input.locator(":scope > pre")).toHaveCount(1); + await expect(input.locator(":scope > pre")).toHaveText("one\ntwo\nthree"); + + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>; + } + ).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content, + ), + ) + .toBe("```\none\ntwo\nthree\n```"); +}); + +test("selected list items become one multiline code block and keep neighbors", async ({ + page, +}) => { + await openGeneral(page); + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await input.pressSequentially("one"); + await input.press("Shift+Enter"); + await input.pressSequentially("two"); + await input.press("Shift+Enter"); + await input.pressSequentially("after"); + await selectTextRange(input, "before", "after"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: "Bullet list" }) + .click(); + await selectTextRange(input, "one", "two"); + await page + .getByTestId("selection-formatting-tray") + .getByRole("button", { name: "Code block" }) + .click(); + + await expect(input.locator(":scope > pre")).toHaveCount(1); + await expect(input.locator(":scope > pre")).toHaveText("one\ntwo"); + await expect(input.locator(":scope > ul li")).toHaveText(["before", "after"]); + + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>; + } + ).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content, + ), + ) + .toBe("- before\n\n```\none\ntwo\n```\n\n- after"); +}); + +test("caret-only block formatting serializes the prior draft unchanged", async ({ + page, +}) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("before"); + await input.press("Shift+Enter"); + await applyCaretFormat(page, "Bullet list"); + await input.pressSequentially("item"); + + await page.getByTestId("send-message").click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>; + } + ).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content, + ), + ) + .toBe("before\n\n- item"); +}); + test("block formatting preserves the lines around a selected composer line", async ({ page, }) => { From 60158fce3e670f11bb35d42627857ccaea50ff06 Mon Sep 17 00:00:00 2001 From: kagan yaldizkaya Date: Tue, 28 Jul 2026 19:44:57 +0200 Subject: [PATCH 06/59] feat(cli): add users set-status command for NIP-38 profile status (#3253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The desktop client renders a persistent user status (NIP-38 kind:30315, `d:general`) as the status line on profiles, but the CLI had no way to set it — only ephemeral presence (`set-presence`, kind:20001). Integrations that want a scriptable, durable status line (for example a now-playing music bridge that shows the current TIDAL track on a profile) had no entry point. ## Screenshots 1 2 This adds: ```bash buzz users set-status --text "Working on the relay" --emoji "🔧" buzz users set-status --text "" --emoji "🎶" # intentional emoji-only status buzz users set-status --clear # removes the status ``` - Signs and submits the replaceable kind:30315 event via the HTTP bridge (no WS needed — unlike presence, user status is a stored event). - Uses the `d:general` coordinate the desktop client already reads for the profile status line, and the same `emoji` tag shape `SetStatusDialog` publishes. - Event construction lives in `buzz_sdk::build_user_status()`, keyed off `buzz_core::kind::KIND_USER_STATUS`, so the CLI command is a thin sign/submit wrapper. Text and emoji are trimmed; a blank emoji is omitted rather than emitted as an empty tag. - Clearing is the explicit `--clear` flag, mutually exclusive with `--text`/`--emoji`. It publishes an empty-content event carrying only `d:general`, which the desktop treats as no status. `--text ""` with an `--emoji` is an emoji-only status, not a clear. --------- Signed-off-by: Kagan Yaldizkaya Signed-off-by: Will Pfleger Co-authored-by: Will Pfleger --- crates/buzz-cli/README.md | 3 ++ crates/buzz-cli/TESTING.md | 17 ++++++- crates/buzz-cli/src/commands/users.rs | 26 +++++++++++ crates/buzz-cli/src/lib.rs | 47 ++++++++++++++++++- crates/buzz-sdk/src/builders.rs | 67 ++++++++++++++++++++++++++- 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..40699459fc 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -57,6 +57,8 @@ buzz users get # your own profile buzz users get --pubkey # single user buzz users get --pubkey --pubkey # batch (max 200) buzz users set-presence --status online +buzz users set-status --text "heads down on the CLI" --emoji "🚀" +buzz users set-status --clear # remove your status # DMs buzz dms open --pubkey @@ -133,6 +135,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `set-profile` | Update your profile | | | `presence` | Get presence status | | | `set-presence` | Set presence status | +| | `set-status` | Set or clear your NIP-38 profile status | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 4b7257aba7..77234b7faa 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -87,7 +87,7 @@ export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output | `channels:read` | ✅ | `channels list`, `channels get`, `channels members` | | `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` | | `users:read` | ✅ | `users get`, `users presence` | -| `users:write` | ✅ | `users set-profile`, `users set-presence` | +| `users:write` | ✅ | `users set-profile`, `users set-presence`, `users set-status` | | `files:read` | ✅ | — | | `files:write` | ✅ | — | | `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` | @@ -331,6 +331,20 @@ buzz users set-presence --status online | jq . buzz users set-presence --status away | jq . buzz users set-presence --status offline | jq . # Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge + +# users set-status — NIP-38 kind:30315 on the d:general coordinate +buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq . +buzz users set-status --text "no emoji this time" | jq . + +# users set-status — emoji-only status (intentional: text is blank, emoji is kept) +buzz users set-status --text "" --emoji "🎶" | jq . + +# users set-status --clear — removes the status (empty content, d:general only) +buzz users set-status --clear | jq . + +# --clear is mutually exclusive with --text/--emoji +buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?" +# Expected: exit 1 — clap conflict error ``` ### 6.8 Channel Members (add/remove require admin:channels) @@ -606,3 +620,4 @@ buzz channels delete --channel "$FORUM_ID" | jq . | 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | | 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit | | 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | +| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 | diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..f5a0bee879 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -304,6 +304,22 @@ pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), C Ok(()) } +/// Set user status — sign and submit a NIP-38 kind:30315 user status event. +/// +/// Uses the `d:general` coordinate that the desktop client reads for the +/// profile status line. A blank `text` with no `emoji` clears the status. +pub async fn cmd_set_status( + client: &BuzzClient, + text: &str, + emoji: Option<&str>, +) -> Result<(), CliError> { + let builder = buzz_sdk::build_user_status(text, emoji).map_err(crate::validate::sdk_err)?; + let event = client.sign_event(builder)?; + let resp = client.submit_event(event).await?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + pub async fn dispatch( cmd: crate::UsersCmd, client: &BuzzClient, @@ -331,6 +347,16 @@ pub async fn dispatch( } UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await, UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await, + UsersCmd::SetStatus { text, emoji, clear } => { + // `--clear` is mutually exclusive with `--text`/`--emoji`: publish the + // empty `d:general` event that clients read as "no status". + let (text, emoji) = if clear { + ("", None) + } else { + (text.as_deref().unwrap_or_default(), emoji.as_deref()) + }; + cmd_set_status(client, text, emoji).await + } } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..0b46734584 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -838,6 +838,19 @@ pub enum UsersCmd { #[arg(long, value_enum)] status: PresenceStatus, }, + /// Set your user status (NIP-38 kind:30315 — the "status" line on your profile) + #[command(name = "set-status")] + SetStatus { + /// Status text (required unless --clear) + #[arg(long, required_unless_present = "clear")] + text: Option, + /// Optional emoji shown before the status text + #[arg(long)] + emoji: Option, + /// Remove your status entirely + #[arg(long, conflicts_with_all = ["text", "emoji"])] + clear: bool, + }, } #[derive(Subcommand)] @@ -1803,6 +1816,30 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn set_status_clear_rejects_text_and_emoji() { + for extra in [["--text", "busy"], ["--emoji", "🎶"]] { + let args = ["buzz", "users", "set-status", "--clear"] + .into_iter() + .chain(extra); + assert!( + Cli::try_parse_from(args).is_err(), + "--clear must conflict with {}", + extra[0] + ); + } + } + + #[test] + fn set_status_requires_text_or_clear() { + assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err()); + assert!( + Cli::try_parse_from(["buzz", "users", "set-status", "--emoji", "🎶"]).is_err(), + "--emoji alone must not imply a status" + ); + assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok()); + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ @@ -1924,7 +1961,13 @@ mod tests { ); assert_eq!( names(&cmd, "users"), - vec!["get", "presence", "set-presence", "set-profile"] + vec![ + "get", + "presence", + "set-presence", + "set-profile", + "set-status" + ] ); assert_eq!( names(&cmd, "workflows"), @@ -2011,7 +2054,7 @@ mod tests { ("repos", 4), ("social", 7), ("upload", 1), - ("users", 4), + ("users", 5), ("workflows", 8), ]; diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..8cc9c8650a 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1580,6 +1580,22 @@ pub fn build_presence_update(status: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags)) } +/// Build a NIP-38 user status event (kind 30315) on the `d:general` coordinate. +/// +/// `text` becomes the event content and `emoji`, when non-blank, an +/// `["emoji", ...]` tag; both are trimmed. Blank text with no emoji clears the +/// status — kind 30315 is parameterized-replaceable, so an event carrying +/// neither is what clients read as "no status". +pub fn build_user_status(text: &str, emoji: Option<&str>) -> Result { + let text = text.trim(); + check_content(text, 64 * 1024)?; + let mut tags = vec![tag(&["d", "general"])?]; + if let Some(emoji) = emoji.map(str::trim).filter(|e| !e.is_empty()) { + tags.push(tag(&["emoji", emoji])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_USER_STATUS as u16), text).tags(tags)) +} + // --------------------------------------------------------------------------- // Community moderation commands (kinds 9040–9044). // @@ -3391,6 +3407,53 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + // ── build_user_status ───────────────────────────────────────────────────── + + #[test] + fn user_status_carries_text_and_emoji_on_d_general() { + let ev = sign(build_user_status("shipping the CLI", Some("🚀")).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, "shipping the CLI"); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(tag_values(&ev, "emoji"), vec!["🚀"]); + } + + #[test] + fn user_status_trims_text_and_emoji() { + let ev = sign(build_user_status(" heads down ", Some(" 🎧 ")).unwrap()); + assert_eq!(ev.content, "heads down"); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎧"]); + } + + #[test] + fn user_status_omits_blank_emoji_tag() { + let ev = sign(build_user_status("on call", Some(" ")).unwrap()); + assert_eq!(ev.content, "on call"); + assert!(tag_values(&ev, "emoji").is_empty()); + } + + #[test] + fn user_status_keeps_emoji_when_text_is_blank() { + let ev = sign(build_user_status("", Some("🎶")).unwrap()); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "emoji"), vec!["🎶"]); + } + + #[test] + fn user_status_clear_shape_is_empty_content_and_d_tag_only() { + let ev = sign(build_user_status("", None).unwrap()); + assert_eq!(ev.kind.as_u16(), 30315); + assert_eq!(ev.content, ""); + assert_eq!(tag_values(&ev, "d"), vec!["general"]); + assert_eq!(ev.tags.len(), 1); + } + + #[test] + fn user_status_rejects_oversize_text() { + let err = build_user_status(&"x".repeat(64 * 1024 + 1), None).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + // ── build_git_pull_request / build_git_pr_update ────────────────────────── fn pr_repo() -> GitRepoCoord { From 4e3998f36e36d68b9a93dcbd85f0864450bb8f5f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 13:56:59 -0400 Subject: [PATCH 07/59] fix(desktop): gate codex-acp on a minimum supported version (#3254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex adapter version gate accepted any `major >= 1`, so a 1.x `codex-acp` older than the version that fixes outbound relay access for `buzz` CLI subprocesses classified as `Available` and was never offered a reinstall. Only the 0.16.x `@zed-industries/codex-acp` adapter — which fails `--version` outright — was caught. `probe_codex_acp_version` now returns the full `(major, minor, patch)` triple and `codex_adapter_availability` compares it against a new `MIN_CODEX_ACP_VERSION` floor of `1.1.7`, the current npm latest. An adapter below the floor classifies as `AdapterOutdated`, which routes it through the existing uninstall-then-install reinstall plan. The parse requires exactly three numeric dot-separated components. Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and therefore classify as `AdapterOutdated` — a version Buzz cannot compare against the floor fails closed, offering a reinstall rather than running an adapter of unknown vintage. Both the floor's bump policy and the strict-parse behavior are stated in doc comments rather than left implicit. Supersedes [#3097](https://github.com/block/buzz/pull/3097) by @Bharathchinneni, whose semver floor and behavior tests this carries. That PR could not land as written: the two `probe_codex_acp_major_version` compatibility wrappers it kept had no non-test callers, which is a hard `clippy -D warnings` failure. The wrappers are deleted here and their call sites collapsed onto `probe_codex_acp_version`. Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 15 ++- .../src-tauri/src/commands/agent_discovery.rs | 37 ++++++- .../src-tauri/src/managed_agents/discovery.rs | 69 ++++++++----- .../src/managed_agents/discovery/tests.rs | 99 ++++++++++++++----- .../discovery/tests/codex_version.rs | 10 +- 5 files changed, 168 insertions(+), 62 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6e44481d57..5b781f63f6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -346,7 +346,10 @@ const overrides = new Map([ // entries.push block collapsed into the helper. // +6: legacy Goose Windows install dir (%USERPROFILE%\goose) probed in // common_binary_paths so pre-#2680 standalone installs are discoverable. - ["src-tauri/src/managed_agents/discovery.rs", 1841], + // +19: codex-acp minimum-version gate — MIN_CODEX_ACP_VERSION plus the strict + // three-component parse in probe_codex_acp_version, so an outdated 1.x adapter + // is offered a reinstall instead of classifying as Available on major alone. + ["src-tauri/src/managed_agents/discovery.rs", 1860], // BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm / // delete_and_warm (persist-mutex serialization for concurrent-safe registry // refresh, B-6). Also: id/collision/load/registry tests (from the file base) + @@ -391,7 +394,11 @@ const overrides = new Map([ // Available both-present AND adapter-present/CLI-absent — the selectability // regression guard), bound to an injectable resolver so the tests stay // PATH-independent. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1871], + // +51: codex-acp minimum-version gate — probe_codex_acp_version assertions carry + // the full (major, minor, patch) triple instead of a bare major, plus + // below-the-floor and uncomparable-version (partial / prerelease) classification + // regressions for the fail-closed parse. + ["src-tauri/src/managed_agents/discovery/tests.rs", 1922], // identity-import-keyring: the identity resolution state machine's behavioral // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, // adoption / read-back-corruption / marker-failure arms, recovery-mode @@ -633,7 +640,9 @@ const overrides = new Map([ // with backoff, output truncation) extracted to agent_discovery/install_exec.rs // alongside its tests, matching the managed_node.rs / post_install_verification.rs // split. The entries above describe the file's history, not its current shape. - ["src-tauri/src/commands/agent_discovery.rs", 1808], + // +27: codex-acp minimum-version gate — test_plan_adapter_install_updates_older_ + // 1x_codex_binary pins that a 1.x adapter below the floor still plans a reinstall. + ["src-tauri/src/commands/agent_discovery.rs", 1835], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 76f8596caf..d6429e0454 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -25,7 +25,8 @@ fn active_installs() -> &'static std::sync::Mutex..` on stdout and exits 0. /// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does /// not recognise `--version` and exits non-zero. /// -/// Returns the major version on success, `None` on any failure (non-zero exit, -/// unparseable output, timeout, or missing binary). +/// Returns the `(major, minor, patch)` triple on success, `None` on any failure +/// (non-zero exit, unparseable output, timeout, or missing binary). +/// +/// The parse is deliberately strict: exactly three numeric dot-separated components. +/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so +/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a +/// reinstall rather than running an adapter whose version cannot be compared. /// /// The probe is bounded by a 5-second deadline. The child is polled with /// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and @@ -1180,16 +1195,16 @@ pub(crate) fn classify_runtime( /// Stdout is redirected to a temporary file rather than a pipe, so forked /// descendants cannot hold EOF open. Reads from a regular file return EOF at its /// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_major_version(binary_path: &Path) -> Option { - probe_codex_acp_major_version_with_path( +pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_with_path( binary_path, crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), ) } -pub(crate) fn probe_codex_acp_major_version_with_path( +pub(crate) fn probe_codex_acp_version_with_path( binary_path: &Path, augmented_path: Option<&str>, -) -> Option { +) -> Option<(u64, u64, u64)> { use std::io::{Read as _, Seek as _, SeekFrom}; use std::time::{Duration, Instant}; const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); @@ -1245,30 +1260,35 @@ pub(crate) fn probe_codex_acp_major_version_with_path( let stdout = String::from_utf8_lossy(&buf); // Output format: " .." let version_str = stdout.split_whitespace().last()?; - let major_str = version_str.split('.').next()?; - major_str.parse::().ok() + let mut components = version_str.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + let patch = components.next()?.parse::().ok()?; + if components.next().is_some() { + return None; + } + Some((major, minor, patch)) } /// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] /// or [`AcpAvailabilityStatus::AdapterOutdated`]. /// /// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. The 1.x adapter -/// (`@agentclientprotocol/codex-acp`) prints its version and exits 0; major ≥ 1 -/// yields `Available`. +/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is +/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. /// /// Used by `discover_acp_runtimes`, `cli_login_requirements`, and /// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_major_version(path) { - Some(major) if major >= 1 => AcpAvailabilityStatus::Available, + match probe_codex_acp_version(path) { + Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, _ => AcpAvailabilityStatus::AdapterOutdated, } } -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed using `augmented_path`. Thin wrapper around -/// [`codex_adapter_is_outdated_with_path`]. +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper +/// around [`codex_adapter_is_outdated_with_path`]. #[cfg(test)] pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { codex_adapter_is_outdated_with_path( @@ -1277,15 +1297,15 @@ pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { ) } -/// Returns `true` when the codex-acp binary at `path` is outdated (major version < 1) -/// or cannot be probed with the supplied PATH. +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. pub(crate) fn codex_adapter_is_outdated_with_path( path: &Path, augmented_path: Option<&str>, ) -> bool { !matches!( - probe_codex_acp_major_version_with_path(path, augmented_path), - Some(major) if major >= 1 + probe_codex_acp_version_with_path(path, augmented_path), + Some(version) if version >= MIN_CODEX_ACP_VERSION ) } @@ -1308,9 +1328,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. + // For codex-acp: when the adapter resolves as Available, probe its full + // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 48e8d5479c..8761346b1f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -6,7 +6,7 @@ use super::{ codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, preset_catalog_entry, probe_codex_acp_major_version, record_agent_command, + parse_semver_tag, preset_catalog_entry, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; @@ -749,37 +749,41 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } -// ── probe_codex_acp_major_version ───────────────────────────────────────────── +// ── probe_codex_acp_version ─────────────────────────────────────────────────── mod managed_path_resolution; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_parses_1x_output() { +fn probe_codex_acp_version_parses_full_semver_output() { use std::os::unix::fs::PermissionsExt; - // Simulate `@agentclientprotocol/codex-acp 1.1.2` output (1.x adapter) + // Simulate a current `@agentclientprotocol/codex-acp` output. let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); - assert_eq!(major, Some(1), "1.x adapter must return major version 1"); + assert_eq!( + version, + Some((1, 1, 7)), + "adapter output must parse to its full semantic version" + ); } mod codex_version; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { +fn probe_codex_acp_version_returns_none_for_nonzero_exit() { use std::os::unix::fs::PermissionsExt; // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero @@ -789,21 +793,21 @@ fn probe_codex_acp_major_version_returns_none_for_nonzero_exit() { std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); assert_eq!( - major, None, + version, None, "old 0.16.x adapter (non-zero exit) must return None" ); } #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_missing_binary() { +fn probe_codex_acp_version_returns_none_for_missing_binary() { let path = std::path::Path::new("/nonexistent/path/codex-acp-does-not-exist"); - let major = probe_codex_acp_major_version(path); - assert_eq!(major, None, "missing binary must return None"); + let version = probe_codex_acp_version(path); + assert_eq!(version, None, "missing binary must return None"); } // ── codex_adapter_availability / codex_adapter_is_outdated ─────────────────── @@ -813,7 +817,7 @@ fn probe_codex_acp_major_version_returns_none_for_missing_binary() { #[cfg(unix)] #[test] -fn codex_adapter_availability_available_for_1x_binary() { +fn codex_adapter_availability_available_for_minimum_supported_binary() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); @@ -821,7 +825,7 @@ fn codex_adapter_availability_available_for_1x_binary() { let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); @@ -832,7 +836,7 @@ fn codex_adapter_availability_available_for_1x_binary() { assert_eq!( status, AcpAvailabilityStatus::Available, - "1.x adapter must classify as Available" + "minimum supported adapter must classify as Available" ); } @@ -858,6 +862,53 @@ fn codex_adapter_availability_outdated_for_0x_binary() { ); } +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_older_1x_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "a 1.x adapter below the floor must be offered an upgrade" + ); +} + +/// The strict three-component parse fails closed: a version Buzz cannot compare +/// against the floor is treated as outdated rather than assumed current. +#[cfg(unix)] +#[test] +fn codex_adapter_availability_outdated_for_uncomparable_version() { + use std::os::unix::fs::PermissionsExt; + + for version in ["1.2", "1.2.0-rc1"] { + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write( + &bin, + format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod script"); + + assert_eq!( + codex_adapter_availability(&bin), + AcpAvailabilityStatus::AdapterOutdated, + "version {version} is not comparable to the floor and must fail closed" + ); + } +} + #[cfg(unix)] #[test] fn codex_adapter_availability_outdated_for_missing_binary() { @@ -876,7 +927,7 @@ fn codex_adapter_availability_outdated_for_missing_binary() { #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { +fn probe_codex_acp_version_returns_none_for_hung_direct_child() { use std::os::unix::fs::PermissionsExt; use std::time::Instant; @@ -894,12 +945,12 @@ fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let elapsed = start.elapsed(); let _ = std::fs::remove_dir_all(dir); assert_eq!( - major, None, + version, None, "hung binary must return None (timeout kills child)" ); // The timeout is 5 s; give a 10 s margin for parallel pre-push suites. @@ -911,7 +962,7 @@ fn probe_codex_acp_major_version_returns_none_for_hung_direct_child() { #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open() { +fn probe_codex_acp_version_returns_version_when_descendant_holds_pipe_open() { use std::os::unix::fs::PermissionsExt; use std::time::Instant; @@ -936,7 +987,7 @@ fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); let start = Instant::now(); - let major = probe_codex_acp_major_version(&bin); + let version = probe_codex_acp_version(&bin); let elapsed = start.elapsed(); let _ = std::fs::remove_dir_all(dir); @@ -947,9 +998,9 @@ fn probe_codex_acp_major_version_returns_version_when_descendant_holds_pipe_open "probe must not block on descendant pipe; elapsed: {elapsed:?}" ); assert_eq!( - major, - Some(1), - "1.x version must be parsed even when descendant holds pipe open" + version, + Some((1, 1, 2)), + "version must be parsed even when descendant holds pipe open" ); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs index 5886a43990..82bfd27f32 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -1,8 +1,8 @@ -use super::super::probe_codex_acp_major_version_with_path; +use super::super::probe_codex_acp_version_with_path; #[cfg(unix)] #[test] -fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter() { +fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { use std::fs; use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); @@ -31,7 +31,7 @@ fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter .to_string_lossy() .into_owned(); assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&scrubbed_path)), + probe_codex_acp_version_with_path(&shim_path, Some(&scrubbed_path)), None, "with a scrubbed PATH, /usr/bin/env should not find node" ); @@ -41,8 +41,8 @@ fn probe_codex_acp_major_version_uses_augmented_path_for_env_shebang_interpreter .to_string_lossy() .into_owned(); assert_eq!( - probe_codex_acp_major_version_with_path(&shim_path, Some(&augmented_path)), - Some(1), + probe_codex_acp_version_with_path(&shim_path, Some(&augmented_path)), + Some((1, 1, 2)), "the injected augmented PATH should allow /usr/bin/env to find node" ); } From 00ede2e7aa7eb95571b7db3ebbd163adbf6cf74e Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 28 Jul 2026 14:01:18 -0400 Subject: [PATCH 08/59] fix(desktop): restore the inbox icon in the sidebar (#3341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Inbox surface was briefly renamed to **Activity** during #2045 and picked up a bell icon to match. The name was reverted to **Inbox** before merge, but the icon was not. A bell says "notification tray." Inbox is a destination — a focused, conversation-oriented place to catch up on work relevant to you, including drafts and reminders that have nothing to do with notifications. The glyph should say that. ## What changed - Swap the sidebar entry from Lucide `Bell` to Lucide `Inbox`. - Assert the icon in `inbox-refactor-screenshots.spec.ts`. Nothing pinned it before, which is exactly how it drifted through a rename. This also brings desktop back in line with mobile, which already uses `LucideIcons.inbox300` / `inbox500` for the same destination. ## Deliberately unchanged The bell on **reminder** rows in the list pane (`InboxListPane.tsx`, reminders → bell, drafts → file) stays. A bell is the right glyph for a reminder; that one was never about the surface's identity. ## Verification - The new assertion is a real guard, not a no-op: with `Bell` restored the test fails with `Expected: 1, Received: 0` on `svg.lucide-inbox`. Confirmed before committing. - `biome` and `tsc` clean. - Playwright smoke: `inbox-refactor-screenshots` 4 passed; `smoke`, `navigation`, `channels`, `sidebar-more-unread-overlap`, `home-collapsed-top-chrome`, `workspace-rail` — 107 passed, 1 skipped. - Screenshot below is the regenerated `02-current-controls` shot from the spec. Signed-off-by: Clay Delk Co-authored-by: Claude Opus 5 (1M context) --- .../features/sidebar/ui/AppSidebarPinnedHeader.tsx | 4 ++-- .../tests/e2e/inbox-refactor-screenshots.spec.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 95a0a47ef1..a673492ef1 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,4 @@ -import { Activity, Bell, Bot, FolderGit2, Zap } from "lucide-react"; +import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { FeatureGate } from "@/shared/features"; @@ -103,7 +103,7 @@ export function AppSidebarPrimaryMenu({ tooltip="Inbox" type="button" > - + Inbox {homeBadgeCount > 0 ? ( diff --git a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts index b7d5b2af2e..15a804d71f 100644 --- a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts +++ b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts @@ -174,7 +174,7 @@ test.describe("inbox refactor screenshots", () => { await page.screenshot({ path: `${SHOTS}/01-current-filters.png` }); }); - test("02 — Inbox label, bell icon, and overflow controls", async ({ + test("02 — Inbox label, inbox icon, and overflow controls", async ({ page, }) => { await installMockBridge(page, { mode: "mock" }); @@ -185,11 +185,13 @@ test.describe("inbox refactor screenshots", () => { }); // The sidebar must be in frame — the label is the point of this shot. - await expect( - page - .getByTestId("sidebar-primary-menu") - .getByRole("button", { name: "Inbox", exact: true }), - ).toBeVisible(); + const inboxButton = page + .getByTestId("sidebar-primary-menu") + .getByRole("button", { name: "Inbox", exact: true }); + await expect(inboxButton).toBeVisible(); + // Inbox is a destination, not a notification tray, so it carries the inbox + // glyph rather than a bell. Asserted because nothing else pins the icon. + await expect(inboxButton.locator("svg.lucide-inbox")).toHaveCount(1); await page.getByTestId("inbox-options-trigger").click(); await expect(page.getByText("Show unread only")).toBeVisible(); From a77212875aea299350d01d94b0f6d9c22a8fce5f Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 28 Jul 2026 19:12:54 +0100 Subject: [PATCH 09/59] Unify mobile loading spinners (#3314) ## What - add the shared desktop-style arc spinner for mobile - replace app loading indicators with the shared component - preserve a static pose when reduced motion is enabled ## Stack - follows #3313 ## Validation - `just mobile-check` - focused spinner and pairing widget tests --------- Signed-off-by: kenny lopez --- mobile/lib/app.dart | 7 +- .../lib/features/activity/activity_page.dart | 1 + .../activity/activity_page/lists.dart | 7 +- .../agent_activity/agent_activity_sheet.dart | 12 +- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_list.dart | 7 +- .../lib/features/channels/channels_page.dart | 1 + .../channels/channels_page/community.dart | 7 +- .../channels/channels_page/sheets.dart | 16 +- mobile/lib/features/channels/compose_bar.dart | 1 + .../channels/compose_bar/attachments.dart | 10 +- .../channels/compose_bar/camera_preview.dart | 5 +- .../compose_bar/ios_attachment_popover.dart | 31 ++- .../compose_bar/ios_photo_picker.dart | 14 +- .../compose_bar/photo_gallery_picker.dart | 27 +- .../channels/compose_bar/send_button.dart | 11 +- .../channels/manage_channel_sheet.dart | 8 +- .../features/channels/media_viewer_page.dart | 6 +- .../lib/features/channels/members_sheet.dart | 19 +- .../lib/features/forum/forum_posts_view.dart | 8 +- .../lib/features/forum/forum_thread_page.dart | 8 +- .../features/invites/invite_join_sheet.dart | 8 +- mobile/lib/features/pairing/pairing_page.dart | 12 +- .../pairing_page/pairing_welcome_view.dart | 16 +- .../lib/features/pulse/compose_note_page.dart | 10 +- mobile/lib/features/search/search_page.dart | 8 +- .../widgets/buzz_loading_indicator.dart | 98 +++++++ .../features/channels/compose_bar_test.dart | 259 ++++++++++++++++++ .../features/pairing/pairing_page_test.dart | 3 +- .../widgets/buzz_loading_indicator_test.dart | 50 ++++ 30 files changed, 582 insertions(+), 89 deletions(-) create mode 100644 mobile/lib/shared/widgets/buzz_loading_indicator.dart create mode 100644 mobile/test/shared/widgets/buzz_loading_indicator_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 3d6a562a92..5b266c93f9 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -16,6 +16,7 @@ import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; +import 'shared/widgets/buzz_loading_indicator.dart'; class App extends HookConsumerWidget { const App({super.key}); @@ -113,6 +114,10 @@ class _SplashScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold( + body: Center( + child: BuzzLoadingIndicator(size: 56, semanticLabel: 'Starting Buzz'), + ), + ); } } diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 50d0544cfc..d5ea8d1c53 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/message_author_meta.dart'; diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 509491b906..37311c9287 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -17,7 +17,12 @@ class _RemindersList extends ConsumerWidget { ]; if (remindersAsync.isLoading && reminders.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading reminders', + ), + ); } if (reminders.isEmpty) { return const _EmptySurface( diff --git a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart index c0c7640617..26a53f8903 100644 --- a/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart +++ b/mobile/lib/features/channels/agent_activity/agent_activity_sheet.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../shared/theme/theme.dart'; +import '../../../shared/widgets/buzz_loading_indicator.dart'; import '../../profile/user_cache_provider.dart'; import '../date_formatters.dart'; import 'observer_models.dart'; @@ -189,13 +190,10 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.onSurfaceVariant, - ), + BuzzLoadingIndicator( + size: 28, + color: context.colors.onSurfaceVariant, + semanticLabel: 'Waiting for agent activity', ), const SizedBox(height: Grid.xxs), Text( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 3e5140f844..6bbb60c0d1 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -11,6 +11,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/message_author_meta.dart'; 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 af1293dd00..6a77069bea 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -264,10 +264,9 @@ class _MessageList extends HookConsumerWidget { return const Padding( padding: EdgeInsets.symmetric(vertical: Grid.xs), child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: BuzzLoadingIndicator( + size: 24, + semanticLabel: 'Loading older messages', ), ), ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index b32778d366..8edd056e76 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -14,6 +14,7 @@ import '../../shared/community/community_icon_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/skeleton.dart'; diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index f1f0219fa9..73935f4631 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -66,7 +66,12 @@ class _CommunitySwitcherSheet extends HookConsumerWidget { child: communitiesAsync.when( loading: () => const SizedBox( height: 120, - child: Center(child: CircularProgressIndicator()), + child: Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading communities', + ), + ), ), error: (e, _) => Padding( padding: const EdgeInsets.all(Grid.xs), diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 51aead28c7..2738be0aa9 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -642,10 +642,11 @@ class _NewDirectMessageSheet extends HookConsumerWidget { ), child: SizedBox.square( dimension: 16, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), + child: BuzzLoadingIndicator( + size: 16, + semanticLabel: + 'Creating conversation', + ), ), ) : null, @@ -686,7 +687,12 @@ class _NewDirectMessageSheet extends HookConsumerWidget { isSearchTransitionPending) { return const SizedBox( height: 280, - child: Center(child: CircularProgressIndicator()), + child: Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading people', + ), + ), ); } if (directoryAsync.hasError) { diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 90a19d8d3d..a2421c9339 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -17,6 +17,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index cfb541fc85..9d9f278edc 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -430,12 +430,10 @@ class _AttachmentStrip extends StatelessWidget { child: Stack( alignment: Alignment.center, children: [ - SizedBox.square( - dimension: 34, - child: CircularProgressIndicator( - strokeWidth: 3, - color: context.colors.primary, - ), + BuzzLoadingIndicator( + size: 34, + color: context.colors.primary, + semanticLabel: label, ), if (uploadingCount > 1) PositionedDirectional( diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index b4a0d220a4..6179bcfb2f 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -198,9 +198,10 @@ class _CameraPlaceholder extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(Grid.sm), child: isInitializing - ? const CircularProgressIndicator( + ? const BuzzLoadingIndicator( + size: 44, color: Colors.white, - strokeWidth: 3, + semanticLabel: 'Starting camera', ) : Column( mainAxisSize: MainAxisSize.min, diff --git a/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart index d56db5998c..0a0c4f1a99 100644 --- a/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart +++ b/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart @@ -25,10 +25,13 @@ class _IOSAttachmentPopoverCallbacks { } class _IOSAttachmentPopoverCoordinator { + static final Object _cancelledSupportCheck = Object(); + final MethodChannel _channel; Object? _activeOwner; _IOSAttachmentPopoverCallbacks? _callbacks; + Completer? _pendingSupportCancellation; bool _didPresent = false; bool _handlerInstalled = false; @@ -44,9 +47,15 @@ class _IOSAttachmentPopoverCoordinator { required VoidCallback onFiles, }) async { if (defaultTargetPlatform != TargetPlatform.iOS) return false; - if (_activeOwner != null) return true; + if (_activeOwner case final activeOwner?) { + if (identical(activeOwner, owner)) return true; + if (!_didPresent) _clearOwner(activeOwner); + return _didPresent; + } + final supportCancellation = Completer(); _activeOwner = owner; + _pendingSupportCancellation = supportCancellation; _callbacks = _IOSAttachmentPopoverCallbacks( onCapture: onCapture, onChoosePhotos: onChoosePhotos, @@ -57,9 +66,16 @@ class _IOSAttachmentPopoverCoordinator { _ensureHandler(); try { - final supported = - await _channel.invokeMethod('isSupported') ?? false; + final supportResult = await Future.any([ + _channel.invokeMethod('isSupported'), + supportCancellation.future.then((_) => _cancelledSupportCheck), + ]); + if (identical(supportResult, _cancelledSupportCheck)) return true; + if (identical(_pendingSupportCancellation, supportCancellation)) { + _pendingSupportCancellation = null; + } if (!identical(_activeOwner, owner)) return false; + final supported = supportResult == true; if (!supported || !sourceContext.mounted) { _clearOwner(owner); return false; @@ -102,6 +118,10 @@ class _IOSAttachmentPopoverCoordinator { try { await _channel.invokeMethod('dismiss'); } on PlatformException { + // The native bridge is unavailable, so there is nothing left to dismiss. + } on MissingPluginException { + // The native bridge is unavailable, so there is nothing left to dismiss. + } finally { _clearOwner(owner); } } @@ -146,6 +166,11 @@ class _IOSAttachmentPopoverCoordinator { void _clearOwner(Object owner) { if (!identical(_activeOwner, owner)) return; + final supportCancellation = _pendingSupportCancellation; + _pendingSupportCancellation = null; + if (supportCancellation != null && !supportCancellation.isCompleted) { + supportCancellation.complete(); + } _activeOwner = null; _callbacks = null; _didPresent = false; 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 b8cc83cbe9..039b5160a3 100644 --- a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -183,9 +183,10 @@ class _IOSInlinePhotoPicker extends HookWidget { child: isPreparingSelection.value ? const SizedBox.square( dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + child: BuzzLoadingIndicator( + size: 20, color: Colors.white, + semanticLabel: 'Preparing selected photos', ), ) : Text( @@ -201,9 +202,10 @@ class _IOSInlinePhotoPicker extends HookWidget { const ColoredBox( color: Color.fromRGBO(0, 0, 0, 0.28), child: Center( - child: CircularProgressIndicator( - strokeWidth: 3, + child: BuzzLoadingIndicator( + size: 44, color: Colors.white, + semanticLabel: 'Preparing selected photos', ), ), ), @@ -222,7 +224,9 @@ class _NativePhotoPickerLoading extends StatelessWidget { key: const ValueKey('ios-inline-photo-picker-loading'), height: _attachmentExpandedHeight, width: double.infinity, - child: const Center(child: CircularProgressIndicator(strokeWidth: 3)), + child: const Center( + child: BuzzLoadingIndicator(size: 44, semanticLabel: 'Opening Photos'), + ), ); } } 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 2b35cc53c6..8b19b74aa2 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -95,7 +95,12 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { Widget buildGalleryBody() { if (recentSnapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator(strokeWidth: 3)); + return const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading recent photos', + ), + ); } if (recentSnapshot.hasError) { return const _PhotoGalleryMessage( @@ -209,12 +214,10 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { key: const ValueKey('photo-gallery-action'), onPressed: isResolving.value ? null : choosePhotos, icon: isResolving.value - ? SizedBox.square( - dimension: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.primary, - ), + ? BuzzLoadingIndicator( + size: 22, + color: context.colors.primary, + semanticLabel: 'Opening all photos', ) : const Icon(LucideIcons.images, size: 18), label: Text(actionLabel), @@ -223,12 +226,10 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { key: const ValueKey('photo-gallery-action'), onPressed: isResolving.value ? null : choosePhotos, icon: isResolving.value - ? const SizedBox.square( - dimension: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), + ? const BuzzLoadingIndicator( + size: 22, + color: Colors.white, + semanticLabel: 'Preparing selected photos', ) : const Icon(LucideIcons.plus, size: 18), label: Text(actionLabel), diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index 45bd1df9c2..54060ae948 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -27,13 +27,10 @@ class _SendButton extends StatelessWidget { ), padding: EdgeInsets.zero, icon: isSending - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.onPrimary, - ), + ? BuzzLoadingIndicator( + size: 18, + color: context.colors.onPrimary, + semanticLabel: 'Sending message', ) : Icon( LucideIcons.arrowUp, diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index d1f0c231ae..15ad07b0cd 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channel_mutes/channel_mutes_provider.dart'; @@ -272,7 +273,12 @@ class ManageChannelSheet extends HookConsumerWidget { ], ); }, - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading channel details', + ), + ), error: (error, _) => Text( error.toString(), style: context.textTheme.bodySmall?.copyWith( diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index 6ea579374f..f7e1af9211 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -10,6 +10,7 @@ import 'package:video_player/video_player.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import 'media_viewer_hero.dart'; export 'media_viewer_hero.dart'; @@ -790,9 +791,10 @@ class _VideoLoadingPoster extends StatelessWidget { _videoPlaceholder(context), const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)), const Center( - child: CircularProgressIndicator( - strokeWidth: 3, + child: BuzzLoadingIndicator( + size: 44, color: Colors.white, + semanticLabel: 'Loading video', ), ), ], diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index b17d9f4823..e1c79164ee 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../profile/user_status.dart'; @@ -150,7 +151,12 @@ class MembersSheet extends HookConsumerWidget { ), ], ), - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading members', + ), + ), error: (error, _) => Center( child: Text( error.toString(), @@ -240,13 +246,10 @@ class _MemberTile extends ConsumerWidget { ? Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: context.appColors.success, - ), + BuzzLoadingIndicator( + size: 14, + color: context.appColors.success, + semanticLabel: 'Agent working', ), const SizedBox(width: Grid.half), Text( diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 4d7482bfdd..e3eed1f96c 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; @@ -61,7 +62,12 @@ class ForumPostsView extends HookConsumerWidget { body: postsAsync.when( loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: const Center(child: CircularProgressIndicator()), + child: const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading posts', + ), + ), ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index f7da19be12..d2e8490d61 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/compose_bar.dart'; @@ -77,7 +78,12 @@ class ForumThreadPage extends HookConsumerWidget { body: threadAsync.when( loading: () => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), - child: const Center(child: CircularProgressIndicator()), + child: const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading thread', + ), + ), ), error: (e, _) => Padding( padding: EdgeInsets.only(top: frostedAppBarHeight(context)), diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index 58221158e5..a88d38ac16 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; @@ -113,10 +114,13 @@ class InviteJoinSheet extends ConsumerWidget { .read(inviteJoinProvider.notifier) .confirmJoin(), icon: isClaiming - ? const SizedBox( + ? SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + child: BuzzLoadingIndicator( + size: 16, + semanticLabel: 'Joining community', + ), ) : const Icon(LucideIcons.check), label: Text(isClaiming ? 'Joining…' : 'Join'), diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 5d471d1f3b..85b781052d 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/tappable_flapping_bee.dart'; import 'pairing_provider.dart'; import 'pairing_qr_scanner.dart'; @@ -255,13 +256,10 @@ class _SasVerificationView extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.primary, - ), + BuzzLoadingIndicator( + size: 24, + color: context.colors.primary, + semanticLabel: 'Connecting', ), const SizedBox(width: Grid.twelve), Text( diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index 58c089bf52..49fc185133 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -87,9 +87,10 @@ class _PairingWelcomeView extends StatelessWidget { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + child: BuzzLoadingIndicator( + size: 20, color: _onboardingCtaLabel, + semanticLabel: 'Opening scanner', ), ) : const Text('Scan a QR code'), @@ -173,12 +174,11 @@ class _PairingWelcomeView extends StatelessWidget { ? const SizedBox( width: 20, height: 20, - child: - CircularProgressIndicator( - strokeWidth: 2, - color: - _onboardingCtaLabel, - ), + child: BuzzLoadingIndicator( + size: 20, + color: _onboardingCtaLabel, + semanticLabel: 'Connecting', + ), ) : const Text('Connect'), ), diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index 240cd980d7..d3df98ee89 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/message_content.dart'; @@ -76,10 +77,15 @@ class ComposeNotePage extends HookConsumerWidget { shape: const StadiumBorder(), ), child: isSending.value - ? const SizedBox( + ? SizedBox( width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + child: BuzzLoadingIndicator( + size: 16, + semanticLabel: _isReply + ? 'Sending reply' + : 'Publishing post', + ), ) : Text(_isReply ? 'Reply' : 'Post'), ), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 1809dc7437..7f4dc24b45 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -365,7 +366,12 @@ class _SearchBody extends ConsumerWidget { if (state.isLoading) const Padding( padding: EdgeInsets.all(Grid.sm), - child: Center(child: CircularProgressIndicator()), + child: Center( + child: BuzzLoadingIndicator( + size: 36, + semanticLabel: 'Loading more search results', + ), + ), ), ], ); diff --git a/mobile/lib/shared/widgets/buzz_loading_indicator.dart b/mobile/lib/shared/widgets/buzz_loading_indicator.dart new file mode 100644 index 0000000000..6d0471ce64 --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_loading_indicator.dart @@ -0,0 +1,98 @@ +import 'dart:math' show pi; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../theme/theme.dart'; + +/// The shared mobile loading indicator, matching the desktop arc spinner. +class BuzzLoadingIndicator extends HookConsumerWidget { + /// The spinner diameter. + final double size; + + /// An optional spinner color. Defaults to the active accent color. + final Color? color; + + /// The accessibility announcement for this loading state. + final String semanticLabel; + + /// Creates a looping arc loading indicator. + const BuzzLoadingIndicator({ + this.size = 40, + this.color, + this.semanticLabel = 'Loading', + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final animation = useAnimationController( + duration: const Duration(milliseconds: 500), + ); + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + final spinnerColor = color ?? context.colors.primary; + final strokeWidth = (size / 6).clamp(2.0, 4.0); + + return Semantics( + liveRegion: true, + label: semanticLabel, + child: ExcludeSemantics( + child: RotationTransition( + key: const ValueKey('buzz-loading-indicator-spinner'), + turns: animation, + child: CustomPaint( + size: Size.square(size), + painter: _ArcSpinnerPainter( + color: spinnerColor, + strokeWidth: strokeWidth, + ), + ), + ), + ), + ); + } +} + +class _ArcSpinnerPainter extends CustomPainter { + final Color color; + final double strokeWidth; + + const _ArcSpinnerPainter({required this.color, required this.strokeWidth}); + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = (size.shortestSide - strokeWidth) / 2; + final bounds = Rect.fromCircle(center: center, radius: radius); + final trackPaint = Paint() + ..color = color.withValues(alpha: color.a * 0.1) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + final arcPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + + canvas + ..drawCircle(center, radius, trackPaint) + ..drawArc(bounds, -pi / 2, pi / 2, false, arcPaint); + } + + @override + bool shouldRepaint(_ArcSpinnerPainter oldDelegate) { + return color != oldDelegate.color || strokeWidth != oldDelegate.strokeWidth; + } +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index b851bf0a6e..bf236e8151 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -610,6 +610,265 @@ void main() { } }); + testWidgets( + 'a pending native popover does not claim another composer tap', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final supportResult = Completer(); + var supportCalls = 0; + var presentCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + supportCalls += 1; + return supportResult.future; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + return null; + } + return null; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => const [], + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: true, + ), + ); + + await tester.tap( + find.byTooltip('Add attachment').hitTestable().at(0), + ); + await tester.pump(); + await tester.tap( + find.byTooltip('Add attachment').hitTestable().at(1), + ); + await tester.pumpAndSettle(); + + expect(supportCalls, 1); + expect(presentCalls, 0); + expect( + find.descendant( + of: find.byKey(const ValueKey('first-composer')), + matching: find.byTooltip('Close attachments'), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('second-composer')), + matching: find.byTooltip('Close attachments'), + ), + findsWidgets, + ); + + supportResult.complete(true); + await tester.pumpAndSettle(); + expect(presentCalls, 0); + expect( + find.descendant( + of: find.byKey(const ValueKey('first-composer')), + matching: find.byTooltip('Close attachments'), + ), + findsNothing, + ); + } finally { + if (!supportResult.isCompleted) supportResult.complete(false); + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets('a repeated owner tap keeps its pending native presentation', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final supportResult = Completer(); + var supportCalls = 0; + var presentCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + supportCalls += 1; + return supportResult.future; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + return null; + } + return null; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => const [], + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: false, + ), + ); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pump(); + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + + expect(supportCalls, 1); + expect(presentCalls, 0); + + supportResult.complete(true); + await tester.pumpAndSettle(); + + expect(presentCalls, 1); + expect(find.byTooltip('Close attachments'), findsNothing); + } finally { + if (!supportResult.isCompleted) supportResult.complete(false); + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('disposing the native popover owner releases ownership', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var presentCalls = 0; + var dismissCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + return true; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + dismissCalls += 1; + return null; + } + return null; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => const [], + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: true, + ), + ); + + await tester.tap(find.byTooltip('Add attachment').hitTestable().at(0)); + await tester.pumpAndSettle(); + expect(presentCalls, 1); + + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: false, + ), + ); + await tester.pumpAndSettle(); + expect(dismissCalls, 1); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + expect(presentCalls, 2); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('missing native dismiss bridge still releases ownership', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var presentCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + return true; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + throw MissingPluginException('dismiss is unavailable'); + } + return null; + }); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryImage: () async => null, + pickGalleryImages: () async => const [], + pickGalleryVideo: () async => null, + ); + + try { + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: true, + ), + ); + + await tester.tap(find.byTooltip('Add attachment').hitTestable().at(0)); + await tester.pumpAndSettle(); + expect(presentCalls, 1); + + await tester.pumpWidget( + _buildNativePopoverOwnershipHarness( + uploadService: uploadService, + includeFirstComposer: false, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + expect(presentCalls, 2); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('uploads an image and sends markdown plus imeta tags', ( tester, ) async { diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index adb61624d8..678be9dfe7 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/tappable_flapping_bee.dart'; import '../../helpers/widget_helpers.dart'; @@ -154,7 +155,7 @@ void main() { ); await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(BuzzLoadingIndicator), findsOneWidget); // Connect text should be replaced by spinner. expect(find.text('Connect'), findsNothing); }); diff --git a/mobile/test/shared/widgets/buzz_loading_indicator_test.dart b/mobile/test/shared/widgets/buzz_loading_indicator_test.dart new file mode 100644 index 0000000000..936c3bc397 --- /dev/null +++ b/mobile/test/shared/widgets/buzz_loading_indicator_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; + +Widget _testable({required bool disableAnimations}) { + return ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: const MediaQueryData().copyWith( + disableAnimations: disableAnimations, + ), + child: const Scaffold( + body: BuzzLoadingIndicator(semanticLabel: 'Loading photos'), + ), + ), + ), + ); +} + +void main() { + testWidgets('animates the shared arc spinner', (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget(_testable(disableAnimations: false)); + await tester.pump(const Duration(milliseconds: 70)); + + final spinner = tester.widget( + find.byKey(const ValueKey('buzz-loading-indicator-spinner')), + ); + expect(spinner.turns.value, greaterThan(0)); + expect(find.bySemanticsLabel('Loading photos'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('holds a static pose when reduced motion is enabled', ( + tester, + ) async { + await tester.pumpWidget(_testable(disableAnimations: true)); + await tester.pump(const Duration(milliseconds: 70)); + + final spinner = tester.widget( + find.byKey(const ValueKey('buzz-loading-indicator-spinner')), + ); + expect(spinner.turns.value, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + }); +} From 35305bfc8fd456ca9a17caa1ddbfaabd87d46981 Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Tue, 28 Jul 2026 11:27:22 -0700 Subject: [PATCH 10/59] docs: restructure DCO guidance into scannable subsection (#3337) Extracts the dense inline DCO paragraph from the "Before You Open a PR" section into a dedicated `### Sign Your Commits` subsection. ## What changed - Adds a `### Sign Your Commits` heading directly below the Conventional Commits paragraph - Leads with the command (`git commit -s`) in a code block - Follows with a plain-English explainer of what the sign-off does - Adds linkable `#### Fix unsigned commits already pushed` and `#### Auto-setup for future commits` subheadings - Removes the old inline paragraph (content preserved, structure only changed) ## Why The existing guidance was buried mid-paragraph; contributors may not find it until CI blocks them. This makes the requirement and its fix immediately visible and actionable. ## Notes Docs-only change, no code modified. Signed-off-by: Cameron Hotchkies Co-authored-by: npub1ep9tf72jk6xgwamqj5m2j0xvqvwm9vdu3zxlz7cesxg53x52tkkqf6pa42 --- CONTRIBUTING.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f319fa20f..53ea0f11c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,28 @@ Buzz is an agent platform, so AI-assisted PRs are welcome. No need to disclose t We squash-merge, so your PR title becomes the commit subject in `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat(mcp): add get_feed_actions tool`. The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is required. See the [Commit Messages](#commit-messages) section for the full reference. -Every commit needs a Developer Certificate of Origin sign-off, so commit with `git commit -s` — it appends the `Signed-off-by` trailer that certifies you wrote the change and can contribute it. The required **DCO Check** blocks merge without it on every commit, and it's the most common reason new PRs stall. If you already pushed unsigned commits, run `git rebase --signoff main` and force-push. Running `just hooks` installs a `commit-msg` hook that adds the trailer to commits created by `git commit` and `git merge`; other flows need their own flag — `git rebase --signoff`, `git cherry-pick -s`. +### Sign Your Commits + +```bash +git commit -s +``` + +Every commit needs a Developer Certificate of Origin (DCO) sign-off. The `-s` flag appends a `Signed-off-by` trailer that certifies you wrote the change and can contribute it under the project license. The **DCO Check** will block your PR without it. + +#### Fix unsigned commits already pushed + +```bash +git rebase --signoff main +git push --force-with-lease +``` + +#### Auto-setup for future commits + +```bash +just hooks +``` + +This installs a `commit-msg` hook that adds the sign-off trailer automatically for `git commit` and `git merge`. Other flows (`git rebase`, `git cherry-pick`) still need their own flag — `--signoff` and `-s` respectively. We review as capacity allows — focused PRs that follow this guide move fastest. From 3afa129ee785cc74d921d0ba969254a8255c4cc0 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Tue, 28 Jul 2026 20:37:12 +0200 Subject: [PATCH 11/59] fix(desktop): keep drafts out of the Inbox All view (#3217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Drafts were showing up in the Home Inbox **All** view, mixed in with messages and reminders (reported in `#buzz-bugs`). Drafts are private composer state, not inbox activity — they now appear only under the dedicated **Drafts** filter. ## Changes - **`inboxListRows.ts`** — drop the `draft` row variant from `buildInboxListRows`; the mixed view builds only `inbox` + `reminder` rows. - **`InboxListPane.tsx`** — remove the draft branch of the All-view render path; `PersonalItemRow` now renders reminders only. - **`useHomePersonalInbox.ts`** — stop enabling draft selection (and its root-status relay probing) for the mixed view; draft selection is scoped to the Drafts filter. - Drafts filter behavior is unchanged: the filter badge count, `DraftsPanel` list, and `DraftDetailPane` all still work. ## Testing - `pnpm test` (desktop unit suite): 3697 passed, 0 failed. - `pnpm exec biome check src/features/home tests`: clean. - Updated `inboxListRows.test.mjs` for the two-variant row model. - Updated the e2e test (`channels.spec.ts`) to assert All never lists drafts and that the draft is still reachable under the Drafts filter. - Added `drafts-all-fix-screenshots.spec.ts` capturing both states (screenshots below). ### All view — draft is gone, messages/reminders unaffected ![01-all-view-no-drafts](https://raw.githubusercontent.com/block/buzz/12c97624832cef40df951c403982994fea58dd80/pr-3217--01-all-view-no-drafts.png) ### Drafts filter — the draft is still listed and editable ![02-drafts-filter-still-lists](https://raw.githubusercontent.com/block/buzz/12c97624832cef40df951c403982994fea58dd80/pr-3217--02-drafts-filter-still-lists.png) Signed-off-by: Thomas Petersen --- desktop/playwright.config.ts | 1 + .../features/home/lib/inboxListRows.test.mjs | 20 +--- .../src/features/home/lib/inboxListRows.ts | 30 ----- .../src/features/home/ui/InboxListPane.tsx | 77 +++---------- .../src/features/home/useHomePersonalInbox.ts | 4 +- desktop/tests/e2e/channels.spec.ts | 11 +- .../e2e/drafts-all-fix-screenshots.spec.ts | 105 ++++++++++++++++++ 7 files changed, 138 insertions(+), 110 deletions(-) create mode 100644 desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..0d89b8e2d2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -103,6 +103,7 @@ export default defineConfig({ "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", + "**/drafts-all-fix-screenshots.spec.ts", "**/inbox-refactor-screenshots.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", diff --git a/desktop/src/features/home/lib/inboxListRows.test.mjs b/desktop/src/features/home/lib/inboxListRows.test.mjs index c4bbcdfff0..b0a093de1b 100644 --- a/desktop/src/features/home/lib/inboxListRows.test.mjs +++ b/desktop/src/features/home/lib/inboxListRows.test.mjs @@ -17,16 +17,6 @@ function inboxItem( }; } -function draftItem(key, updatedAt, rootStatus = "available") { - return { - entry: { - key, - draft: { createdAt: updatedAt, updatedAt }, - }, - rootStatus, - }; -} - function reminder( id, createdAt, @@ -46,20 +36,18 @@ function reminder( test("Inbox All combines rows in latest-first order", () => { const rows = buildInboxListRows({ - drafts: [draftItem("draft", "2026-07-21T12:00:00.000Z")], items: [inboxItem("message", 1_753_099_300)], reminders: [reminder("reminder", 1_753_099_100)], }); assert.deepEqual( rows.map((row) => row.kind), - ["draft", "inbox", "reminder"], + ["inbox", "reminder"], ); }); -test("Inbox All excludes completed reminders and deleted-root drafts", () => { +test("Inbox All excludes completed reminders", () => { const rows = buildInboxListRows({ - drafts: [draftItem("deleted", "2026-07-21T12:00:00.000Z", "deleted")], items: [], reminders: [reminder("done", 1_753_099_100, "done")], }); @@ -69,12 +57,10 @@ test("Inbox All excludes completed reminders and deleted-root drafts", () => { test("Inbox conversation keys stay stable when the representative changes", () => { const first = buildInboxListRows({ - drafts: [], items: [inboxItem("reply-1", 1, "thread-root")], reminders: [], }); const second = buildInboxListRows({ - drafts: [], items: [inboxItem("reply-2", 2, "thread-root")], reminders: [], }); @@ -87,7 +73,6 @@ test("due reminder enriches its existing conversation instead of duplicating it" const item = inboxItem("message", 100); item.groupItems = [{ id: "reminded-reply" }]; const rows = buildInboxListRows({ - drafts: [], items: [item], reminders: [ reminder("reminder", 50, "pending", { @@ -105,7 +90,6 @@ test("due reminder enriches its existing conversation instead of duplicating it" test("due reminder without a represented conversation sorts at trigger time", () => { const rows = buildInboxListRows({ - drafts: [], items: [inboxItem("newer-than-creation", 150)], reminders: [ reminder("reminder", 50, "pending", { diff --git a/desktop/src/features/home/lib/inboxListRows.ts b/desktop/src/features/home/lib/inboxListRows.ts index 499ff96eab..70311a0d13 100644 --- a/desktop/src/features/home/lib/inboxListRows.ts +++ b/desktop/src/features/home/lib/inboxListRows.ts @@ -1,5 +1,4 @@ import type { InboxItem } from "@/features/home/lib/inbox"; -import type { DraftViewItem } from "@/features/messages/ui/DraftsPanel"; import type { Reminder } from "@/features/reminders/lib/reminderTypes"; export type InboxListRow = @@ -15,31 +14,12 @@ export type InboxListRow = kind: "reminder"; reminder: Reminder; sortAt: number; - } - | { - key: string; - kind: "draft"; - item: DraftViewItem; - sortAt: number; }; -function draftActivityAt(item: DraftViewItem): number { - for (const value of [ - item.entry.draft.updatedAt, - item.entry.draft.createdAt, - ]) { - const timestamp = Date.parse(value); - if (Number.isFinite(timestamp)) return timestamp / 1_000; - } - return 0; -} - export function buildInboxListRows({ - drafts, items, reminders, }: { - drafts: readonly DraftViewItem[]; items: readonly InboxItem[]; reminders: readonly Reminder[]; }): InboxListRow[] { @@ -98,15 +78,5 @@ export function buildInboxListRows({ sortAt: reminder.notBefore ?? reminder.createdAt, }), ), - ...drafts - .filter((item) => item.rootStatus !== "deleted") - .map( - (item): InboxListRow => ({ - key: `draft:${item.entry.key}`, - kind: "draft", - item, - sortAt: draftActivityAt(item), - }), - ), ].sort((left, right) => right.sortAt - left.sortAt); } diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 20db0b5150..fa214dc730 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,11 +1,4 @@ -import { - Bell, - Clock, - Ellipsis, - ExternalLink, - FileText, - MailOpen, -} from "lucide-react"; +import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; import * as React from "react"; import { @@ -18,7 +11,6 @@ import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, - getDraftPreview, type DraftViewItem, } from "@/features/messages/ui/DraftsPanel"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; @@ -130,7 +122,6 @@ function formatReminderStatus(notBefore: number | undefined) { function PersonalItemRow({ id, - kind, location, onClick, preview, @@ -138,16 +129,12 @@ function PersonalItemRow({ status, }: { id: string; - kind: "drafts" | "reminders"; location: InboxTypeLabel | null; onClick: () => void; preview: string; selected: boolean; status: string; }) { - const isDraft = kind === "drafts"; - const Icon = isDraft ? FileText : Bell; - return ( - - + handleOpenChange(false)} + publishesCatalogUpdates={ + publishCatalogUpdatesOnSave && hasUserChanges + } + submitBlockReason={null} + submitLabel={submitLabel} + /> } >
setHasUserChanges(true)} onSubmit={handleSubmitForm} > setAvatarUrl("")} + onClearAvatar={() => { + setHasUserChanges(true); + setAvatarUrl(""); + }} onUploadPendingChange={setIsAvatarUploadPending} - onSelectAvatar={setAvatarUrl} + onSelectAvatar={(nextAvatarUrl) => { + setHasUserChanges(true); + setAvatarUrl(nextAvatarUrl); + }} />
@@ -1008,7 +994,10 @@ export function AgentDefinitionDialog({ model={model} modelTuningRuntimeId={runtime} namePoolText={namePoolText} - onBehaviorDraftChange={setBehaviorDraft} + onBehaviorDraftChange={(nextBehaviorDraft) => { + setHasUserChanges(true); + setBehaviorDraft(nextBehaviorDraft); + }} onEnvVarsChange={setEnvVars} onNamePoolTextChange={setNamePoolText} provider={effectiveProvider} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx new file mode 100644 index 0000000000..92428ad95c --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/shared/ui/button"; + +type AgentDefinitionDialogFooterProps = { + canSubmit: boolean; + isAvatarUploadPending: boolean; + isPending: boolean; + onCancel: () => void; + publishesCatalogUpdates: boolean; + submitBlockReason: string | null; + submitLabel: string; +}; + +export function AgentDefinitionDialogFooter({ + canSubmit, + isAvatarUploadPending, + isPending, + onCancel, + publishesCatalogUpdates, + submitBlockReason, + submitLabel, +}: AgentDefinitionDialogFooterProps) { + return ( +
+
+ {submitBlockReason ? ( +

+ {submitBlockReason} +

+ ) : null} + {publishesCatalogUpdates ? ( +

+ This agent is in the community catalog. Your changes will be + published when you save. +

+ ) : null} +
+ +
+ + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx new file mode 100644 index 0000000000..50109143cd --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/shared/lib/cn"; + +export function AgentDefinitionMetadata({ + className, + isBuiltIn, + model, + runtime, +}: { + className?: string; + isBuiltIn: boolean; + model: string | null; + runtime: string | null; +}) { + const items = [ + { + label: "Type", + value: isBuiltIn ? "Built-in agent" : "Custom agent", + }, + { + label: "Preferred model", + value: model ?? "Use app default", + }, + { + label: "Preferred runtime", + value: runtime ?? "Use app default", + }, + ]; + + return ( +
+
+ {items.map((item, index) => ( +
0 && + "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", + )} + key={item.label} + > +

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index 02a6d0e64a..f5be3cc7e8 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -11,7 +11,10 @@ import type { AgentCreateIntent } from "./agentCreateIntent"; import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog"; import { createPersonaDialogState } from "./personaDialogState"; -import { AgentDefinitionDialog } from "./AgentDefinitionDialog"; +import { + AgentDefinitionDialog, + type AgentDefinitionSubmitOptions, +} from "./AgentDefinitionDialog"; import { WhereToRunSection } from "./WhereToRunSection"; import { canSubmitWhereToRun, @@ -64,7 +67,9 @@ type AgentDialogDefinitionEditProps = { onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + publishCatalogUpdatesOnSave?: boolean; }; type AgentDialogProps = diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310d..4a9584dfb9 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -15,6 +15,8 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + // ── Types ───────────────────────────────────────────────────────────────────── type ImportPhase = "preview" | "confirming" | "result"; @@ -164,6 +166,12 @@ function PreviewBody({ ) : null}
+ +

A new agent will be created with a fresh keypair. The imported agent is independent of the source — identity never travels. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 8e1c47c615..6e55f92dfe 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX } from "lucide-react"; +import { OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,7 +20,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { + AGENT_CARD_GRID_COLUMNS_CLASS, + UnifiedAgentsSection, +} from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -70,11 +73,14 @@ export function AgentsView() { const runningAgentCount = agents.managedAgents.filter((agent) => isManagedAgentActive(agent), ).length; - // Show the resolved effective model, not just the structured `model` field: - // most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL) - // or inherit a baked build default, leaving `globalConfig.model` null. - const configuredGlobalModel = inheritedDefaults.model.value; - + const hasSavedAgentDefaults = Boolean( + globalConfig.preferred_runtime?.trim() || + globalConfig.provider?.trim() || + globalConfig.model?.trim() || + Object.values(globalConfig.env_vars).some( + (value) => value.trim().length > 0, + ), + ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { // Consume a snapshot import that was enqueued before navigation (e.g. from @@ -106,18 +112,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -135,11 +146,10 @@ export function AgentsView() { ) : null}
} - className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> -
+
0} personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error @@ -186,10 +195,8 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={() => { - openUnifiedCreate(); - }} - onChooseCatalog={personas.openCatalog} + onCreatePersona={openUnifiedCreate} + onDiscoverPersonas={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -289,9 +296,11 @@ export function AgentsView() { error={ personas.updatePersonaMutation.error instanceof Error ? personas.updatePersonaMutation.error - : personas.createPersonaMutation.error instanceof Error - ? personas.createPersonaMutation.error - : null + : personas.updatePersonaAndPublishMutation.error instanceof Error + ? personas.updatePersonaAndPublishMutation.error + : personas.createPersonaMutation.error instanceof Error + ? personas.createPersonaMutation.error + : null } initialValues={personas.personaDialogState.initialValues} isPending={personas.isPending} @@ -303,8 +312,22 @@ export function AgentsView() { personas.setPersonaDialogState(null); } }} - onSubmit={personas.handleSubmit} + onSubmit={(input, options) => + personas.handleSubmit( + input, + undefined, + undefined, + undefined, + options, + ) + } open={personas.personaDialogState !== null} + publishCatalogUpdatesOnSave={ + "id" in personas.personaDialogState.initialValues && + personas.sharedCatalogPersonaIdSet.has( + personas.personaDialogState.initialValues.id, + ) + } submitLabel={personas.personaDialogState.submitLabel} title={personas.personaDialogState.title} /> @@ -330,8 +353,19 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + void personas.setPersonaCatalogShareLevel( + shareTarget.persona, + shareLevel, + ); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; @@ -390,8 +424,8 @@ export function AgentsView() { {personas.isCatalogDialogOpen ? ( { personas.clearFeedback("catalog"); }} diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx index 70d063098b..4fdd6db26f 100644 --- a/desktop/src/features/agents/ui/CreateIdentityCard.tsx +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn"; type CreateIdentityCardProps = React.ButtonHTMLAttributes & { ariaLabel: string; dataTestId: string; - label: string; + label?: string; }; export const CreateIdentityCard = React.forwardRef< @@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef< > - {label} + {label ? ( + {label} + ) : null} ); diff --git a/desktop/src/features/agents/ui/PersonaAddedBy.tsx b/desktop/src/features/agents/ui/PersonaAddedBy.tsx index 66e5ee31f9..3cdec29104 100644 --- a/desktop/src/features/agents/ui/PersonaAddedBy.tsx +++ b/desktop/src/features/agents/ui/PersonaAddedBy.tsx @@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn"; type PersonaAddedByProps = { className?: string; + label?: string; }; -export function PersonaAddedBy({ className }: PersonaAddedByProps) { +export function PersonaAddedBy({ + className, + label = "You", +}: PersonaAddedByProps) { return (

Added by{" "} - You + {label}

); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583ff..ba76d6e4ed 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -11,6 +12,8 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; +import agentOutlineUrl from "../assets/agent-outline.svg"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -28,7 +31,7 @@ type PersonaCatalogDialogProps = { }; const agentInstructionMarkdownClassName = [ - "mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", + "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", @@ -100,7 +103,7 @@ export function PersonaCatalogDialog({ +
+ +

+ {personaCatalogCopy.emptyCatalogTitle} +

+

+ {personaCatalogCopy.emptyCatalogDescription} +

+
+
+ ); + } + return (
@@ -200,9 +228,9 @@ function PersonaCatalogChooser({
-
+
{isLoading ? : null} @@ -211,19 +239,6 @@ function PersonaCatalogChooser({ ) : null} - {!isLoading && personas.length === 0 && !error ? ( -
-
-

- {personaCatalogCopy.emptyCatalogTitle} -

-

- {personaCatalogCopy.emptyCatalogDescription} -

-
-
- ) : null} - {error ? (

{error.message} @@ -263,7 +278,7 @@ function PersonaCatalogChooser({ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { return ( -

+
{persona.displayName} - {persona.isBuiltIn ? null : } + {persona.isBuiltIn ? null : ( + + )}
- -
+

Agent instruction

@@ -309,36 +322,6 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { ); } -function PersonaCatalogMetaGroup({ - items, -}: { - items: { label: string; value: string }[]; -}) { - return ( -
-
- {items.map((item, index) => ( -
0 && - "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", - )} - key={item.label} - > -

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); -} - function PersonaCatalogListSkeleton() { return (
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index b6d3fafd3c..af45e8f071 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AlertCircle, + BookUser, Check, ChevronRight, Download, @@ -11,6 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -20,7 +22,6 @@ import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; import type { AgentPersona, UserSearchResult } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; import { AlertDialog, AlertDialogAction, @@ -39,7 +40,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -51,8 +51,10 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + catalogShareLevel: CatalogPersonaShareLevel; isPending: boolean; linkedAgentPubkey: string | null; + onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -60,6 +62,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { + afterLink?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -109,6 +112,20 @@ type PendingMemoryShare = { recipientNames?: string[]; }; +function buildSnapshotShareLevels(itemLabel: "Agent" | "Team") { + return [ + { value: "none" as const, label: `${itemLabel} only` }, + { + value: "core" as const, + label: `${itemLabel} + core memory`, + }, + { + value: "everything" as const, + label: `${itemLabel} + all memories`, + }, + ]; +} + function formatRecipientAudience(names: readonly string[]): string { if (names.length === 0) return "The people you selected"; if (names.length === 1) return names[0] ?? "The person you selected"; @@ -179,39 +196,32 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, - className, disabled, hasMemoryOptions, - onOpenChange, - staticClassName, - staticLabel, testId, value, options, onChange, }: { ariaLabel: string; - className?: string; disabled: boolean; hasMemoryOptions: boolean; - onOpenChange?: (open: boolean) => void; - staticClassName?: string; - staticLabel: string; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { if (!hasMemoryOptions) { + // Nothing to choose from, so there is no dropdown to open. State the + // outcome rather than naming the sole option: the memory-level labels + // ("Agent only", "+ core memory", …) are comparative and only make sense + // when the alternatives are actually offered. return ( - {staticLabel} + No memories included ); } @@ -219,9 +229,7 @@ function ShareLevelControl({ return ( onChange(nextValue as SnapshotMemoryLevel)} options={options} testId={testId} @@ -231,6 +239,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + afterLink, displayName, encodeSnapshot, hasMemoryOptions, @@ -252,9 +261,7 @@ export function SnapshotShareDialog({ const [copyStatus, setCopyStatus] = React.useState("idle"); const [pendingMemoryShare, setPendingMemoryShare] = React.useState(null); - const [linkShareLevel, setLinkShareLevel] = - React.useState("none"); - const [recipientShareLevel, setRecipientShareLevel] = + const [shareLevel, setShareLevel] = React.useState("none"); const encodedSnapshotCacheRef = React.useRef( new Map>(), @@ -273,9 +280,7 @@ export function SnapshotShareDialog({ const isActionPending = isPending || isCopying || isSending; const isInterfacePending = isPending || isSending; const hasSelectedRecipients = selectedRecipients.length > 0; - const showMemoryWarning = - linkShareLevel !== "none" || - (hasSelectedRecipients && recipientShareLevel !== "none"); + const showMemoryWarning = shareLevel !== "none"; const recipientActionTransition = shouldReduceMotion ? { duration: 0 } : RECIPIENT_ACTION_TRANSITION; @@ -298,17 +303,7 @@ export function SnapshotShareDialog({ const itemLabel = snapshotKind === "team" ? "team" : "agent"; const itemLabelTitle = snapshotKind === "team" ? "Team" : "Agent"; const shareLevels = React.useMemo( - () => [ - { value: "none" as const, label: `${itemLabelTitle} only` }, - { - value: "core" as const, - label: `${itemLabelTitle} + core memory`, - }, - { - value: "everything" as const, - label: `${itemLabelTitle} + all memories`, - }, - ], + () => buildSnapshotShareLevels(itemLabelTitle), [itemLabelTitle], ); const getEncodedSnapshot = React.useCallback( @@ -337,8 +332,7 @@ export function SnapshotShareDialog({ setSelectedRecipients([]); setCopyStatus("idle"); setPendingMemoryShare(null); - setLinkShareLevel("none"); - setRecipientShareLevel("none"); + setShareLevel("none"); onReset?.(); snapshotSendController.reset(); } @@ -495,21 +489,6 @@ export function SnapshotShareDialog({ excludedPubkeys={excludedRecipientPubkeys} onSelectionChange={setSelectedRecipients} open={open} - renderEndControl={(handleAccessOpenChange) => ( - - )} selectedUsers={selectedRecipients} testIdPrefix={testIdPrefix} /> @@ -532,9 +511,7 @@ export function SnapshotShareDialog({ isActionPending || !snapshotSendController.isDmSafetyReady } - onClick={() => - requestMemoryShare("send", recipientShareLevel) - } + onClick={() => requestMemoryShare("send", shareLevel)} type="button" > {isSending ? "Sending…" : "Send"} @@ -552,6 +529,116 @@ export function SnapshotShareDialog({

+
+ + + +
+

Share with a link

+

+ Anyone with the link can add and use a copy. +

+
+ +
+ +
+

+ What’s included +

+ +
+ {showMemoryWarning ? ( -
-
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
- -
- -
- -
-
+ {afterLink}
- {selectedUsers.length > 0 && renderEndControl - ? renderEndControl((controlOpen) => { - if (controlOpen) setIsPickerOpen(false); - }) - : null}
0 ? 1 : 0); if (visiblePersonas.length === 0 && overflowCount === 0) { return ( @@ -130,16 +131,26 @@ function TeamAvatarRow({
{visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? ( - - +{overflowCount} - +
0 ? "-ml-5" : ""} + style={{ zIndex: stackItemCount }} + > + + +{overflowCount} + +
) : null}
@@ -148,25 +159,39 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, + isFollowedByAnother, persona, }: { index: number; + isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return ( -
+
0 ? "-ml-5" : ""}`} + data-team-member-avatar="avatar" + style={{ + zIndex: index + 1, + ...(isFollowedByAnother && { + mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", + WebkitMask: + "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", + }), + }} + > {avatarUrl ? ( ) : ( - + - - Import team snapshot + Import diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index f24d6a41ff..34f9f9819f 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -45,7 +45,6 @@ type UnifiedAgentsSectionProps = { onOpenPersonaProfile: (persona: AgentPersona) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; - canChooseCatalog: boolean; personas: AgentPersona[]; personasError: Error | null; personaFeedbackErrorMessage: string | null; @@ -53,7 +52,7 @@ type UnifiedAgentsSectionProps = { isPersonasLoading: boolean; isPersonasPending: boolean; onCreatePersona: () => void; - onChooseCatalog: () => void; + onDiscoverPersonas: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; onSharePersona: ( @@ -66,7 +65,9 @@ type UnifiedAgentsSectionProps = { }; const AGENT_CARD_COLUMN_CLASS = "w-full"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; +export const AGENT_CARD_GRID_COLUMNS_CLASS = + "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -83,7 +84,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onOpenPersonaProfile, onStartAgent, onStartPersona, - canChooseCatalog, personas, personasError, personaFeedbackErrorMessage, @@ -91,7 +91,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasLoading, isPersonasPending, onCreatePersona, - onChooseCatalog, + onDiscoverPersonas, onDuplicatePersona, onEditPersona, onSharePersona, @@ -184,11 +184,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); })}
@@ -430,50 +429,37 @@ function firstAvatarUrl( } function NewAgentCard({ - canChooseCatalog, - isPersonasPending, - openFilePicker, - onChooseCatalog, - onCreatePersona, + isPending, + onCreate, + onDiscover, + onImport, }: { - canChooseCatalog: boolean; - isPersonasPending: boolean; - openFilePicker: () => void; - onChooseCatalog: () => void; - onCreatePersona: () => void; + isPending: boolean; + onCreate: () => void; + onDiscover: () => void; + onImport: () => void; }) { return ( - + event.preventDefault()} > - - Create from scratch + + Create agent + + + Discover agents - {canChooseCatalog ? ( - - Choose from catalog - - ) : null} - Import agent snapshot + Import diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..1313d2cec4 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -426,6 +426,60 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { return `${runtime.label}${suffix}`; } +export function buildPersonaRuntimeDropdownOptions({ + defaultRuntimeId, + isCreateMode, + runtime, + runtimes, + runtimesLoading, +}: { + defaultRuntimeId?: string; + isCreateMode: boolean; + runtime: string; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; +}): { + blankRuntimeOptionLabel: string; + runtimeDropdownOptions: PersonaDropdownOption[]; +} { + const blankRuntimeOptionLabel = runtimesLoading + ? "Loading harnesses..." + : isCreateMode + ? "Choose a harness" + : "No preference (use app default)"; + const runtimeDropdownOptions: PersonaDropdownOption[] = [ + ...(!isCreateMode + ? [ + { + label: blankRuntimeOptionLabel, + value: NO_RUNTIME_DROPDOWN_VALUE, + }, + ] + : []), + ...sortPersonaRuntimes(runtimes).map((candidate) => ({ + disabled: + isCreateMode && + defaultRuntimeId !== undefined && + candidate.availability !== "available", + label: `${formatRuntimeOptionLabel(candidate)}${ + isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : "" + }`, + value: candidate.id, + })), + ]; + const currentRuntime = runtime.trim(); + if ( + currentRuntime.length > 0 && + !runtimeDropdownOptions.some((option) => option.value === currentRuntime) + ) { + runtimeDropdownOptions.push({ + label: `${currentRuntime} (current)`, + value: currentRuntime, + }); + } + return { blankRuntimeOptionLabel, runtimeDropdownOptions }; +} + function runtimeAvailabilitySortRank( availability: AcpRuntimeCatalogEntry["availability"], ) { diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 53c5e7a16f..79ddad1c3c 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -14,14 +14,13 @@ export const personaLibraryCopy = { export const personaCatalogCopy = { title: "Agent Catalog", - description: "Browse built-in agents and add them to My Agents.", + description: "Browse agents shared to this relay.", dialogTitle: "Agent Catalog", - dialogDescription: "Browse built-in agents and add them to My Agents.", + dialogDescription: "Browse agents shared to this relay.", emptyTitle: "You're all set", emptyDescription: "Everything in Agent Catalog is already in My Agents.", - emptyCatalogDescription: - "New agents will show up here when the app ships more options.", - emptyCatalogTitle: "No agents in the catalog yet", + emptyCatalogDescription: "Shared agents will appear here.", + emptyCatalogTitle: "No agents are being shared", detailsAction: "View details", selectAction: "Choose", deselectAction: "Deselect", diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 54535d121c..0c56eeac10 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,9 +17,26 @@ import { type AgentSnapshotImportPreview, type AgentSnapshotImportResult, } from "@/features/agents/hooks"; -import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; -import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; +import { + getLibraryPersonas, + getPersonaLabelsById, +} from "@/features/agents/lib/catalog"; +import { + type CatalogPersonaShareLevel, + catalogPersonasFromPublications, + findLocalPersonaForCatalogEntry, + isCatalogPersona, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + useSetPersonaCatalogSharedMutation, + useUpdatePersonaAndPublishMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; +import { personaSaveNotice } from "@/features/agents/lib/personaSaveNotice"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -51,7 +68,14 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const catalogQuery = usePersonaCatalogQuery(communityId); + usePersonaCatalogLiveUpdates(communityId); + const setCatalogSharedMutation = + useSetPersonaCatalogSharedMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -60,6 +84,8 @@ export function usePersonaActions() { const createAgentMutation = useCreateManagedAgentMutation(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); + const updatePersonaAndPublishMutation = + useUpdatePersonaAndPublishMutation(communityId); const deletePersonaMutation = useDeletePersonaMutation(); const setPersonaActiveMutation = useSetPersonaActiveMutation(); const exportAgentSnapshotMutation = useExportAgentSnapshotMutation(); @@ -101,9 +127,15 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); + return new Set( + publications + .filter((publication) => publication.ownerPubkey === currentPubkey) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -112,8 +144,21 @@ export function usePersonaActions() { ), [acpRuntimesQuery.data], ); - const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas), + const catalogPersonas = React.useMemo( + () => + catalogPersonasFromPublications( + publications, + personas, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, personas, publications], + ); + const libraryPersonas = React.useMemo( + () => getLibraryPersonas(personas), + [personas], + ); + const personaLabelsById = React.useMemo( + () => getPersonaLabelsById(personas), [personas], ); @@ -130,6 +175,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -139,8 +185,24 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); - setPersonaNoticeMessage(`Updated ${input.displayName}.`); + // "Save and publish" promises the community catalog sees this edit, so + // it must use the command that awaits the relay. A plain save only + // enqueues the head and cannot report the outcome. + if (options?.publishCatalogUpdates) { + const result = + await updatePersonaAndPublishMutation.mutateAsync(input); + if (result.publicationStatus === "queued" && result.relayMessage) { + console.warn( + `[updatePersonaAndPublish] relay publication queued: ${result.relayMessage}`, + ); + } + setPersonaNoticeMessage( + personaSaveNotice(input.displayName, result.publicationStatus), + ); + } else { + await updatePersonaMutation.mutateAsync(input); + setPersonaNoticeMessage(personaSaveNotice(input.displayName, null)); + } } else { const runtime = availableRuntimes.find( (candidate) => candidate.id === input.runtime, @@ -240,7 +302,46 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const localPersona = findLocalPersonaForCatalogEntry( + personas, + persona.catalogSource, + ); + + if (localPersona) { + if (!localPersona.isActive) { + await setPersonaActiveMutation.mutateAsync({ + id: localPersona.id, + active: true, + }); + } + } else { + await createPersonaMutation.mutateAsync({ + displayName: persona.displayName, + avatarUrl: persona.avatarUrl ?? undefined, + systemPrompt: persona.systemPrompt, + runtime: persona.runtime ?? undefined, + model: persona.model ?? undefined, + provider: persona.provider ?? undefined, + namePool: persona.namePool, + behavior: { + respondTo: + persona.respondTo === "anyone" ? "anyone" : "owner-only", + parallelism: persona.parallelism ?? undefined, + }, + // Provenance on the copy: without it the copy's fresh local id is + // the only identifier, and the catalog offers "Add" again. + catalogSource: persona.catalogSource.isOwn + ? undefined + : { + ownerPubkey: persona.catalogSource.ownerPubkey, + personaId: persona.catalogSource.personaId, + }, + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -334,6 +435,7 @@ export function usePersonaActions() { function openCatalog() { clearFeedback("catalog"); + void catalogQuery.refetch(); setIsCatalogDialogOpen(true); } @@ -386,22 +488,83 @@ export function usePersonaActions() { ); } + function getPersonaCatalogShareLevel( + persona: AgentPersona, + ): CatalogPersonaShareLevel { + return persona.shared ? "none" : "not-shared"; + } + + async function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, + ): Promise { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + try { + const shared = shareLevel !== "not-shared"; + const result = await setCatalogSharedMutation.mutateAsync({ + id: persona.id, + shared, + }); + setPersonaToShare((current) => + current?.persona.id === result.persona.id + ? { ...current, persona: result.persona } + : current, + ); + if (result.publicationStatus === "queued") { + if (shared) { + setPersonaNoticeMessage( + `Sharing ${persona.displayName} is queued. It will appear after the relay accepts the update.`, + ); + } else { + setPersonaNoticeMessage( + `Removing ${persona.displayName} is queued. It may remain discoverable until the relay accepts the update.`, + ); + } + if (result.relayMessage) { + console.warn( + `[setPersonaShared] relay publication queued: ${result.relayMessage}`, + ); + } + } else if (!shared) { + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); + } else { + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); + } + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || createAgentMutation.isPending || updatePersonaMutation.isPending || + updatePersonaAndPublishMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + setCatalogSharedMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, + updatePersonaAndPublishMutation, setPersonaActiveMutation, catalogPersonas, libraryPersonas, @@ -431,6 +594,9 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx deleted file mode 100644 index 268bd863f2..0000000000 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import * as React from "react"; - -import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { - useManagedAgentRuntimeAction, - useManagedAgentRuntimesQuery, -} from "@/features/agents/managedAgentRuntimeHooks"; -import { - agentCommunityAvailability, - agentCommunityStatusDetail, - managedAgentRuntimeKey, -} from "@/features/agents/managedAgentRuntimeStatus"; -import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { Badge } from "@/shared/ui/badge"; -import { truncatePubkey } from "@/shared/lib/pubkey"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -export function ActiveAgentCommunitiesSettingsCard() { - const agentsQuery = useManagedAgentsQuery(); - const runtimesQuery = useManagedAgentRuntimesQuery(); - const action = useManagedAgentRuntimeAction(); - const [pendingRuntimeKey, setPendingRuntimeKey] = React.useState< - string | null - >(null); - - const agentNames = React.useMemo( - () => - new Map( - (agentsQuery.data ?? []).map((agent) => [ - agent.pubkey.toLowerCase(), - agent.name, - ]), - ), - [agentsQuery.data], - ); - const runtimes = runtimesQuery.data ?? []; - - async function runAction(runtime: ManagedAgentRuntimeStatus) { - setPendingRuntimeKey(managedAgentRuntimeKey(runtime)); - try { - await action.mutateAsync({ - action: - runtime.lifecycle === "starting" || - runtime.lifecycle === "listening" || - runtime.lifecycle === "waking" || - runtime.lifecycle === "ready" - ? "stop" - : runtime.lifecycle === "stopped" - ? "start" - : "restart", - pubkey: runtime.pubkey, - relayUrl: runtime.relayUrl, - }); - } finally { - setPendingRuntimeKey(null); - } - } - - return ( -
- -
- {runtimesQuery.isPending ? ( -

Loading…

- ) : runtimes.length === 0 ? ( -

- No agent community runtimes found. -

- ) : ( - runtimes.map((runtime) => { - const status = agentCommunityAvailability(runtime); - const detail = agentCommunityStatusDetail(runtime); - const runtimeKey = managedAgentRuntimeKey(runtime); - const pending = pendingRuntimeKey === runtimeKey; - return ( -
-
-
-

- {agentNames.get(runtime.pubkey.toLowerCase()) ?? - truncatePubkey(runtime.pubkey)} -

- - {status} - -
-

- {runtime.relayUrl} -

- {detail ? ( -

{detail}

- ) : null} -
- {runtime.localSetup ? ( - - ) : null} -
- ); - }) - )} -
- {action.error instanceof Error ? ( -

{action.error.message}

- ) : null} -
- ); -} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 156be00b72..e74d1f3837 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -77,7 +77,6 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; -import { ActiveAgentCommunitiesSettingsCard } from "./ActiveAgentCommunitiesSettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -815,7 +814,6 @@ export function renderSettingsSection(
-
); diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs index 8503a79349..d4e0b1d5f3 100644 --- a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs @@ -12,6 +12,9 @@ import test from "node:test"; function makePreview(overrides = {}) { return { displayName: "Test Agent", + isBuiltIn: false, + model: null, + runtime: null, systemPrompt: "You are helpful.", avatarUrl: null, memoryLevel: "none", diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index bd3be92407..66e07f5e88 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -17,7 +17,14 @@ export type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active?: boolean; + shared?: boolean; source_team?: string | null; + /** + * Provenance of a local copy of another owner's catalog entry. Serialized by + * the backend `CatalogSource` in snake_case; the create payload sends the + * camelCase aliases it accepts. + */ + catalog_source?: { owner_pubkey: string; persona_id: string } | null; env_vars?: Record; respond_to?: string | null; respond_to_allowlist?: string[]; @@ -40,7 +47,14 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { namePool: persona.name_pool ?? [], isBuiltIn: persona.is_builtin, isActive: persona.is_active ?? true, + shared: persona.shared ?? false, sourceTeam: persona.source_team ?? null, + catalogSource: persona.catalog_source + ? { + ownerPubkey: persona.catalog_source.owner_pubkey, + personaId: persona.catalog_source.persona_id, + } + : null, envVars: persona.env_vars ?? {}, respondTo: (persona.respond_to as RespondToMode | undefined) ?? null, respondToAllowlist: persona.respond_to_allowlist ?? [], @@ -69,31 +83,37 @@ export async function createPersona( namePool: input.namePool ?? [], envVars: input.envVars ?? {}, behavior: input.behavior, + catalogSource: input.catalogSource, }, }), ); } +/** The `UpdatePersonaRequest` payload shared by both edit commands. */ +function updatePersonaPayload(input: UpdatePersonaInput) { + return { + id: input.id, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + systemPrompt: input.systemPrompt, + runtime: input.runtime, + model: input.model, + provider: input.provider, + namePool: input.namePool ?? [], + // Send envVars only when caller explicitly provided it; omitting + // tells the backend "don't touch the stored env vars" so editing + // unrelated fields can't silently wipe saved credentials. + envVars: input.envVars, + // Same absent-vs-present contract as envVars for the behavioral quad. + behavior: input.behavior, + }; +} + export async function updatePersona( input: UpdatePersonaInput, ): Promise { const raw = await invokeTauri("update_persona", { - input: { - id: input.id, - displayName: input.displayName, - avatarUrl: input.avatarUrl, - systemPrompt: input.systemPrompt, - runtime: input.runtime, - model: input.model, - provider: input.provider, - namePool: input.namePool ?? [], - // Send envVars only when caller explicitly provided it; omitting - // tells the backend "don't touch the stored env vars" so editing - // unrelated fields can't silently wipe saved credentials. - envVars: input.envVars, - // Same absent-vs-present contract as envVars for the behavioral quad. - behavior: input.behavior, - }, + input: updatePersonaPayload(input), }); if (raw.writeback_warning) { console.warn( @@ -103,6 +123,41 @@ export async function updatePersona( return fromRawPersona(raw); } +/** + * Save an edit AND publish the persona's catalog head, reporting whether the + * relay accepted it. + * + * `updatePersona` only enqueues the head best-effort, so it cannot tell the UI + * whether the community catalog actually received the change. Use this for the + * "Save and publish" affordance, which promises exactly that. + */ +export async function updatePersonaAndPublish( + input: UpdatePersonaInput, +): Promise { + return fromRawPublicationResult( + await invokeTauri( + "update_persona_and_publish", + { input: updatePersonaPayload(input) }, + ), + ); +} + +type RawPersonaSharePublicationResult = { + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}; + +function fromRawPublicationResult( + raw: RawPersonaSharePublicationResult, +): PersonaSharePublicationResult { + return { + persona: fromRawPersona(raw.persona), + publicationStatus: raw.publicationStatus, + relayMessage: raw.relayMessage ?? null, + }; +} + export async function deletePersona(id: string): Promise { await invokeTauri("delete_persona", { id }); } @@ -116,6 +171,24 @@ export async function setPersonaActive( ); } +export async function setPersonaShared( + id: string, + shared: boolean, +): Promise { + return fromRawPublicationResult( + await invokeTauri("set_persona_shared", { + id, + shared, + }), + ); +} + +export type PersonaSharePublicationResult = { + persona: AgentPersona; + publicationStatus: "published" | "queued"; + relayMessage: string | null; +}; + export type SnapshotMemoryLevel = "none" | "core" | "everything"; export type SnapshotFormat = "json" | "png"; @@ -172,6 +245,10 @@ export async function encodeAgentSnapshotForSend( /** Preview returned by `preview_agent_snapshot_import` before any write. */ export type AgentSnapshotImportPreview = { displayName: string; + /** Source classification shown in the preview; imports remain custom. */ + isBuiltIn: boolean; + model: string | null; + runtime: string | null; systemPrompt: string | null; /** Effective avatar: data URL if present, source URL fallback otherwise. */ avatarUrl: string | null; @@ -235,9 +312,15 @@ export async function confirmAgentSnapshotImport( // Patches a single inbound persona/team/agent projection event into the local // store (personas.json). The backend resolves the match key and the -// pending-edit race; the frontend only forwards the raw Nostr event JSON. +// pending-edit race; the frontend forwards the raw Nostr event JSON plus the +// relay it arrived on, so a workspace switch mid-flight cannot retain the event +// into the newly active community's scoped store. export async function reconcileInboundPersonaEvent( eventJson: string, + arrivalRelayUrl: string, ): Promise { - await invokeTauri("reconcile_inbound_persona_event", { eventJson }); + await invokeTauri("reconcile_inbound_persona_event", { + eventJson, + arrivalRelayUrl, + }); } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d14b66eebb..689c400b03 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -753,10 +753,17 @@ export type AgentPersona = { namePool: string[]; isBuiltIn: boolean; isActive: boolean; + /** Whether this persona is discoverable in the active community catalog. */ + shared: boolean; /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ sourceTeam?: string | null; - /** Environment variables injected for agents created from this persona. - * Layered as: desktop parent env < persona envVars < agent envVars. */ + /** + * Set only on a local copy of another owner's shared catalog entry. A copy + * carries a fresh local `id`, so this coordinate is the only thing that can + * answer "is this catalog entry already added" without minting a duplicate. + */ + catalogSource?: CatalogSourceCoordinate | null; + /** Agent environment variables, layered after desktop parent and persona values. */ envVars: Record; /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ respondTo: RespondToMode | null; @@ -767,9 +774,18 @@ export type AgentPersona = { }; /** - * NIP-AP behavioral group for a definition, sent as one group: absent = don't - * touch the stored behavior group (legacy callers), present = replace the fields as a - * unit. Mirrors `PersonaBehaviorRequest`. + * A catalog publication's coordinate: the owner who published it and the + * `d`-tag identifying the persona within that owner's catalog. Mirrors the + * backend `CatalogSource`. + */ +export type CatalogSourceCoordinate = { + ownerPubkey: string; + personaId: string; +}; + +/** + * NIP-AP behavioral group for a definition: absent preserves the stored group + * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. */ export type PersonaBehaviorInput = { respondTo?: RespondToMode; @@ -787,6 +803,11 @@ export type CreatePersonaInput = { namePool?: string[]; envVars?: Record; behavior?: PersonaBehaviorInput; + /** + * Set when this persona is a copy of another owner's shared catalog entry, + * so the catalog can tell an already-added foreign entry from a new one. + */ + catalogSource?: CatalogSourceCoordinate; }; export type UpdatePersonaInput = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03c05fe877..7b13273c60 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -40,6 +40,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PERSONA, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -112,13 +113,17 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; runtime?: string | null; model?: string | null; provider?: string | null; namePool?: string[]; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockTeamSeed = { @@ -224,6 +229,10 @@ type E2eConfig = { * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -809,7 +818,9 @@ type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active: boolean; + shared: boolean; source_team?: string | null; + catalog_source?: { owner_pubkey: string; persona_id: string } | null; env_vars?: Record; respond_to?: string | null; respond_to_allowlist?: string[]; @@ -2171,6 +2182,7 @@ function resetMockPersonas(config?: E2eConfig) { name_pool: [], is_builtin: true, is_active: activePersonaIds.has(persona.id), + shared: false, source_team: null, created_at: now, updated_at: now, @@ -2186,12 +2198,18 @@ function resetMockPersonas(config?: E2eConfig) { model: persona.model ?? null, provider: persona.provider ?? null, name_pool: persona.namePool ?? [], + respond_to: persona.respondTo ?? null, + respond_to_allowlist: + persona.respondTo === "allowlist" + ? [...(persona.respondToAllowlist ?? [])] + : [], is_builtin: false, is_active: persona.isActive ?? true, + shared: persona.shared ?? false, source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, - updated_at: now, + updated_at: persona.updatedAt ?? now, }); } } @@ -2786,6 +2804,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2816,6 +2835,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { })); } +function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { + mockPersonaEvents.length = 0; + for (const event of config?.mock?.personaCatalogEvents ?? []) { + mockPersonaEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -3846,6 +3875,13 @@ function emitMockLiveEvent(channelId: string, event: RelayEvent) { } function emitMockGlobalEvent(event: RelayEvent) { + if ( + event.kind === KIND_PERSONA && + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !personaHasExactSharedTag(event) + ) { + return; + } for (const socket of mockSockets.values()) { for (const [subId, subscription] of socket.subscriptions) { if (subscription.kinds && !subscription.kinds.includes(event.kind)) { @@ -7158,6 +7194,9 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +// Per-page explicit catalog publication outcomes. +let personaSharePublicationCallCount = 0; + // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -7351,6 +7390,7 @@ async function handleCreatePersona(args: { provider?: string; envVars?: Record; behavior?: PersonaBehaviorInput; + catalogSource?: { ownerPubkey: string; personaId: string }; }; }): Promise { const now = new Date().toISOString(); @@ -7364,49 +7404,78 @@ async function handleCreatePersona(args: { provider: args.input.provider?.trim() || null, is_builtin: false, is_active: true, + shared: false, source_team: null, + // Mirrors `CatalogSource::normalized`: the coordinate a catalog copy keeps + // so the catalog can tell an already-added foreign entry from a new one. + catalog_source: args.input.catalogSource + ? { + owner_pubkey: args.input.catalogSource.ownerPubkey + .trim() + .toLowerCase(), + persona_id: args.input.catalogSource.personaId.trim(), + } + : null, env_vars: { ...(args.input.envVars ?? {}) }, created_at: now, updated_at: now, }; applyMockPersonaBehavior(persona, args.input.behavior); mockPersonas.push(persona); + upsertMockPersonaEvent(persona); return { ...persona }; } +type MockUpdatePersonaInput = { + id: string; + displayName: string; + avatarUrl?: string; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + envVars?: Record; + behavior?: PersonaBehaviorInput; +}; + async function handleUpdatePersona(args: { - input: { - id: string; - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - envVars?: Record; - behavior?: PersonaBehaviorInput; - }; + input: MockUpdatePersonaInput; }): Promise { - const persona = mockPersonas.find( - (candidate) => candidate.id === args.input.id, - ); + return { ...applyMockPersonaUpdate(args.input) }; +} + +/** + * Save an edit to the mock persona store, exactly like `update_persona_with`, + * and return the live record so a caller can publish it. + * + * Deliberately does NOT publish a catalog event: the real `update_persona` + * only enqueues a pending head for the out-of-band flush loop, so nothing has + * reached the relay by the time the command returns. Publishing here would + * make a UI that never calls `update_persona_and_publish` look like it kept + * the "Save and publish" promise. + */ +function applyMockPersonaUpdate(input: MockUpdatePersonaInput): RawPersona { + const persona = mockPersonas.find((candidate) => candidate.id === input.id); if (!persona) { - throw new Error(`agent ${args.input.id} not found`); - } - persona.display_name = args.input.displayName.trim(); - persona.avatar_url = args.input.avatarUrl?.trim() || null; - persona.system_prompt = args.input.systemPrompt.trim(); - persona.runtime = args.input.runtime?.trim() || null; - persona.model = args.input.model?.trim() || null; - persona.provider = args.input.provider?.trim() || null; - if (args.input.envVars !== undefined) { + throw new Error(`agent ${input.id} not found`); + } + persona.display_name = input.displayName.trim(); + persona.avatar_url = input.avatarUrl?.trim() || null; + persona.system_prompt = input.systemPrompt.trim(); + persona.runtime = input.runtime?.trim() || null; + persona.model = input.model?.trim() || null; + persona.provider = input.provider?.trim() || null; + if (input.envVars !== undefined) { // Absent = preserve; present = replace entirely (matches Rust handler). - persona.env_vars = { ...args.input.envVars }; + persona.env_vars = { ...input.envVars }; } - applyMockPersonaBehavior(persona, args.input.behavior); + applyMockPersonaBehavior(persona, input.behavior); persona.updated_at = new Date().toISOString(); - return { ...persona }; + for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { + callback(); + } + return persona; } async function handleDeletePersona(args: { id: string }): Promise { @@ -7468,15 +7537,115 @@ async function handleSetPersonaActive(args: { return { ...persona }; } +function personaHasExactSharedTag(event: RelayEvent): boolean { + const tags = event.tags.filter((tag) => tag[0] === "shared"); + return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true"; +} + +function upsertMockPersonaRelayEvent(event: RelayEvent): void { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) return; + const existingIndex = mockPersonaEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), + ); + if (existingIndex >= 0) { + mockPersonaEvents.splice(existingIndex, 1); + } + mockPersonaEvents.push(event); +} + +function upsertMockPersonaEvent(persona: RawPersona): void { + const event: RelayEvent = { + id: mockEventId(), + pubkey: MOCK_IDENTITY_PUBKEY, + created_at: Math.floor(Date.now() / 1_000), + kind: KIND_PERSONA, + tags: [["d", persona.id], ...(persona.shared ? [["shared", "true"]] : [])], + content: JSON.stringify({ + display_name: persona.display_name, + system_prompt: persona.system_prompt, + avatar_url: persona.avatar_url, + runtime: persona.runtime ?? null, + model: persona.model ?? null, + provider: persona.provider ?? null, + name_pool: persona.name_pool ?? [], + respond_to: persona.respond_to ?? null, + respond_to_allowlist: persona.respond_to_allowlist ?? [], + parallelism: persona.parallelism ?? null, + }), + sig: "0".repeat(128), + }; + upsertMockPersonaRelayEvent(event); + emitMockGlobalEvent(event); +} + +type MockPersonaPublicationResult = { + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}; + +/** + * Publish a persona's catalog head and report the relay outcome, like + * `publish_prepared_persona`. A `queued` outcome must NOT make the event + * visible to catalog readers — that is the whole distinction the UI reports. + */ +function publishMockPersonaHead( + persona: RawPersona, + config: E2eConfig | undefined, +): MockPersonaPublicationResult { + const publicationStatus = + config?.mock?.personaSharePublicationStatuses?.[ + personaSharePublicationCallCount++ + ] ?? "published"; + if (publicationStatus === "published") { + upsertMockPersonaEvent(persona); + } + return { + persona: { ...persona }, + publicationStatus, + ...(publicationStatus === "queued" + ? { relayMessage: "relay unreachable: could not connect to relay" } + : {}), + }; +} + +async function handleSetPersonaShared( + args: { + id: string; + shared: boolean; + }, + config?: E2eConfig, +): Promise { + const persona = mockPersonas.find((candidate) => candidate.id === args.id); + if (!persona) { + throw new Error(`agent ${args.id} not found`); + } + if (persona.is_builtin) { + throw new Error("Built-in agents cannot be shared to the catalog."); + } + persona.shared = args.shared; + persona.updated_at = new Date().toISOString(); + return publishMockPersonaHead(persona, config); +} + +/** Mirrors `update_persona_and_publish`: save the edit, then await the relay. */ +async function handleUpdatePersonaAndPublish( + args: { input: MockUpdatePersonaInput }, + config?: E2eConfig, +): Promise { + return publishMockPersonaHead(applyMockPersonaUpdate(args.input), config); +} + function ensureMockPersonaIsActive(personaId: string) { const persona = mockPersonas.find((candidate) => candidate.id === personaId); if (!persona) { throw new Error(`agent ${personaId} not found`); } if (!persona.is_active) { - throw new Error( - `${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`, - ); + throw new Error(`${persona.display_name} is not in My Agents.`); } } @@ -8235,6 +8404,35 @@ async function resolveMockUploadDescriptors( ]; } +async function resolveMockUploadDescriptorForBytes( + args: { data: number[]; filename?: string | null }, + config: E2eConfig | undefined, +): Promise { + const configured = config?.mock?.uploadDescriptors; + if (configured !== undefined) { + const descriptors = await resolveMockUploadDescriptors(config); + const descriptor = descriptors[0]; + if (!descriptor) throw new Error("mock upload returned no descriptor"); + return descriptor; + } + + const bytes = Uint8Array.from(args.data); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const sha256 = Array.from(new Uint8Array(digest), (value) => + value.toString(16).padStart(2, "0"), + ).join(""); + const filename = args.filename ?? "upload.bin"; + const isAgentJson = filename.toLowerCase().endsWith(".agent.json"); + return { + url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`, + sha256, + size: bytes.length, + type: isAgentJson ? "application/json" : "application/octet-stream", + uploaded: Math.floor(Date.now() / 1000), + filename, + }; +} + async function handleSendChannelMessage( args: { channelId: string; @@ -8930,6 +9128,25 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_PERSONA)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const sourceIds = filter["#d"]; + for (const event of mockPersonaEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + if ( + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !personaHasExactSharedTag(event) + ) { + continue; + } + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a` // tag (PR/issue discussions, approvals, review requests). if ( @@ -9031,6 +9248,36 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PERSONA) { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: persona event missing d tag.", + ]); + return; + } + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + if ( + sharedTags.length > 1 || + (sharedTags.length === 1 && !personaHasExactSharedTag(event)) + ) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + 'invalid: shared tag must be exactly ["shared","true"].', + ]); + return; + } + upsertMockPersonaRelayEvent(event); + emitMockGlobalEvent(event); + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + if (event.kind === 20001) { const status = event.content; if (status === "online" || status === "away" || status === "offline") { @@ -9149,6 +9396,7 @@ export function maybeInstallE2eTauriMocks() { resetMockWorkflows(); resetMockMesh(); resetMockUserStatuses(); + resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; @@ -10222,6 +10470,11 @@ export function maybeInstallE2eTauriMocks() { return handleUpdatePersona( payload as Parameters[0], ); + case "update_persona_and_publish": + return handleUpdatePersonaAndPublish( + payload as Parameters[0], + activeConfig, + ); case "delete_persona": return handleDeletePersona( payload as Parameters[0], @@ -10245,11 +10498,16 @@ export function maybeInstallE2eTauriMocks() { }; const now = new Date().toISOString(); const existing = mockPersonas.find((p) => p.id === dTag); + const shared = nostrEvent.tags.some( + (tag) => + tag.length === 2 && tag[0] === "shared" && tag[1] === "true", + ); if (existing) { existing.display_name = content.display_name ?? existing.display_name; existing.system_prompt = content.system_prompt ?? existing.system_prompt; + existing.shared = shared; existing.updated_at = now; } else { mockPersonas.push({ @@ -10259,6 +10517,7 @@ export function maybeInstallE2eTauriMocks() { system_prompt: content.system_prompt ?? "", is_builtin: false, is_active: true, + shared, env_vars: {}, created_at: now, updated_at: now, @@ -10285,6 +10544,11 @@ export function maybeInstallE2eTauriMocks() { return handleSetPersonaActive( payload as Parameters[0], ); + case "set_persona_shared": + return handleSetPersonaShared( + payload as Parameters[0], + activeConfig, + ); case "list_teams": return handleListTeams(); case "list_channel_templates": @@ -10331,8 +10595,8 @@ export function maybeInstallE2eTauriMocks() { // Specs assert invocation via __BUZZ_E2E_COMMANDS__. return true; case "encode_agent_snapshot_for_send": { - // Return a minimal PNG-shaped payload so the send flow can proceed - // through upload_media_bytes without a real Rust encode step. + // Return the requested wire format so both message sharing (PNG) and + // community catalog publication (JSON) exercise their real branches. // Optional encodeDelayMs lets specs observe the "preparing" phase before // the upload begins. const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0; @@ -10341,6 +10605,46 @@ export function maybeInstallE2eTauriMocks() { window.setTimeout(resolve, encodeDelayMs), ); } + const input = payload as { + id: string; + memoryLevel: "none" | "core" | "everything"; + format: "json" | "png"; + }; + if (input.format === "json") { + const persona = mockPersonas.find( + (candidate) => candidate.id === input.id, + ); + const snapshot = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: persona?.display_name ?? "E2E Agent", + sourceIsBuiltIn: persona?.is_builtin ?? false, + systemPrompt: persona?.system_prompt ?? "", + runtime: persona?.runtime ?? null, + model: persona?.model ?? null, + provider: persona?.provider ?? null, + respondTo: persona?.respond_to ?? null, + respondToAllowlist: persona?.respond_to_allowlist ?? [], + namePool: persona?.name_pool ?? [], + }, + profile: { + displayName: persona?.display_name ?? "E2E Agent", + avatarUrl: persona?.avatar_url ?? null, + }, + memory: { + level: input.memoryLevel, + entries: [], + }, + }; + const fileBytes = Array.from( + new TextEncoder().encode(JSON.stringify(snapshot)), + ); + return { + fileBytes, + fileName: "e2e-agent.agent.json", + }; + } return { fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], fileName: "e2e-agent.agent.png", @@ -10350,6 +10654,9 @@ export function maybeInstallE2eTauriMocks() { // Return a minimal preview — no writes performed. return { displayName: "Imported Agent", + isBuiltIn: true, + model: "claude-opus-4-5", + runtime: "goose", systemPrompt: null, avatarUrl: null, memoryLevel: "none", @@ -10861,7 +11168,10 @@ export function maybeInstallE2eTauriMocks() { case "pick_and_upload_image": return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null; case "upload_media_bytes": - return (await resolveMockUploadDescriptors(activeConfig))[0]; + return resolveMockUploadDescriptorForBytes( + payload as { data: number[]; filename?: string | null }, + activeConfig, + ); case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and // replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 8f4c7d1478..a92efcd40a 100644 --- a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -18,7 +18,7 @@ async function openCreateDialog(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill("Test Agent"); } diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index a20c649df4..9ce80593f1 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -271,6 +271,13 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ // Decoded display name must appear. await expect(dialog).toContainText("Imported Agent"); + const metadata = dialog.getByTestId("agent-definition-metadata"); + await expect(metadata).toContainText("Type"); + await expect(metadata).toContainText("Built-in agent"); + await expect(metadata).toContainText("Preferred model"); + await expect(metadata).toContainText("claude-opus-4-5"); + await expect(metadata).toContainText("Preferred runtime"); + await expect(metadata).toContainText("goose"); }); // ── Confirm imports the agent ───────────────────────────────────────────────── diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 0579e98dad..2e7cdc9e83 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,8 +1,43 @@ import { expect, test } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; + +import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils"; + import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +function createCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + displayName: string; + systemPrompt: string; + createdAt?: number; + shared?: boolean; + avatarUrl?: string; +}): RelayEvent { + return { + id: "1".repeat(64), + pubkey: input.ownerPubkey, + created_at: input.createdAt ?? 1_721_750_400, + kind: 30175, + tags: [ + ["d", input.sourcePersonaId], + ...(input.shared === false ? [] : [["shared", "true"]]), + ], + content: JSON.stringify({ + display_name: input.displayName, + system_prompt: input.systemPrompt, + avatar_url: input.avatarUrl ?? null, + runtime: null, + model: null, + provider: null, + name_pool: [], + }), + sig: "2".repeat(128), + }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -32,7 +67,9 @@ async function gotoApp(page: import("@playwright/test").Page) { async function openPersonaCatalog(page: import("@playwright/test").Page) { await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Choose from catalog" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); } async function getCatalogOrder(page: import("@playwright/test").Page) { @@ -50,12 +87,19 @@ async function selectCatalogPersona( await page.getByTestId(`persona-catalog-list-item-${personaId}`).click(); } -async function useCatalogPersona( +async function sharePersonaToCatalog( page: import("@playwright/test").Page, - personaId: string, + displayName: string, ) { + await page.getByLabel(`Open actions for ${displayName}`).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-catalog-access").click(); await page - .getByTestId(`persona-catalog-use-agent-target-${personaId}`) + .getByRole("menuitemradio", { name: "Shared", exact: true }) + .click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) .click(); } @@ -154,78 +198,86 @@ async function invokeTauriExpectError( ); } -test("built-in personas are used from the catalog dialog", async ({ page }) => { +async function countCommandInvocations( + page: import("@playwright/test").Page, + command: string, +): Promise { + return page.evaluate( + (targetCommand) => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__?.filter((invoked) => invoked === targetCommand) + .length ?? 0, + command, + ); +} + +test("catalog hides built-ins and shows the shared-agent empty state", async ({ + page, +}) => { await page.setViewportSize({ width: 1280, height: 420 }); + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - await openPersonaCatalog(page); - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( - "Fizz", - ); for (const personaName of ["Fizz", "Honey", "Bumble"]) { - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( + await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } - for (const retiredPersonaName of [ - "Product Strategist", - "Implementation Partner", - "QA Reviewer", - "Work Coordinator", - "Support Guide", - "Experiment Designer", - ]) { + + await openPersonaCatalog(page); + for (const personaName of ["Fizz", "Honey", "Bumble"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - retiredPersonaName, + personaName, ); } await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible(); - await expect( - page.getByTestId("persona-catalog-dialog-scroll-area"), - ).toBeVisible(); - await expect( - page.getByTestId("persona-catalog-dialog-scroll-area"), - ).toHaveCSS("overflow-y", "auto"); - const catalogScrollAreaMetrics = await page - .getByTestId("persona-catalog-dialog-scroll-area") - .evaluate((element) => ({ - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0); - expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual( - catalogScrollAreaMetrics.clientHeight, - ); await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Done", - ); - await expect(page.getByRole("tooltip")).toHaveCount(0); - const initialCatalogOrder = await getCatalogOrder(page); - - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); + const emptyState = page.getByTestId("persona-catalog-empty-state"); + await expect(emptyState).toContainText("No agents are being shared"); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), + emptyState.getByTestId("persona-catalog-empty-agent-artwork"), ).toBeVisible(); - - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", - ); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toHaveText("Added to My Agents"); + page.locator('[data-testid^="persona-catalog-list-item-"]'), + ).toHaveCount(0); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Delete", + page.getByTestId("persona-catalog-use-agent-target"), + ).toHaveCount(0); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await page.getByLabel("Open actions for Fizz").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("persona-share-catalog")).toHaveCount(0); + await expect(page.getByTestId("persona-share-catalog-access")).toHaveCount(0); +}); + +test("catalog empty state remains available after reopening", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toContainText( + "No agents are being shared", ); - await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); test("built-in persona edits persist", async ({ page }) => { @@ -267,7 +319,9 @@ test("searches agent avatar emoji with focus on open", async ({ page }) => { await gotoApp(page); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); await expect(page.getByTestId("persona-dialog")).toBeVisible(); await page.getByLabel("Add avatar").click(); @@ -292,7 +346,9 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); await expect(page.getByTestId("persona-dialog")).toBeVisible(); await page.getByLabel("Add avatar").click(); @@ -329,70 +385,315 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ .toBeGreaterThan(before); }); -test("agent catalog can reopen from the populated library header", async ({ +test("the new agent card offers create, discover, and import", async ({ page, }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + personas: [ + { + id: "custom:code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review code changes.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", - ); + const newAgentCard = page.getByTestId("new-agent-card"); + await expect(newAgentCard).toHaveText(""); + await expect(newAgentCard.locator(".lucide-plus")).toBeVisible(); - await page.keyboard.press("Escape"); - await openPersonaCatalog(page); + const agentCards = page.locator( + '[data-testid^="persona-agent-row-"], [data-testid="new-agent-card"]', + ); + await expect(agentCards.first()).toBeVisible(); + const headerBox = await page + .getByRole("heading", { level: 1, name: "Agents" }) + .locator("../..") + .boundingBox(); + const cardBoxes = await agentCards.evaluateAll((cards) => + cards.map((card) => { + const box = card.getBoundingClientRect(); + return { right: box.right, top: box.top }; + }), + ); + const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); + const rightmostFirstRowCard = Math.max( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ right }) => right), + ); + expect(headerBox).not.toBeNull(); + expect( + Math.abs( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, + ), + ).toBeLessThan(1); + await newAgentCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create agent" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Discover agents" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible(); - await selectCatalogPersona(page, "builtin:fizz"); + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await newAgentCard.click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible(); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); + dialog.getByTestId("import-agent-snapshot-dialog-action"), + ).toHaveCount(0); + await expect(dialog).not.toContainText("Enter a name for this agent."); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await newAgentCard.click(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { exact: true, name: "Import" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + buffer: Buffer.from("{}"), + mimeType: "application/json", + name: "imported.agent.json", + }); + await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible(); }); -test("agent catalog chooser order stays stable when selection changes", async ({ +test("the new team card offers create and import", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const newTeamCard = page.getByTestId("new-team-card"); + await expect(newTeamCard).toHaveText(""); + await expect(newTeamCard.locator(".lucide-plus")).toBeVisible(); + + await newTeamCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create team" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); +}); + +test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { + const personaIds = ["custom:design", "custom:build", "custom:ship"]; + await installMockBridge(page, { + personas: [ + { + avatarUrl: "/onboarding/starter-team/fizz.png", + id: personaIds[0], + displayName: "Design", + systemPrompt: "You design interfaces.", + }, + { + id: personaIds[1], + displayName: "Build", + systemPrompt: "You build interfaces.", + }, + { + id: personaIds[2], + displayName: "Ship", + systemPrompt: "You ship interfaces.", + }, + ], + teams: [ + { + name: "Product crew", + personaIds, + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - const before = await getCatalogOrder(page); + const stack = page.getByLabel("Product crew member avatars"); + const avatars = stack.locator('[data-team-member-avatar="avatar"]'); + await expect(avatars).toHaveCount(3); + await expect(avatars.nth(1)).toHaveClass(/-ml-5/); + await expect(avatars.nth(2)).toHaveClass(/-ml-5/); + + const boxes = await avatars.evaluateAll((elements) => + elements.map((element) => { + const box = element.getBoundingClientRect(); + return { left: box.left, right: box.right }; + }), + ); + expect(boxes[1]?.left).toBeLessThan(boxes[0]?.right ?? 0); + expect(boxes[2]?.left).toBeLessThan(boxes[1]?.right ?? 0); + await expect(avatars.first()).not.toHaveCSS("mask-image", "none"); + await expect(avatars.last()).toHaveCSS("mask-image", "none"); + const avatarSurfaceStyles = await avatars + .locator(":scope > *") + .evaluateAll((elements) => + elements.map((element) => { + const styles = getComputedStyle(element); + const hasVisibleShadow = [ + ...styles.boxShadow.matchAll(/rgba?\(([^)]+)\)/g), + ].some((match) => { + if (match[0].startsWith("rgb(")) return true; + const channels = match[1]?.split(/[\s,/]+/).filter(Boolean) ?? []; + return Number(channels.at(-1)) > 0; + }); + return { + borderWidth: styles.borderTopWidth, + hasVisibleShadow, + }; + }), + ); + expect(avatarSurfaceStyles).toEqual([ + { borderWidth: "0px", hasVisibleShadow: false }, + { borderWidth: "0px", hasVisibleShadow: false }, + { borderWidth: "0px", hasVisibleShadow: false }, + ]); +}); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); +test("agent defaults stays in the header without an actions menu", async ({ + page, +}) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + auth_status: { status: "logged_in" }, + availability: "available", + avatar_url: "", + binary_path: "/usr/local/bin/codex", + can_auto_install: false, + command: "codex", + default_args: [], + id: "codex", + install_hint: "", + install_instructions_url: "https://example.com", + label: "Codex", + login_hint: null, + mcp_command: null, + node_required: false, + underlying_cli_path: null, + }, + ], + globalAgentConfig: { + env_vars: {}, + model: "gpt-5.5[high]", + preferred_runtime: "codex", + provider: null, + }, + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-header-actions-button")).toHaveCount(0); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), - ).toBeVisible(); + page.getByRole("menuitem", { name: "Import agent" }), + ).toHaveCount(0); + + const defaultsButton = page.getByTestId("agent-defaults-button"); + await expect(defaultsButton).toHaveText("Agent defaults"); + await defaultsButton.click(); + const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog"); + await expect(defaultsDialog).toBeVisible(); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toHaveAttribute("data-value", "codex"); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toContainText("Codex"); + await expect( + defaultsDialog.getByTestId("global-agent-model"), + ).toHaveAttribute("data-value", "gpt-5.5[high]"); + await expect(defaultsDialog.getByTestId("global-agent-model")).toContainText( + "gpt-5.5[high]", + ); + await page.keyboard.press("Escape"); + await expect(defaultsDialog).toHaveCount(0); +}); + +test("unconfigured agent defaults use the setup label", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-defaults-button")).toHaveText( + "Set agent defaults", + ); +}); + +test("agent catalog chooser order stays stable when selection changes", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:builder", + displayName: "Builder", + systemPrompt: "Build the requested change.", + }, + { + id: "custom:reviewer", + displayName: "Reviewer", + systemPrompt: "Review the requested change.", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Builder"); + await sharePersonaToCatalog(page, "Reviewer"); + await openPersonaCatalog(page); + const before = await getCatalogOrder(page); + await selectCatalogPersona(page, "custom:reviewer"); expect(await getCatalogOrder(page)).toEqual(before); }); test("catalog detail pane shows the full persona details", async ({ page }) => { + const personaId = "custom:researcher"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Researcher", + systemPrompt: "Research the question and cite the evidence.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Researcher"); await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); + await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - "persona-catalog-use-agent-target-builtin:fizz", + `persona-catalog-use-agent-target-${personaId}`, ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Fizz", + "Researcher", ); - await expect( - page.getByTestId("persona-catalog-detail-pane"), - ).not.toContainText("Added by You"); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "You are Fizz.", + "Added by You", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Built-in agent", + "Research the question and cite the evidence.", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Custom agent", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( "Preferred model", @@ -405,14 +706,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { ); await expect(useAgentTarget).toHaveAttribute( "aria-label", - "Add Fizz from Agent Catalog", - ); - await expect(useAgentTarget).toHaveText("Add agent"); - - await useAgentTarget.click(); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + "Researcher is already in My Agents", ); + await expect(useAgentTarget).toHaveText("Added to My Agents"); + await expect(useAgentTarget).toBeDisabled(); }); type AgentShareCommand = { command: string; payload: unknown }; @@ -586,80 +883,99 @@ test("custom personas share with people and keep export separate", async ({ ).toHaveCount(0); await expect(shareDialog.getByText("Owner", { exact: true })).toHaveCount(0); await expect(shareDialog.getByText("(You)", { exact: true })).toHaveCount(0); - const copyLinkFooter = page.getByTestId("persona-share-copy-link-footer"); + const linkRow = page.getByTestId("persona-share-link-row"); await expect( - copyLinkFooter.getByRole("heading", { name: "Share with a link" }), + linkRow.getByRole("heading", { name: "Share with a link" }), ).toBeVisible(); await expect( - copyLinkFooter.getByText("Anyone with the link can add and use a copy."), + linkRow.getByText("Anyone with the link can add and use a copy."), ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); await expect(page.getByTestId("persona-share-send")).toHaveCount(0); const copyLinkButton = page.getByTestId("persona-share-copy-link"); - const linkRow = page.getByTestId("persona-share-link-row"); const linkIcon = page.getByTestId("persona-share-link-icon"); const linkCopy = page.getByTestId("persona-share-link-copy"); - const linkDivider = page.getByTestId("persona-share-link-divider"); - const staticLinkAccess = page.getByTestId("persona-share-link-access"); + const catalogSection = page.getByTestId("persona-share-catalog"); + const staticShareLevel = page.getByTestId("persona-share-share-level"); + const shareLevelRow = page.getByTestId("persona-share-share-level-row"); await waitForAnimations(page); const [ linkRowBox, initialCopyLinkButtonBox, linkIconBox, linkCopyBox, - linkDividerBox, - staticLinkAccessBox, + catalogSectionBox, + staticShareLevelBox, + shareLevelRowBox, ] = await Promise.all([ linkRow.boundingBox(), copyLinkButton.boundingBox(), linkIcon.boundingBox(), linkCopy.boundingBox(), - linkDivider.boundingBox(), - staticLinkAccess.boundingBox(), + catalogSection.boundingBox(), + staticShareLevel.boundingBox(), + shareLevelRow.boundingBox(), ]); const sendDescriptionBox = await sendDescription.boundingBox(); - expect((linkRowBox?.y ?? 0) - (sendDescriptionBox?.y ?? 0)).toBeGreaterThan( - (sendDescriptionBox?.height ?? 0) + 30, + const recipientFieldBox = await page + .getByTestId("persona-share-recipient-field") + .boundingBox(); + // Reading order: who → how it goes out → what's included → catalog. + expect(sendDescriptionBox?.y ?? 0).toBeGreaterThanOrEqual( + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), + ); + expect(linkRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (sendDescriptionBox?.y ?? 0) + (sendDescriptionBox?.height ?? 0), + ); + expect(shareLevelRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelRowBox?.y ?? 0) + (shareLevelRowBox?.height ?? 0), ); + // Copy link is the link row's own action, not a stranded footer button, so + // it rides on that row, vertically centred with the link icon and flush to + // the row's right edge. expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 23, + linkRowBox?.y ?? 0, ); + expect( + (initialCopyLinkButtonBox?.y ?? 0) + + (initialCopyLinkButtonBox?.height ?? 0), + ).toBeLessThanOrEqual((linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 1); expect( Math.abs( - (linkCopyBox?.y ?? 0) + - (linkCopyBox?.height ?? 0) / 2 - + (initialCopyLinkButtonBox?.y ?? 0) + + (initialCopyLinkButtonBox?.height ?? 0) / 2 - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), ), ).toBeLessThanOrEqual(1); - expect(linkDividerBox?.y ?? 0).toBeGreaterThan( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), - ); - expect(linkDividerBox?.y ?? 0).toBeLessThan(initialCopyLinkButtonBox?.y ?? 0); expect( - Math.abs((linkDividerBox?.width ?? 0) - (linkRowBox?.width ?? 0)), + Math.abs( + (linkRowBox?.x ?? 0) + + (linkRowBox?.width ?? 0) - + ((initialCopyLinkButtonBox?.x ?? 0) + + (initialCopyLinkButtonBox?.width ?? 0)), + ), ).toBeLessThanOrEqual(1); - await expect(linkDivider).toHaveClass(/my-4.*bg-input\/40/); expect( Math.abs( (linkCopyBox?.y ?? 0) + (linkCopyBox?.height ?? 0) / 2 - - ((staticLinkAccessBox?.y ?? 0) + - (staticLinkAccessBox?.height ?? 0) / 2), + ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), + ), + ).toBeLessThanOrEqual(1); + await expect(page.getByTestId("persona-share-link-divider")).toHaveCount(0); + await expect(page.getByTestId("persona-share-copy-link-footer")).toHaveCount( + 0, + ); + expect( + Math.abs( + (shareLevelRowBox?.y ?? 0) + + (shareLevelRowBox?.height ?? 0) / 2 - + ((staticShareLevelBox?.y ?? 0) + + (staticShareLevelBox?.height ?? 0) / 2), ), ).toBeLessThanOrEqual(1); - const shareMainCardForLinkSpacing = page.getByTestId( - "persona-share-main-card", - ); - const shareMainCardForLinkSpacingBox = - await shareMainCardForLinkSpacing.boundingBox(); - const gapAboveCopyLink = - (initialCopyLinkButtonBox?.y ?? 0) - - ((linkDividerBox?.y ?? 0) + (linkDividerBox?.height ?? 0)); - const gapBelowCopyLink = - (shareMainCardForLinkSpacingBox?.y ?? 0) + - (shareMainCardForLinkSpacingBox?.height ?? 0) - - ((initialCopyLinkButtonBox?.y ?? 0) + - (initialCopyLinkButtonBox?.height ?? 0)); - expect(Math.abs(gapAboveCopyLink - gapBelowCopyLink)).toBeLessThanOrEqual(1); await expect(copyLinkButton).toHaveClass( /border.*bg-background.*border-border/, ); @@ -676,21 +992,28 @@ test("custom personas share with people and keep export separate", async ({ await expect.poll(copyLinkHasVisibleShadow).toBe(false); await copyLinkButton.hover(); await expect.poll(copyLinkHasVisibleShadow).toBe(false); - await expect(page.getByTestId("persona-share-link-access")).toHaveText( - "Agent only", + await expect(page.getByTestId("persona-share-share-level")).toHaveText( + "No memories included", ); + await expect( + shareDialog.getByText("No memories included", { exact: true }), + ).toHaveCount(1); await expect(page.getByTestId("persona-share-recipient-access")).toHaveCount( 0, ); + await expect(page.getByTestId("persona-share-link-access")).toHaveCount(0); await expect( shareDialog.getByLabel("What to include in the link"), ).toHaveCount(0); await expect( shareDialog.getByLabel("What to include", { exact: true }), ).toHaveCount(0); - await expect(shareDialog.getByText("Memories")).toHaveCount(0); - await expect(shareDialog.getByText("File format")).toHaveCount(0); - await expect(page.getByText("Show in my catalog")).toHaveCount(0); + await expect(shareDialog.getByText("Memories", { exact: true })).toHaveCount( + 0, + ); + await expect( + shareDialog.getByText("File format", { exact: true }), + ).toHaveCount(0); const shareMainCard = page.getByTestId("persona-share-main-card"); const exportAgentRow = page.getByTestId("persona-share-export"); await expect(exportAgentRow).toHaveText("Export agent"); @@ -723,6 +1046,9 @@ test("custom personas share with people and keep export separate", async ({ expect(exportAgentRowShadow).toBe(shareMainCardShadow); expect(exportAgentRowShadow).not.toBe("none"); await expect(exportAgentRow).toHaveCSS("position", "relative"); + expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0) + 12, + ); await expect(page.getByTestId("agent-snapshot-export-dialog")).toHaveCount(0); await exportAgentRow.click(); @@ -934,29 +1260,7 @@ test("custom personas share with people and keep export separate", async ({ page .getByTestId("persona-share-recipient-field") .getByTestId("persona-share-recipient-access"), - ).toHaveText("Agent only"); - const staticRecipientAccess = page.getByTestId( - "persona-share-recipient-access", - ); - const [ - staticRecipientAccessBox, - recipientAccessPaddingRight, - recipientFieldBox, - ] = await Promise.all([ - staticRecipientAccess.boundingBox(), - staticRecipientAccess.evaluate((element) => - Number.parseFloat(getComputedStyle(element).paddingRight), - ), - recipientField.boundingBox(), - ]); - const staticRecipientTextInset = - (recipientFieldBox?.x ?? 0) + - (recipientFieldBox?.width ?? 0) - - ((staticRecipientAccessBox?.x ?? 0) + - (staticRecipientAccessBox?.width ?? 0) - - recipientAccessPaddingRight); - expect(staticRecipientTextInset).toBeGreaterThanOrEqual(8); - expect(staticRecipientTextInset).toBeLessThanOrEqual(10); + ).toHaveCount(0); await expect(page.getByTestId("persona-share-send")).toBeVisible(); await recipientSearch.fill("bob"); @@ -1057,7 +1361,374 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog).toHaveCount(0); }); -test("share access controls include the selected memories", async ({ +test("custom personas can be shared to the relay catalog", async ({ page }) => { + const personaId = "custom:catalog-analyst"; + await installMockBridge(page, { + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + provider: "anthropic", + model: "claude-opus-4-5", + }, + personas: [ + { + id: personaId, + displayName: "Catalog Analyst", + respondTo: "allowlist", + respondToAllowlist: [TEST_IDENTITIES.alice.pubkey], + systemPrompt: `## Design System And Styling + +- For design-system changes, check the local guidance in \`DESIGN.md\`, \`docs/color-token-mapping.md\`, \`src/shared/ui/AGENTS.md\`, and \`src/features/design-system/AGENTS.md\` before judging the implementation. +- Check every changed visual surface in both light and dark mode. Missing dark-mode support is a review issue, not visual polish. +- Review the selected changes and explain whether \`git diff --cached --name-only --some-extremely-long-inline-option-that-must-wrap\` stays inside the catalog detail column. + +\`\`\`text +This deliberately long fenced-code example must not establish the minimum width of the full custom-agent instruction document or force earlier prose outside the catalog detail pane. +\`\`\` + +| Before | After | Why | +| --- | --- | --- | +| \`transition: all 300ms\` | \`transition: transform 200ms ease-out\` | Specify exact properties so a wide instruction table stays independently scrollable without expanding the full catalog detail pane. | +| \`transform: scale(0)\` | \`transform: scale(0.95); opacity: 0\` | Preserve physicality while keeping the shared agent instructions inside their container. |`, + }, + ], + }); + await gotoApp(page); + await page.evaluate(() => { + document.documentElement.style.fontSize = "24px"; + }); + + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); + const shareDialog = page.getByTestId("persona-share-dialog"); + const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); + const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); + const catalogSection = shareDialog.getByTestId("persona-share-catalog"); + await expect( + shareMainCard.getByTestId("persona-share-catalog"), + ).toBeVisible(); + await expect(catalogSection).toContainText("Share to catalog"); + await expect(catalogSection).toContainText( + "Anyone in this community can find and use a copy.", + ); + await expect(catalogSection).toContainText( + "Your agent instruction is shared as plaintext. Memories and secrets aren’t included.", + ); + const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = + await Promise.all([ + copyLinkButton.boundingBox(), + catalogSection.boundingBox(), + shareMainCard.boundingBox(), + ]); + // Copy link belongs to the link row above, so the catalog is the section + // that closes the card rather than trailing an orphaned button. + expect( + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + ).toBeLessThanOrEqual(catalogSectionBox?.y ?? 0); + expect( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), + ).toBeLessThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), + ); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Shared", + ]); + await page + .getByRole("menuitemradio", { name: "Shared", exact: true }) + .click(); + await expect(catalogAccess).toHaveText("Shared"); + const storedPersonas = await invokeTauri< + Array<{ id: string; shared: boolean }> + >(page, "list_personas"); + expect( + storedPersonas.find((persona) => persona.id === personaId)?.shared, + ).toBe(true); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toContainText("Catalog Analyst"); + await selectCatalogPersona(page, personaId); + const catalogDialog = page.getByTestId("persona-catalog-dialog"); + const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + await expect(catalogDetailPane).toContainText("Design System And Styling"); + await expect(catalogDialog).toBeVisible(); + await expect(catalogDetailPane).toBeVisible(); + await waitForAnimations(page); + const [catalogDialogRight, catalogDetailPaneRight] = await Promise.all([ + catalogDialog.evaluate((element) => element.getBoundingClientRect().right), + catalogDetailPane.evaluate( + (element) => element.getBoundingClientRect().right, + ), + ]); + expect(catalogDetailPaneRight).toBeLessThanOrEqual(catalogDialogRight); + expect( + await catalogDetailPane.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + const catalogInstruction = catalogDetailPane.locator(".message-markdown"); + expect( + await catalogInstruction.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const editDialog = page.getByTestId("persona-dialog"); + const catalogPublishNotice = editDialog.getByTestId( + "persona-dialog-catalog-publish-notice", + ); + await expect(catalogPublishNotice).toHaveCount(0); + await expect( + editDialog.getByRole("button", { name: "Save and publish" }), + ).toHaveCount(0); + await expect( + editDialog.getByRole("button", { name: "Save changes" }), + ).toBeVisible(); + await editDialog + .getByLabel("Agent instructions") + .fill("Review the latest catalog changes."); + await expect(catalogPublishNotice).toHaveText( + "This agent is in the community catalog. Your changes will be published when you save.", + ); + await expect( + editDialog.getByRole("button", { name: "Save changes" }), + ).toHaveCount(0); + await editDialog.getByRole("button", { name: "Save and publish" }).click(); + await expect(editDialog).toHaveCount(0); + // The promise in the button label is only kept by the command that awaits the + // relay; a plain `update_persona` merely enqueues a head best-effort. + await expect + .poll(() => countCommandInvocations(page, "update_persona_and_publish")) + .toBe(1); + expect(await countCommandInvocations(page, "update_persona")).toBe(0); + await expect( + page.getByText( + "Updated Catalog Analyst and published it to the community catalog.", + ), + ).toBeVisible(); + + await openPersonaCatalog(page); + await selectCatalogPersona(page, personaId); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Review the latest catalog changes.", + ); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogAccess).toHaveText("Shared"); + await catalogAccess.click(); + await page + .getByRole("menuitemradio", { name: "Not shared", exact: true }) + .click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); +}); + +test("a queued catalog share is not presented as relay-published", async ({ + page, +}) => { + const personaId = "custom:queued-catalog-agent"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Queued Catalog Agent", + systemPrompt: "Wait for relay acceptance.", + }, + ], + personaSharePublicationStatuses: ["queued"], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Queued Catalog Agent").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-catalog-access").click(); + await page + .getByRole("menuitemradio", { name: "Shared", exact: true }) + .click(); + + await expect( + page.getByText( + "Sharing Queued Catalog Agent is queued. It will appear after the relay accepts the update.", + ), + ).toBeVisible(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); +}); + +test("a foreign reader does not receive an unshared kind 30175 persona", async ({ + page, +}) => { + const personaId = "private-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Private Reviewer", + systemPrompt: "This instruction must remain private.", + shared: false, + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + await expect( + page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + ).toHaveCount(0); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); +}); + +test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => { + const personaId = "emoji-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + // Emoji avatars persist as inline percent-encoded SVG rather than a hosted + // URL, so build the value with the same producer the editor uses — a + // hand-rolled data URL would pass even if the real shape stopped matching. + const avatarUrl = emojiAvatarDataUrl("🐝", "#FFCC00"); + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + avatarUrl, + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + // An `` carrying the avatar — not the initials fallback — in both the + // list row and the detail header is what proves the projection kept it. + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry.locator("img")).toHaveAttribute("src", avatarUrl); + await remoteEntry.click(); + await expect( + page.getByTestId("persona-catalog-detail-pane").locator("img").first(), + ).toHaveAttribute("src", avatarUrl); +}); + +test("a community member can discover and add another member's catalog agent", async ({ + page, +}) => { + const personaId = "shared-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry).toContainText("Alice’s Reviewer"); + await remoteEntry.click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); + + await page + .getByRole("button", { + name: "Add Alice’s Reviewer from Agent Catalog", + }) + .click(); + await expect + .poll(() => countCommandInvocations(page, "create_persona")) + .toBe(1); + const imported = await invokeTauri< + Array<{ + display_name: string; + system_prompt: string; + shared: boolean; + catalog_source: { owner_pubkey: string; persona_id: string } | null; + }> + >(page, "list_personas"); + expect( + imported.find((persona) => persona.display_name === "Alice’s Reviewer"), + ).toMatchObject({ + system_prompt: "Review changes for the whole community.", + shared: false, + // Provenance is what lets the catalog recognise the copy on the next open. + catalog_source: { + owner_pubkey: TEST_IDENTITIES.alice.pubkey, + persona_id: personaId, + }, + }); + + // Reopening must offer the entry as already added rather than minting a + // second copy — the copy has a fresh local id, so only the stored + // coordinate can link it back to Alice's publication. + await page.keyboard.press("Escape"); + await openPersonaCatalog(page); + // The entry now projects onto the local copy, so its list-item testid is the + // local persona id rather than the catalog coordinate. + await expect( + page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + ).toHaveCount(0); + await page + .locator('[data-testid^="persona-catalog-list-item-"]') + .filter({ hasText: "Alice’s Reviewer" }) + .click(); + const addedTarget = page.getByRole("button", { + name: "Alice’s Reviewer is already in My Agents", + }); + await expect(addedTarget).toBeDisabled(); + await expect(addedTarget).toHaveText("Added to My Agents"); + expect(await countCommandInvocations(page, "create_persona")).toBe(1); +}); + +test("one share level selector drives both the link and send paths", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); @@ -1107,34 +1778,60 @@ test("share access controls include the selected memories", async ({ const initialShareCardHeight = await shareMainCard.evaluate( (element) => element.getBoundingClientRect().height, ); - const linkAccess = shareDialog.getByLabel("What to include in the link"); + const shareLevel = shareDialog.getByLabel("What to include", { + exact: true, + }); + const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); - await expect(linkAccess).toHaveText("Agent only"); - expect((await linkAccess.boundingBox())?.width).toBeLessThan(120); - expect(await linkAccess.evaluate((element) => element.tagName)).toBe( + await expect(shareLevel).toHaveText("Agent only"); + expect((await shareLevel.boundingBox())?.width).toBeLessThan(140); + expect(await shareLevel.evaluate((element) => element.tagName)).toBe( "BUTTON", ); - await expect(linkAccess).toHaveCSS("text-decoration-line", "none"); - await expect(linkAccess).toHaveCSS("padding-left", "8px"); - await expect(linkAccess).toHaveCSS("padding-right", "8px"); - const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); - const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ - linkAccess.boundingBox(), - copyLinkButton.boundingBox(), + await expect(shareLevel).toHaveCSS("text-decoration-line", "none"); + await expect(shareLevel).toHaveCSS("padding-left", "8px"); + await expect(shareLevel).toHaveCSS("padding-right", "8px"); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Shared", ]); + await page.keyboard.press("Escape"); + const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); + const recipientFieldBox = await recipientField.boundingBox(); + const [shareLevelBox, copyLinkButtonBox, catalogAccessBox] = + await Promise.all([ + shareLevel.boundingBox(), + copyLinkButton.boundingBox(), + catalogAccess.boundingBox(), + ]); + // Reading order: who → how it goes out → what's included → catalog. expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkAccessBox?.y ?? 0) + (linkAccessBox?.height ?? 0) + 8, + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), + ); + expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + ); + expect(catalogAccessBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), ); + // The memory choice is stated once, governing both delivery actions — + // neither the recipients row nor the link row carries its own copy. await expect( - shareDialog.getByLabel("What to include", { exact: true }), + shareDialog.getByTestId("persona-share-recipient-access"), + ).toHaveCount(0); + await expect( + shareDialog.getByTestId("persona-share-link-access"), ).toHaveCount(0); + await expect(shareLevel).toHaveCount(1); await expect( shareDialog.getByTestId("persona-share-memory-warning"), ).toHaveCount(0); - await linkAccess.click(); + await shareLevel.click(); await expect(page.getByRole("menuitemradio")).toHaveText([ "Agent only", "Agent + core memory", @@ -1143,7 +1840,7 @@ test("share access controls include the selected memories", async ({ await page .getByRole("menuitemradio", { name: "Agent + core memory" }) .click(); - await expect(linkAccess).toHaveText("Agent + core memory"); + await expect(shareLevel).toHaveText("Agent + core memory"); await waitForAnimations(page); const expandedShareCardHeight = await shareMainCard.evaluate( (element) => element.getBoundingClientRect().height, @@ -1151,6 +1848,9 @@ test("share access controls include the selected memories", async ({ const inlineMemoryWarning = shareDialog.getByTestId( "persona-share-memory-warning", ); + // No recipient is selected yet: the warning tracks the chosen contents, not + // whichever delivery button might be pressed. + await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); await expect(inlineMemoryWarning).toBeVisible(); await expect(inlineMemoryWarning).toContainText( "Memory is stored as plaintext in the snapshot.", @@ -1193,11 +1893,11 @@ test("share access controls include the selected memories", async ({ await expect(page.getByTestId("persona-share-copy-link")).toContainText( "Copied", ); - await linkAccess.click(); + await shareLevel.click(); await page .getByRole("menuitemradio", { name: "Agent only", exact: true }) .click(); - await expect(linkAccess).toHaveText("Agent only"); + await expect(shareLevel).toHaveText("Agent only"); await expect(inlineMemoryWarning).toHaveCount(0); const recipientSearch = page.getByTestId("persona-share-recipient-search"); @@ -1210,11 +1910,6 @@ test("share access controls include the selected memories", async ({ const recipientInputRegion = recipientField.getByTestId( "persona-share-recipient-input-region", ); - const recipientAccess = recipientField.getByLabel("What to include", { - exact: true, - }); - await expect(recipientAccess).toHaveText("Agent only"); - expect((await recipientAccess.boundingBox())?.width).toBeLessThan(140); await expect(recipientField).toHaveCSS("column-gap", "12px"); await expect(recipientInputRegion).toHaveCSS("flex-wrap", "wrap"); const sendButton = shareDialog.getByTestId("persona-share-send"); @@ -1238,42 +1933,17 @@ test("share access controls include the selected memories", async ({ ); }) .toBeLessThanOrEqual(1); - const recipientInputRegionBox = await recipientInputRegion.boundingBox(); - const recipientAccessBox = await recipientAccess.boundingBox(); - expect( - (recipientAccessBox?.x ?? 0) - - ((recipientInputRegionBox?.x ?? 0) + - (recipientInputRegionBox?.width ?? 0)), - ).toBeGreaterThanOrEqual(12); - const recipientAccessRightEdge = - (recipientAccessBox?.x ?? 0) + (recipientAccessBox?.width ?? 0); - expect( - Math.abs( - (resizedRecipientFieldBox?.x ?? 0) + - (resizedRecipientFieldBox?.width ?? 0) - - 8 - - recipientAccessRightEdge, - ), - ).toBeLessThanOrEqual(8); - await recipientAccess.click(); + // Same single selector now drives the send path; picking a level here is + // what the send confirmation must report. + await shareLevel.click(); await page .getByRole("menuitemradio", { name: "Agent + all memories" }) .click(); - await expect(recipientAccess).toHaveText("Agent + all memories"); + await expect(shareLevel).toHaveText("Agent + all memories"); await expect(inlineMemoryWarning).toBeVisible(); await waitForAnimations(page); - await expect - .poll(async () => { - const expandedRecipientAccessBox = await recipientAccess.boundingBox(); - return Math.abs( - (expandedRecipientAccessBox?.x ?? 0) + - (expandedRecipientAccessBox?.width ?? 0) - - recipientAccessRightEdge, - ); - }) - .toBeLessThanOrEqual(1); expect( - await recipientAccess + await shareLevel .locator("span") .evaluate((element) => element.scrollWidth <= element.clientWidth), ).toBe(true); @@ -1594,19 +2264,16 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => { }, }); - expect(error).toBe( - "Honey is not in My Agents. Choose it from Agent Catalog first.", - ); + expect(error).toBe("Honey is not in My Agents."); }); test("built-in removal failures show up from My Agents", async ({ page }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:honey"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:honey"); - await useCatalogPersona(page, "builtin:honey"); - await invokeTauri(page, "create_team", { input: { name: "Honeys", @@ -1614,7 +2281,6 @@ test("built-in removal failures show up from My Agents", async ({ page }) => { }, }); - await page.keyboard.press("Escape"); await page.getByLabel("Open actions for Honey").click(); await page.getByRole("menuitem", { name: "Delete" }).click(); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 099c2cb752..7ec1daa236 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -32,7 +32,7 @@ async function openCreateDialog(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill("Test Agent"); } @@ -712,7 +712,7 @@ test.describe("global agent config screenshots", () => { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({ timeout: 10_000, diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 53efa09a0e..1e9b077a82 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -267,10 +267,10 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({ }) => { await gotoApp(page); - // Open the Agents view, click New > New agent to open the persona dialog. + // Open the Agents view, then choose Create agent from the new-agent menu. await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); // Scope all env-vars queries to the dialog: AgentDefaultsSettingsCard // also renders an EnvVarsEditor in the background settings pane (introduced @@ -315,7 +315,7 @@ test("persona model options follow the selected LLM provider", async ({ await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const provider = page.locator("#persona-runtime"); await page.getByRole("tab", { name: "Customize for this agent" }).click(); diff --git a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts index 38d1df0914..508b123d7f 100644 --- a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts +++ b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts @@ -36,7 +36,7 @@ async function openNewPersonaDialog(page: import("@playwright/test").Page) { }); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const dialog = page.getByTestId("persona-dialog"); await expect(dialog).toBeVisible({ timeout: 8_000 }); diff --git a/desktop/tests/e2e/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts index 5dfa7e1a16..84b24f7eb7 100644 --- a/desktop/tests/e2e/persona-sync.spec.ts +++ b/desktop/tests/e2e/persona-sync.spec.ts @@ -14,6 +14,10 @@ const TYLER_PUBKEY = const D_TAG = "sync-test-persona"; const KIND_PERSONA = 30175; const KIND_DELETION = 5; +// The command scopes an inbound event to the community it arrived on. Under the +// mock bridge the app subscribes on e2eBridge's DEFAULT_RELAY_WS_URL, so that is +// the arrival relay these direct invocations stand in for. +const ARRIVAL_RELAY_URL = "ws://localhost:3000"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -139,6 +143,7 @@ test("upsert round-trip: reconcile_inbound_persona_event writes record and emits // Drive the inbound reconcile path. await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Assert the record landed on disk. @@ -176,6 +181,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 2: confirm it landed. @@ -202,6 +208,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(tombstoneEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 4: assert the record is gone. diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index bc32a31064..0f61de0f7c 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -138,7 +138,7 @@ test("Buzz shared compute explains automatic model selection", async ({ }); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await chooseSharedComputeProvider(page); await expect @@ -167,7 +167,7 @@ test("create agent persists Buzz shared compute with auto model", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await chooseSharedComputeProvider(page); @@ -211,7 +211,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await page diff --git a/desktop/tests/e2e/team-snapshot.spec.ts b/desktop/tests/e2e/team-snapshot.spec.ts index c04a040047..6246c7ac8c 100644 --- a/desktop/tests/e2e/team-snapshot.spec.ts +++ b/desktop/tests/e2e/team-snapshot.spec.ts @@ -271,7 +271,7 @@ test("team sharing uses the people picker and gates memory before sending", asyn `team-share-recipient-option-${TEST_IDENTITIES.charlie.pubkey}`, ) .click(); - await shareDialog.getByTestId("team-share-recipient-access").click(); + await shareDialog.getByTestId("team-share-share-level").click(); await page.getByRole("menuitemradio", { name: "Team + core memory" }).click(); await shareDialog.getByTestId("team-share-send").click(); @@ -311,6 +311,73 @@ test("team sharing uses the people picker and gates memory before sending", asyn expect(sendPayload?.content).not.toContain("![image]("); }); +test("team share level carries memories onto the link path too", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + status: "running", + }, + ], + agentMemory: createMockAgentMemoryListing(), + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Engineering team actions").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const shareDialog = page.getByTestId("team-share-dialog"); + await expect(shareDialog).toBeVisible(); + + // No recipient selected — the copy-link path alone must still honour the + // shared selector and gate plaintext memories behind the confirmation. + await shareDialog.getByTestId("team-share-share-level").click(); + await page.getByRole("menuitemradio", { name: "Team + core memory" }).click(); + await expect( + shareDialog.getByTestId("team-share-memory-warning"), + ).toBeVisible(); + await shareDialog.getByTestId("team-share-copy-link").click(); + + const memoryConfirmation = page.getByTestId("team-share-memory-confirmation"); + await expect(memoryConfirmation).toBeVisible(); + await expect(memoryConfirmation).toContainText("plaintext core memory"); + await expect(memoryConfirmation).toContainText( + "Anyone with the link can view it.", + ); + const encodeLevelsBeforeConfirmation = (await readCommandLog(page)) + .filter((entry) => entry.command === "encode_team_snapshot_for_send") + .map( + (entry) => + (entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel, + ); + expect(encodeLevelsBeforeConfirmation).toEqual([]); + + await memoryConfirmation.getByTestId("team-share-memory-confirm").click(); + await expect(shareDialog.getByTestId("team-share-copy-link")).toContainText( + "Copied", + ); + expect( + (await readCommandLog(page)).filter( + (entry) => + entry.command === "encode_team_snapshot_for_send" && + (entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel === + "core", + ), + ).toHaveLength(1); +}); + test("team sharing keeps link copy and export in the shared surface", async ({ page, }) => { @@ -343,7 +410,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ await menu.getByRole("menuitem", { name: "Share" }).click(); const shareDialog = page.getByTestId("team-share-dialog"); - await expect(shareDialog.getByTestId("team-share-link-access")).toHaveText( + await expect(shareDialog.getByTestId("team-share-share-level")).toHaveText( "Team only", ); const exportTeamRow = shareDialog.getByTestId("team-share-export"); @@ -351,7 +418,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ const recipientSearch = shareDialog.getByTestId( "team-share-recipient-search", ); - const linkAccess = shareDialog.getByTestId("team-share-link-access"); + const shareLevel = shareDialog.getByTestId("team-share-share-level"); const closeButton = shareDialog.getByRole("button", { name: "Close" }); await waitForAnimations(page); await expect( @@ -363,7 +430,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ await expect(copyLinkButton).toContainText("Copying…"); await expect(copyLinkButton).toHaveCSS("opacity", "1"); await expect(recipientSearch).toBeEnabled(); - await expect(linkAccess).toBeEnabled(); + await expect(shareLevel).toBeEnabled(); await expect(closeButton).toBeDisabled(); await expect(exportTeamRow).toBeDisabled(); await expect(exportTeamRow).toHaveCSS("opacity", "1"); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d65a260f30..ca4d62ddd6 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import type { ChannelTemplate } from "../../src/shared/api/types"; +import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -88,7 +88,9 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; /** @@ -103,6 +105,8 @@ type MockPersonaSeed = { /** Provider pinned on the persona. Leave empty for Codex/Claude runtimes. */ provider?: string | null; namePool?: string[]; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockTeamSeed = { @@ -220,6 +224,10 @@ type MockBridgeOptions = { | "stopped"; }>; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; From 1e307e178a7b7fc157cde1ef0721ba1a69cbc274 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 28 Jul 2026 12:31:24 -0700 Subject: [PATCH 13/59] chore(compose): remove stale typesense env vars (#3332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search migrated to Postgres FTS (commit f8bbe6efc). The Typesense container was removed from compose.yml and the Helm chart, but the cleanup missed two template/config files: - `deploy/compose/.env.example`: `TYPESENSE_API_KEY` and `TYPESENSE_PORT` are dead — no typesense service exists in compose.yml and the relay binary no longer reads `TYPESENSE_API_KEY`. The `CHANGE_ME_RANDOM_API_KEY` placeholder was never consumed, so removing it also unbreaks the sed loop in the blog draft (one fewer no-op secret to generate). - `benchmarks/harbor-buzz-orchestra/scripts/benchmark.py`: generates a typesense_api_key in state and writes `TYPESENSE_API_KEY` to the .env file it creates. - *Editing this file caused the https://github.com/block/buzz/blob/main/.github/workflows/benchmark-harbor.yml linter ci checks to run, which seemingly haven't run before, so I needed fix the lint issues to pass this.* --------- Signed-off-by: Kalvin Chau Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c --- .../scripts/benchmark.py | 151 ++++++++++++------ .../scripts/run_leaderboard.py | 93 ++++++++--- .../src/harbor_buzz_orchestra/__init__.py | 10 +- .../src/harbor_buzz_orchestra/agent.py | 4 +- .../container_runtime.py | 72 ++++++--- .../src/harbor_buzz_testbed/buzz_cli.py | 1 + .../src/harbor_buzz_testbed/provisioner.py | 4 +- .../testbed/tests/test_benchmark.py | 31 +++- .../testbed/tests/test_keys.py | 7 +- .../testbed/tests/test_provisioner_live.py | 1 + .../testbed/tests/test_provisioner_unit.py | 15 +- .../harbor-buzz-orchestra/tests/conftest.py | 1 + .../harbor-buzz-orchestra/tests/test_agent.py | 6 +- .../tests/test_container_runtime.py | 48 +++--- .../tests/test_manifest.py | 2 + .../tests/test_run_leaderboard.py | 12 +- deploy/compose/.env.example | 2 - 17 files changed, 311 insertions(+), 149 deletions(-) diff --git a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py index f1e91d022b..b6f5601a82 100755 --- a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py @@ -84,51 +84,75 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) problems = parser.add_mutually_exclusive_group() problems.add_argument( - "--dataset", "-d", default=None, + "--dataset", + "-d", + default=None, help=f"Registry dataset (default: {DEFAULT_DATASET})", ) problems.add_argument( "--path", "-p", type=Path, help="Local task or dataset directory" ) parser.add_argument( - "--include-task", "-i", action="append", default=[], + "--include-task", + "-i", + action="append", + default=[], help="Task name to include (glob, repeatable)", ) parser.add_argument( - "--exclude-task", "-x", action="append", default=[], + "--exclude-task", + "-x", + action="append", + default=[], help="Task name to exclude (glob, repeatable)", ) parser.add_argument( - "--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS, + "--attempts", + "-k", + type=int, + default=DEFAULT_ATTEMPTS, help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)", ) parser.add_argument( - "--manifest", type=Path, default=DEFAULT_MANIFEST, + "--manifest", + type=Path, + default=DEFAULT_MANIFEST, help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})", ) parser.add_argument( - "--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS, + "--endpoint-config", + type=Path, + default=DEFAULT_ENDPOINTS, help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})", ) - parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") + parser.add_argument( + "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials" + ) parser.add_argument( "--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root" ) - parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") parser.add_argument( - "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + "--job-name", default=None, help="Job name (default: lb--)" + ) + parser.add_argument( + "--upload", + action="store_true", + help="Upload to Harbor Hub when the job finishes", ) parser.add_argument( - "--gui", action="store_true", + "--gui", + action="store_true", help="Open the Buzz desktop app as the benchmark user to watch the run live", ) parser.add_argument( - "--fresh", action="store_true", + "--fresh", + action="store_true", help="Reset first: drop the stack's Docker volumes and the benchmark " - "GUI's app state (keys in state.json are kept)", + "GUI's app state (keys in state.json are kept)", ) parser.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Print the underlying harbor command and exit (no stack bring-up)", ) return parser.parse_args(argv) @@ -153,7 +177,6 @@ def load_state() -> dict[str, str]: "user_secret_key": user.secret_key, "postgres_password": secrets.token_urlsafe(24), "redis_password": secrets.token_urlsafe(24), - "typesense_api_key": secrets.token_hex(16), "s3_access_key": secrets.token_hex(10), "s3_secret_key": secrets.token_hex(20), "git_hook_hmac_secret": secrets.token_hex(32), @@ -202,7 +225,6 @@ def write_env_file(state: dict[str, str]) -> Path: "POSTGRES_USER": "buzz", "POSTGRES_PASSWORD": state["postgres_password"], "REDIS_PASSWORD": state["redis_password"], - "TYPESENSE_API_KEY": state["typesense_api_key"], "BUZZ_S3_ACCESS_KEY": state["s3_access_key"], "BUZZ_S3_SECRET_KEY": state["s3_secret_key"], "BUZZ_S3_BUCKET": "buzz-media", @@ -217,14 +239,11 @@ def write_env_file(state: dict[str, str]) -> Path: def postgres_dsn(state: dict[str, str]) -> str: return ( - f"postgresql://buzz:{state['postgres_password']}" - f"@127.0.0.1:{PG_HOST_PORT}/buzz" + f"postgresql://buzz:{state['postgres_password']}@127.0.0.1:{PG_HOST_PORT}/buzz" ) -def write_provisioner_config( - state: dict[str, str], endpoint_config: Path -) -> Path: +def write_provisioner_config(state: dict[str, str], endpoint_config: Path) -> Path: """Resolve per-endpoint API keys from the environment and write the provisioner config: pinned user, keep-channels teardown.""" endpoints = json.loads(endpoint_config.read_text()) @@ -262,10 +281,14 @@ def write_provisioner_config( def compose_command(*args: str) -> list[str]: command = [ - "docker", "compose", - "--project-name", COMPOSE_PROJECT, - "--project-directory", str(STATE_DIR), - "--env-file", str(STATE_DIR / ".env"), + "docker", + "compose", + "--project-name", + COMPOSE_PROJECT, + "--project-directory", + str(STATE_DIR), + "--env-file", + str(STATE_DIR / ".env"), ] for file in COMPOSE_FILES: command += ["-f", str(file)] @@ -360,7 +383,9 @@ def linux_triple() -> str: """The musl triple matching the Docker engine that runs task containers.""" arch = subprocess.run( ["docker", "version", "--format", "{{.Server.Arch}}"], - capture_output=True, text=True, check=True, + capture_output=True, + text=True, + check=True, ).stdout.strip() try: return { @@ -385,22 +410,32 @@ def ensure_agent_binaries() -> Path: targets = AGENT_BINARIES + (FORWARDER_BINARY,) if all((bin_dir / name).is_file() for name in targets): return bin_dir - print(f"Linux agent binaries missing — cross-building for {triple} " - f"in {RUST_IMAGE} (first run only, ~2 min)...") + print( + f"Linux agent binaries missing — cross-building for {triple} " + f"in {RUST_IMAGE} (first run only, ~2 min)..." + ) LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True) (STATE_DIR / "cargo-registry").mkdir(exist_ok=True) packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)] forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT) subprocess.run( [ - "docker", "run", "--rm", - "-v", f"{REPO_ROOT}:/src:ro", - "-v", f"{LINUX_TARGET_DIR}:/target", - "-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry", - "-e", "CARGO_TARGET_DIR=/target", - "-w", "/src", + "docker", + "run", + "--rm", + "-v", + f"{REPO_ROOT}:/src:ro", + "-v", + f"{LINUX_TARGET_DIR}:/target", + "-v", + f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry", + "-e", + "CARGO_TARGET_DIR=/target", + "-w", + "/src", RUST_IMAGE, - "sh", "-c", + "sh", + "-c", "apk add --no-cache musl-dev >/dev/null && " f"cargo build --release --locked --target {triple} " + " ".join(packages) @@ -429,8 +464,13 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen: """ subprocess.run( compose_command( - "exec", "-T", "relay", - "buzz-admin", "add-member", "--pubkey", state["user_pubkey"], + "exec", + "-T", + "relay", + "buzz-admin", + "add-member", + "--pubkey", + state["user_pubkey"], ), check=True, ) @@ -445,12 +485,20 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen: ["rustc", "-vV"], capture_output=True, text=True, check=True ).stdout triple = next( - line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ") + line.split(": ", 1)[1] + for line in target.splitlines() + if line.startswith("host: ") ) sidecar_dir = desktop_dir / "src-tauri" / "binaries" sidecar_dir.mkdir(parents=True, exist_ok=True) binaries = ensure_binaries() - for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"): + for name in ( + "buzz-acp", + "buzz-agent", + "buzz-dev-mcp", + "git-credential-nostr", + "buzz", + ): stub = sidecar_dir / f"{name}-{triple}" if not stub.exists(): stub.touch() @@ -498,21 +546,30 @@ def leaderboard_argv( for pattern in args.exclude_task: argv += ["--exclude-task", pattern] argv += [ - "--attempts", str(args.attempts), - "--manifest", str(args.manifest), - "--endpoint-config", str(args.endpoint_config), - "--provisioner-config", str(provisioner_config), - "--agent-bin-dir", str(agent_bin_dir), + "--attempts", + str(args.attempts), + "--manifest", + str(args.manifest), + "--endpoint-config", + str(args.endpoint_config), + "--provisioner-config", + str(provisioner_config), + "--agent-bin-dir", + str(agent_bin_dir), # The relay as reachable from inside a task container: Docker's # host alias, bridged to the canonical localhost address by the # uploaded forwarder. Override the alias with # BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host # differently. "--relay-gateway", - f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}" - f":{RELAY_HTTP_PORT}", - "--n-concurrent", str(args.n_concurrent), - "--jobs-dir", str(args.jobs_dir), + ( + f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}" + f":{RELAY_HTTP_PORT}" + ), + "--n-concurrent", + str(args.n_concurrent), + "--jobs-dir", + str(args.jobs_dir), ] if args.job_name: argv += ["--job-name", args.job_name] diff --git a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py index 6fd43ea6fb..6eaf8d6af0 100755 --- a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py +++ b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py @@ -45,64 +45,101 @@ # host-header tenant-bound, so agents must present its canonical Host). FORWARDER_BINARY = "relay-forwarder" -PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"} +PROVIDER_ORGS = { + "anthropic": "Anthropic", + "openai": "OpenAI", + "databricks": "Databricks", +} def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter + description=__doc__.splitlines()[0], + formatter_class=argparse.RawDescriptionHelpFormatter, ) problems = parser.add_mutually_exclusive_group(required=True) problems.add_argument( - "--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)" + "--dataset", + "-d", + help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)", ) problems.add_argument( "--path", "-p", type=Path, help="Local task or dataset directory" ) parser.add_argument( - "--include-task", "-i", action="append", default=[], + "--include-task", + "-i", + action="append", + default=[], help="Task name to include from the dataset (glob, repeatable)", ) parser.add_argument( - "--exclude-task", "-x", action="append", default=[], + "--exclude-task", + "-x", + action="append", + default=[], help="Task name to exclude from the dataset (glob, repeatable)", ) parser.add_argument( - "--attempts", "-k", type=int, required=True, + "--attempts", + "-k", + type=int, + required=True, help="Runs per problem (leaderboards require 5)", ) - parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML") parser.add_argument( - "--endpoint-config", type=Path, required=True, + "--manifest", type=Path, required=True, help="Team manifest YAML" + ) + parser.add_argument( + "--endpoint-config", + type=Path, + required=True, help="JSON mapping manifest endpoint names to providers/API keys", ) parser.add_argument( - "--provisioner-config", type=Path, required=True, + "--provisioner-config", + type=Path, + required=True, help="JSON config for the Buzz relay/Postgres provisioner", ) parser.add_argument( - "--buzz-bin-dir", type=Path, default=None, + "--buzz-bin-dir", + type=Path, + default=None, help="Directory with the host buzz CLI (default: repo target/release, then target/debug)", ) parser.add_argument( - "--agent-bin-dir", type=Path, required=True, + "--agent-bin-dir", + type=Path, + required=True, help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp " "to upload into each task container", ) parser.add_argument( - "--relay-gateway", default="", + "--relay-gateway", + default="", help="host:port of the benchmark relay as reachable from inside the " "task container (e.g. host.docker.internal:3600). When set, a " "loopback forwarder from --agent-bin-dir bridges the canonical " "relay address to this gateway", ) - parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials") - parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root") - parser.add_argument("--job-name", default=None, help="Job name (default: lb--)") parser.add_argument( - "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes" + "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials" + ) + parser.add_argument( + "--jobs-dir", type=Path, default=Path("jobs"), help="Job output root" + ) + parser.add_argument( + "--job-name", default=None, help="Job name (default: lb--)" + ) + parser.add_argument( + "--upload", + action="store_true", + help="Upload to Harbor Hub when the job finishes", + ) + parser.add_argument( + "--dry-run", action="store_true", help="Print the harbor command and exit" ) - parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit") return parser.parse_args(argv) @@ -110,7 +147,9 @@ def find_binaries(bin_dir: Path | None) -> dict[str, Path]: candidates = ( [bin_dir] if bin_dir is not None - else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")] + else [ + PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug") + ] ) for candidate in candidates: found = {name: candidate / name for name in BINARIES} @@ -146,11 +185,17 @@ def build_command( resource override would fail leaderboard static validation, so none are accepted or forwarded.""" command = [ - "harbor", "run", "--yes", - "--job-name", args.job_name, - "--jobs-dir", str(args.jobs_dir), - "-k", str(args.attempts), - "--n-concurrent", str(args.n_concurrent), + "harbor", + "run", + "--yes", + "--job-name", + args.job_name, + "--jobs-dir", + str(args.jobs_dir), + "-k", + str(args.attempts), + "--n-concurrent", + str(args.n_concurrent), ] if args.dataset: command += ["--dataset", args.dataset] @@ -250,7 +295,7 @@ def main(argv: list[str] | None = None) -> int: f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..." ) - result = subprocess.run(command) + result = subprocess.run(command, check=False) job_dir = args.jobs_dir / args.job_name if result.returncode != 0: print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}") diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py index b423e8aa47..1b79d233b9 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py @@ -1,25 +1,25 @@ """Buzz orchestra custom agent for Harbor.""" from .agent import BuzzOrchestraAgent -from .manifest import ExperimentManifest, ManifestError -from .provisioning import AgentCredential, TrialHandle, TrialProvisioner -from .runtime import OrchestraRuntime, RuntimeResult from .container_runtime import ( BuzzContainerRuntime, EndpointLaunchConfig, RuntimeLaunchError, ) +from .manifest import ExperimentManifest, ManifestError +from .provisioning import AgentCredential, TrialHandle, TrialProvisioner +from .runtime import OrchestraRuntime, RuntimeResult __all__ = [ "AgentCredential", - "BuzzOrchestraAgent", "BuzzContainerRuntime", + "BuzzOrchestraAgent", "EndpointLaunchConfig", "ExperimentManifest", "ManifestError", "OrchestraRuntime", - "RuntimeResult", "RuntimeLaunchError", + "RuntimeResult", "TrialHandle", "TrialProvisioner", ] diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py index 6354e9a587..3d1c81364f 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py @@ -9,10 +9,10 @@ from harbor.environments.base import BaseEnvironment from harbor.models.agent.context import AgentContext +from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig from .manifest import ExperimentManifest from .provisioning import TrialProvisioner from .runtime import OrchestraRuntime -from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig class BuzzOrchestraAgent(BaseAgent): @@ -83,7 +83,7 @@ def _load_mapping( except (OSError, json.JSONDecodeError) as error: raise ValueError(f"cannot load JSON config {path}: {error}") from error if not isinstance(value, dict): - raise ValueError(f"JSON config {path} must contain an object") + raise TypeError(f"JSON config {path} must contain an object") return value @classmethod diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index 3909f081f5..149a5295a7 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -24,7 +24,6 @@ from .provisioning import AgentCredential, TrialHandle from .runtime import RuntimeResult - DEFAULT_MAX_AGENT_ROUNDS = 32 # Container-side layout for the uploaded Buzz stack. REMOTE_ROOT = "/opt/buzz" @@ -128,12 +127,20 @@ async def run( if forwarder is not None: infra.append(forwarder) await self._buzz_json( - trial.user, trial, "users", "set-profile", "--name", + trial.user, + trial, + "users", + "set-profile", + "--name", trial.user.agent_id, ) for credential in trial.credentials: await self._buzz_json( - credential, trial, "users", "set-profile", "--name", + credential, + trial, + "users", + "set-profile", + "--name", credential.agent_id, ) agents.append( @@ -244,11 +251,17 @@ async def _start_forwarder( ) from error forwarder = _Agent( AgentCredential( - agent_id="relay-forwarder", role="infra", - nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="", - llm_endpoint="", llm_api_key="", + agent_id="relay-forwarder", + role="infra", + nostr_secret_key="", + nostr_pubkey="", + nostr_auth_tag="", + llm_endpoint="", + llm_api_key="", ), - pid, log, log, + pid, + log, + log, ) deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds while True: @@ -418,9 +431,14 @@ async def _wait_for_done( await self._raise_for_dead_agents(environment, agents) polls += 1 messages = await self._buzz_json( - trial.user, trial, - "messages", "get", "--channel", trial.channel_id, - "--limit", "100", + trial.user, + trial, + "messages", + "get", + "--channel", + trial.channel_id, + "--limit", + "100", ) for message in messages: if message.get("pubkey") == orchestrator.nostr_pubkey and str( @@ -451,9 +469,7 @@ async def _raise_for_dead_agents( ) @staticmethod - async def _stop_agents( - environment: BaseEnvironment, agents: list[_Agent] - ) -> None: + async def _stop_agents(environment: BaseEnvironment, agents: list[_Agent]) -> None: """Terminate every process of the uploaded stack (acp, agent, mcp).""" if not agents: return @@ -461,14 +477,14 @@ async def _stop_agents( # to exist in task images, the /proc filesystem is. sweep = ( "for d in /proc/[0-9]*; do " - f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null " - "&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true" + f'grep -aq {REMOTE_BIN} "$d/cmdline" 2>/dev/null ' + '&& kill -TERM "${d#/proc/}" 2>/dev/null; done; true' ) try: await environment.exec(sweep) await asyncio.sleep(2) await environment.exec(sweep.replace("-TERM", "-KILL")) - except Exception: # noqa: BLE001 — environment may already be gone + except Exception: # noqa: S110, BLE001 — environment may already be gone pass async def _collect_logs( @@ -476,7 +492,7 @@ async def _collect_logs( ) -> None: try: await environment.download_dir(REMOTE_LOGS, trial_dir) - except Exception: # noqa: BLE001 — best effort; env may be torn down + except Exception: # noqa: S110, BLE001 — best effort; env may be torn down pass # -- Buzz CLI as the trial user / provisioning identities ------------------- @@ -506,9 +522,14 @@ async def _send( self, credential: AgentCredential, trial: TrialHandle, content: str ) -> None: await self._buzz_json( - credential, trial, - "messages", "send", "--channel", trial.channel_id, - "--content", content, + credential, + trial, + "messages", + "send", + "--channel", + trial.channel_id, + "--content", + content, ) async def _buzz_json( @@ -614,9 +635,11 @@ def _compose_system_prompt( "", f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).", f"The team coordinates in Buzz channel `{trial.channel_id}`.", - f"Tasks come from the user `{trial.user.agent_id}` " - f"(pubkey `{trial.user.nostr_pubkey}`); address your final report " - "to them.", + ( + f"Tasks come from the user `{trial.user.agent_id}` " + f"(pubkey `{trial.user.nostr_pubkey}`); address your final report " + "to them." + ), "", "| Name | Role | Pubkey |", "|------|------|--------|", @@ -625,8 +648,7 @@ def _compose_system_prompt( if teammate.agent_id == credential.agent_id: continue lines.append( - f"| {teammate.agent_id} | {teammate.role} " - f"| `{teammate.nostr_pubkey}` |" + f"| {teammate.agent_id} | {teammate.role} | `{teammate.nostr_pubkey}` |" ) composed = persona + "\n".join(lines) + "\n" path = trial_dir / f"{credential.agent_id}.system-prompt.md" diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index ed2bb31cc9..bd11f193ca 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -38,6 +38,7 @@ def run(self, *args: str) -> Any: capture_output=True, text=True, timeout=self._timeout, + check=False, env={ "BUZZ_RELAY_URL": self._relay_url, "BUZZ_PRIVATE_KEY": self._secret_key, diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index cfda6b59fa..d8f380387d 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -44,7 +44,7 @@ class TestbedConfig: archive_on_teardown: bool = True -def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner": +def provisioner_from_dict(config: dict[str, object]) -> BuzzTrialProvisioner: """Harbor CLI factory for a JSON-decoded testbed configuration.""" return BuzzTrialProvisioner(TestbedConfig(**config)) @@ -100,7 +100,7 @@ def teardown(self, handle: TrialHandle) -> None: cli = self._cli_for(handle.credentials[0]) try: cli.archive_channel(handle.channel_id) - except Exception as error: # noqa: BLE001 — idempotent re-teardown + except Exception as error: if "archived" not in str(error).lower(): raise with psycopg.connect(self._config.postgres_dsn) as conn: diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py index 2d88e339f5..e0c6d32ec4 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py @@ -37,8 +37,19 @@ def test_defaults_are_leaderboard_eligible(): def test_selectors_pass_through(): args = benchmark.parse_args( - ["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1", - "--job-name", "smoke", "--dry-run"] + [ + "--path", + "/tmp/task", + "-i", + "cobol*", + "-x", + "flaky*", + "-k", + "1", + "--job-name", + "smoke", + "--dry-run", + ] ) argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b")) assert argv[argv.index("--path") + 1] == "/tmp/task" @@ -59,11 +70,15 @@ def test_state_is_generated_once_and_reused(state_dir): assert "user_pubkey" not in stored # derived, never persisted -def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch): +def test_provisioner_config_pins_user_and_keeps_channels( + state_dir, tmp_path, monkeypatch +): monkeypatch.setenv("FAKE_KEY_ENV", "sk-test") endpoints = tmp_path / "endpoints.json" endpoints.write_text( - json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}}) + json.dumps( + {"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}} + ) ) state = benchmark.load_state() path = benchmark.write_provisioner_config(state, endpoints) @@ -78,7 +93,9 @@ def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, mo assert config["relay_http_url"].startswith("http://localhost:") -def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch): +def test_provisioner_config_missing_api_key_is_explicit( + state_dir, tmp_path, monkeypatch +): monkeypatch.delenv("MISSING_KEY_ENV", raising=False) endpoints = tmp_path / "endpoints.json" endpoints.write_text( @@ -91,9 +108,7 @@ def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, mon def test_env_file_wires_owner_and_ports(state_dir): state = benchmark.load_state() env_path = benchmark.write_env_file(state) - env = dict( - line.split("=", 1) for line in env_path.read_text().splitlines() if line - ) + env = dict(line.split("=", 1) for line in env_path.read_text().splitlines() if line) assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"] assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT) assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py index 0f82578369..0ac794e0fa 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py @@ -6,6 +6,7 @@ import json import coincurve + from harbor_buzz_testbed.keys import ( compute_auth_tag, encode_nsec, @@ -21,8 +22,10 @@ "auth", "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", "", - "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da" - "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867", + ( + "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da" + "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867" + ), ] diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py index 5a6b40d8cf..711b877ab4 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py @@ -14,6 +14,7 @@ import psycopg import pytest + from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index 9620be4bc8..e784de5825 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -7,6 +7,7 @@ import coincurve import pytest + from harbor_buzz_testbed.provisioner import ( BuzzTrialProvisioner, ProvisioningError, @@ -17,13 +18,13 @@ def config(**overrides) -> TestbedConfig: - defaults = dict( - relay_http_url="http://localhost:3000", - relay_ws_url="ws://host.docker.internal:3000", - owner_secret_key=OWNER_SECRET, - postgres_dsn="postgresql://unused", - llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"}, - ) + defaults = { + "relay_http_url": "http://localhost:3000", + "relay_ws_url": "ws://host.docker.internal:3000", + "owner_secret_key": OWNER_SECRET, + "postgres_dsn": "postgresql://unused", + "llm_api_keys": {"databricks/glm": "glm-key", "databricks/opus": "opus-key"}, + } defaults.update(overrides) return TestbedConfig(**defaults) diff --git a/benchmarks/harbor-buzz-orchestra/tests/conftest.py b/benchmarks/harbor-buzz-orchestra/tests/conftest.py index bd0bcaf2dd..b1de094d76 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/conftest.py +++ b/benchmarks/harbor-buzz-orchestra/tests/conftest.py @@ -1,4 +1,5 @@ from typing import Any + import pytest diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py index 62c6047ab3..b305344c51 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py @@ -1,7 +1,9 @@ from types import SimpleNamespace from uuid import uuid4 + import pytest from harbor.models.agent.context import AgentContext + from harbor_buzz_orchestra import ( AgentCredential, BuzzOrchestraAgent, @@ -73,9 +75,7 @@ async def run(self, **kwargs): async def test_agent_lifecycle_and_context(tmp_path, manifest_data): provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4() - environment = SimpleNamespace( - context_id=context_id, environment_name="hello-world" - ) + environment = SimpleNamespace(context_id=context_id, environment_name="hello-world") agent = BuzzOrchestraAgent( logs_dir=tmp_path, manifest=manifest_data, diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index 8669fe980c..ebf0eb4b5d 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -8,8 +8,6 @@ import pytest from harbor.environments.base import ExecResult -from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle from harbor_buzz_orchestra.container_runtime import ( REMOTE_BIN, REMOTE_LOGS, @@ -17,6 +15,8 @@ EndpointLaunchConfig, RuntimeLaunchError, ) +from harbor_buzz_orchestra.manifest import ExperimentManifest +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle def write_manifest(tmp_path: Path) -> ExperimentManifest: @@ -33,10 +33,20 @@ def write_manifest(tmp_path: Path) -> ExperimentManifest: { "condition": "test", "roster": [ - {"id": "orch", "kind": "orchestrator", "role": "lead", - "endpoint": "orch-model", **roster_entry}, - {"id": "worker", "kind": "worker", "role": "implementer", - "endpoint": "worker-model", **roster_entry}, + { + "id": "orch", + "kind": "orchestrator", + "role": "lead", + "endpoint": "orch-model", + **roster_entry, + }, + { + "id": "worker", + "kind": "worker", + "role": "implementer", + "endpoint": "worker-model", + **roster_entry, + }, ], "prices": { name: { @@ -162,10 +172,7 @@ def test_user_relay_url_prefers_host_view(tmp_path): == "http://localhost:3600" ) # pre-v1.2 handles fall back to deriving http from the agents' ws view. - assert ( - rt._user_relay_url(trial_handle(())) - == "http://host.docker.internal:3600" - ) + assert rt._user_relay_url(trial_handle(())) == "http://host.docker.internal:3600" with pytest.raises(RuntimeLaunchError, match="ws://"): rt._cli_relay_url("http://relay") @@ -209,16 +216,21 @@ async def test_forwarder_bridges_the_canonical_relay_address(tmp_path): forwarder_binary=str(forwarder), ) trial = TrialHandle( - run_id="run", trial_id="trial", manifest_hash="hash", - relay_ws_url="ws://localhost:3600", channel_id="channel", - credentials=(), user=user_credential(), + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://localhost:3600", + channel_id="channel", + credentials=(), + user=user_credential(), ) environment = Environment( responses={ FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0), "cat ": ExecResult( stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600", - stderr="", return_code=0, + stderr="", + return_code=0, ), } ) @@ -295,9 +307,7 @@ class ReadyEnvironment(Environment): async def exec(self, command, env=None, **kwargs): if command.startswith("cat "): agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1) - return ExecResult( - stdout=logs[agent_id], stderr="", return_code=0 - ) + return ExecResult(stdout=logs[agent_id], stderr="", return_code=0) return ExecResult(stdout="", stderr="", return_code=0) from harbor_buzz_orchestra.container_runtime import _Agent @@ -326,9 +336,7 @@ async def exec(self, command, env=None, **kwargs): async def test_dead_agent_processes_fail_the_trial(tmp_path): from harbor_buzz_orchestra.container_runtime import _Agent - agents = [ - _Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e") - ] + agents = [_Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")] environment = Environment( responses={ "kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py index 36533db3bf..f8230036b3 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py @@ -1,6 +1,8 @@ import copy + import pytest import yaml + from harbor_buzz_orchestra import ExperimentManifest, ManifestError diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py index ed048ee5d9..451de72e79 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py @@ -109,8 +109,16 @@ def test_forbidden_flags_are_not_accepted(tmp_path): for flag in FORBIDDEN_FLAGS: with pytest.raises(SystemExit): run_leaderboard.parse_args( - ["--dataset", "d", "--attempts", "5", - "--agent-bin-dir", str(tmp_path), flag, "1"] + [ + "--dataset", + "d", + "--attempts", + "5", + "--agent-bin-dir", + str(tmp_path), + flag, + "1", + ] ) diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index cebe879da0..838824c17f 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -30,7 +30,6 @@ POSTGRES_DB=buzz POSTGRES_USER=buzz POSTGRES_PASSWORD=CHANGE_ME_RANDOM_PASSWORD REDIS_PASSWORD=CHANGE_ME_RANDOM_PASSWORD -TYPESENSE_API_KEY=CHANGE_ME_RANDOM_API_KEY BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY BUZZ_S3_BUCKET=buzz-media @@ -45,7 +44,6 @@ CADDY_HTTPS_PORT=443 # Dev override ports. Only used with compose.dev.yml. POSTGRES_PORT=5432 REDIS_PORT=6379 -TYPESENSE_PORT=8108 MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 ADMINER_PORT=8082 From b0503d80c298b1ece3b0a43b41d316829a3379e7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 16:01:41 -0400 Subject: [PATCH 14/59] feat(desktop): add custom harness inline from agent dialogs (#3252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering a custom ACP harness works today, but only from Settings → Agents. Anyone whose first touchpoint is "New agent" has no way to discover the custom path — the dropdown just lists the baked-in presets plus whatever was registered earlier. This adds an inline "Add custom harness…" entry to the harness dropdown in all three agent surfaces: create, edit-definition (`AgentDefinitionDialog`), and instance edit (`AgentInstanceEditDialog`). The entry is a sentinel option (`ADD_CUSTOM_HARNESS_VALUE`, NUL-prefixed so it can never collide with a real harness id — backend ids match `[a-z0-9_][a-z0-9_-]*`), mirroring the `CUSTOM_ENTRY_ID` trick already used in `HarnessCatalogDialog`. Picking it never writes into form state; it opens `AddCustomHarnessDialog`, a thin modal wrapper hosting the existing `CustomHarnessForm` in `chromeless` mode. `CustomHarnessForm`'s `onSaved` now carries the saved `definition.id` (the form may rewrite it); the two existing call sites ignore the argument, so their behavior is unchanged. Selection after save is deferred rather than immediate. `usePendingHarnessSelection` holds the saved id until the runtime catalog actually publishes it via discovery, then selects it exactly once — so the dialog never selects an id it cannot render, and back-to-back registrations resolve correctly. The wait is scoped to the owning dialog's `open` state: both host dialogs stay mounted when closed, so an unpublished id is dropped on close rather than selecting into reset form state when discovery later catches up. Selection is routed through each dialog's normal dropdown change handler, so provider/model reset (and command pinning in the instance dialog) behave identically to a hand-picked harness. Dismissing the modal leaves the previous selection untouched. `AgentInstanceEditDialog`'s existing "Custom command" option is a different feature (ad-hoc command override vs. a registered reusable harness) and is untouched. Coverage is 16 unit tests in `addCustomHarness.test.mjs` (real React mount, following the existing `.test.mjs` pattern) plus 4 Playwright specs in `inline-custom-harness.spec.ts` covering all three surfaces end-to-end. Both suites were mutation-verified: treating the sentinel as a real selection, selecting before the catalog publishes, never clearing the pending id, ignoring the dialog's open state, and reversing latest-save-wins each turn the unit tests red; reverting the two dialog diffs turns all four e2e specs red. The `check-file-sizes.mjs` overrides for the two dialogs are ratcheted to their exact new counts (1048 and 1229) — verified tight in both directions, N passes and N−1 fails, so no headroom is introduced. --------- Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 10 +- .../agents/ui/AddCustomHarnessDialog.tsx | 47 +++ .../agents/ui/AgentDefinitionDialog.tsx | 32 +- .../agents/ui/AgentInstanceEditDialog.tsx | 32 +- .../agents/ui/addCustomHarness.test.mjs | 344 ++++++++++++++++++ .../features/agents/ui/addCustomHarness.ts | 104 ++++++ .../settings/ui/CustomHarnessForm.tsx | 11 +- .../tests/e2e/inline-custom-harness.spec.ts | 194 ++++++++++ 9 files changed, 763 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx create mode 100644 desktop/src/features/agents/ui/addCustomHarness.test.mjs create mode 100644 desktop/src/features/agents/ui/addCustomHarness.ts create mode 100644 desktop/tests/e2e/inline-custom-harness.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 0d89b8e2d2..459fa75743 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ "**/observer-archive-policy.spec.ts", "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", + "**/inline-custom-harness.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f8ee32dfab..3983fa591d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -697,12 +697,18 @@ const overrides = new Map([ // hidden-key projection keeps the top-level secret out of Advanced rows. // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring // lands on top of main's dialog growth. Queued to split. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1201], + // +28 (1201 -> 1229): inline "Add custom harness…" entry — sentinel option, + // modal state, and the AddCustomHarnessDialog mount. The shared routing and + // deferred-selection logic lives in addCustomHarness.ts to keep this minimal. + ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1229], // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); // hideProviderIds computation for Databricks v1 gate. Queued to split. - ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035], + // +28 (1020 -> 1048): inline "Add custom harness…" entry — sentinel option, + // modal state, and the AddCustomHarnessDialog mount. The shared routing and + // deferred-selection logic lives in addCustomHarness.ts to keep this minimal. + ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1048], // #2630 emoji picker search: the shadow-root search-input autofocus effect // (rAF retry loop) took this file 999 -> 1026 and landed without this entry, // so main's Desktop Core went red. Queued to split with the rest of this list. diff --git a/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx new file mode 100644 index 0000000000..0c84e99275 --- /dev/null +++ b/desktop/src/features/agents/ui/AddCustomHarnessDialog.tsx @@ -0,0 +1,47 @@ +import { CustomHarnessForm } from "@/features/settings/ui/CustomHarnessForm"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +/** + * Registers a custom ACP harness from inside an agent dialog, so "New agent" + * is a complete entry point and not a dead end that sends the user to + * Settings. Hosts the same `CustomHarnessForm` the harness catalog uses. + */ +export function AddCustomHarnessDialog({ + onOpenChange, + onSaved, + open, +}: { + onOpenChange: (open: boolean) => void; + /** Called with the id of the harness that was just registered. */ + onSaved: (id: string) => void; + open: boolean; +}) { + return ( + + + + Register any ACP-speaking agent tool as a selectable harness. +

+ } + onCancel={() => onOpenChange(false)} + onSaved={(id) => { + // Dismiss on save as well as cancel — both exits belong to this + // dialog, so callers only handle the resulting selection. + onOpenChange(false); + onSaved(id); + }} + /> +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 45031e0371..5425131448 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -83,6 +83,12 @@ import { import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; +import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; +import { + ADD_CUSTOM_HARNESS_OPTION, + runtimeDropdownAction, + usePendingHarnessSelection, +} from "./addCustomHarness"; type AgentDefinitionDialogProps = { open: boolean; @@ -167,6 +173,7 @@ export function AgentDefinitionDialog({ const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); const [hasUserChanges, setHasUserChanges] = React.useState(false); + const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const { globalConfig, inheritedDefaults: { @@ -308,6 +315,7 @@ export function AgentDefinitionDialog({ setShowAdvancedFields(false); setIsAvatarUploadPending(false); setHasUserChanges(false); + setIsAddHarnessOpen(false); // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the // [initialValues, open] effect resets both when the dialog re-opens. } @@ -578,6 +586,7 @@ export function AgentDefinitionDialog({ runtimes, runtimesLoading, }); + runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION); const runtimeSummaryLabel = selectedRuntime ? formatRuntimeOptionLabel(selectedRuntime) : runtime.trim() || "Not configured"; @@ -662,9 +671,13 @@ export function AgentDefinitionDialog({ } function handleRuntimeDropdownChange(nextValue: string) { + const action = runtimeDropdownAction(nextValue); + if (action.kind === "add-custom-harness") { + setIsAddHarnessOpen(true); + return; + } setHasUserChanges(true); - const nextRuntime = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; + const nextRuntime = action.runtimeId; // The user made an explicit choice — no longer auto-seeded. isRuntimeAutoSeededRef.current = false; setRuntime(nextRuntime); @@ -680,6 +693,15 @@ export function AgentDefinitionDialog({ ); } + // Routed through the normal change handler so a harness registered inline + // resets model/provider exactly as a hand-picked one would. Scoped to `open` + // so a pending id can't outlive the dialog that started the registration. + const selectSavedHarness = usePendingHarnessSelection( + runtimes, + handleRuntimeDropdownChange, + open, + ); + function handleProviderDropdownChange(nextValue: string) { setHasUserChanges(true); const nextProvider = @@ -944,6 +966,12 @@ export function AgentDefinitionDialog({ returnFocusRef={aiDefaultsTriggerRef} /> + + {isCreateMode ? createRunSection : null}
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 601d57f95d..f3c410e2ff 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -83,6 +83,12 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; +import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog"; +import { + ADD_CUSTOM_HARNESS_OPTION, + runtimeDropdownAction, + usePendingHarnessSelection, +} from "./addCustomHarness"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -157,6 +163,7 @@ export function AgentInstanceEditDialog({ const [avatarUrl, setAvatarUrl] = React.useState(agent.avatarUrl ?? ""); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); + const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); // Runtime selector: defaults to "custom" until the dialog opens and the @@ -191,6 +198,7 @@ export function AgentInstanceEditDialog({ setAvatarUrl(agent.avatarUrl ?? ""); setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setIsAddHarnessOpen(false); runtimeTouched.current = false; const matched = runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ?? @@ -244,6 +252,7 @@ export function AgentInstanceEditDialog({ value: selectedRuntimeId, }); } + options.push(ADD_CUSTOM_HARNESS_OPTION); return options; }, [sortedRuntimes, selectedRuntimeId]); @@ -484,8 +493,12 @@ export function AgentInstanceEditDialog({ } function handleRuntimeDropdownChange(nextValue: string) { - const nextRuntimeId = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; + const action = runtimeDropdownAction(nextValue); + if (action.kind === "add-custom-harness") { + setIsAddHarnessOpen(true); + return; + } + const nextRuntimeId = action.runtimeId; const previousRuntimeId = selectedRuntimeId; const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId); @@ -532,6 +545,16 @@ export function AgentInstanceEditDialog({ ); } + // Routed through the normal change handler so a harness registered inline + // pins its command and resets model/provider like a hand-picked one. Scoped + // to `open` so a pending id can't outlive the dialog that started the + // registration. + const selectSavedHarness = usePendingHarnessSelection( + runtimes, + handleRuntimeDropdownChange, + open, + ); + function handleProviderDropdownChange(nextValue: string) { const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; @@ -949,6 +972,11 @@ export function AgentInstanceEditDialog({

) : null} +
{selectedRuntimeId === "custom" && !inheritHarness ? (
diff --git a/desktop/src/features/agents/ui/addCustomHarness.test.mjs b/desktop/src/features/agents/ui/addCustomHarness.test.mjs new file mode 100644 index 0000000000..6c0aa32daf --- /dev/null +++ b/desktop/src/features/agents/ui/addCustomHarness.test.mjs @@ -0,0 +1,344 @@ +/** + * Behavior tests for the inline "Add custom harness…" dropdown entry shared by + * AgentDefinitionDialog and AgentInstanceEditDialog. + * + * Two seams carry the feature, and both are pinned here: + * + * 1. ROUTING (`runtimeDropdownAction`) — the sentinel must resolve to "open + * the form", never to a selection. If it ever resolved to a selection the + * dialogs would write "\u0000add-custom-harness" into `runtime` and try to + * spawn an agent on a harness that does not exist. + * 2. DEFERRED SELECTION (`usePendingHarnessSelection`) — saving only writes + * the definition file; the harness becomes a catalog entry when the + * invalidated discovery query refetches. Selecting on save would pick an + * id no entry backs. The hook must wait for the catalog, fire exactly + * once, stay silent when the user cancels, and drop the pending id when + * its dialog closes — the host dialogs stay mounted, so a stale id would + * otherwise select into reset form state on a later publish. + * + * The hook is mounted for real (react-dom/client + act) rather than simulated, + * so its effect wiring — including the guard that survives the dialogs' + * non-memoized change handlers — is what gets tested. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Minimal DOM shim ───────────────────────────────────────────────────────── +// react-dom/client needs a container element and a document; node has neither. +// The harness renders null, so no real node operations are exercised. + +class ElementShim { + constructor() { + this.children = []; + this.childNodes = []; + this.nodeType = 1; + this.nodeName = "DIV"; + this.tagName = "DIV"; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + } + get ownerDocument() { + return globalThis.document; + } + addEventListener() {} + removeEventListener() {} + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + return child; + } + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + return child; + } + insertBefore(child) { + return this.appendChild(child); + } + contains(target) { + return this === target; + } +} + +globalThis.document = { + activeElement: null, + addEventListener() {}, + createElement: () => new ElementShim(), + get defaultView() { + return globalThis.window; + }, + nodeType: 9, + removeEventListener() {}, +}; +// react-dom derives update priority from window.event and walks iframe +// boundaries via window.HTMLIFrameElement during commit. +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener() {}, + document: globalThis.document, + event: undefined, + HTMLIFrameElement: ElementShim, + removeEventListener() {}, + }, +}); +globalThis.HTMLElement = ElementShim; +globalThis.Node = ElementShim; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { NO_RUNTIME_DROPDOWN_VALUE } from "./agentConfigOptions.tsx"; +import { + ADD_CUSTOM_HARNESS_OPTION, + ADD_CUSTOM_HARNESS_VALUE, + readyHarnessId, + runtimeDropdownAction, + usePendingHarnessSelection, +} from "./addCustomHarness.ts"; + +// ── Routing: the sentinel opens the form, it is never a selection ──────────── + +test("selecting the add-custom entry requests the form and yields no runtime id", () => { + const action = runtimeDropdownAction(ADD_CUSTOM_HARNESS_VALUE); + assert.equal(action.kind, "add-custom-harness"); + // The dialogs read `action.runtimeId` on the select branch; the sentinel + // must not carry one, or it could leak into form state. + assert.equal("runtimeId" in action, false); +}); + +test("selecting a harness yields that harness id", () => { + assert.deepEqual(runtimeDropdownAction("my-harness"), { + kind: "select", + runtimeId: "my-harness", + }); +}); + +test("selecting the no-runtime entry yields the empty id", () => { + assert.deepEqual(runtimeDropdownAction(NO_RUNTIME_DROPDOWN_VALUE), { + kind: "select", + runtimeId: "", + }); +}); + +test("the add-custom sentinel cannot collide with a backend-valid harness id", () => { + // Backend ids match [a-z0-9_][a-z0-9_-]* (custom_harnesses.rs), so a + // NUL-prefixed value is unreachable as a real id. + assert.equal(ADD_CUSTOM_HARNESS_VALUE.startsWith("\u0000"), true); + assert.equal(ADD_CUSTOM_HARNESS_OPTION.value, ADD_CUSTOM_HARNESS_VALUE); + assert.equal(ADD_CUSTOM_HARNESS_OPTION.label, "Add custom harness…"); +}); + +// ── Readiness: an id is selectable only once the catalog publishes it ──────── + +test("a pending id absent from the catalog is not ready", () => { + assert.equal(readyHarnessId([{ id: "claude" }], "my-harness"), null); +}); + +test("a pending id present in the catalog is ready", () => { + assert.equal( + readyHarnessId([{ id: "claude" }, { id: "my-harness" }], "my-harness"), + "my-harness", + ); +}); + +test("no pending id is never ready even against a populated catalog", () => { + assert.equal(readyHarnessId([{ id: "claude" }], null), null); +}); + +// ── Deferred selection: mounted hook ───────────────────────────────────────── + +/** + * Mount the real hook over a mutable catalog. Returns the setter the dialogs + * call on save, a `setRuntimes` to simulate the discovery refetch, a `setOpen` + * to simulate the owning dialog closing and reopening, and the log of ids the + * hook handed back for selection. + */ +async function mountPendingSelection(initialRuntimes = []) { + const selected = []; + const control = {}; + + function Harness() { + const [runtimes, setRuntimes] = React.useState(initialRuntimes); + const [open, setOpen] = React.useState(true); + // Deliberately NOT memoized: both dialogs pass a plain function + // declaration, so `onReady` has a fresh identity on every render. + const onReady = (id) => selected.push(id); + control.save = usePendingHarnessSelection(runtimes, onReady, open); + control.setRuntimes = setRuntimes; + control.setOpen = setOpen; + return null; + } + + const root = createRoot(new ElementShim()); + await act(async () => { + root.render(React.createElement(Harness)); + }); + return { control, root, selected }; +} + +test("saving a harness selects it only once the catalog publishes it", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Save returns before discovery refetches — nothing to select yet. + await act(async () => control.save("my-harness")); + assert.deepEqual(selected, []); + + // The invalidated discovery query resolves with the new entry. + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); + +test("a published harness is selected exactly once across later catalog updates", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("my-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + // Any later refetch re-renders with a new array identity and a new onReady + // identity. Re-firing here would clobber a selection the user made in + // between, so the pending id must have been cleared. + await act(async () => + control.setRuntimes([ + { id: "claude" }, + { id: "my-harness" }, + { id: "codex" }, + ]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); + +test("cancelling the form leaves the current selection untouched", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Cancel never reports a saved id, so no selection is ever requested — even + // as the catalog keeps refreshing underneath. + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "codex" }]), + ); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("a saved harness discovery never publishes is never selected", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // e.g. the definition file was written but the entry failed to load. The + // hook must stall rather than select an id no catalog entry backs. + await act(async () => control.save("ghost-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "codex" }]), + ); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("two harnesses registered in a row are each selected when published", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("first")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }]), + ); + await act(async () => control.save("second")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }, { id: "second" }]), + ); + assert.deepEqual(selected, ["first", "second"]); + + await act(async () => root.unmount()); +}); + +test("a second save before the first publishes selects only the later harness", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // The dropdown holds one harness, so the latest registration wins: the + // first id is dropped rather than queued behind the second. + await act(async () => control.save("first")); + await act(async () => control.save("second")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "first" }, { id: "second" }]), + ); + assert.deepEqual(selected, ["second"]); + + await act(async () => root.unmount()); +}); + +// ── Lifecycle: a pending id never outlives the dialog that created it ──────── + +test("a harness published after its dialog closed is never selected", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + // Both host dialogs stay mounted when closed, so the hook keeps running. + await act(async () => control.save("my-harness")); + await act(async () => control.setOpen(false)); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + + // Selecting here would write into form state the close already reset. + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("reopening after closing mid-registration does not select the abandoned harness", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.save("my-harness")); + await act(async () => control.setOpen(false)); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + // The reopened dialog seeds from its own initial values; a stale pending id + // must not overwrite them. + await act(async () => control.setOpen(true)); + assert.deepEqual(selected, []); + + await act(async () => root.unmount()); +}); + +test("a harness saved after reopening is still selected when published", async () => { + const { control, root, selected } = await mountPendingSelection([ + { id: "claude" }, + ]); + + await act(async () => control.setOpen(false)); + await act(async () => control.setOpen(true)); + await act(async () => control.save("my-harness")); + await act(async () => + control.setRuntimes([{ id: "claude" }, { id: "my-harness" }]), + ); + assert.deepEqual(selected, ["my-harness"]); + + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/agents/ui/addCustomHarness.ts b/desktop/src/features/agents/ui/addCustomHarness.ts new file mode 100644 index 0000000000..f9c2143530 --- /dev/null +++ b/desktop/src/features/agents/ui/addCustomHarness.ts @@ -0,0 +1,104 @@ +/** + * Shared pieces of the inline "Add custom harness…" entry the agent dialogs + * append to their harness dropdown. + * + * Registering a custom harness used to be reachable only from Settings, so + * anyone whose first stop was "New agent" never learned the path existed. + * These helpers keep the entry identical across the dropdowns, keep its + * sentinel value out of form state, and defer selecting a freshly registered + * harness until discovery has actually published it. + */ + +import * as React from "react"; + +import { + NO_RUNTIME_DROPDOWN_VALUE, + type PersonaDropdownOption, +} from "./agentConfigOptions"; + +/** + * Dropdown value for the add-custom-harness entry. NUL-prefixed so it can + * never collide with a harness id (`[a-z0-9_][a-z0-9_-]*`) — same trick as the + * harness catalog's `CUSTOM_ENTRY_ID`. + */ +export const ADD_CUSTOM_HARNESS_VALUE = "\u0000add-custom-harness"; + +export const ADD_CUSTOM_HARNESS_OPTION: PersonaDropdownOption = { + label: "Add custom harness…", + value: ADD_CUSTOM_HARNESS_VALUE, +}; + +export type RuntimeDropdownAction = + | { kind: "add-custom-harness" } + | { kind: "select"; runtimeId: string }; + +/** + * Route a harness-dropdown change. The add-custom entry only opens the + * registration form — it is never a selection, so its sentinel can't reach + * form state. Every other value selects, with the no-runtime sentinel + * normalized to the empty id. + */ +export function runtimeDropdownAction(value: string): RuntimeDropdownAction { + if (value === ADD_CUSTOM_HARNESS_VALUE) { + return { kind: "add-custom-harness" }; + } + return { + kind: "select", + runtimeId: value === NO_RUNTIME_DROPDOWN_VALUE ? "" : value, + }; +} + +/** + * The pending harness id once discovery has published it, else `null`. + * + * Saving only writes the definition file — the harness becomes a catalog entry + * when the invalidated discovery query refetches. Selecting before then would + * pick an id no entry backs: the create dialog would block Save on an unknown + * availability, and the instance dialog could not read the command to pin. + */ +export function readyHarnessId( + runtimes: ReadonlyArray<{ id: string }>, + pendingId: string | null, +): string | null { + return runtimes.some((runtime) => runtime.id === pendingId) + ? pendingId + : null; +} + +/** + * Selects a newly registered custom harness once discovery publishes it. + * + * Returns the setter to hand the saved id; `onReady` then fires with it, so + * callers reuse their normal dropdown-change path instead of growing a second + * selection code path. + * + * `active` is the owning dialog's open state. The wait is only meaningful + * while that dialog is open: both host dialogs stay mounted across closes, so + * a pending id would otherwise survive the close and select into reset — or + * hidden — form state whenever discovery caught up. Going inactive both blocks + * `onReady` and drops the pending id, so a later publish is a no-op and + * reopening starts clean. A second save before the first publishes replaces + * it: the field holds one harness, so the latest save wins. + */ +export function usePendingHarnessSelection( + runtimes: ReadonlyArray<{ id: string }>, + onReady: (id: string) => void, + active: boolean, +): (id: string) => void { + const [pendingId, setPendingId] = React.useState(null); + // Gated at render, not just in the effect, so a catalog update landing in + // the same commit as the close cannot slip a selection through. + const readyId = active ? readyHarnessId(runtimes, pendingId) : null; + + React.useEffect(() => { + if (!active) { + setPendingId(null); + return; + } + if (readyId === null) return; + setPendingId(null); + onReady(readyId); + }, [active, onReady, readyId]); + + return setPendingId; +} diff --git a/desktop/src/features/settings/ui/CustomHarnessForm.tsx b/desktop/src/features/settings/ui/CustomHarnessForm.tsx index 60f3a66af1..52e7906265 100644 --- a/desktop/src/features/settings/ui/CustomHarnessForm.tsx +++ b/desktop/src/features/settings/ui/CustomHarnessForm.tsx @@ -216,7 +216,8 @@ export function CustomHarnessForm({ * delete the old file when the id changes. */ originalId?: string; onCancel: () => void; - onSaved: () => void; + /** Receives the id the harness was saved under (the form may rewrite it). */ + onSaved: (id: string) => void; /** Render without the bordered card chrome (for embedding in the catalog * dialog detail pane). */ chromeless?: boolean; @@ -273,11 +274,9 @@ export function CustomHarnessForm({ return; } try { - await save.mutateAsync({ - definition: definitionFromFormValues(form), - originalId, - }); - onSaved(); + const definition = definitionFromFormValues(form); + await save.mutateAsync({ definition, originalId }); + onSaved(definition.id); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } diff --git a/desktop/tests/e2e/inline-custom-harness.spec.ts b/desktop/tests/e2e/inline-custom-harness.spec.ts new file mode 100644 index 0000000000..6b3845d65a --- /dev/null +++ b/desktop/tests/e2e/inline-custom-harness.spec.ts @@ -0,0 +1,194 @@ +/** + * E2E spec for the inline "Add custom harness…" entry in the agent dialogs. + * + * Registering a custom harness used to be reachable only from Settings → + * Agents, so anyone whose first touchpoint was "New agent" never learned the + * path existed. The harness dropdowns now carry the entry directly. + * + * Covers, on all three surfaces (create, edit definition, edit instance): + * - choosing the entry opens the registration form + * - saving registers the harness and selects it in the dropdown + * + * Create and instance edit additionally assert the sentinel never becomes the + * selection; create alone covers dismissing the form leaving the previous + * selection untouched. The three surfaces share the same routing, so those + * checks are not repeated on every one. + */ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const ADD_ENTRY = "Add custom harness…"; +const HARNESS_LABEL = "My Weird Agent"; +const HARNESS_COMMAND = "my-weird-acp"; + +type Page = import("@playwright/test").Page; +type Locator = import("@playwright/test").Locator; + +/** Open a PersonaDropdownField (button trigger + menuitemradio options). */ +async function openDropdown(trigger: Locator) { + await expect(trigger).toBeVisible({ timeout: 10_000 }); + await trigger.click(); +} + +/** Register a harness through the inline form and wait for it to close. */ +async function registerHarness(page: Page) { + const form = page.getByTestId("custom-harness-form"); + await expect(form).toBeVisible({ timeout: 8_000 }); + await page.fill("#ch-label", HARNESS_LABEL); + await page.fill("#ch-command", HARNESS_COMMAND); + // The id auto-derives from the label; it is what the dropdown selects on. + await expect(page.locator("#ch-id")).toHaveValue("my-weird-agent"); + await form.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByTestId("add-custom-harness-dialog")).not.toBeVisible({ + timeout: 8_000, + }); +} + +/** Open the create-agent dialog (AgentDefinitionDialog, create mode). */ +async function openCreateDialog(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + return dialog; +} + +/** Open the edit dialog for a saved definition (same dialog, edit mode). */ +async function openDefinitionEditDialog(page: Page, name: string) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); + await page.getByRole("button", { name: `Open actions for ${name}` }).click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + return dialog; +} + +test.describe("inline add custom harness", () => { + test("create dialog registers a harness inline and selects it", async ({ + page, + }) => { + await installMockBridge(page); + const dialog = await openCreateDialog(page); + + const harness = dialog.locator("#persona-runtime"); + await openDropdown(harness); + await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click(); + + // The sentinel opens the form; it must never become the selection. + await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({ + timeout: 8_000, + }); + await expect(harness).not.toContainText(ADD_ENTRY); + + await registerHarness(page); + + // The saved harness is now the selected harness. + await expect(harness).toContainText(HARNESS_LABEL, { timeout: 8_000 }); + }); + + test("dismissing the form leaves the create dialog's harness unchanged", async ({ + page, + }) => { + await installMockBridge(page); + const dialog = await openCreateDialog(page); + + const harness = dialog.locator("#persona-runtime"); + await expect(harness).toBeVisible({ timeout: 10_000 }); + const before = await harness.textContent(); + + await openDropdown(harness); + await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click(); + await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({ + timeout: 8_000, + }); + + await page.keyboard.press("Escape"); + await expect( + page.getByTestId("add-custom-harness-dialog"), + ).not.toBeVisible(); + + // No harness was registered, so the prior selection must survive. + await expect(harness).toHaveText(before ?? ""); + }); + + test("definition edit dialog registers a harness inline and selects it", async ({ + page, + }) => { + await installMockBridge(page, { + personas: [ + { + displayName: "Editable Agent", + systemPrompt: "An agent whose harness gets replaced.", + }, + ], + }); + const dialog = await openDefinitionEditDialog(page, "Editable Agent"); + + const harness = dialog.locator("#persona-runtime"); + await openDropdown(harness); + await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click(); + await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({ + timeout: 8_000, + }); + + await registerHarness(page); + + await expect(harness).toContainText(HARNESS_LABEL, { timeout: 8_000 }); + }); + + test("instance edit dialog registers a harness inline and keeps Custom command", async ({ + page, + }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "npub1e2e00000000000000000000000000000000000000000000000000000000", + name: "Instance Agent", + status: "stopped", + channelNames: ["agents"], + }, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await page + .getByRole("button", { name: "Instance Agent agent profile" }) + .click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); + + const provider = page.locator("#edit-agent-runtime"); + await openDropdown(provider); + + // "Custom command" is a different feature (ad-hoc command override) and + // must survive alongside the new entry. + await expect( + page.getByRole("menuitemradio", { name: "Custom command" }), + ).toBeVisible(); + await page.getByRole("menuitemradio", { name: ADD_ENTRY }).click(); + await expect(page.getByTestId("add-custom-harness-dialog")).toBeVisible({ + timeout: 8_000, + }); + await expect(provider).not.toContainText(ADD_ENTRY); + + await registerHarness(page); + + await expect(provider).toContainText(HARNESS_LABEL, { timeout: 8_000 }); + }); +}); From 1d4f97b959a0d91f7bac0e1f97189e5c10347712 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 16:03:13 -0400 Subject: [PATCH 15/59] fix(acp): disable goose cron scheduler in managed agent children (#3144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Buzz install with a scheduled goose recipe fires each cron entry once per `goose acp` child instead of once, because every child unconditionally starts its own cron scheduler over the shared `~/.local/share/goose/schedule.json`. With a pool of N children per harness and multiple harnesses, one scheduled recipe fans out to N × harness_count executions — each running under the managed agent's identity rather than the operator's, and racing the operator's own standalone goose over the same schedule file. This injects `GOOSE_ACP_SCHEDULER_DISABLED=true` into every child spawned by `AcpClient::spawn`, so a managed agent never owns the operator's cron schedule. ## Placement The `cmd.env` call is set last — after the `extra_env` operator-wins loop and after the `CODEX_CONFIG` merge — deliberately with no escape hatch. Managed children not running the operator's schedule is a correctness invariant rather than an operator-tunable default, so the injection must beat both a conflicting persona `extra_env` entry and any value inherited from the parent process. It is injected for all agents, not just goose. Agent builds that don't recognize the variable ignore it. ## Sequencing The goose-side flag that reads this variable and skips scheduler startup lands separately (repo TBD). Until it does, this change is a forward-compatible no-op: it sets an environment variable nothing currently reads. Merging it first means no coordinated release is needed — the fix takes effect as soon as the goose side ships. Related: https://github.com/aaif-goose/goose/pull/10738 Signed-off-by: Will Pfleger --- crates/buzz-acp/src/acp.rs | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index c0147baf1b..23f0345e96 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,10 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +/// Env var that tells a goose ACP child not to start its cron scheduler. +/// Injected unconditionally by [`AcpClient::spawn`]; see the call site for why. +pub(crate) const GOOSE_SCHEDULER_DISABLED_ENV: &str = "GOOSE_ACP_SCHEDULER_DISABLED"; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -460,6 +464,16 @@ impl AcpClient { cmd.env("CODEX_CONFIG", merged); } + // Buzz-managed agents must never execute the operator's personal cron + // schedule. A goose ACP child starts a scheduler over the shared + // `schedule.json`, so a pool of N children fires every scheduled job N + // times — under the wrong identity and racing standalone goose. + // + // Set last, and with no operator-wins escape hatch, so it beats both a + // conflicting persona `extra_env` entry and any inherited parent value. + // Agent builds that don't recognize the variable ignore it. + cmd.env(GOOSE_SCHEDULER_DISABLED_ENV, "true"); + // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -2653,6 +2667,77 @@ mod tests { .expect("failed to spawn test script") } + /// Spawn a script that echoes the named env vars as the child observes + /// them, one per line. `` means the child did not receive the var. + async fn spawn_and_read_child_env( + vars: &[&str], + extra_env: &[(String, String)], + ) -> Vec { + let script = vars + .iter() + .map(|var| format!("printf '%s\\n' \"${{{var}:-}}\"")) + .collect::>() + .join("\n"); + let mut client = AcpClient::spawn("bash", &["-c".into(), script], extra_env, false) + .await + .expect("failed to spawn env probe script"); + let mut observed = Vec::with_capacity(vars.len()); + for var in vars { + observed.push( + client + .reader + .next() + .await + .unwrap_or_else(|| panic!("child produced no output for {var}")) + .expect("child stdout was not readable"), + ); + } + observed + } + + /// Every spawned agent must be told not to run the operator's cron + /// schedule, without the caller having to opt in. + #[tokio::test] + async fn spawn_injects_scheduler_disabled_env_by_default() { + let observed = spawn_and_read_child_env(&[GOOSE_SCHEDULER_DISABLED_ENV], &[]).await; + assert_eq!( + observed, + vec!["true"], + "{GOOSE_SCHEDULER_DISABLED_ENV} must be injected into every spawn" + ); + } + + /// Persona config must not be able to re-enable the scheduler: this is a + /// correctness invariant, not an operator-tunable default, so the + /// injection is set after (and therefore wins over) the `extra_env` loop. + /// + /// The control var pins that `extra_env` really did reach the child, so a + /// pass here means the conflicting entry lost the fight rather than + /// `extra_env` being dropped wholesale. + #[tokio::test] + async fn spawn_scheduler_disabled_env_overrides_conflicting_extra_env() { + let extra_env = vec![ + ( + GOOSE_SCHEDULER_DISABLED_ENV.to_string(), + "false".to_string(), + ), + ( + "BUZZ_ENV_PROBE_CONTROL".to_string(), + "delivered".to_string(), + ), + ]; + let observed = spawn_and_read_child_env( + &[GOOSE_SCHEDULER_DISABLED_ENV, "BUZZ_ENV_PROBE_CONTROL"], + &extra_env, + ) + .await; + assert_eq!( + observed, + vec!["true", "delivered"], + "a persona extra_env entry must not override {GOOSE_SCHEDULER_DISABLED_ENV}" + ); + } + #[tokio::test] async fn idle_timeout_fires_on_silent_process() { let mut client = spawn_script("sleep 10").await; From 1d3b810ad70d6325718ed91e723f32c4a376d5e1 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 28 Jul 2026 14:17:01 -0600 Subject: [PATCH 16/59] fix(desktop): paint community rail full height (#3382) ## Summary - paint the community rail across the full app height instead of exposing the parent background through external margins - preserve the existing community-button alignment and balanced horizontal gutters by moving vertical spacing inside the rail - update the rail geometry coverage to require full-height paint ownership ## Root cause PR #2972 aligned the rail box with the inset content by adding top and bottom margins to the `bg-sidebar` element. Margins are outside the painted box, so flat light and dark themes exposed a differently colored app background above and below the rail. ## Validation - pre-push `desktop-check` - pre-push desktop unit suite: 3,751 passed - `git diff --check` Local Playwright/E2E was not run; CI owns the full browser matrix. Signed-off-by: Wes Co-authored-by: Carl --- .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/tests/e2e/community-rail.spec.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index b15e0bab71..386ee20691 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -370,7 +370,7 @@ export function CommunityRail({ return (
) : null} - {deliveryRoomQuery.data ? ( + {deliveryRoomQuery.data && !deliveryRoomQuery.isError ? ( <>
diff --git a/desktop/tests/e2e/cos-delivery-room.spec.ts b/desktop/tests/e2e/cos-delivery-room.spec.ts index 488187bd33..73fca41e90 100644 --- a/desktop/tests/e2e/cos-delivery-room.spec.ts +++ b/desktop/tests/e2e/cos-delivery-room.spec.ts @@ -209,3 +209,70 @@ test("stale signed source hides all delivery claims", async ({ page }) => { page.locator("[data-testid^='delivery-room-item-']"), ).toHaveCount(0); }); + +test("a stale refetch clears previously verified delivery claims", async ({ + page, +}) => { + let stale = false; + await installMockBridge(page, { cosUserContext: "admin" }); + await page.route("**/api/mac-delivery-room/v1", async (route) => { + const fixture = await currentFixture(); + if (stale) fixture.source.status = "stale"; + fixture.generationId = await cosDeliveryRoomGenerationId(fixture); + await route.fulfill({ + body: JSON.stringify(fixture), + contentType: "application/json", + status: 200, + }); + }); + + await page.goto("/#/running-order"); + await expect(page.getByTestId("delivery-room-item-COS-901")).toBeVisible(); + stale = true; + await page.getByRole("button", { name: "Refresh Delivery Room" }).click(); + + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); + await expect(page.getByText("Signed source verified")).toHaveCount(0); +}); + +test("an open card resolves against the latest signed generation", async ({ + page, +}) => { + let revised = false; + await installMockBridge(page, { cosUserContext: "admin" }); + await page.route("**/api/mac-delivery-room/v1", async (route) => { + const fixture = await currentFixture(); + if (revised) { + fixture.deliveryRoom.workItems.find( + (item: { id: string }) => item.id === "COS-901", + ).title = "Build the revised signed Delivery Room projection"; + } + fixture.generationId = await cosDeliveryRoomGenerationId(fixture); + await route.fulfill({ + body: JSON.stringify(fixture), + contentType: "application/json", + status: 200, + }); + }); + + await page.goto("/#/running-order"); + await page.getByTestId("delivery-room-item-COS-901").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText( + "Build the signed Delivery Room projection", + ); + + revised = true; + await page + .locator('button[aria-label="Refresh Delivery Room"]') + .evaluate((button: HTMLButtonElement) => button.click()); + await expect(dialog).toContainText( + "Build the revised signed Delivery Room projection", + ); + await expect(dialog).not.toContainText( + "Build the signed Delivery Room projection", + ); +}); From bdea593abc867719b3a45c3089b8da05fce8d1c5 Mon Sep 17 00:00:00 2001 From: Marc Copson Date: Fri, 31 Jul 2026 15:42:31 +0000 Subject: [PATCH 56/59] fix(mac): reject contradictory Delivery Room feeds Signed-off-by: Marc Copson --- .../lib/cosDeliveryRoom.test.mjs | 59 +++++++++++++++++++ .../cos-running-order/lib/cosDeliveryRoom.ts | 15 +++++ .../ui/CosDeliveryRoomScreen.tsx | 13 ++-- desktop/tests/e2e/cos-delivery-room.spec.ts | 13 ++++ 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs index 60da273f84..fa5105910d 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs @@ -248,6 +248,65 @@ test("counts only current actor-attributed participation and preserves quiet inv assert.equal(team?.signOff.status, "not_signed_off"); }); +test("fails closed on duplicate fixed-room mappings while preserving reviewed optional mapping semantics", () => { + const canonicalTemplateIds = [ + "senior-development-team", + "planning-council", + "board-of-advisors", + ]; + const sourceTeam = envelope().deliveryRoom.teams[0]; + + for (const templateId of canonicalTemplateIds) { + const duplicate = copy(envelope()); + duplicate.deliveryRoom.teams = [ + { ...copy(sourceTeam), id: `${templateId}-one`, templateId }, + { ...copy(sourceTeam), id: `${templateId}-two`, templateId }, + ]; + assert.throws( + () => projectCosDeliveryRoom(duplicate, { now: NOW }), + /team-room template mappings are duplicated/, + ); + } + + const fallbackCollision = copy(envelope()); + fallbackCollision.deliveryRoom.teams = [ + { ...copy(sourceTeam), id: "explicit-senior-team" }, + { ...copy(sourceTeam), id: "senior-development-team" }, + ]; + delete fallbackCollision.deliveryRoom.teams[1].templateId; + assert.throws( + () => projectCosDeliveryRoom(fallbackCollision, { now: NOW }), + /team-room template mappings are duplicated/, + ); + + const unique = copy(envelope()); + unique.deliveryRoom.teams = canonicalTemplateIds.map((templateId) => ({ + ...copy(sourceTeam), + id: `${templateId}-instance`, + templateId, + })); + assert.equal( + projectCosDeliveryRoom(unique, { now: NOW }).deliveryRoom.teams.length, + 3, + ); + + const absent = copy(envelope()); + absent.deliveryRoom.teams[0].id = "unmapped-observation-room"; + delete absent.deliveryRoom.teams[0].templateId; + assert.equal( + projectCosDeliveryRoom(absent, { now: NOW }).deliveryRoom.teams[0] + .templateId, + undefined, + ); + + const unknown = copy(envelope()); + unknown.deliveryRoom.teams[0].templateId = "unknown-team-room"; + assert.throws( + () => projectCosDeliveryRoom(unknown, { now: NOW }), + /templateId is unsupported/, + ); +}); + test("links a detailed card thread only through an explicit current evidence reference", () => { const result = projectCosDeliveryRoom(envelope(), { now: NOW }); const team = result.deliveryRoom.teams[0]; diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts index 70bafcea79..cdb0465193 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts @@ -75,6 +75,14 @@ const TEMPLATE_IDS = new Set([ ]); const SHA256 = /^[0-9a-f]{64}$/; +export function deliveryRoomTeamTemplateId( + team: Pick, +): DeliveryRoomTemplateId | undefined { + if (team.templateId) return team.templateId; + const id = team.id as DeliveryRoomTemplateId; + return TEMPLATE_IDS.has(id) ? id : undefined; +} + function fail(message: string): never { throw new Error(`Delivery Room evidence is unverifiable: ${message}`); } @@ -915,6 +923,13 @@ export function projectCosDeliveryRoom( ); if (new Set(teams.map((team) => team.id)).size !== teams.length) fail("team IDs are duplicated"); + const mappedTeamTemplates = teams + .map(deliveryRoomTeamTemplateId) + .filter((templateId): templateId is DeliveryRoomTemplateId => + Boolean(templateId), + ); + if (new Set(mappedTeamTemplates).size !== mappedTeamTemplates.length) + fail("team-room template mappings are duplicated"); const teamTemplates = array( projection.teamTemplates, "deliveryRoom.teamTemplates", diff --git a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx index 24686d9321..29fe67f6ca 100644 --- a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx +++ b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx @@ -23,6 +23,7 @@ import { type DeliveryRoomTeamTemplate, type DeliveryRoomWorkHealth, type DeliveryRoomWorkItem, + deliveryRoomTeamTemplateId, loadCosDeliveryRoom, teamThreadForWork, } from "@/features/cos-running-order/lib/cosDeliveryRoom"; @@ -332,8 +333,7 @@ function CardTeamThreads({ {templates.map((template) => { const team = teams.find( (candidate) => - candidate.templateId === template.id || - candidate.id === template.id, + deliveryRoomTeamTemplateId(candidate) === template.id, ); const thread = teamThreadForWork(team, item); const entries = [...thread.contributions, ...thread.dissent]; @@ -720,8 +720,7 @@ function DeliveryRoomView({ room }: { room: CosDeliveryRoom }) { ); if (!template) return null; const team = projection.teams.find( - (candidate) => - candidate.templateId === template.id || candidate.id === template.id, + (candidate) => deliveryRoomTeamTemplateId(candidate) === template.id, ); return { kind: "team", template, team }; } @@ -775,8 +774,7 @@ function DeliveryRoomView({ room }: { room: CosDeliveryRoom }) { {projection.teamTemplates.map((template) => { const team = projection.teams.find( (candidate) => - candidate.templateId === template.id || - candidate.id === template.id, + deliveryRoomTeamTemplateId(candidate) === template.id, ); return ( { let stale = false; + let requestCount = 0; + let acknowledgeRejectedResponse: (() => void) | undefined; + const rejectedResponseDelivered = new Promise((resolve) => { + acknowledgeRejectedResponse = resolve; + }); await installMockBridge(page, { cosUserContext: "admin" }); await page.route("**/api/mac-delivery-room/v1", async (route) => { + requestCount += 1; const fixture = await currentFixture(); if (stale) fixture.source.status = "stale"; fixture.generationId = await cosDeliveryRoomGenerationId(fixture); @@ -224,6 +230,7 @@ test("a stale refetch clears previously verified delivery claims", async ({ contentType: "application/json", status: 200, }); + if (stale) acknowledgeRejectedResponse?.(); }); await page.goto("/#/running-order"); @@ -231,7 +238,13 @@ test("a stale refetch clears previously verified delivery claims", async ({ stale = true; await page.getByRole("button", { name: "Refresh Delivery Room" }).click(); + await rejectedResponseDelivered; + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0, { timeout: 500 }); await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await page.waitForTimeout(1_100); + expect(requestCount).toBe(2); await expect( page.locator("[data-testid^='delivery-room-item-']"), ).toHaveCount(0); From 918ef4e4052af1fceed030f0d48bf2625f321551 Mon Sep 17 00:00:00 2001 From: Marc Copson Date: Fri, 31 Jul 2026 16:03:22 +0000 Subject: [PATCH 57/59] fix(mac): expire stale Delivery Room evidence Signed-off-by: Marc Copson --- .../lib/cosDeliveryRoom.test.mjs | 67 +++++++++++++++++++ .../cos-running-order/lib/cosDeliveryRoom.ts | 1 + .../lib/cosDeliveryRoomExpiry.ts | 54 +++++++++++++++ .../ui/CosDeliveryRoomScreen.tsx | 45 +++++++++++-- desktop/tests/e2e/cos-delivery-room.spec.ts | 29 ++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs index fa5105910d..8e857b11f9 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { cosDeliveryRoomEndpoint, + cosDeliveryRoomExpiresAt, cosDeliveryRoomGenerationId, loadCosDeliveryRoom, projectCosDeliveryRoom, @@ -248,6 +249,72 @@ test("counts only current actor-attributed participation and preserves quiet inv assert.equal(team?.signOff.status, "not_signed_off"); }); +test("expires the projection at the earliest source or presented evidence deadline", () => { + const sourceFirst = projectCosDeliveryRoom(envelope(), { now: NOW }); + assert.equal( + cosDeliveryRoomExpiresAt(sourceFirst), + new Date("2026-07-31T09:19:00.000Z").getTime(), + ); + + const envelopeGeneratedFirst = copy(sourceFirst); + envelopeGeneratedFirst.source.reconciliation.observedAt = + envelopeGeneratedFirst.generatedAt; + envelopeGeneratedFirst.source.agentHealth.observedAt = + envelopeGeneratedFirst.generatedAt; + assert.equal( + cosDeliveryRoomExpiresAt(envelopeGeneratedFirst), + new Date("2026-07-31T09:20:00.000Z").getTime(), + ); + + const evidenceDeadline = new Date("2026-07-31T09:05:30.000Z").getTime(); + const shortEvidence = evidence("short-lived", "human"); + shortEvidence.freshForMs = 90_000; + const evidenceLocations = [ + (room) => { + room.deliveryRoom.workItems[1].evidence = [shortEvidence]; + }, + (room) => { + room.deliveryRoom.workItems[1].objectiveGates[0].evidence = shortEvidence; + }, + (room) => { + room.deliveryRoom.teams[0].participants[0].evidence = [shortEvidence]; + }, + (room) => { + room.deliveryRoom.teams[0].contributions[0].evidence = [shortEvidence]; + }, + (room) => { + room.deliveryRoom.teams[0].dissent = [ + { + id: "dissent", + participantId: "builder", + summary: "Recorded dissent.", + evidence: [shortEvidence], + }, + ]; + }, + (room) => { + room.deliveryRoom.teams[0].synthesis = { + participantId: "builder", + summary: "Recorded synthesis.", + evidence: [shortEvidence], + }; + }, + (room) => { + room.deliveryRoom.teams[0].signOff = { + status: "signed_off", + participantId: "builder", + evidence: shortEvidence, + }; + }, + ]; + + for (const installEvidence of evidenceLocations) { + const room = copy(sourceFirst); + installEvidence(room); + assert.equal(cosDeliveryRoomExpiresAt(room), evidenceDeadline); + } +}); + test("fails closed on duplicate fixed-room mappings while preserving reviewed optional mapping semantics", () => { const canonicalTemplateIds = [ "senior-development-team", diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts index cdb0465193..a20df48ea7 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts @@ -24,6 +24,7 @@ import { REVIEWED_TEAM_TEMPLATES } from "./cosDeliveryRoomTemplates.ts"; import { strictDeliveryRoomDate } from "./cosDeliveryRoomTime.ts"; export * from "./cosDeliveryRoomDigest.ts"; +export * from "./cosDeliveryRoomExpiry.ts"; export * from "./cosDeliveryRoomThreads.ts"; export * from "./cosDeliveryRoomTypes.ts"; diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts new file mode 100644 index 0000000000..fb62f95bab --- /dev/null +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts @@ -0,0 +1,54 @@ +import type { + CosDeliveryRoom, + DeliveryRoomEvidence, +} from "./cosDeliveryRoomTypes.ts"; +import { strictDeliveryRoomDate } from "./cosDeliveryRoomTime.ts"; + +function expiresAt(observedAt: string, lifetimeMs: number): number { + const observed = strictDeliveryRoomDate(observedAt); + if (!observed) { + throw new Error("Delivery Room evidence expiry is unverifiable"); + } + return observed.getTime() + lifetimeMs; +} + +/** + * Returns the first instant at which any signed source or presented evidence + * claim ceases to be current. Parsed projections have already validated every + * timestamp and freshness lifetime before reaching this boundary. + */ +export function cosDeliveryRoomExpiresAt(room: CosDeliveryRoom): number { + const sourceLifetimeMs = room.source.maxAgeSeconds * 1000; + const deadlines = [ + expiresAt(room.generatedAt, sourceLifetimeMs), + expiresAt(room.source.reconciliation.observedAt, sourceLifetimeMs), + expiresAt(room.source.agentHealth.observedAt, sourceLifetimeMs), + ]; + const addEvidence = (evidence: DeliveryRoomEvidence) => { + deadlines.push(expiresAt(evidence.observedAt, evidence.freshForMs)); + }; + + for (const item of room.deliveryRoom.workItems) { + for (const evidence of item.evidence) addEvidence(evidence); + for (const gate of item.objectiveGates) { + if (gate.evidence) addEvidence(gate.evidence); + } + } + + for (const team of room.deliveryRoom.teams) { + for (const participant of team.participants) { + for (const evidence of participant.evidence) addEvidence(evidence); + } + for (const contribution of [...team.contributions, ...team.dissent]) { + for (const evidence of contribution.evidence) addEvidence(evidence); + } + if (team.synthesis) { + for (const evidence of team.synthesis.evidence) addEvidence(evidence); + } + if (team.signOff.status === "signed_off") { + addEvidence(team.signOff.evidence); + } + } + + return Math.min(...deadlines); +} diff --git a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx index 29fe67f6ca..4111c83572 100644 --- a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx +++ b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx @@ -23,6 +23,7 @@ import { type DeliveryRoomTeamTemplate, type DeliveryRoomWorkHealth, type DeliveryRoomWorkItem, + cosDeliveryRoomExpiresAt, deliveryRoomTeamTemplateId, loadCosDeliveryRoom, teamThreadForWork, @@ -854,6 +855,7 @@ function DeliveryRoomView({ room }: { room: CosDeliveryRoom }) { export function CosDeliveryRoomScreen() { const { activeCommunity } = useCommunities(); + const [freshnessNow, setFreshnessNow] = React.useState(() => Date.now()); const deliveryRoomQuery = useQuery({ queryKey: ["cos-delivery-room", activeCommunity?.relayUrl], queryFn: ({ signal }) => @@ -868,6 +870,37 @@ export function CosDeliveryRoomScreen() { retry: false, staleTime: 30_000, }); + const semanticExpiry = deliveryRoomQuery.data + ? cosDeliveryRoomExpiresAt(deliveryRoomQuery.data) + : undefined; + const evidenceExpired = + semanticExpiry !== undefined && freshnessNow >= semanticExpiry; + const failClosed = deliveryRoomQuery.isError || evidenceExpired; + + React.useEffect(() => { + if (semanticExpiry === undefined) return; + + const checkFreshness = () => setFreshnessNow(Date.now()); + let timer: number | undefined; + const scheduleCheck = () => { + const remainingMs = Math.max(semanticExpiry - Date.now(), 0); + timer = window.setTimeout( + () => { + checkFreshness(); + if (Date.now() < semanticExpiry) scheduleCheck(); + }, + Math.min(remainingMs, 2_147_483_647), + ); + }; + scheduleCheck(); + window.addEventListener("focus", checkFreshness); + document.addEventListener("visibilitychange", checkFreshness); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + window.removeEventListener("focus", checkFreshness); + document.removeEventListener("visibilitychange", checkFreshness); + }; + }, [semanticExpiry]); return (
@@ -907,7 +940,7 @@ export function CosDeliveryRoomScreen() {
) : null} - {deliveryRoomQuery.isError ? ( + {failClosed ? (

- {deliveryRoomQuery.error instanceof Error - ? deliveryRoomQuery.error.message - : "The signed Delivery Room projection could not be verified."} + {evidenceExpired + ? "Delivery Room evidence expired before a new signed projection was available." + : deliveryRoomQuery.error instanceof Error + ? deliveryRoomQuery.error.message + : "The signed Delivery Room projection could not be verified."}

No progress, activity, participation or completion state is @@ -932,7 +967,7 @@ export function CosDeliveryRoomScreen() {

) : null} - {deliveryRoomQuery.data && !deliveryRoomQuery.isError ? ( + {deliveryRoomQuery.data && !failClosed ? ( <>
diff --git a/desktop/tests/e2e/cos-delivery-room.spec.ts b/desktop/tests/e2e/cos-delivery-room.spec.ts index 09600dfedd..10a1ea6db1 100644 --- a/desktop/tests/e2e/cos-delivery-room.spec.ts +++ b/desktop/tests/e2e/cos-delivery-room.spec.ts @@ -210,6 +210,35 @@ test("stale signed source hides all delivery claims", async ({ page }) => { ).toHaveCount(0); }); +test("current claims fail closed when their signed freshness expires", async ({ + page, +}) => { + await installMockBridge(page, { cosUserContext: "admin" }); + await page.route("**/api/mac-delivery-room/v1", async (route) => { + const fixture = await currentFixture(); + fixture.source.maxAgeSeconds = 3; + fixture.generationId = await cosDeliveryRoomGenerationId(fixture); + await route.fulfill({ + body: JSON.stringify(fixture), + contentType: "application/json", + status: 200, + }); + }); + + await page.goto("/#/running-order"); + await expect(page.getByTestId("delivery-room-item-COS-901")).toBeVisible(); + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible({ + timeout: 6_000, + }); + await expect(page.getByTestId("delivery-room-fail-closed")).toContainText( + "Delivery Room evidence expired", + ); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); + await expect(page.getByText("Signed source verified")).toHaveCount(0); +}); + test("a stale refetch clears previously verified delivery claims", async ({ page, }) => { From c542ed28773e14043cb129ac21240505eb4684bd Mon Sep 17 00:00:00 2001 From: Marc Copson Date: Fri, 31 Jul 2026 17:03:33 +0000 Subject: [PATCH 58/59] fix(COS-745): fail closed on expiry edge cases Signed-off-by: Marc Copson --- .../lib/cosDeliveryRoom.test.mjs | 90 ++++++++++++++++ .../cos-running-order/lib/cosDeliveryRoom.ts | 63 +++++------ .../lib/cosDeliveryRoomExpiry.ts | 101 +++++++++++++++++- .../ui/CosDeliveryRoomScreen.tsx | 34 ++---- .../ui/useCosDeliveryRoomExpiryLatch.ts | 45 ++++++++ desktop/tests/e2e/cos-delivery-room.spec.ts | 61 ++++++++++- 6 files changed, 326 insertions(+), 68 deletions(-) create mode 100644 desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs index 8e857b11f9..4dc5a4ff19 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.test.mjs @@ -3,9 +3,14 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + calculatedDeliveryRoomFreshness, + checkedDeliveryRoomExpiryMs, cosDeliveryRoomEndpoint, cosDeliveryRoomExpiresAt, cosDeliveryRoomGenerationId, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + DELIVERY_ROOM_MAX_SOURCE_AGE_SECONDS, + deliveryRoomSourceLifetimeMs, loadCosDeliveryRoom, projectCosDeliveryRoom, teamThreadForWork, @@ -315,6 +320,91 @@ test("expires the projection at the earliest source or presented evidence deadli } }); +test("rejects unbounded or unsafe lifetimes and accepts the reviewed boundaries", () => { + const hugeSourceLifetime = copy(envelope()); + hugeSourceLifetime.source.maxAgeSeconds = 1e308; + assert.throws( + () => projectCosDeliveryRoom(hugeSourceLifetime, { now: NOW }), + /source\.maxAgeSeconds is outside the reviewed lifetime bound/, + ); + + const hugeEvidenceLifetime = copy(envelope()); + hugeEvidenceLifetime.deliveryRoom.workItems[1].evidence[0].freshForMs = 1e308; + assert.throws( + () => projectCosDeliveryRoom(hugeEvidenceLifetime, { now: NOW }), + /freshForMs is outside the reviewed lifetime bound/, + ); + + const boundary = copy(envelope()); + boundary.source.maxAgeSeconds = DELIVERY_ROOM_MAX_SOURCE_AGE_SECONDS; + boundary.deliveryRoom.workItems[1].evidence[0].freshForMs = + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS; + const projected = projectCosDeliveryRoom(boundary, { now: NOW }); + assert.equal( + projected.deliveryRoom.workItems[1].evidence[0].freshForMs, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + ); + assert.equal( + checkedDeliveryRoomExpiryMs( + new Date(CURRENT).getTime(), + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + ), + new Date(CURRENT).getTime() + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + ); + const boundaryExpiry = + new Date(CURRENT).getTime() + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS; + assert.equal( + calculatedDeliveryRoomFreshness( + CURRENT, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + new Date(boundaryExpiry), + ), + "current", + ); + assert.equal( + calculatedDeliveryRoomFreshness( + CURRENT, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + new Date(boundaryExpiry + 1), + ), + "stale", + ); + + const aboveSourceBoundary = copy(envelope()); + aboveSourceBoundary.source.maxAgeSeconds = + DELIVERY_ROOM_MAX_SOURCE_AGE_SECONDS + 1; + assert.throws( + () => projectCosDeliveryRoom(aboveSourceBoundary, { now: NOW }), + /source\.maxAgeSeconds is outside the reviewed lifetime bound/, + ); +}); + +test("rejects lifetime multiplication and timestamp addition overflow", () => { + assert.equal(deliveryRoomSourceLifetimeMs(1e308), undefined); + assert.equal( + checkedDeliveryRoomExpiryMs(Number.POSITIVE_INFINITY, 1), + undefined, + ); + assert.equal( + checkedDeliveryRoomExpiryMs(0, Number.POSITIVE_INFINITY), + undefined, + ); + assert.equal( + checkedDeliveryRoomExpiryMs( + Number.MAX_SAFE_INTEGER - DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS + 1, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + ), + undefined, + ); + assert.equal( + checkedDeliveryRoomExpiryMs( + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + ), + undefined, + ); +}); + test("fails closed on duplicate fixed-room mappings while preserving reviewed optional mapping semantics", () => { const canonicalTemplateIds = [ "senior-development-team", diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts index a20df48ea7..2f54d2392b 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoom.ts @@ -1,7 +1,6 @@ import { COS_DELIVERY_ROOM_SCHEMA, type CosDeliveryRoom, - DELIVERY_ROOM_MAX_CLOCK_SKEW_MS, DELIVERY_ROOM_PROJECTION_SCHEMA, type AttentionView, type DeliveryRoomContribution, @@ -20,6 +19,12 @@ import { type SourceEvidence, } from "./cosDeliveryRoomTypes.ts"; import { verifyCosDeliveryRoomGeneration } from "./cosDeliveryRoomDigest.ts"; +import { + boundedDeliveryRoomLifetime, + calculatedDeliveryRoomFreshness, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + deliveryRoomSourceLifetimeMs, +} from "./cosDeliveryRoomExpiry.ts"; import { REVIEWED_TEAM_TEMPLATES } from "./cosDeliveryRoomTemplates.ts"; import { strictDeliveryRoomDate } from "./cosDeliveryRoomTime.ts"; @@ -116,13 +121,6 @@ function boolean(value: unknown, label: string): boolean { return value; } -function integer(value: unknown, label: string): number { - if (!Number.isInteger(value) || (value as number) <= 0) { - return fail(`${label} must be a positive integer`); - } - return value as number; -} - function exactKeys(value: JsonRecord, allowed: string[], label: string): void { const extras = Object.keys(value).filter((key) => !allowed.includes(key)); if (extras.length > 0) fail(`${label} contains unsupported fields`); @@ -154,27 +152,15 @@ function parseDate(value: unknown, label: string): Date { return date; } -function calculatedFreshness( - observedAt: unknown, - freshForMs: number, - now: Date, -): DeliveryRoomEvidenceFreshness { - if (typeof observedAt !== "string" || observedAt.length === 0) - return "invalid"; - const observed = strictDeliveryRoomDate(observedAt); - if (!observed) return "invalid"; - const age = now.getTime() - observed.getTime(); - if (age < -DELIVERY_ROOM_MAX_CLOCK_SKEW_MS) return "invalid"; - return age <= freshForMs ? "current" : "stale"; -} - function assertCurrent( observedAt: unknown, freshForMs: number, now: Date, label: string, ): string { - if (calculatedFreshness(observedAt, freshForMs, now) !== "current") { + if ( + calculatedDeliveryRoomFreshness(observedAt, freshForMs, now) !== "current" + ) { return fail(`${label} is stale or invalid`); } return string(observedAt, label); @@ -220,13 +206,23 @@ function parseEvidence( ["kind", "label", "actorId", "reference", "href"], `${label}.source`, ); - const freshForMs = integer(raw.freshForMs, `${label}.freshForMs`); + const freshForMs = boundedDeliveryRoomLifetime( + raw.freshForMs, + 1, + DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS, + ); + if (freshForMs === undefined) + fail(`${label}.freshForMs is outside the reviewed lifetime bound`); const declaredFreshness = enumValue( raw.freshness, new Set(["current", "stale", "invalid"]), `${label}.freshness`, ); - const actualFreshness = calculatedFreshness(raw.observedAt, freshForMs, now); + const actualFreshness = calculatedDeliveryRoomFreshness( + raw.observedAt, + freshForMs, + now, + ); if (declaredFreshness !== actualFreshness) fail(`${label} freshness contradicts its timestamp`); const gateOutcome = @@ -702,7 +698,7 @@ function parseTemplate( function parseSourceEvidence( value: unknown, - maxAgeSeconds: number, + sourceLifetimeMs: number, now: Date, label: string, ): SourceEvidence { @@ -711,7 +707,7 @@ function parseSourceEvidence( if (raw.freshness !== "current") fail(`${label} is not current`); const observedAt = assertCurrent( raw.observedAt, - maxAgeSeconds * 1000, + sourceLifetimeMs, now, `${label}.observedAt`, ); @@ -781,25 +777,30 @@ export function projectCosDeliveryRoom( "source", ); if (source.status !== "fresh") fail("the signed source is stale or invalid"); - const maxAgeSeconds = integer(source.maxAgeSeconds, "source.maxAgeSeconds"); + const sourceLifetimeMs = deliveryRoomSourceLifetimeMs( + source.maxAgeSeconds as number, + ); + if (sourceLifetimeMs === undefined) + fail("source.maxAgeSeconds is outside the reviewed lifetime bound"); + const maxAgeSeconds = source.maxAgeSeconds as number; const issues = uniqueStrings(source.issues, "source.issues"); if (issues.length > 0) fail("the signed source reports reconciliation issues"); const generatedAt = assertCurrent( raw.generatedAt, - maxAgeSeconds * 1000, + sourceLifetimeMs, now, "generatedAt", ); const reconciliation = parseSourceEvidence( source.reconciliation, - maxAgeSeconds, + sourceLifetimeMs, now, "source.reconciliation", ); const agentHealth = parseSourceEvidence( source.agentHealth, - maxAgeSeconds, + sourceLifetimeMs, now, "source.agentHealth", ); diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts index fb62f95bab..036213717c 100644 --- a/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiry.ts @@ -1,15 +1,102 @@ import type { CosDeliveryRoom, DeliveryRoomEvidence, + DeliveryRoomEvidenceFreshness, } from "./cosDeliveryRoomTypes.ts"; +import { DELIVERY_ROOM_MAX_CLOCK_SKEW_MS } from "./cosDeliveryRoomTypes.ts"; import { strictDeliveryRoomDate } from "./cosDeliveryRoomTime.ts"; -function expiresAt(observedAt: string, lifetimeMs: number): number { +// These are the maximum lifetimes emitted by the independently reviewed +// COS-746 producer (MAX_SOURCE_AGE_SECONDS and DELIVERY_EVIDENCE_FRESH_MS). +export const DELIVERY_ROOM_MAX_SOURCE_AGE_SECONDS = 15 * 60; +export const DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1_000; + +export function boundedDeliveryRoomLifetime( + value: unknown, + minimum: number, + maximum: number, +): number | undefined { + return Number.isSafeInteger(value) && + (value as number) >= minimum && + (value as number) <= maximum + ? (value as number) + : undefined; +} + +export function deliveryRoomSourceLifetimeMs( + maxAgeSeconds: number, +): number | undefined { + if ( + !Number.isSafeInteger(maxAgeSeconds) || + maxAgeSeconds < 60 || + maxAgeSeconds > DELIVERY_ROOM_MAX_SOURCE_AGE_SECONDS + ) { + return undefined; + } + const lifetimeMs = maxAgeSeconds * 1_000; + if ( + !Number.isSafeInteger(lifetimeMs) || + lifetimeMs > DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS || + lifetimeMs / 1_000 !== maxAgeSeconds + ) { + return undefined; + } + return lifetimeMs; +} + +export function checkedDeliveryRoomExpiryMs( + observedAtMs: number, + lifetimeMs: number, +): number | undefined { + if ( + !Number.isSafeInteger(observedAtMs) || + !Number.isSafeInteger(lifetimeMs) || + lifetimeMs <= 0 || + lifetimeMs > DELIVERY_ROOM_MAX_EVIDENCE_LIFETIME_MS + ) { + return undefined; + } + const expiry = observedAtMs + lifetimeMs; + if (!Number.isSafeInteger(expiry) || expiry - observedAtMs !== lifetimeMs) { + return undefined; + } + return expiry; +} + +export function checkedDeliveryRoomExpiry( + observedAt: string, + lifetimeMs: number, +): number | undefined { + const observed = strictDeliveryRoomDate(observedAt); + return observed + ? checkedDeliveryRoomExpiryMs(observed.getTime(), lifetimeMs) + : undefined; +} + +export function calculatedDeliveryRoomFreshness( + observedAt: unknown, + freshForMs: number, + now: Date, +): DeliveryRoomEvidenceFreshness { + if (typeof observedAt !== "string" || observedAt.length === 0) + return "invalid"; const observed = strictDeliveryRoomDate(observedAt); - if (!observed) { + if (!observed) return "invalid"; + const nowMs = now.getTime(); + const observedMs = observed.getTime(); + const expiry = checkedDeliveryRoomExpiry(observedAt, freshForMs); + if (!Number.isSafeInteger(nowMs) || expiry === undefined) return "invalid"; + const age = nowMs - observedMs; + if (!Number.isSafeInteger(age)) return "invalid"; + if (age < -DELIVERY_ROOM_MAX_CLOCK_SKEW_MS) return "invalid"; + return nowMs <= expiry ? "current" : "stale"; +} + +function expiresAt(observedAt: string, lifetimeMs: number): number { + const expiry = checkedDeliveryRoomExpiry(observedAt, lifetimeMs); + if (expiry === undefined) throw new Error("Delivery Room evidence expiry is unverifiable"); - } - return observed.getTime() + lifetimeMs; + return expiry; } /** @@ -18,7 +105,11 @@ function expiresAt(observedAt: string, lifetimeMs: number): number { * timestamp and freshness lifetime before reaching this boundary. */ export function cosDeliveryRoomExpiresAt(room: CosDeliveryRoom): number { - const sourceLifetimeMs = room.source.maxAgeSeconds * 1000; + const sourceLifetimeMs = deliveryRoomSourceLifetimeMs( + room.source.maxAgeSeconds, + ); + if (sourceLifetimeMs === undefined) + throw new Error("Delivery Room source expiry is unverifiable"); const deadlines = [ expiresAt(room.generatedAt, sourceLifetimeMs), expiresAt(room.source.reconciliation.observedAt, sourceLifetimeMs), diff --git a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx index 4111c83572..9809ee72f1 100644 --- a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx +++ b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx @@ -36,6 +36,7 @@ import { HEALTH_PRESENTATION, PARTICIPANT_PRESENTATION, } from "@/features/cos-running-order/lib/cosDeliveryRoomUiPresentation"; +import { useCosDeliveryRoomExpiryLatch } from "@/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; @@ -855,7 +856,6 @@ function DeliveryRoomView({ room }: { room: CosDeliveryRoom }) { export function CosDeliveryRoomScreen() { const { activeCommunity } = useCommunities(); - const [freshnessNow, setFreshnessNow] = React.useState(() => Date.now()); const deliveryRoomQuery = useQuery({ queryKey: ["cos-delivery-room", activeCommunity?.relayUrl], queryFn: ({ signal }) => @@ -873,35 +873,13 @@ export function CosDeliveryRoomScreen() { const semanticExpiry = deliveryRoomQuery.data ? cosDeliveryRoomExpiresAt(deliveryRoomQuery.data) : undefined; - const evidenceExpired = - semanticExpiry !== undefined && freshnessNow >= semanticExpiry; + const generationId = deliveryRoomQuery.data?.generationId; + const evidenceExpired = useCosDeliveryRoomExpiryLatch( + generationId, + semanticExpiry, + ); const failClosed = deliveryRoomQuery.isError || evidenceExpired; - React.useEffect(() => { - if (semanticExpiry === undefined) return; - - const checkFreshness = () => setFreshnessNow(Date.now()); - let timer: number | undefined; - const scheduleCheck = () => { - const remainingMs = Math.max(semanticExpiry - Date.now(), 0); - timer = window.setTimeout( - () => { - checkFreshness(); - if (Date.now() < semanticExpiry) scheduleCheck(); - }, - Math.min(remainingMs, 2_147_483_647), - ); - }; - scheduleCheck(); - window.addEventListener("focus", checkFreshness); - document.addEventListener("visibilitychange", checkFreshness); - return () => { - if (timer !== undefined) window.clearTimeout(timer); - window.removeEventListener("focus", checkFreshness); - document.removeEventListener("visibilitychange", checkFreshness); - }; - }, [semanticExpiry]); - return (
diff --git a/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts b/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts new file mode 100644 index 0000000000..9a5081b324 --- /dev/null +++ b/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts @@ -0,0 +1,45 @@ +import * as React from "react"; + +export function useCosDeliveryRoomExpiryLatch( + generationId: string | undefined, + semanticExpiry: number | undefined, +): boolean { + const [expiredGenerationIds, setExpiredGenerationIds] = React.useState< + ReadonlySet + >(() => new Set()); + + React.useEffect(() => { + if (semanticExpiry === undefined || generationId === undefined) return; + + const checkFreshness = () => { + if (Date.now() < semanticExpiry) return false; + setExpiredGenerationIds((current) => { + if (current.has(generationId)) return current; + const next = new Set(current); + next.add(generationId); + return next; + }); + return true; + }; + let timer: number | undefined; + const scheduleCheck = () => { + const remainingMs = Math.max(semanticExpiry - Date.now(), 0); + timer = window.setTimeout( + () => { + if (!checkFreshness()) scheduleCheck(); + }, + Math.min(remainingMs, 2_147_483_647), + ); + }; + scheduleCheck(); + window.addEventListener("focus", checkFreshness); + document.addEventListener("visibilitychange", checkFreshness); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + window.removeEventListener("focus", checkFreshness); + document.removeEventListener("visibilitychange", checkFreshness); + }; + }, [generationId, semanticExpiry]); + + return generationId ? expiredGenerationIds.has(generationId) : false; +} diff --git a/desktop/tests/e2e/cos-delivery-room.spec.ts b/desktop/tests/e2e/cos-delivery-room.spec.ts index 10a1ea6db1..e6445f2e41 100644 --- a/desktop/tests/e2e/cos-delivery-room.spec.ts +++ b/desktop/tests/e2e/cos-delivery-room.spec.ts @@ -210,14 +210,39 @@ test("stale signed source hides all delivery claims", async ({ page }) => { ).toHaveCount(0); }); -test("current claims fail closed when their signed freshness expires", async ({ +test("expired claims stay latched across clock rollback and a new generation replaces them", async ({ page, }) => { + await page.addInitScript(() => { + const systemNow = Date.now.bind(Date); + let offsetMs = 0; + Date.now = () => systemNow() + offsetMs; + ( + window as typeof window & { + __SET_DELIVERY_ROOM_CLOCK_OFFSET__: (value: number) => void; + } + ).__SET_DELIVERY_ROOM_CLOCK_OFFSET__ = (value) => { + offsetMs = value; + }; + }); + const expiredGeneration = await currentFixture(); + expiredGeneration.deliveryRoom.workItems.find( + (item: { id: string }) => item.id === "COS-901", + ).evidence[0].freshForMs = 3_000; + expiredGeneration.generationId = + await cosDeliveryRoomGenerationId(expiredGeneration); + let serveReplacement = false; await installMockBridge(page, { cosUserContext: "admin" }); await page.route("**/api/mac-delivery-room/v1", async (route) => { - const fixture = await currentFixture(); - fixture.source.maxAgeSeconds = 3; - fixture.generationId = await cosDeliveryRoomGenerationId(fixture); + const fixture = serveReplacement + ? await currentFixture() + : structuredClone(expiredGeneration); + if (serveReplacement) { + fixture.deliveryRoom.workItems.find( + (item: { id: string }) => item.id === "COS-901", + ).title = "Build the replacement signed Delivery Room projection"; + fixture.generationId = await cosDeliveryRoomGenerationId(fixture); + } await route.fulfill({ body: JSON.stringify(fixture), contentType: "application/json", @@ -237,6 +262,34 @@ test("current claims fail closed when their signed freshness expires", async ({ page.locator("[data-testid^='delivery-room-item-']"), ).toHaveCount(0); await expect(page.getByText("Signed source verified")).toHaveCount(0); + + await page.evaluate(() => { + ( + window as typeof window & { + __SET_DELIVERY_ROOM_CLOCK_OFFSET__: (value: number) => void; + } + ).__SET_DELIVERY_ROOM_CLOCK_OFFSET__(-60 * 60 * 1_000); + window.dispatchEvent(new Event("focus")); + document.dispatchEvent(new Event("visibilitychange")); + }); + await page.setViewportSize({ height: 719, width: 1_279 }); + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Refresh Delivery Room" }).click(); + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); + + serveReplacement = true; + await page.getByRole("button", { name: "Refresh Delivery Room" }).click(); + await expect( + page.getByText("Build the replacement signed Delivery Room projection"), + ).toBeVisible(); + await expect(page.getByTestId("delivery-room-fail-closed")).toHaveCount(0); }); test("a stale refetch clears previously verified delivery claims", async ({ From cf0830ce841c9904fd698395781850ec0b5dca63 Mon Sep 17 00:00:00 2001 From: Marc Copson Date: Fri, 31 Jul 2026 18:04:07 +0000 Subject: [PATCH 59/59] fix(desktop): persist delivery room expiry latch Signed-off-by: Marc Copson --- ...cosDeliveryRoomExpiryLatchStorage.test.mjs | 205 ++++++++++++++ .../lib/cosDeliveryRoomExpiryLatchStorage.ts | 252 ++++++++++++++++++ .../ui/CosDeliveryRoomScreen.tsx | 8 + .../ui/useCosDeliveryRoomExpiryLatch.ts | 51 +++- desktop/tests/e2e/cos-delivery-room.spec.ts | 66 ++++- 5 files changed, 565 insertions(+), 17 deletions(-) create mode 100644 desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.test.mjs create mode 100644 desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.ts diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.test.mjs b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.test.mjs new file mode 100644 index 0000000000..895e365b89 --- /dev/null +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.test.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS, + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES, + COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY, + cosDeliveryRoomExpiryLatchScope, + latchCosDeliveryRoomGenerationExpiry, + readCosDeliveryRoomGenerationExpiry, +} from "./cosDeliveryRoomExpiryLatchStorage.ts"; + +const USER_A = "a".repeat(64); +const USER_B = "b".repeat(64); +const SOURCE_A = "wss://delivery-a.example.test/relay"; +const SOURCE_B = "wss://delivery-b.example.test/relay"; + +class MemoryStorage { + #values = new Map(); + + getItem(key) { + return this.#values.get(key) ?? null; + } + + removeItem(key) { + this.#values.delete(key); + } + + setItem(key, value) { + this.#values.set(key, String(value)); + } + + raw(key) { + return this.#values.get(key); + } +} + +function generation(index) { + return index.toString(16).padStart(64, "0"); +} + +test("expired generations are isolated by canonical source and user", () => { + const storage = new MemoryStorage(); + const scopeA = cosDeliveryRoomExpiryLatchScope(SOURCE_A, USER_A); + const otherUser = cosDeliveryRoomExpiryLatchScope(SOURCE_A, USER_B); + const otherSource = cosDeliveryRoomExpiryLatchScope(SOURCE_B, USER_A); + assert.ok(scopeA); + assert.ok(otherUser); + assert.ok(otherSource); + assert.notEqual(scopeA, otherUser); + assert.notEqual(scopeA, otherSource); + + assert.equal( + latchCosDeliveryRoomGenerationExpiry(storage, scopeA, generation(1)), + "latched", + ); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, scopeA, generation(1)), + "latched", + ); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, otherUser, generation(1)), + "clear", + ); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, otherSource, generation(1)), + "clear", + ); +}); + +test("malformed or unavailable persistence fails closed without throwing", () => { + const scope = cosDeliveryRoomExpiryLatchScope(SOURCE_A, USER_A); + assert.ok(scope); + const malformed = new MemoryStorage(); + malformed.setItem(COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY, "{not-json"); + assert.equal( + readCosDeliveryRoomGenerationExpiry(malformed, scope, generation(1)), + "unavailable", + ); + assert.equal( + latchCosDeliveryRoomGenerationExpiry(malformed, scope, generation(1)), + "unavailable", + ); + + const unavailable = { + getItem() { + throw new Error("session storage denied"); + }, + setItem() { + throw new Error("session storage denied"); + }, + }; + assert.equal( + readCosDeliveryRoomGenerationExpiry(unavailable, scope, generation(1)), + "unavailable", + ); + assert.equal( + latchCosDeliveryRoomGenerationExpiry(unavailable, scope, generation(1)), + "unavailable", + ); +}); + +test("generation capacity is bounded and overflow blocks the scope", () => { + const storage = new MemoryStorage(); + const scope = cosDeliveryRoomExpiryLatchScope(SOURCE_A, USER_A); + assert.ok(scope); + for ( + let index = 0; + index < COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS; + index += 1 + ) { + assert.equal( + latchCosDeliveryRoomGenerationExpiry(storage, scope, generation(index)), + "latched", + ); + } + assert.equal( + latchCosDeliveryRoomGenerationExpiry( + storage, + scope, + generation(COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS), + ), + "latched", + ); + const persisted = JSON.parse( + storage.raw(COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY), + ); + assert.equal(persisted.scopes.length, 1); + assert.equal( + persisted.scopes[0].generations.length, + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS, + ); + assert.equal(persisted.scopes[0].blocked, true); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, scope, generation(999)), + "latched", + ); +}); + +test("scope capacity is bounded and overflow fails closed globally", () => { + const storage = new MemoryStorage(); + for ( + let index = 0; + index < COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES; + index += 1 + ) { + const scope = cosDeliveryRoomExpiryLatchScope( + `wss://delivery-${index}.example.test/relay`, + USER_A, + ); + assert.ok(scope); + assert.equal( + latchCosDeliveryRoomGenerationExpiry(storage, scope, generation(index)), + "latched", + ); + } + const overflowScope = cosDeliveryRoomExpiryLatchScope( + "wss://delivery-overflow.example.test/relay", + USER_A, + ); + assert.ok(overflowScope); + assert.equal( + latchCosDeliveryRoomGenerationExpiry( + storage, + overflowScope, + generation(999), + ), + "latched", + ); + const persisted = JSON.parse( + storage.raw(COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY), + ); + assert.equal( + persisted.scopes.length, + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES, + ); + assert.equal(persisted.blocked, true); + + const firstScope = cosDeliveryRoomExpiryLatchScope(SOURCE_A, USER_A); + assert.ok(firstScope); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, firstScope, generation(1000)), + "latched", + ); +}); + +test("invalid scope and generation identifiers fail closed", () => { + const storage = new MemoryStorage(); + assert.equal( + cosDeliveryRoomExpiryLatchScope("not-a-relay", USER_A), + undefined, + ); + assert.equal( + cosDeliveryRoomExpiryLatchScope(SOURCE_A, "short-pubkey"), + undefined, + ); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, "", generation(1)), + "unavailable", + ); + assert.equal( + readCosDeliveryRoomGenerationExpiry(storage, "[]", "not-a-digest"), + "unavailable", + ); +}); diff --git a/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.ts b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.ts new file mode 100644 index 0000000000..0847c310ec --- /dev/null +++ b/desktop/src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage.ts @@ -0,0 +1,252 @@ +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const MAX_SOURCE_LENGTH = 2_048; +const MAX_SCOPE_LENGTH = 2_128; +const MAX_SERIALIZED_LENGTH = 64 * 1_024; +const STORAGE_VERSION = 1; + +export const COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY = + "buzz:cos-delivery-room-expiry-latch.v1"; +export const COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES = 8; +export const COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS = 64; + +type StorageLike = Pick; +export type CosDeliveryRoomExpiryLatchStatus = + | "clear" + | "latched" + | "unavailable"; + +type PersistedScope = { + blocked: boolean; + generations: string[]; + scope: string; +}; + +type PersistedState = { + blocked: boolean; + scopes: PersistedScope[]; + version: 1; +}; + +const storageAvailability = new WeakMap(); +const STORAGE_PROBE_KEY = `${COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY}.probe`; + +function emptyState(): PersistedState { + return { blocked: false, scopes: [], version: STORAGE_VERSION }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: string[]): boolean { + const actualKeys = Object.keys(value).sort(); + return ( + actualKeys.length === keys.length && + actualKeys.every((key, index) => key === keys[index]) + ); +} + +function canonicalRelaySource(source: string): string | undefined { + if (source.length === 0 || source.length > MAX_SOURCE_LENGTH) + return undefined; + try { + const url = new URL(source); + if (url.protocol !== "ws:" && url.protocol !== "wss:") return undefined; + if (url.username || url.password || url.search || url.hash) + return undefined; + return url.href; + } catch { + return undefined; + } +} + +export function cosDeliveryRoomExpiryLatchScope( + source: string | undefined, + userPubkey: string | undefined, +): string | undefined { + if (!source || !userPubkey) return undefined; + const canonicalSource = canonicalRelaySource(source); + const canonicalPubkey = userPubkey.toLowerCase(); + if (!canonicalSource || !DIGEST_PATTERN.test(canonicalPubkey)) + return undefined; + const scope = JSON.stringify([canonicalSource, canonicalPubkey]); + return scope.length <= MAX_SCOPE_LENGTH ? scope : undefined; +} + +function isCanonicalScope(value: unknown): value is string { + if (typeof value !== "string" || value.length > MAX_SCOPE_LENGTH) + return false; + try { + const decoded: unknown = JSON.parse(value); + if (!Array.isArray(decoded) || decoded.length !== 2) return false; + if (typeof decoded[0] !== "string" || typeof decoded[1] !== "string") { + return false; + } + return cosDeliveryRoomExpiryLatchScope(decoded[0], decoded[1]) === value; + } catch { + return false; + } +} + +function isGenerationId(value: unknown): value is string { + return typeof value === "string" && DIGEST_PATTERN.test(value); +} + +function parseState(raw: string | null): PersistedState | undefined { + if (raw === null) return emptyState(); + if (raw.length > MAX_SERIALIZED_LENGTH) return undefined; + try { + const value: unknown = JSON.parse(raw); + if ( + !isRecord(value) || + !hasExactKeys(value, ["blocked", "scopes", "version"]) || + value.version !== STORAGE_VERSION || + typeof value.blocked !== "boolean" || + !Array.isArray(value.scopes) || + value.scopes.length > COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES + ) { + return undefined; + } + const scopes: PersistedScope[] = []; + const seenScopes = new Set(); + for (const candidate of value.scopes) { + if ( + !isRecord(candidate) || + !hasExactKeys(candidate, ["blocked", "generations", "scope"]) || + typeof candidate.blocked !== "boolean" || + !isCanonicalScope(candidate.scope) || + !Array.isArray(candidate.generations) || + candidate.generations.length > + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS || + seenScopes.has(candidate.scope) + ) { + return undefined; + } + const generations = candidate.generations; + if ( + generations.some((generation) => !isGenerationId(generation)) || + new Set(generations).size !== generations.length + ) { + return undefined; + } + seenScopes.add(candidate.scope); + scopes.push({ + blocked: candidate.blocked, + generations: [...generations], + scope: candidate.scope, + }); + } + return { blocked: value.blocked, scopes, version: STORAGE_VERSION }; + } catch { + return undefined; + } +} + +function storageIsAvailable(storage: StorageLike): boolean { + const key = storage as object; + const known = storageAvailability.get(key); + if (known !== undefined) return known; + try { + const previous = storage.getItem(STORAGE_PROBE_KEY); + storage.setItem(STORAGE_PROBE_KEY, "1"); + if (previous === null) storage.removeItem(STORAGE_PROBE_KEY); + else storage.setItem(STORAGE_PROBE_KEY, previous); + storageAvailability.set(key, true); + return true; + } catch { + storageAvailability.set(key, false); + return false; + } +} + +function readState( + storage: StorageLike | undefined, +): PersistedState | undefined { + if (!storage || !storageIsAvailable(storage)) return undefined; + try { + return parseState( + storage.getItem(COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY), + ); + } catch { + storageAvailability.set(storage as object, false); + return undefined; + } +} + +function persistState(storage: StorageLike, state: PersistedState): boolean { + try { + const serialized = JSON.stringify(state); + if (serialized.length > MAX_SERIALIZED_LENGTH) { + storageAvailability.set(storage as object, false); + return false; + } + storage.setItem(COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY, serialized); + return true; + } catch { + storageAvailability.set(storage as object, false); + return false; + } +} + +export function readCosDeliveryRoomGenerationExpiry( + storage: StorageLike | undefined, + scope: string | undefined, + generationId: string | undefined, +): CosDeliveryRoomExpiryLatchStatus { + if (!isCanonicalScope(scope) || !isGenerationId(generationId)) { + return "unavailable"; + } + const state = readState(storage); + if (!state) return "unavailable"; + if (state.blocked) return "latched"; + const persistedScope = state.scopes.find( + (candidate) => candidate.scope === scope, + ); + if (!persistedScope) return "clear"; + return persistedScope.blocked || + persistedScope.generations.includes(generationId) + ? "latched" + : "clear"; +} + +export function latchCosDeliveryRoomGenerationExpiry( + storage: StorageLike | undefined, + scope: string | undefined, + generationId: string | undefined, +): CosDeliveryRoomExpiryLatchStatus { + if (!storage || !isCanonicalScope(scope) || !isGenerationId(generationId)) { + return "unavailable"; + } + const state = readState(storage); + if (!state) return "unavailable"; + if (state.blocked) return "latched"; + + const persistedScope = state.scopes.find( + (candidate) => candidate.scope === scope, + ); + if (!persistedScope) { + if (state.scopes.length >= COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_SCOPES) { + state.blocked = true; + } else { + state.scopes.push({ + blocked: false, + generations: [generationId], + scope, + }); + } + } else if ( + !persistedScope.blocked && + !persistedScope.generations.includes(generationId) + ) { + if ( + persistedScope.generations.length >= + COS_DELIVERY_ROOM_EXPIRY_LATCH_MAX_GENERATIONS + ) { + persistedScope.blocked = true; + } else { + persistedScope.generations.push(generationId); + } + } + + return persistState(storage, state) ? "latched" : "unavailable"; +} diff --git a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx index 9809ee72f1..e7c840b1d8 100644 --- a/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx +++ b/desktop/src/features/cos-running-order/ui/CosDeliveryRoomScreen.tsx @@ -28,6 +28,7 @@ import { loadCosDeliveryRoom, teamThreadForWork, } from "@/features/cos-running-order/lib/cosDeliveryRoom"; +import { cosDeliveryRoomExpiryLatchScope } from "@/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage"; import { formatDeliveryRoomTimestamp, latestCurrentEvidence, @@ -37,6 +38,7 @@ import { PARTICIPANT_PRESENTATION, } from "@/features/cos-running-order/lib/cosDeliveryRoomUiPresentation"; import { useCosDeliveryRoomExpiryLatch } from "@/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; @@ -856,6 +858,7 @@ function DeliveryRoomView({ room }: { room: CosDeliveryRoom }) { export function CosDeliveryRoomScreen() { const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); const deliveryRoomQuery = useQuery({ queryKey: ["cos-delivery-room", activeCommunity?.relayUrl], queryFn: ({ signal }) => @@ -874,7 +877,12 @@ export function CosDeliveryRoomScreen() { ? cosDeliveryRoomExpiresAt(deliveryRoomQuery.data) : undefined; const generationId = deliveryRoomQuery.data?.generationId; + const expiryLatchScope = cosDeliveryRoomExpiryLatchScope( + activeCommunity?.relayUrl, + identityQuery.data?.pubkey, + ); const evidenceExpired = useCosDeliveryRoomExpiryLatch( + expiryLatchScope, generationId, semanticExpiry, ); diff --git a/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts b/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts index 9a5081b324..c7bee85136 100644 --- a/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts +++ b/desktop/src/features/cos-running-order/ui/useCosDeliveryRoomExpiryLatch.ts @@ -1,24 +1,51 @@ import * as React from "react"; +import { + latchCosDeliveryRoomGenerationExpiry, + readCosDeliveryRoomGenerationExpiry, +} from "@/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage"; + +function deliveryRoomSessionStorage(): Storage | undefined { + try { + return window.sessionStorage; + } catch { + return undefined; + } +} + export function useCosDeliveryRoomExpiryLatch( + scope: string | undefined, generationId: string | undefined, semanticExpiry: number | undefined, ): boolean { - const [expiredGenerationIds, setExpiredGenerationIds] = React.useState< - ReadonlySet - >(() => new Set()); + const [, forcePersistenceRead] = React.useReducer( + (revision) => revision + 1, + 0, + ); + const storage = deliveryRoomSessionStorage(); + const persistedStatus = generationId + ? readCosDeliveryRoomGenerationExpiry(storage, scope, generationId) + : "clear"; + const evidenceExpired = Boolean( + generationId && + (semanticExpiry === undefined || + persistedStatus !== "clear" || + Date.now() >= semanticExpiry), + ); React.useEffect(() => { - if (semanticExpiry === undefined || generationId === undefined) return; + if ( + semanticExpiry === undefined || + generationId === undefined || + persistedStatus !== "clear" + ) { + return; + } const checkFreshness = () => { if (Date.now() < semanticExpiry) return false; - setExpiredGenerationIds((current) => { - if (current.has(generationId)) return current; - const next = new Set(current); - next.add(generationId); - return next; - }); + latchCosDeliveryRoomGenerationExpiry(storage, scope, generationId); + forcePersistenceRead(); return true; }; let timer: number | undefined; @@ -39,7 +66,7 @@ export function useCosDeliveryRoomExpiryLatch( window.removeEventListener("focus", checkFreshness); document.removeEventListener("visibilitychange", checkFreshness); }; - }, [generationId, semanticExpiry]); + }, [generationId, persistedStatus, scope, semanticExpiry, storage]); - return generationId ? expiredGenerationIds.has(generationId) : false; + return evidenceExpired; } diff --git a/desktop/tests/e2e/cos-delivery-room.spec.ts b/desktop/tests/e2e/cos-delivery-room.spec.ts index e6445f2e41..dc35b52686 100644 --- a/desktop/tests/e2e/cos-delivery-room.spec.ts +++ b/desktop/tests/e2e/cos-delivery-room.spec.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { expect, test, type Page } from "@playwright/test"; import { cosDeliveryRoomGenerationId } from "../../src/features/cos-running-order/lib/cosDeliveryRoom"; +import { COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY } from "../../src/features/cos-running-order/lib/cosDeliveryRoomExpiryLatchStorage"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -214,9 +215,29 @@ test("expired claims stay latched across clock rollback and a new generation rep page, }) => { await page.addInitScript(() => { - const systemNow = Date.now.bind(Date); + const SystemDate = Date; + const systemNow = SystemDate.now.bind(SystemDate); let offsetMs = 0; - Date.now = () => systemNow() + offsetMs; + const DeliveryRoomDate = new Proxy(SystemDate, { + apply(target, thisArg, argumentsList) { + if (argumentsList.length === 0) { + return new target(systemNow() + offsetMs).toString(); + } + return Reflect.apply(target, thisArg, argumentsList); + }, + construct(target, argumentsList, newTarget) { + return Reflect.construct( + target, + argumentsList.length === 0 ? [systemNow() + offsetMs] : argumentsList, + newTarget, + ); + }, + get(target, property, receiver) { + if (property === "now") return () => systemNow() + offsetMs; + return Reflect.get(target, property, receiver); + }, + }); + window.Date = DeliveryRoomDate; ( window as typeof window & { __SET_DELIVERY_ROOM_CLOCK_OFFSET__: (value: number) => void; @@ -228,7 +249,7 @@ test("expired claims stay latched across clock rollback and a new generation rep const expiredGeneration = await currentFixture(); expiredGeneration.deliveryRoom.workItems.find( (item: { id: string }) => item.id === "COS-901", - ).evidence[0].freshForMs = 3_000; + ).evidence[0].freshForMs = 5_000; expiredGeneration.generationId = await cosDeliveryRoomGenerationId(expiredGeneration); let serveReplacement = false; @@ -253,7 +274,7 @@ test("expired claims stay latched across clock rollback and a new generation rep await page.goto("/#/running-order"); await expect(page.getByTestId("delivery-room-item-COS-901")).toBeVisible(); await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible({ - timeout: 6_000, + timeout: 10_000, }); await expect(page.getByTestId("delivery-room-fail-closed")).toContainText( "Delivery Room evidence expired", @@ -268,7 +289,23 @@ test("expired claims stay latched across clock rollback and a new generation rep window as typeof window & { __SET_DELIVERY_ROOM_CLOCK_OFFSET__: (value: number) => void; } - ).__SET_DELIVERY_ROOM_CLOCK_OFFSET__(-60 * 60 * 1_000); + ).__SET_DELIVERY_ROOM_CLOCK_OFFSET__(-10_000); + window.location.hash = "#/"; + }); + await expect( + page.getByRole("heading", { name: "Delivery Room", exact: true }), + ).toHaveCount(0); + await page.evaluate(() => { + window.location.hash = "#/running-order"; + }); + await expect( + page.getByRole("heading", { name: "Delivery Room", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); + await page.evaluate(() => { window.dispatchEvent(new Event("focus")); document.dispatchEvent(new Event("visibilitychange")); }); @@ -292,6 +329,25 @@ test("expired claims stay latched across clock rollback and a new generation rep await expect(page.getByTestId("delivery-room-fail-closed")).toHaveCount(0); }); +test("malformed persisted expiry state fails closed without crashing", async ({ + page, +}) => { + await page.addInitScript((storageKey) => { + window.sessionStorage.setItem(storageKey, "{not-json"); + }, COS_DELIVERY_ROOM_EXPIRY_LATCH_STORAGE_KEY); + await installMockBridge(page, { cosUserContext: "admin" }); + await installDeliveryRoomRoute(page); + + await page.goto("/#/running-order"); + await expect( + page.getByRole("heading", { name: "Delivery Room", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("delivery-room-fail-closed")).toBeVisible(); + await expect( + page.locator("[data-testid^='delivery-room-item-']"), + ).toHaveCount(0); +}); + test("a stale refetch clears previously verified delivery claims", async ({ page, }) => {