From feccf4eabc23fdba94ce3537a194357ed17b197c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 08:05:13 +0100 Subject: [PATCH 01/30] Polish mobile inbox and media flows (#4512) ## Summary - make mobile unread state visible with bold channel names, an animated Inbox badge, and swipe-to-toggle Inbox rows - add directional transitions for top-level mobile navigation - let mobile send while media uploads, with cancellable progress UI - normalize iOS and Android video uploads, attach poster frames, and improve native video playback ## Validation - `just mobile-check` - `just mobile-test` - `cargo test -p buzz-media` - Pixel smoke test - iPhone smoke test Desktop background uploads moved to #4522 so the two platforms can be reviewed independently. --------- Signed-off-by: kenny lopez Signed-off-by: Tom Brow Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow --- crates/buzz-media/src/validation.rs | 27 +- .../xyz/block/buzz/mobile/MainActivity.kt | 54 + mobile/ios/Runner/AppDelegate.swift | 252 ++++- mobile/ios/RunnerTests/RunnerTests.swift | 43 + mobile/lib/app.dart | 35 +- .../lib/features/activity/activity_page.dart | 6 +- .../activity/activity_page/inbox_row.dart | 403 +++++-- .../features/activity/activity_provider.dart | 160 ++- .../features/activity/inbox_read_state.dart | 2 +- .../channels/camera_capture_cleanup.dart | 50 + .../channels/channel_actions_sheet.dart | 4 +- .../channels/channel_detail_page.dart | 23 +- .../channels/channel_management_provider.dart | 21 +- .../channel_mutes/channel_mutes_manager.dart | 2 +- .../channel_sections_manager.dart | 2 +- .../channel_sort/channel_sort_manager.dart | 2 +- .../channel_stars/channel_stars_manager.dart | 2 +- .../lib/features/channels/channels_page.dart | 22 +- .../channels/channels_page/badges.dart | 51 - .../features/channels/channels_page/body.dart | 9 - .../channels/channels_page/channel_tile.dart | 6 - .../channels_page/quick_actions_launcher.dart | 6 +- .../channels/channels_page/sections.dart | 6 - .../features/channels/channels_provider.dart | 2 +- mobile/lib/features/channels/compose_bar.dart | 948 +---------------- .../channels/compose_bar/attachments.dart | 224 ++-- .../compose_bar/compose_bar_widget.dart | 992 ++++++++++++++++++ .../channels/compose_bar/draft_lifecycle.dart | 60 ++ .../channels/compose_bar/helpers.dart | 37 + .../compose_bar/ios_photo_picker.dart | 4 +- .../features/channels/compose_bar/layout.dart | 9 +- .../compose_bar/photo_gallery_picker.dart | 10 +- .../compose_bar/upload_progress_pill.dart | 197 ++++ .../features/channels/media_viewer_page.dart | 218 +--- .../media_viewer_page/video_controls.dart | 140 +++ .../media_viewer_page/video_viewer.dart | 385 +++++++ .../features/channels/message_actions.dart | 6 +- .../features/channels/message_content.dart | 85 +- .../message_content/video_preview.dart | 228 ++++ .../lib/features/channels/message_media.dart | 6 +- .../channels/send_message_provider.dart | 20 +- .../features/channels/thread_detail_page.dart | 23 +- .../thread_follows_provider.dart | 2 +- .../unread_badge/observed_unread_event.dart | 2 +- .../unread_badge/unread_badge_provider.dart | 4 +- .../lib/features/forum/forum_posts_view.dart | 7 +- mobile/lib/features/forum/forum_provider.dart | 171 +-- .../lib/features/forum/forum_thread_page.dart | 6 +- mobile/lib/features/home/home_page.dart | 126 ++- .../deferred_read_state_update.dart | 0 .../read_state/message_read_state.dart | 0 .../read_state/read_state_format.dart | 2 +- .../read_state/read_state_manager.dart | 4 +- .../read_state/read_state_provider.dart | 6 +- .../read_state/read_state_storage.dart | 0 .../read_state/read_state_time.dart | 0 mobile/lib/shared/relay/media_upload.dart | 226 +++- .../widgets/directional_transition_scope.dart | 67 ++ .../lib/shared/widgets/frosted_app_bar.dart | 88 +- .../lib/shared/widgets/frosted_scaffold.dart | 16 +- .../features/activity/activity_page_test.dart | 151 ++- .../activity/activity_provider_test.dart | 245 ++++- .../channels/camera_capture_cleanup_test.dart | 17 + .../channels/channel_detail_page_test.dart | 2 +- .../features/channels/channels_page_test.dart | 28 +- .../features/channels/compose_bar_test.dart | 642 ++++++++---- .../channels/message_actions_test.dart | 2 +- .../channels/message_content_test.dart | 140 ++- .../features/channels/message_media_test.dart | 4 +- .../read_state/message_read_state_test.dart | 4 +- .../read_state/read_state_format_test.dart | 2 +- .../read_state/read_state_manager_test.dart | 4 +- .../read_state/read_state_provider_test.dart | 4 +- .../read_state/read_state_time_test.dart | 2 +- .../channels/send_message_provider_test.dart | 25 + .../unread_badge_provider_test.dart | 2 +- .../features/forum/forum_widgets_test.dart | 24 + mobile/test/features/home/home_page_test.dart | 176 +++- .../test/shared/relay/media_upload_test.dart | 179 ++++ 79 files changed, 5233 insertions(+), 1929 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/compose_bar_widget.dart create mode 100644 mobile/lib/features/channels/compose_bar/draft_lifecycle.dart create mode 100644 mobile/lib/features/channels/compose_bar/upload_progress_pill.dart create mode 100644 mobile/lib/features/channels/media_viewer_page/video_controls.dart create mode 100644 mobile/lib/features/channels/media_viewer_page/video_viewer.dart create mode 100644 mobile/lib/features/channels/message_content/video_preview.dart rename mobile/lib/{features/channels => shared}/read_state/deferred_read_state_update.dart (100%) rename mobile/lib/{features/channels => shared}/read_state/message_read_state.dart (100%) rename mobile/lib/{features/channels => shared}/read_state/read_state_format.dart (98%) rename mobile/lib/{features/channels => shared}/read_state/read_state_manager.dart (99%) rename mobile/lib/{features/channels => shared}/read_state/read_state_provider.dart (97%) rename mobile/lib/{features/channels => shared}/read_state/read_state_storage.dart (100%) rename mobile/lib/{features/channels => shared}/read_state/read_state_time.dart (100%) create mode 100644 mobile/lib/shared/widgets/directional_transition_scope.dart diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index f1387fc9d..450f8f353 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -858,7 +858,7 @@ fn validate_mp4_metadata_free(path: &Path) -> Result<(), MediaError> { *b"ftyp", *b"moov", *b"mdat", *b"free", *b"skip", *b"wide", *b"trak", *b"mdia", *b"minf", *b"stbl", *b"edts", *b"dinf", *b"sinf", *b"schi", *b"udta", *b"mvhd", *b"tkhd", *b"mdhd", *b"hdlr", *b"vmhd", *b"smhd", *b"dref", *b"url ", *b"urn ", *b"stsd", *b"stts", *b"stss", - *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"elst", + *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"sdtp", *b"elst", ]; fn walk( file: &mut std::fs::File, @@ -2337,6 +2337,31 @@ mod tests { assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); } + #[test] + fn test_accepts_standard_sample_dependency_table() { + let bytes = [ + box_wrap(b"ftyp", b"isom\0\0\0\0isom"), + box_wrap( + b"moov", + &box_wrap( + b"trak", + &box_wrap( + b"mdia", + &box_wrap( + b"minf", + &box_wrap(b"stbl", &box_wrap(b"sdtp", &[0x20, 0x10])), + ), + ), + ), + ), + box_wrap(b"mdat", b""), + ] + .concat(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); + } + #[test] fn test_rejects_excessive_mp4_box_nesting() { let mut nested = box_wrap(b"free", b""); diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index 8718ffd8e..d9b456b27 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -6,6 +6,7 @@ import android.graphics.Canvas import android.graphics.ColorSpace import android.graphics.ImageDecoder import android.media.MediaExtractor +import android.media.MediaMetadataRetriever import android.media.MediaMuxer import android.os.Build import androidx.annotation.RequiresApi @@ -97,6 +98,9 @@ class MainActivity : FlutterActivity() { TRANSCODE_VIDEO_TO_MP4_METHOD -> { handleTranscodeVideoToMp4(call.arguments, result) } + GENERATE_VIDEO_POSTER_METHOD -> { + handleGenerateVideoPoster(call.arguments, result) + } REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD -> { result.success(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) } @@ -275,6 +279,55 @@ class MainActivity : FlutterActivity() { }.start() } + private fun handleGenerateVideoPoster( + arguments: Any?, + result: MethodChannel.Result, + ) { + val sourcePath = arguments as? String ?: run { + invalidArguments(result, "Expected source file path as String.") + return + } + + Thread { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(sourcePath) + val source = retriever.getFrameAtTime( + 0, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + ) ?: retriever.getFrameAtTime( + 100_000, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + ) ?: throw IllegalArgumentException("Unable to decode a video frame.") + val scale = minOf(1f, 720f / maxOf(source.width, source.height)) + val frame = if (scale < 1f) { + Bitmap.createScaledBitmap( + source, + (source.width * scale).toInt(), + (source.height * scale).toInt(), + true, + ).also { source.recycle() } + } else { + source + } + val bytes = AndroidImageProcessor.encodeAndScrub( + frame, + Bitmap.CompressFormat.JPEG, + ) ?: throw IllegalArgumentException("Unable to encode a video preview.") + frame.recycle() + result.success(bytes) + } catch (e: Exception) { + result.error( + "poster_failed", + "Unable to create a video preview.", + e.message, + ) + } finally { + retriever.release() + } + }.start() + } + private fun invalidArguments( result: MethodChannel.Result, message: String, @@ -287,6 +340,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 GENERATE_VIDEO_POSTER_METHOD = "generateVideoPoster" private const val REQUIRES_LEGACY_MEDIA_STORAGE_PERMISSION_METHOD = "requiresLegacyMediaStoragePermission" } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f6fe3aaf4..53fa6b996 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -231,6 +231,18 @@ import UserNotifications return } transcodeVideoToMp4(sourcePath: sourcePath, result: result) + case "generateVideoPoster": + guard let sourcePath = call.arguments as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected source file path as String.", + details: nil + ) + ) + return + } + generateVideoPoster(sourcePath: sourcePath, result: result) case "clipboardHasImage": result(UIPasteboard.general.hasImages) case "readClipboardImage": @@ -269,10 +281,72 @@ import UserNotifications let sourceURL = URL(fileURLWithPath: sourcePath) let asset = AVURLAsset(url: sourceURL) + // Do not export the source asset directly. An iPhone video can carry GPS, + // spatial-video, and other data tracks even when its user-visible metadata + // is cleared. A fresh composition copies only one video and one audio + // track, so those private channels cannot reach the relay. + let composition = AVMutableComposition() + guard + let sourceVideo = asset.tracks(withMediaType: .video).first, + let destinationVideo = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + else { + result( + FlutterError( + code: "transcode_failed", + message: "The selected file does not contain a video track.", + details: nil + ) + ) + return + } + + do { + let sourceAudio = asset.tracks(withMediaType: .audio).first + let insertionTimes = Self.relativeTrackInsertionTimes( + videoStart: sourceVideo.timeRange.start, + audioStart: sourceAudio?.timeRange.start + ) + try destinationVideo.insertTimeRange( + sourceVideo.timeRange, + of: sourceVideo, + at: insertionTimes.video + ) + destinationVideo.preferredTransform = sourceVideo.preferredTransform + + if + let sourceAudio, + let destinationAudio = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + { + try destinationAudio.insertTimeRange( + sourceAudio.timeRange, + of: sourceAudio, + at: insertionTimes.audio ?? .zero + ) + } + } catch { + result( + FlutterError( + code: "transcode_failed", + message: error.localizedDescription, + details: nil + ) + ) + return + } + guard let exportSession = AVAssetExportSession( - asset: asset, - presetName: AVAssetExportPresetPassthrough + asset: composition, + // Passthrough preserves the source's HEVC codec and container + // metadata. Buzz accepts only canonical H.264/AAC MP4s with no + // metadata channels, so re-encode instead of copying the movie. + presetName: AVAssetExportPresetMediumQuality ) else { result( @@ -292,12 +366,32 @@ import UserNotifications exportSession.outputURL = outputURL exportSession.outputFileType = .mp4 exportSession.shouldOptimizeForNetworkUse = true - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + // `forSharing()` intentionally retains playback metadata. The relay + // rejects every descriptive metadata channel to avoid leaking location or + // other private information, so write no source metadata at all. + exportSession.metadata = [] + exportSession.metadataItemFilter = nil exportSession.exportAsynchronously { switch exportSession.status { case .completed: - result(outputURL.path) + do { + // AVFoundation writes a standard sample-dependency table (`sdtp`). + // Older Buzz relays mistook that playback-only box for metadata. Keep + // its size and payload in a `free` box so chunk offsets stay valid and + // uploads work before those relays receive the validator fix. + try Self.neutralizeSampleDependencyBoxes(at: outputURL) + result(outputURL.path) + } catch { + try? FileManager.default.removeItem(at: outputURL) + result( + FlutterError( + code: "transcode_failed", + message: "Unable to canonicalize transcoded video.", + details: error.localizedDescription + ) + ) + } default: let errorMessage = exportSession.error?.localizedDescription @@ -314,4 +408,154 @@ import UserNotifications } } } + + static func relativeTrackInsertionTimes( + videoStart: CMTime, + audioStart: CMTime? + ) -> (video: CMTime, audio: CMTime?) { + guard let audioStart else { + return (video: .zero, audio: nil) + } + + let timelineStart = + CMTimeCompare(audioStart, videoStart) < 0 ? audioStart : videoStart + return ( + video: CMTimeSubtract(videoStart, timelineStart), + audio: CMTimeSubtract(audioStart, timelineStart) + ) + } + + private func generateVideoPoster( + sourcePath: String, + result: @escaping FlutterResult + ) { + DispatchQueue.global(qos: .userInitiated).async { + let asset = AVURLAsset(url: URL(fileURLWithPath: sourcePath)) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 720, height: 720) + generator.requestedTimeToleranceBefore = .positiveInfinity + generator.requestedTimeToleranceAfter = .positiveInfinity + + do { + let durationSeconds = CMTimeGetSeconds(asset.duration) + let middleTime = durationSeconds.isFinite && durationSeconds > 0 + ? min(durationSeconds / 2, 1) + : 0 + let candidateTimes = [0, 0.1, middleTime] + var posterImage: CGImage? + var lastError: Error? + + for seconds in candidateTimes { + do { + posterImage = try generator.copyCGImage( + at: CMTime(seconds: seconds, preferredTimescale: 600), + actualTime: nil + ) + if posterImage != nil { break } + } catch { + lastError = error + } + } + + guard let posterImage else { + throw lastError ?? NSError( + domain: "BuzzVideoPoster", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] + ) + } + guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else { + throw NSError( + domain: "BuzzVideoPoster", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Unable to encode video poster."] + ) + } + DispatchQueue.main.async { + result(FlutterStandardTypedData(bytes: jpegData)) + } + } catch { + DispatchQueue.main.async { + result( + FlutterError( + code: "poster_failed", + message: "Unable to create a video preview.", + details: error.localizedDescription + ) + ) + } + } + } + } + + private static func neutralizeSampleDependencyBoxes(at url: URL) throws { + var data = try Data(contentsOf: url) + try neutralizeSampleDependencyBoxes(in: &data, start: 0, end: data.count) + try data.write(to: url, options: .atomic) + } + + private static func neutralizeSampleDependencyBoxes( + in data: inout Data, + start: Int, + end: Int + ) throws { + let containers: Set<[UInt8]> = [ + Array("moov".utf8), Array("trak".utf8), Array("mdia".utf8), + Array("minf".utf8), Array("stbl".utf8), Array("edts".utf8), + Array("dinf".utf8), Array("sinf".utf8), Array("schi".utf8), + ] + let sampleDependencyType = Array("sdtp".utf8) + let freeType = Array("free".utf8) + var offset = start + + while offset < end { + guard end - offset >= 8 else { throw invalidMp4BoxError() } + let compactSize = Int(readBigEndianUInt32(data, at: offset)) + var headerSize = 8 + let boxSize: Int + if compactSize == 1 { + guard end - offset >= 16 else { throw invalidMp4BoxError() } + let extendedSize = readBigEndianUInt64(data, at: offset + 8) + guard extendedSize <= UInt64(Int.max) else { throw invalidMp4BoxError() } + boxSize = Int(extendedSize) + headerSize = 16 + } else if compactSize == 0 { + boxSize = end - offset + } else { + boxSize = compactSize + } + + guard boxSize >= headerSize, offset + boxSize <= end else { + throw invalidMp4BoxError() + } + let type = Array(data[(offset + 4)..<(offset + 8)]) + if type == sampleDependencyType { + data.replaceSubrange((offset + 4)..<(offset + 8), with: freeType) + } else if containers.contains(type) { + try neutralizeSampleDependencyBoxes( + in: &data, + start: offset + headerSize, + end: offset + boxSize + ) + } + offset += boxSize + } + } + + private static func readBigEndianUInt32(_ data: Data, at offset: Int) -> UInt32 { + data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | UInt32($1) } + } + + private static func readBigEndianUInt64(_ data: Data, at offset: Int) -> UInt64 { + data[offset..<(offset + 8)].reduce(0) { ($0 << 8) | UInt64($1) } + } + + private static func invalidMp4BoxError() -> NSError { + NSError( + domain: "BuzzVideoTranscode", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid MP4 box structure."] + ) + } } diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 8374ca77b..e1c2ce00f 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,3 +1,4 @@ +import AVFoundation import Flutter import UIKit import XCTest @@ -6,6 +7,48 @@ import XCTest class RunnerTests: XCTestCase { + func testRelativeTrackInsertionTimesPreserveAudioDelay() { + let times = AppDelegate.relativeTrackInsertionTimes( + videoStart: CMTime(seconds: 1, preferredTimescale: 600), + audioStart: CMTime(seconds: 1.5, preferredTimescale: 600) + ) + + XCTAssertEqual(CMTimeCompare(times.video, .zero), 0) + XCTAssertEqual( + CMTimeCompare( + times.audio ?? .invalid, + CMTime(seconds: 0.5, preferredTimescale: 600) + ), + 0 + ) + } + + func testRelativeTrackInsertionTimesPreserveVideoDelay() { + let times = AppDelegate.relativeTrackInsertionTimes( + videoStart: CMTime(seconds: 2, preferredTimescale: 600), + audioStart: CMTime(seconds: 1, preferredTimescale: 600) + ) + + XCTAssertEqual( + CMTimeCompare( + times.video, + CMTime(seconds: 1, preferredTimescale: 600) + ), + 0 + ) + XCTAssertEqual(CMTimeCompare(times.audio ?? .invalid, .zero), 0) + } + + func testRelativeTrackInsertionTimesZeroBasesVideoWithoutAudio() { + let times = AppDelegate.relativeTrackInsertionTimes( + videoStart: CMTime(seconds: 3, preferredTimescale: 600), + audioStart: nil + ) + + XCTAssertEqual(CMTimeCompare(times.video, .zero), 0) + XCTAssertNil(times.audio) + } + @MainActor func testExpandedAttachmentSurfaceDismissesKeyboard() { let window = KeyboardDismissalSpyWindow() diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 053821510..b1dad2a5c 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -4,6 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'features/activity/activity_provider.dart'; +import 'features/activity/inbox_local_state_provider.dart'; +import 'features/activity/inbox_read_state.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/pairing/pairing_page.dart'; @@ -16,9 +19,32 @@ import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; import 'shared/relay/relay.dart'; +import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; +/// App-shell projection that joins Activity state for the Home navigation. +/// +/// This belongs at the composition root because it deliberately aggregates +/// Activity feature providers for a sibling navigation surface. +final _unreadInboxItemCountProvider = Provider((ref) { + final readState = ref.watch(readStateProvider); + if (!readState.isReady) return 0; + + final localState = ref.watch(inboxLocalStateProvider); + final items = ref.watch(inboxItemsProvider); + return items + .where( + (item) => !isInboxItemDone( + item, + markerOf: readState.effectiveTimestamp, + localUnreadOverrides: localState.unreadIds, + localDoneSet: localState.doneIds, + ), + ) + .length; +}); + class App extends HookConsumerWidget { const App({super.key}); @@ -50,11 +76,13 @@ class App extends HookConsumerWidget { // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. + var hasUnreadInbox = false; if (authState.value?.status == AuthStatus.authenticated) { ref.watch(relaySessionProvider); ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; } // Start listening for buzz:// links immediately (even pre-auth) so a @@ -99,8 +127,11 @@ class App extends HookConsumerWidget { loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { - AuthStatus.authenticated => const DeepLinkDispatcher( - child: HomePage(settingsPageBuilder: _buildSettingsPage), + AuthStatus.authenticated => DeepLinkDispatcher( + child: HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: hasUnreadInbox, + ), ), _ => const DeepLinkDispatcher( dispatchMessageLinks: false, diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 5b4cebfa9..82a64e375 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -21,8 +22,8 @@ import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; import '../channels/dm_channel_labels.dart'; import '../channels/message_content.dart'; -import '../channels/read_state/read_state_format.dart'; -import '../channels/read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'activity_provider.dart'; @@ -288,6 +289,7 @@ class ActivityPage extends HookConsumerWidget { children: [ if (index == newBoundaryIndex) const _NewBoundaryDivider(), _InboxRow( + key: ValueKey(item.id), item: item, channel: channel, currentPubkey: myPk, diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 9dd8b6ddf..398bd16ce 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -1,5 +1,10 @@ part of '../activity_page.dart'; +const _inboxSwipeActionInset = Grid.half; +const _inboxSwipeActionLabelMinWidth = Grid.xxl; +const _inboxSwipeLabelRevealWidth = + _inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2); + /// "New" boundary between unread and previously read rows. class _NewBoundaryDivider extends StatelessWidget { const _NewBoundaryDivider(); @@ -37,7 +42,10 @@ class _NewBoundaryDivider extends StatelessWidget { /// One conversation row, matching desktop's inbox item hierarchy: /// avatar | sender + unread dot + time | contextual label | preview. -class _InboxRow extends ConsumerWidget { +/// +/// Swiping left reveals the row's read-state action. Opening remains a tap or +/// long-press action, so the swipe has one clear, easily recoverable outcome. +class _InboxRow extends HookConsumerWidget { final InboxItem item; final Channel? channel; final String? currentPubkey; @@ -47,6 +55,7 @@ class _InboxRow extends ConsumerWidget { final VoidCallback onMarkUnread; const _InboxRow({ + super.key, required this.item, required this.channel, required this.currentPubkey, @@ -58,6 +67,11 @@ class _InboxRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + const restingRevealWidth = 132.0; + const commitThreshold = 0.58; + final revealAmount = useState(0.0); + final isDragging = useState(false); + final labelHapticFired = useRef(false); final userCache = ref.watch(userCacheProvider); final profile = userCache[item.item.pubkey.toLowerCase()]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); @@ -100,114 +114,228 @@ class _InboxRow extends ConsumerWidget { ? context.colors.tertiary : mutedColor; - return InkWell( - key: ValueKey('inbox-row-${item.id}'), - onTap: onTap, - onLongPress: () => _showRowActions(context), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _RowAvatar(pubkey: item.item.pubkey, profile: profile), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Sender + unread dot + timestamp. - Row( - children: [ - Expanded( - 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}', - ), - ), - ), - if (!isDone) ...[ - const SizedBox(width: Grid.xxs), - Container( - key: ValueKey('inbox-unread-dot-${item.id}'), - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.colors.primary, - ), - ), - ], - ], - ), - const SizedBox(height: Grid.quarter), - // Contextual label: "Mentioned in #channel" etc. - Row( - children: [ - Flexible( - child: Text( - label.text, - style: activityContextTextStyle.copyWith( - color: labelColor, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (label.channelLabel != null) ...[ - 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(Grid.half), - ), - child: Text( - '#${label.channelLabel}', - style: activityContextTextStyle.copyWith( - color: mutedColor, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ], - ), - const SizedBox(height: Grid.half), - // Message preview. - MessageContent( - content: item.item.displayContent, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - tags: item.item.tags, - maxLines: 2, - baseStyle: activityPreviewTextStyle.copyWith( - color: context.colors.onSurface, + final reducedMotion = MediaQuery.of(context).disableAnimations; + final swipeDirection = Directionality.of(context) == TextDirection.ltr + ? 1.0 + : -1.0; + void closeActions() => revealAmount.value = 0; + void toggleReadState() { + closeActions(); + isDone ? onMarkUnread() : onMarkRead(); + } + + return LayoutBuilder( + builder: (context, constraints) { + final actionExtent = constraints.maxWidth; + final revealedWidth = revealAmount.value + .clamp(0, actionExtent) + .toDouble(); + final actionColor = isDone + ? context.colors.primary + : context.appColors.success; + return ClipRect( + child: Stack( + children: [ + if (revealedWidth > 0) + PositionedDirectional( + top: 0, + end: 0, + bottom: 0, + width: revealedWidth, + child: Padding( + key: ValueKey('inbox-swipe-background-${item.id}'), + padding: const EdgeInsets.all(_inboxSwipeActionInset), + child: _InboxSwipeAction( + key: ValueKey('inbox-swipe-read-${item.id}'), + color: actionColor, + foregroundColor: contrastForeground(actionColor), + icon: isDone ? LucideIcons.mail : LucideIcons.mailOpen, + label: isDone ? 'Mark unread' : 'Mark as read', + onTap: toggleReadState, ), ), - ], + ), + AnimatedSlide( + offset: Offset( + -swipeDirection * revealAmount.value / constraints.maxWidth, + 0, + ), + duration: reducedMotion || isDragging.value + ? Duration.zero + : const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + isDragging.value = true; + labelHapticFired.value = false; + }, + onHorizontalDragUpdate: (details) { + isDragging.value = true; + final previous = revealAmount.value; + final next = + (previous - (details.delta.dx * swipeDirection)) + .clamp(0, actionExtent) + .toDouble(); + if (!labelHapticFired.value && + previous < _inboxSwipeLabelRevealWidth && + next >= _inboxSwipeLabelRevealWidth) { + labelHapticFired.value = true; + unawaited(HapticFeedback.selectionClick()); + } + revealAmount.value = next; + }, + onHorizontalDragEnd: (details) { + isDragging.value = false; + final velocity = details.primaryVelocity ?? 0; + final shouldCommit = + (velocity * swipeDirection) < -900 || + revealAmount.value >= actionExtent * commitThreshold; + if (shouldCommit) { + toggleReadState(); + } else { + revealAmount.value = + (velocity * swipeDirection) < -200 || + revealAmount.value >= restingRevealWidth / 2 + ? restingRevealWidth + : 0; + } + }, + child: Material( + color: context.colors.surface, + child: InkWell( + key: ValueKey('inbox-row-${item.id}'), + onTap: onTap, + onLongPress: () => _showRowActions(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.twelve, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _RowAvatar( + pubkey: item.item.pubkey, + profile: profile, + ), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sender + unread dot + timestamp. + Row( + children: [ + Expanded( + 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}', + ), + ), + ), + if (!isDone) ...[ + const SizedBox(width: Grid.xxs), + Container( + key: ValueKey( + 'inbox-unread-dot-${item.id}', + ), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + ), + ), + ], + ], + ), + const SizedBox(height: Grid.quarter), + // Contextual label: "Mentioned in #channel" etc. + Row( + children: [ + Flexible( + child: Text( + label.text, + style: activityContextTextStyle + .copyWith(color: labelColor), + overflow: TextOverflow.ellipsis, + ), + ), + if (label.channelLabel != null) ...[ + 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( + Grid.half, + ), + ), + child: Text( + '#${label.channelLabel}', + style: activityContextTextStyle + .copyWith(color: mutedColor), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], + ), + const SizedBox(height: Grid.half), + // Message preview. + MessageContent( + content: item.item.displayContent, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + tags: item.item.tags, + maxLines: 2, + baseStyle: activityPreviewTextStyle + .copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), ), - ), - ], - ), - ), + ], + ), + ); + }, ); } @@ -244,6 +372,69 @@ class _InboxRow extends ConsumerWidget { } } +class _InboxSwipeAction extends StatelessWidget { + final Color color; + final Color foregroundColor; + final IconData icon; + final String label; + final VoidCallback onTap; + + const _InboxSwipeAction({ + super.key, + required this.color, + required this.foregroundColor, + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => Material( + color: color, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(Radii.full), + child: Semantics( + button: true, + label: label, + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < Grid.lg) { + return const SizedBox.shrink(); + } + final showLabel = + constraints.maxWidth >= _inboxSwipeActionLabelMinWidth; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: foregroundColor), + if (showLabel) ...[ + const SizedBox(height: Grid.quarter), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Text( + label, + maxLines: 2, + softWrap: true, + textAlign: TextAlign.center, + style: context.textTheme.labelSmall?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ); + }, + ), + ), + ), + ); +} + class _RowAvatar extends StatelessWidget { final String pubkey; final UserProfile? profile; diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 87f00bf2b..3581cbd55 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; @@ -16,15 +19,164 @@ import 'inbox_item.dart'; /// - recent DM messages from others (desktop surfaces DMs through p-tags; /// mobile queries DM channels directly so untagged DM sends still appear) class ActivityNotifier extends AsyncNotifier { + static const _addressedKinds = [ + 1, + 9, + 40002, + 43001, + 43002, + 43003, + 43004, + 43005, + 43006, + 45001, + 45003, + 46010, + 46011, + 46012, + ]; + + void Function()? _unsubscribeAddressed; + void Function()? _unsubscribeDms; + Timer? _liveRefreshTimer; + Future? _refreshInFlight; + int? _refreshGeneration; + bool _refreshQueued = false; + int _subscriptionGeneration = 0; + @override - Future build() { + Future build() async { ref.watch(relayConfigProvider); - ref.watch(relaySessionProvider); + final sessionState = ref.watch(relaySessionProvider); // React to the DM channel set (loading → data, membership changes) so a // cold start where channels resolve after the first fetch still surfaces // DMs without a manual refresh. ref.watch(channelsProvider.select(_dmChannelKey)); - return _fetch(); + + final generation = ++_subscriptionGeneration; + _clearLiveSubscriptions(); + ref.onDispose(() { + _subscriptionGeneration += 1; + _clearLiveSubscriptions(); + }); + + final response = await _fetch(); + if (sessionState.status == SessionStatus.connected && + generation == _subscriptionGeneration) { + unawaited(_subscribeLive(generation)); + } + return response; + } + + Future _subscribeLive(int generation) async { + final myPk = ref.read(myPubkeyProvider); + if (myPk == null || generation != _subscriptionGeneration) return; + + final session = ref.read(relaySessionProvider.notifier); + final since = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5; + try { + final unsubscribeAddressed = await session.subscribe( + NostrFilter( + kinds: _addressedKinds, + tags: { + '#p': [myPk], + }, + since: since, + limit: 100, + ), + (_) => _scheduleLiveRefresh(generation), + ); + if (generation != _subscriptionGeneration) { + unsubscribeAddressed(); + return; + } + _unsubscribeAddressed = unsubscribeAddressed; + + final dmChannelIds = [ + for (final channel + in ref.read(channelsProvider).asData?.value ?? const []) + if (channel.isDm && channel.isMember) channel.id, + ]; + if (dmChannelIds.isEmpty) return; + + final unsubscribeDms = await session.subscribe( + NostrFilter( + kinds: const [9], + tags: {'#h': dmChannelIds}, + since: since, + limit: 100, + ), + (_) => _scheduleLiveRefresh(generation), + ); + if (generation != _subscriptionGeneration) { + unsubscribeDms(); + return; + } + _unsubscribeDms = unsubscribeDms; + } catch (error) { + if (generation == _subscriptionGeneration) { + debugPrint('[ActivityNotifier] live subscription failed: $error'); + } + } + } + + void _scheduleLiveRefresh(int generation) { + if (generation != _subscriptionGeneration) return; + _liveRefreshTimer?.cancel(); + _liveRefreshTimer = Timer( + const Duration(milliseconds: 50), + () => unawaited(_queueRefresh(generation)), + ); + } + + Future _queueRefresh(int generation) { + if (generation != _subscriptionGeneration) return Future.value(); + _refreshQueued = true; + final inFlight = _refreshInFlight; + if (_refreshGeneration == generation && inFlight != null) { + return inFlight; + } + + _refreshGeneration = generation; + final future = _drainRefreshQueue(generation); + _refreshInFlight = future; + return future; + } + + Future _drainRefreshQueue(int generation) async { + try { + do { + _refreshQueued = false; + try { + final next = await _fetch(); + if (generation != _subscriptionGeneration) return; + state = AsyncData(next); + } catch (error) { + if (generation != _subscriptionGeneration) return; + debugPrint( + '[ActivityNotifier] inbox refresh failed; retaining feed: $error', + ); + } + } while (_refreshQueued && generation == _subscriptionGeneration); + } finally { + if (_refreshGeneration == generation) { + _refreshInFlight = null; + _refreshGeneration = null; + _refreshQueued = false; + } + } + } + + void _clearLiveSubscriptions() { + _liveRefreshTimer?.cancel(); + _liveRefreshTimer = null; + _refreshInFlight = null; + _refreshGeneration = null; + _refreshQueued = false; + _unsubscribeAddressed?.call(); + _unsubscribeAddressed = null; + _unsubscribeDms?.call(); + _unsubscribeDms = null; } /// Stable identity for the joined DM channel set: null while channels are @@ -162,7 +314,7 @@ class ActivityNotifier extends AsyncNotifier { } Future refresh() async { - state = await AsyncValue.guard(_fetch); + await _queueRefresh(_subscriptionGeneration); } } diff --git a/mobile/lib/features/activity/inbox_read_state.dart b/mobile/lib/features/activity/inbox_read_state.dart index 130562072..8626b574f 100644 --- a/mobile/lib/features/activity/inbox_read_state.dart +++ b/mobile/lib/features/activity/inbox_read_state.dart @@ -1,4 +1,4 @@ -import '../channels/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_format.dart'; import 'inbox_item.dart'; /// Resolves the effective NIP-RS read marker for one inbox row, mirroring diff --git a/mobile/lib/features/channels/camera_capture_cleanup.dart b/mobile/lib/features/channels/camera_capture_cleanup.dart index 469a150e1..479a5c04d 100644 --- a/mobile/lib/features/channels/camera_capture_cleanup.dart +++ b/mobile/lib/features/channels/camera_capture_cleanup.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:image_picker/image_picker.dart'; +var _retainedImageCounter = 0; + Future processCapturedImage( XFile image, Future Function(XFile image) onCapture, @@ -29,3 +31,51 @@ Future processTemporaryImages( } } } + +/// Copies native-owned [images] into composer-owned temporary files. +/// +/// Native camera and picker integrations delete their source files as soon as +/// their callback returns. Deferred sends must therefore retain their own copy +/// before queueing a preview for later upload. +Future> retainTemporaryImages(List images) async { + final retained = []; + try { + for (final image in images) { + final sourcePath = image.path; + final extension = _safeFileExtension(sourcePath); + final destination = File( + '${Directory.systemTemp.path}${Platform.pathSeparator}' + 'buzz-compose-${DateTime.now().microsecondsSinceEpoch}-' + '${_retainedImageCounter++}$extension', + ); + if (sourcePath.isNotEmpty && await File(sourcePath).exists()) { + await File(sourcePath).copy(destination.path); + } else { + await destination.writeAsBytes(await image.readAsBytes(), flush: true); + } + retained.add( + XFile(destination.path, name: image.name, mimeType: image.mimeType), + ); + } + return retained; + } catch (_) { + for (final image in retained) { + try { + await File(image.path).delete(); + } on FileSystemException { + // A failed copy may not have created every destination. + } + } + rethrow; + } +} + +String _safeFileExtension(String path) { + final separator = path.lastIndexOf(Platform.pathSeparator); + final dot = path.lastIndexOf('.'); + if (dot <= separator || dot == path.length - 1) return '.jpg'; + final extension = path.substring(dot); + return RegExp(r'^\.[A-Za-z0-9]{1,10}$').hasMatch(extension) + ? extension + : '.jpg'; +} diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 843ee992e..42e13be54 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -14,8 +14,8 @@ import 'channel_sections/channel_sections_provider.dart'; import 'channel_stars/channel_stars_provider.dart'; import 'channels_provider.dart'; import 'manage_channel_sheet.dart'; -import 'read_state/read_state_provider.dart'; -import 'read_state/read_state_time.dart'; +import '../../shared/read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_time.dart'; /// Opens the mobile channel actions sheet and returns whether its parent page /// should close after a successful lifecycle action. diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index cf5a30304..d1387c717 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -44,10 +44,10 @@ import 'ephemeral_channel_display.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'read_state/deferred_read_state_update.dart'; -import 'read_state/read_state_format.dart'; -import 'read_state/read_state_provider.dart'; -import 'read_state/read_state_time.dart'; +import '../../shared/read_state/deferred_read_state_update.dart'; +import '../../shared/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_time.dart'; import 'reaction_row.dart'; import 'send_message_provider.dart'; import '../profile/user_profile_sheet.dart'; @@ -131,6 +131,7 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final composerDockHeight = useState(0.0); + final sendMessage = ref.read(sendMessageProvider); final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); @@ -517,14 +518,12 @@ class ChannelDetailPage extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - mediaTags: mediaTags, - ), + }) => sendMessage.call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ), ), ], ), diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index ed9f6842f..286a931db 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -498,16 +498,19 @@ class ChannelActions { final RelaySessionNotifier _session; final SignedEventRelay _signedEventRelay; final String? _currentPubkey; + final bool Function()? _isCommunityValid; ChannelActions({ required Ref ref, required RelaySessionNotifier session, required SignedEventRelay signedEventRelay, required String? currentPubkey, + bool Function()? isCommunityValid, }) : _ref = ref, _session = session, _signedEventRelay = signedEventRelay, - _currentPubkey = currentPubkey; + _currentPubkey = currentPubkey, + _isCommunityValid = isCommunityValid; Future createChannel({ required String name, @@ -561,7 +564,9 @@ class ChannelActions { for (final pubkey in pubkeys) if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(), }; + _ensureCommunityValid(); for (final pubkey in normalizedPubkeys) { + _ensureCommunityValid(); await _signedEventRelay.submit( kind: 9000, content: '', @@ -572,10 +577,19 @@ class ChannelActions { ], ); } + _ensureCommunityValid(); _ref.invalidate(channelMembersProvider(channelId)); _ref.invalidate(channelBotPubkeysProvider(channelId)); } + void _ensureCommunityValid() { + if (_isCommunityValid?.call() == false) { + throw StateError( + 'Channel action cancelled because the active community changed', + ); + } + } + Future joinChannel(String channelId) async { await _signedEventRelay.submit( kind: 9021, @@ -796,5 +810,10 @@ final channelActionsProvider = Provider((ref) { nsec: relayConfig.nsec, ), currentPubkey: currentPubkey, + isCommunityValid: () { + final currentConfig = ref.read(relayConfigProvider); + return currentConfig.baseUrl == relayConfig.baseUrl && + currentConfig.nsec == relayConfig.nsec; + }, ); }); diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart index 44bc6ba8e..eb768dd8f 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart @@ -8,7 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../shared/crypto/nip44.dart'; import '../../../shared/relay/relay.dart'; -import '../read_state/read_state_time.dart'; +import '../../../shared/read_state/read_state_time.dart'; import 'channel_mutes_storage.dart'; class ChannelMutesCrypto { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart index 1d103755b..445ce6a28 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -9,7 +9,7 @@ import 'package:uuid/uuid.dart'; import '../../../shared/crypto/nip44.dart'; import '../../../shared/relay/relay.dart'; -import '../read_state/read_state_time.dart'; +import '../../../shared/read_state/read_state_time.dart'; import 'channel_sections_storage.dart'; const _uuid = Uuid(); diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 629e1bc32..a434fec17 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -8,7 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../shared/crypto/nip44.dart'; import '../../../shared/relay/relay.dart'; -import '../read_state/read_state_time.dart'; +import '../../../shared/read_state/read_state_time.dart'; import 'channel_sort_storage.dart'; const _dTag = 'channel-sort'; diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart index 664c13a09..c4f4da862 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart @@ -8,7 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../shared/crypto/nip44.dart'; import '../../../shared/relay/relay.dart'; -import '../read_state/read_state_time.dart'; +import '../../../shared/read_state/read_state_time.dart'; import 'channel_stars_storage.dart'; class ChannelStarsCrypto { diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 94fe60cb2..c00792b96 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -4,6 +4,7 @@ import 'dart:math' show max, min, pi; import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/physics.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -41,10 +42,10 @@ import 'channel_sort/channel_sort_provider.dart'; import 'channel_sort/channel_sort_storage.dart'; import 'channel_stars/channel_stars_provider.dart'; import 'channels_provider.dart'; -import 'read_state/deferred_read_state_update.dart'; -import 'read_state/read_state_format.dart'; -import 'read_state/read_state_provider.dart'; -import 'read_state/read_state_time.dart'; +import '../../shared/read_state/deferred_read_state_update.dart'; +import '../../shared/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_time.dart'; import 'unread_badge/observed_unread_event.dart'; part 'channels_page/body.dart'; @@ -92,9 +93,8 @@ const double _kSectionCollapsedScaleY = 0.98; class _UnreadChannelState { final Set ids; - final Map counts; - const _UnreadChannelState({required this.ids, required this.counts}); + const _UnreadChannelState({required this.ids}); } _UnreadChannelState _computeUnreadChannelState({ @@ -103,19 +103,17 @@ _UnreadChannelState _computeUnreadChannelState({ required ChannelsNotifier channelsNotifier, }) { if (!readState.isReady) { - return const _UnreadChannelState(ids: {}, counts: {}); + return const _UnreadChannelState(ids: {}); } final latestObservedByChannel = channelsNotifier.latestObservedByChannel; final observedEventsByChannel = channelsNotifier.observedUnreadEventsByChannel; final ids = {}; - final counts = {}; for (final channel in channels) { if (readState.locallyForcedChannelIds.contains(channel.id)) { ids.add(channel.id); - counts[channel.id] = 1; continue; } @@ -141,13 +139,9 @@ _UnreadChannelState _computeUnreadChannelState({ if (unreadCount == 0) continue; ids.add(channel.id); - counts[channel.id] = countUnreadBadgeObservedEvents( - observedEvents, - readAtForObservedEvent, - ); } - return _UnreadChannelState(ids: ids, counts: counts); + return _UnreadChannelState(ids: ids); } class ChannelsPage extends HookConsumerWidget { diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 164d17171..e7e480e11 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -1,56 +1,5 @@ part of '../channels_page.dart'; -class _UnreadBadge extends StatelessWidget { - final String channelId; - final int count; - - const _UnreadBadge({required this.channelId, required this.count}); - - @override - Widget build(BuildContext context) { - if (count <= 0) { - return SizedBox( - key: Key('channel-unread-dot-$channelId'), - width: 20, - height: 20, - child: Center( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: context.colors.primary, - shape: BoxShape.circle, - ), - child: Semantics(label: 'unread'), - ), - ), - ); - } - - return Container( - key: Key('channel-unread-$channelId'), - constraints: const BoxConstraints(minWidth: 20, minHeight: 20), - padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), - alignment: Alignment.center, - decoration: BoxDecoration( - color: context.colors.primary, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - _formatUnreadCount(count), - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimary, - fontSize: 10, - fontWeight: FontWeight.w700, - height: 1, - ), - ), - ); - } -} - -String _formatUnreadCount(int count) => count > 99 ? '99+' : count.toString(); - class _EphemeralBadge extends StatelessWidget { final Channel channel; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 87cc5dd73..6ce4ce5f8 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -148,11 +148,6 @@ class _SliverChannelsList extends HookConsumerWidget { readState.effectiveTimestamp(channelId) != null) channelId, }; - final unreadChannelCounts = { - for (final entry in unreadState.counts.entries) - if (unreadChannelIds.contains(entry.key)) entry.key: entry.value, - }; - // Build sorted user-defined sections and compute which stream channels // belong to each section. Channels not assigned to any valid section fall // through to the built-in "Channels" list. @@ -226,7 +221,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => starredExpanded.value = !starredExpanded.value, channels: starredStreamChannels, unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: '', @@ -249,7 +243,6 @@ class _SliverChannelsList extends HookConsumerWidget { sortState.sortModeFor(sectionSortGroupKey(section.id)), ), unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, expanded: sectionExpanded(section.id), @@ -340,7 +333,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => channelsExpanded.value = !channelsExpanded.value, channels: ungroupedStreamChannels, unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No stream channels yet', @@ -356,7 +348,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => dmsExpanded.value = !dmsExpanded.value, channels: sortedDmChannels, unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No direct messages yet', diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 95d0b2c0b..5443e6b9d 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -2,7 +2,6 @@ part of '../channels_page.dart'; class _ChannelTile extends ConsumerWidget { final Channel channel; - final int? unreadCount; final bool isUnread; final bool isMuted; final String? currentPubkey; @@ -17,7 +16,6 @@ class _ChannelTile extends ConsumerWidget { const _ChannelTile({ required this.channel, - this.unreadCount, required this.isUnread, required this.currentPubkey, required this.onTap, @@ -86,10 +84,6 @@ class _ChannelTile extends ConsumerWidget { color: context.colors.onSurfaceVariant, ), ], - if (isUnread && !channel.isDm) ...[ - const SizedBox(width: Grid.xxs), - _UnreadBadge(channelId: channel.id, count: unreadCount ?? 0), - ], if (!channel.isMember && !channel.isDm) Padding( padding: const EdgeInsets.only(right: Grid.xxs), diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index 365393208..f57514487 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -161,8 +161,10 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { child: _MorphingQuickActionsButton( open: effectiveOpen, openEdgeOffset: rightInset - Grid.gutter, - onToggle: () => - quickActionsOpen.value = !quickActionsOpen.value, + onToggle: () { + unawaited(HapticFeedback.lightImpact()); + quickActionsOpen.value = !quickActionsOpen.value; + }, onSelected: (action) => unawaited(selectQuickAction(action)), ), ), diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index fa559c12b..b6d04cdb9 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -6,7 +6,6 @@ class _CustomChannelSection extends StatelessWidget { final ChannelSection section; final List channels; final Set unreadChannelIds; - final Map unreadChannelCounts; final Set mutedChannelIds; final String? currentPubkey; final bool expanded; @@ -27,7 +26,6 @@ class _CustomChannelSection extends StatelessWidget { required this.section, required this.channels, required this.unreadChannelIds, - required this.unreadChannelCounts, required this.mutedChannelIds, required this.currentPubkey, required this.expanded, @@ -72,7 +70,6 @@ class _CustomChannelSection extends StatelessWidget { for (final channel in channels) _ChannelTile( channel: channel, - unreadCount: unreadChannelCounts[channel.id], isUnread: unreadChannelIds.contains(channel.id), isMuted: mutedChannelIds.contains(channel.id), currentPubkey: currentPubkey, @@ -380,7 +377,6 @@ class _ChannelSection extends StatelessWidget { final List channels; final bool showTopDivider; final Set unreadChannelIds; - final Map unreadChannelCounts; final Set mutedChannelIds; final String? currentPubkey; final String emptyLabel; @@ -396,7 +392,6 @@ class _ChannelSection extends StatelessWidget { required this.channels, required this.showTopDivider, required this.unreadChannelIds, - required this.unreadChannelCounts, required this.mutedChannelIds, required this.currentPubkey, required this.emptyLabel, @@ -443,7 +438,6 @@ class _ChannelSection extends StatelessWidget { for (final channel in channels) _ChannelTile( channel: channel, - unreadCount: unreadChannelCounts[channel.id], isUnread: unreadChannelIds.contains(channel.id), isMuted: mutedChannelIds.contains(channel.id), currentPubkey: currentPubkey, diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c0525a0b2..5d7b7df86 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -11,7 +11,7 @@ import '../../shared/utils/string_utils.dart'; import 'channel.dart'; import 'channel_management_provider.dart' show channelDetailsProvider; import 'channel_mutes/channel_mutes_provider.dart'; -import 'read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_provider.dart'; import 'thread_follows/thread_follows_provider.dart'; import 'unread_badge/is_high_priority_event.dart'; import 'unread_badge/observed_unread_event.dart'; diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index fe0d5cf5d..35083610d 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:io'; import 'dart:math' as math; import 'dart:ui' show FlutterView; @@ -42,9 +43,11 @@ import 'photo_library.dart'; part 'compose_bar/helpers.dart'; part 'compose_bar/agent_mention_labels.dart'; part 'compose_bar/markdown_editing_controller.dart'; +part 'compose_bar/draft_lifecycle.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; +part 'compose_bar/upload_progress_pill.dart'; part 'compose_bar/photo_gallery_picker.dart'; part 'compose_bar/ios_photo_picker.dart'; part 'compose_bar/ios_attachment_popover.dart'; @@ -52,947 +55,4 @@ part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; part 'compose_bar/dock.dart'; - -const _maxConcurrentImageUploads = 3; - -/// Rich compose bar with @mention autocomplete and a markdown formatting -/// toolbar. Used in both channel and thread views — the caller provides an -/// [onSend] callback that handles actual message submission. -typedef ComposeBarOnSend = - Future Function( - String content, - List mentionPubkeys, { - List> mediaTags, - }); - -class ComposeBar extends HookConsumerWidget { - final String channelId; - final String channelName; - final String? hintText; - final ComposeBarOnSend onSend; - - /// Optional thread IDs for thread-scoped typing indicators. - final String? threadHeadId; - final String? rootId; - - const ComposeBar({ - super.key, - required this.channelId, - this.channelName = '', - this.hintText, - this.threadHeadId, - this.rootId, - required this.onSend, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useMemoized(_MarkdownEditingController.new); - useListenable(controller); - useEffect(() => controller.dispose, [controller]); - - // Restore and persist unsent text as a local draft so the Activity - // inbox Drafts filter reflects real composer state. - // - // The effect is additionally keyed on the active relay + pubkey identity: - // provider-level namespacing alone cannot protect a composer that stays - // mounted through an in-place community/account switch — the controller - // would retain the old identity's text and the next edit would persist it - // into the new identity's store. On identity change we replace the - // controller content with the new identity's own saved draft (or clear). - final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); - final draftIdentity = - '${ref.watch(relayConfigProvider).baseUrl}' - ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; - final lastDraftIdentity = useRef(null); - useEffect(() { - final identityChanged = - lastDraftIdentity.value != null && - lastDraftIdentity.value != draftIdentity; - lastDraftIdentity.value = draftIdentity; - final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); - if (identityChanged) { - controller.text = saved ?? ''; - } else if (saved != null && controller.text.isEmpty) { - controller.text = saved; - } - void persistDraft() { - ref - .read(composeDraftsProvider.notifier) - .save( - key: draftKey, - channelId: channelId, - threadHeadId: threadHeadId, - text: controller.text, - ); - } - - controller.addListener(persistDraft); - return () => controller.removeListener(persistDraft); - }, [controller, draftKey, draftIdentity]); - final focusNode = useFocusNode(); - useEffect( - () => - () => _dismissComposerKeyboard(focusNode), - [focusNode], - ); - final isComposerExpanded = useState(false); - final isEmojiPickerOpen = 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>([]); - final uploadError = useState(null); - final uploadingCount = useState(0); - final clipboardHasImage = useState(false); - final hasAttachments = attachments.value.isNotEmpty; - final hasPendingUploads = uploadingCount.value > 0; - final canSend = controller.text.trim().isNotEmpty || hasAttachments; - final customEmoji = ref.watch(customEmojiListProvider); - final reducedMotion = MediaQuery.disableAnimationsOf(context); - final composerExpansionController = useAnimationController( - initialValue: 0, - upperBound: 1.05, - ); - final composerExpansionValue = useAnimation(composerExpansionController); - final composerExpansionProgress = composerExpansionValue - .clamp(0.0, 1.0) - .toDouble(); - - void collapseComposer() { - if (!isComposerExpanded.value) return; - showFormatting.value = false; - isComposerExpanded.value = false; - } - - // A focus loss covers deliberate dismiss gestures. The metrics observer - // also catches the system back/swipe dismissal path, where the platform can - // hide the keyboard while Flutter keeps the TextField focused. - useEffect(() { - void collapseWhenUnfocused() { - if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { - collapseComposer(); - } - } - - focusNode.addListener(collapseWhenUnfocused); - return () => focusNode.removeListener(collapseWhenUnfocused); - }, [focusNode]); - - final appView = View.of(context); - useEffect(() { - final observer = _ComposerKeyboardMetricsObserver( - view: appView, - onKeyboardHidden: () { - collapseComposer(); - // Android Back and iOS dismissal gestures can hide the keyboard - // without changing Flutter focus. Clear it as well so reopening the - // compact capsule establishes a new text-input connection. - focusNode.unfocus(); - }, - ); - WidgetsBinding.instance.addObserver(observer); - return () => WidgetsBinding.instance.removeObserver(observer); - }, [appView, focusNode]); - - final resolvedHint = - hintText ?? - (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); - - useEffect(() { - final target = isComposerExpanded.value ? 1.0 : 0.0; - if (reducedMotion) { - composerExpansionController.value = target; - } else if ((composerExpansionController.value - target).abs() > 0.001) { - composerExpansionController.animateWith( - SpringSimulation( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 220), - bounce: 0.08, - ), - composerExpansionController.value, - target, - 0, - snapToEnd: true, - ), - ); - } - return null; - }, [isComposerExpanded.value, reducedMotion]); - - useEffect(() { - if (defaultTargetPlatform != TargetPlatform.iOS) return null; - - var disposed = false; - Future refreshClipboardAvailability() async { - final hasImage = await ref - .read(mediaUploadServiceProvider) - .clipboardHasImage(); - if (!disposed && context.mounted) { - clipboardHasImage.value = hasImage; - } - } - - void refreshWhenFocused() { - if (focusNode.hasFocus) refreshClipboardAvailability(); - } - - final lifecycleListener = AppLifecycleListener( - onResume: refreshClipboardAvailability, - ); - focusNode.addListener(refreshWhenFocused); - refreshClipboardAvailability(); - return () { - disposed = true; - focusNode.removeListener(refreshWhenFocused); - lifecycleListener.dispose(); - }; - }, [focusNode]); - - // Mention state -------------------------------------------------------- - final mentionQuery = useState(null); - final mentionStartIdx = useState(-1); - // Map of displayName → selected mention candidate built as the user selects - // mentions. Used to pass resolved pubkeys directly to onSend and to attach - // selected non-member agents before the message is published. - final mentionMap = useRef({}); - - // Channel autocomplete state ---------------------------------------------- - final channelQuery = useState(null); - final channelStartIdx = useState(-1); - final channelsAsync = ref.watch(channelsProvider); - - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final currentPubkey = ref.watch(currentPubkeyProvider); - final userCache = ref.watch(userCacheProvider); - final isDmChannel = - channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ?? - false; - - // Preload profiles for channel members, mentionable agents, and their - // owners so @mention suggestions show names ("managed by …" included). - final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; - final agentOwners = ref.watch(agentOwnersProvider).asData?.value; - final agentMentionLabels = _agentMentionLabels( - candidates: mentionMap.value.values, - ); - final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( - '\u0000', - ); - useEffect(() { - controller.setAgentMentionNames(agentMentionLabels); - return null; - }, [controller, agentMentionLabelsKey]); - useEffect( - () { - final memberList = membersAsync.asData?.value ?? []; - final pubkeys = [ - ...memberList.map((m) => m.pubkey), - ...?relayAgents?.map((a) => a.pubkey), - ...?agentOwners?.values, - ]; - if (pubkeys.isNotEmpty) { - ref.read(userCacheProvider.notifier).preload(pubkeys); - } - return null; - }, - [ - membersAsync.asData?.value.length, - relayAgents?.length, - agentOwners?.length, - ], - ); - - // Typing indicator broadcast — throttled to one event per 3 seconds. - final lastTypingSentMs = useRef(0); - final isModifyingText = useRef(false); - - // Detect @mention query and broadcast typing on text / selection change. - useEffect(() { - void listener() { - if (isModifyingText.value) return; - final text = controller.text; - final sel = controller.selection; - - // Broadcast typing indicator (throttled). - if (text.isNotEmpty) { - final now = DateTime.now().millisecondsSinceEpoch; - if (now - lastTypingSentMs.value > _typingThrottleMs) { - lastTypingSentMs.value = now; - _sendTypingIndicator( - ref, - channelId: channelId, - threadHeadId: threadHeadId, - rootId: rootId, - ); - } - } - - if (!sel.isValid || !sel.isCollapsed) { - mentionQuery.value = null; - channelQuery.value = null; - return; - } - final cursor = sel.baseOffset; - if (cursor < 1) { - mentionQuery.value = null; - channelQuery.value = null; - return; - } - - // Walk backward from cursor looking for trigger characters. - // stopAtSpace: false — @mentions support multi-word display names. - final atPos = findTrigger(text, cursor, '@', stopAtSpace: false); - - if (atPos != null) { - mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase(); - mentionStartIdx.value = atPos; - channelQuery.value = null; - } else { - mentionQuery.value = null; - } - - // Channel autocomplete detection — only when no @mention is active. - if (mentionQuery.value == null) { - final hashPos = findTrigger(text, cursor, '#'); - if (hashPos != null) { - channelQuery.value = text - .substring(hashPos + 1, cursor) - .toLowerCase(); - channelStartIdx.value = hashPos; - } else { - channelQuery.value = null; - } - } else { - channelQuery.value = null; - } - } - - controller.addListener(listener); - return () => controller.removeListener(listener); - }, [controller]); - - // Ranked mention candidates (desktop-parity ordering + eligibility). - final suggestions = mentionQuery.value == null - ? const [] - : ref - .watch( - mentionCandidatesProvider(( - channelId: channelId, - query: mentionQuery.value!, - )), - ) - .take(_mentionSuggestionLimit) - .toList(); - - // Resolve owner names for the visible "managed by …" subtitles. - useEffect(() { - final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey]; - if (ownerPubkeys.isNotEmpty) { - ref.read(userCacheProvider.notifier).preload(ownerPubkeys); - } - return null; - }, [suggestions.length, mentionQuery.value]); - - // Filter channels against the query. - final channels = channelsAsync.asData?.value ?? []; - final channelSuggestions = filterChannels(channels, channelQuery.value); - - // Insert a selected mention into the text field. - void insertMention(MentionCandidate candidate) { - final name = candidate.label; - // Track the resolved candidate so we can pass its pubkey and prepare - // selected non-member agents at send time. - mentionMap.value[name] = candidate; - - final start = mentionStartIdx.value.clamp(0, controller.text.length); - spliceAndMoveCursor( - controller, - focusNode, - start: start, - replacement: '@$name ', - ); - mentionQuery.value = null; - } - - // Insert a selected channel into the text field. - void insertChannel(Channel channel) { - final start = channelStartIdx.value.clamp(0, controller.text.length); - spliceAndMoveCursor( - controller, - focusNode, - start: start, - replacement: '#${channel.name} ', - ); - channelQuery.value = null; - } - - // Insert `@` at the cursor to manually trigger mention mode. - void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@'); - - // Insert `#` at the cursor to manually trigger channel mode. - void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#'); - - // Insert a selected emoji at the cursor without replacing the draft. - void insertEmoji(String emoji) { - final text = controller.text; - final selection = controller.selection; - final cursor = selection.isValid - ? selection.baseOffset.clamp(0, text.length) - : text.length; - controller.value = TextEditingValue( - text: text.replaceRange(cursor, cursor, emoji), - selection: TextSelection.collapsed(offset: cursor + emoji.length), - ); - focusNode.requestFocus(); - } - - void clearComposer() { - controller.clear(); - attachments.value = []; - mentionMap.value.clear(); - mentionQuery.value = null; - channelQuery.value = null; - attachmentSurface.value = _AttachmentSurface.closed; - showFormatting.value = false; - uploadError.value = null; - focusNode.requestFocus(); - } - - void removeAttachment(String url) { - attachments.value = _withoutAttachment(attachments.value, url); - } - - // Send the message. - Future send() async { - final text = controller.text.trim(); - if ((text.isEmpty && !hasAttachments) || - isSending.value || - hasPendingUploads) { - return; - } - - // Extract pubkeys for mentions present in the final text. - final selectedMentions = [ - for (final entry in mentionMap.value.entries) - if (hasMention(text, entry.key)) entry.value, - ]; - final pubkeys = LinkedHashSet.from( - selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), - ).toList(); - final nonMemberAgentPubkeys = []; - final nonMemberHumans = []; - if (selectedMentions.isNotEmpty) { - final currentChannel = (await ref.read( - channelsProvider.future, - )).firstWhere((channel) => channel.id == channelId); - if (!currentChannel.isDm) { - final memberPubkeys = (await ref.read( - channelMembersProvider(channelId).future, - )).map((member) => member.pubkey.toLowerCase()).toSet(); - final seenNonMembers = {}; - for (final candidate in selectedMentions) { - final pk = candidate.pubkey.toLowerCase(); - if (memberPubkeys.contains(pk)) continue; - if (!seenNonMembers.add(pk)) continue; - if (candidate.isAgent) { - nonMemberAgentPubkeys.add(pk); - } else { - nonMemberHumans.add(candidate); - } - } - } - } - - // Mentioning humans outside the channel prompts "Invite" / "Do - // nothing" (send without inviting) — mirrors desktop's - // NonMemberMentionDialog. Agents keep the existing silent auto-add. - var mentionPubkeys = pubkeys; - final referenceMentionTags = >[]; - var inviteHumanPubkeys = const []; - if (nonMemberHumans.isNotEmpty) { - if (!context.mounted) return; - final choice = await _promptNonMemberMention( - context, - names: [for (final candidate in nonMemberHumans) candidate.label], - ); - switch (choice) { - case null: - return; // Dismissed — keep the draft, send nothing. - case _NonMemberMentionChoice.invite: - inviteHumanPubkeys = [ - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - ]; - case _NonMemberMentionChoice.sendWithoutInviting: - // Strip their p-tags (no channel notification) but keep a - // `mention` reference tag so their name still renders — - // mirrors desktop's mergeOutgoingTagsWithReferenceMentions. - final excluded = { - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - }; - mentionPubkeys = [ - for (final pk in pubkeys) - if (!excluded.contains(pk)) pk, - ]; - referenceMentionTags.addAll([ - for (final pk in excluded) ['mention', pk], - ]); - } - } - - final payload = _ComposeDraftPayload.fromDraft( - text: text, - attachments: attachments.value, - customEmoji: customEmoji, - ); - - isSending.value = true; - try { - if (nonMemberAgentPubkeys.isNotEmpty) { - await ref - .read(channelActionsProvider) - .addMembers( - channelId: channelId, - pubkeys: nonMemberAgentPubkeys, - role: 'bot', - ); - } - if (inviteHumanPubkeys.isNotEmpty) { - await ref - .read(channelActionsProvider) - .addMembers(channelId: channelId, pubkeys: inviteHumanPubkeys); - } - await onSend( - payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], - ); - if (context.mounted) { - clearComposer(); - } - } finally { - if (context.mounted) isSending.value = false; - } - } - - Future pickAndUpload(Future Function() pick) async { - uploadError.value = null; - uploadingCount.value += 1; - try { - final uploaded = await pick(); - if (uploaded != null && context.mounted) { - attachments.value = [...attachments.value, uploaded]; - } - } catch (error) { - if (context.mounted) { - uploadError.value = _formatUploadError(error); - } - } finally { - if (context.mounted) { - uploadingCount.value -= 1; - } - } - } - - 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, - ) { - void pasteImage() { - ContextMenuController.removeAny(); - pickAndUpload( - ref.read(mediaUploadServiceProvider).readAndUploadClipboardImage, - ); - } - - if (defaultTargetPlatform == TargetPlatform.iOS && - SystemContextMenu.isSupportedByField(editableTextState)) { - return SystemContextMenu.editableText( - editableTextState: editableTextState, - items: [ - if (clipboardHasImage.value) - IOSSystemContextMenuItemCustom( - title: 'Paste Image', - onPressed: pasteImage, - ), - ...SystemContextMenu.getDefaultItems(editableTextState), - ], - ); - } - - final buttonItems = [...editableTextState.contextMenuButtonItems]; - if (defaultTargetPlatform == TargetPlatform.iOS && - clipboardHasImage.value) { - buttonItems.insert( - 0, - ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage), - ); - } - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: editableTextState.contextMenuAnchors, - buttonItems: buttonItems, - ); - } - - void uploadPastedImage(KeyboardInsertedContent content) { - final bytes = content.data; - if (bytes == null || bytes.isEmpty) { - uploadError.value = 'Unable to read pasted image'; - return; - } - - pickAndUpload( - () => ref - .read(mediaUploadServiceProvider) - .uploadImage(XFile.fromData(bytes)), - ); - } - - // Wrap (or insert) markdown formatting around the current selection. - void applyFormat(String prefix, [String? suffix]) { - suffix ??= prefix; - final text = controller.text; - final sel = controller.selection; - if (!sel.isValid) return; - - isModifyingText.value = true; - try { - if (sel.isCollapsed) { - final offset = sel.baseOffset; - final updated = - '${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}'; - controller.text = updated; - controller.selection = TextSelection.collapsed( - offset: offset + prefix.length, - ); - } else { - final selected = text.substring(sel.start, sel.end); - final updated = - '${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}'; - controller.text = updated; - controller.selection = TextSelection.collapsed( - offset: sel.start + prefix.length + selected.length + suffix.length, - ); - } - } finally { - isModifyingText.value = false; - } - focusNode.requestFocus(); - } - - // ----- Widget tree ---------------------------------------------------- - - 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() { - 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; - } - - 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) { - focusNode.unfocus(); - toggleAttachments(); - } - }), - ); - } - - void openCamera() { - focusNode.unfocus(); - attachmentSurface.value = _AttachmentSurface.camera; - } - - final motionDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - attachmentSurface.value == _AttachmentSurface.camera || - attachmentSurface.value == _AttachmentSurface.photos - ? 320 - : 250, - ); - final suggestionOverlayController = useMemoized( - OverlayPortalController.new, - ); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) suggestionOverlayController.show(); - }); - return null; - }, [suggestionOverlayController]); - - void expandComposer() { - if (isComposerExpanded.value) return; - attachmentSurface.value = _AttachmentSurface.closed; - isComposerExpanded.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) focusNode.requestFocus(); - }); - } - - final suggestionPanel = channelSuggestions.isNotEmpty - ? KeyedSubtree( - key: const ValueKey('channel-suggestions'), - child: _ChannelSuggestions( - suggestions: channelSuggestions, - onSelect: insertChannel, - ), - ) - : suggestions.isNotEmpty - ? KeyedSubtree( - key: const ValueKey('mention-suggestions'), - child: _MentionSuggestions( - suggestions: suggestions, - userCache: userCache, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - onSelect: insertMention, - ), - ) - : const SizedBox.shrink(key: ValueKey('no-suggestions')); - 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. - final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; - return _ComposerDockFrame( - widthFactor: composerWidthFactor, - child: _ComposerOverlayPortal( - controller: suggestionOverlayController, - attachmentSurface: attachmentSurface, - reducedMotion: reducedMotion, - buildOverlayPanel: buildOverlayPanel, - onDismissAttachmentSurface: () { - attachmentSurface.value = _AttachmentSurface.closed; - }, - 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; - isEmojiPickerOpen.value = true; - _showComposerEmojiPicker(context, insertEmoji, () { - if (!context.mounted) return; - isEmojiPickerOpen.value = false; - focusNode.requestFocus(); - }); - }, - onOpenFormatting: () { - attachmentSurface.value = _AttachmentSurface.closed; - showFormatting.value = true; - }, - canSend: canSend, - hasPendingUploads: hasPendingUploads, - isSending: isSending.value, - ), - ), - ); - } -} +part 'compose_bar/compose_bar_widget.dart'; diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index eee4d222a..84bf5b590 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -60,6 +60,7 @@ class _AttachmentSurfacePanel extends HookWidget { final Future Function(XFile image) onCapture; final Future> Function() onPickAllPhotos; final Future Function(List photos) onChoosePhotos; + final Future Function(List photos) onChooseAllPhotos; const _AttachmentSurfacePanel({ super.key, @@ -73,6 +74,7 @@ class _AttachmentSurfacePanel extends HookWidget { required this.onCapture, required this.onPickAllPhotos, required this.onChoosePhotos, + required this.onChooseAllPhotos, }); @override @@ -150,6 +152,7 @@ class _AttachmentSurfacePanel extends HookWidget { onBack: onBack, onPickAllPhotos: onPickAllPhotos, onChoosePhotos: onChoosePhotos, + onChooseAllPhotos: onChooseAllPhotos, ), ), _AttachmentSurface.closed || @@ -266,6 +269,101 @@ class _ComposeDraftPayload { } } +enum _PendingAttachmentKind { image, video, file } + +@immutable +class _PendingAttachment { + static var _nextId = 0; + + final int id; + final XFile file; + final _PendingAttachmentKind kind; + final bool deleteAfterUse; + + _PendingAttachment({ + required this.file, + required this.kind, + this.deleteAfterUse = false, + }) : id = _nextId++; +} + +Future _deleteXFile(XFile file) async { + final path = file.path; + if (path.isEmpty) return; + try { + await File(path).delete(); + } on FileSystemException { + // Temporary files may already have been removed by the operating system. + } +} + +Future _deleteOwnedAttachments( + Iterable<_PendingAttachment> attachments, +) async { + for (final attachment in attachments) { + if (attachment.deleteAfterUse) await _deleteXFile(attachment.file); + } +} + +void _useOwnedAttachmentCleanup( + ValueNotifier> attachments, +) { + useEffect( + () => + () => unawaited(_deleteOwnedAttachments(attachments.value)), + [attachments], + ); +} + +void _removePendingAttachment( + ValueNotifier> attachments, + ObjectRef draftRevision, + int id, +) { + draftRevision.value += 1; + final removed = attachments.value.where((attachment) => attachment.id == id); + attachments.value = _withoutAttachment(attachments.value, id); + unawaited(_deleteOwnedAttachments(removed)); +} + +Future _retainAndQueueImages( + BuildContext context, + List images, + void Function(List, {bool deleteAfterUse}) queueImages, +) async { + final retained = await retainTemporaryImages(images); + if (!context.mounted) { + for (final image in retained) { + await _deleteXFile(image); + } + return; + } + queueImages(retained, deleteAfterUse: true); +} + +Future _uploadPendingAttachment( + MediaUploadService service, + _PendingAttachment attachment, { + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, +}) => switch (attachment.kind) { + _PendingAttachmentKind.image => service.uploadImage( + attachment.file, + onProgress: onProgress, + cancellationToken: cancellationToken, + ), + _PendingAttachmentKind.video => service.uploadVideo( + attachment.file, + onProgress: onProgress, + cancellationToken: cancellationToken, + ), + _PendingAttachmentKind.file => service.uploadFile( + attachment.file, + onProgress: onProgress, + cancellationToken: cancellationToken, + ), +}; + class _AttachmentTrigger extends StatelessWidget { final _AttachmentSurface surface; final bool formattingOpen; @@ -451,26 +549,21 @@ class _AttachmentMenuItem extends StatelessWidget { } } -List _withoutAttachment( - List attachments, - String url, +List<_PendingAttachment> _withoutAttachment( + List<_PendingAttachment> attachments, + int id, ) { return [ for (final attachment in attachments) - if (attachment.url != url) attachment, + if (attachment.id != id) attachment, ]; } class _AttachmentStrip extends StatelessWidget { - final List attachments; - final int uploadingCount; - final void Function(String url) onRemove; + final List<_PendingAttachment> attachments; + final ValueChanged onRemove; - const _AttachmentStrip({ - required this.attachments, - required this.uploadingCount, - required this.onRemove, - }); + const _AttachmentStrip({required this.attachments, required this.onRemove}); @override Widget build(BuildContext context) { @@ -481,70 +574,12 @@ class _AttachmentStrip extends StatelessWidget { height: thumbHeight, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: attachments.length + (uploadingCount > 0 ? 1 : 0), + itemCount: attachments.length, separatorBuilder: (_, _) => const SizedBox(width: Grid.half), itemBuilder: (context, index) { - if (index == attachments.length) { - final label = uploadingCount == 1 - ? 'Uploading attachment…' - : 'Uploading $uploadingCount attachments…'; - return Semantics( - excludeSemantics: true, - liveRegion: true, - label: label, - child: Container( - key: const ValueKey('compose-upload-progress'), - width: thumbWidth, - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Stack( - alignment: Alignment.center, - children: [ - BuzzLoadingIndicator( - size: 34, - color: context.colors.primary, - semanticLabel: label, - ), - 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, - ), - ), - ), - ), - ], - ), - ), - ); - } - final attachment = attachments[index]; - final isVideo = attachment.type.startsWith('video/'); - final isImage = attachment.type.startsWith('image/'); - final previewUrl = attachment.thumb ?? attachment.url; return Container( - key: ValueKey('compose-attachment:${attachment.url}'), + key: ValueKey('compose-attachment:${attachment.id}'), width: thumbWidth, decoration: BoxDecoration( borderRadius: BorderRadius.circular(Radii.md), @@ -555,7 +590,7 @@ class _AttachmentStrip extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(Radii.md), - child: isVideo + child: attachment.kind == _PendingAttachmentKind.video ? ColoredBox( color: Colors.black, child: Center( @@ -566,9 +601,10 @@ class _AttachmentStrip extends StatelessWidget { ), ), ) - : isImage - ? MediaImage( - url: previewUrl, + : attachment.kind == _PendingAttachmentKind.image && + attachment.file.path.isNotEmpty + ? Image.file( + File(attachment.file.path), fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox( color: context.colors.surface, @@ -578,6 +614,8 @@ class _AttachmentStrip extends StatelessWidget { ), ), ) + : attachment.kind == _PendingAttachmentKind.image + ? _MemoryAttachmentImage(file: attachment.file) : ColoredBox( color: context.colors.surface, child: Padding( @@ -591,7 +629,9 @@ class _AttachmentStrip extends StatelessWidget { ), const SizedBox(height: Grid.quarter), Text( - attachment.filename ?? 'File', + attachment.file.name.isEmpty + ? 'File' + : attachment.file.name, maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, @@ -612,7 +652,7 @@ class _AttachmentStrip extends StatelessWidget { height: 24, child: IconButton( onPressed: () => - _runComposerAction(() => onRemove(attachment.url)), + _runComposerAction(() => onRemove(attachment.id)), tooltip: 'Remove attachment', visualDensity: VisualDensity.compact, style: IconButton.styleFrom( @@ -640,3 +680,31 @@ class _AttachmentStrip extends StatelessWidget { ); } } + +class _MemoryAttachmentImage extends HookConsumerWidget { + final XFile file; + + const _MemoryAttachmentImage({required this.file}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bytes = useFuture(useMemoized(() => file.readAsBytes(), [file])); + final data = bytes.data; + + if (data == null || data.isEmpty) { + return ColoredBox( + color: context.colors.surface, + child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant), + ); + } + + return Image.memory( + data, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: context.colors.surface, + child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant), + ), + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart new file mode 100644 index 000000000..3cac205ab --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -0,0 +1,992 @@ +part of '../compose_bar.dart'; + +/// Rich compose bar with @mention autocomplete and a markdown formatting +/// toolbar. Used in both channel and thread views — the caller provides an +/// [onSend] callback that handles actual message submission. +typedef ComposeBarOnSend = + Future Function( + String content, + List mentionPubkeys, { + List> mediaTags, + }); + +class ComposeBar extends HookConsumerWidget { + final String channelId; + final String channelName; + final String? hintText; + final ComposeBarOnSend onSend; + + /// Optional thread IDs for thread-scoped typing indicators. + final String? threadHeadId; + final String? rootId; + const ComposeBar({ + super.key, + required this.channelId, + this.channelName = '', + this.hintText, + this.threadHeadId, + this.rootId, + required this.onSend, + }); + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useMemoized(_MarkdownEditingController.new); + useListenable(controller); + useEffect(() => controller.dispose, [controller]); + // Restore and persist unsent text as a local draft so the Activity + // inbox Drafts filter reflects real composer state. + // + // The effect is additionally keyed on the active relay + pubkey identity: + // provider-level namespacing alone cannot protect a composer that stays + // mounted through an in-place community/account switch — the controller + // would retain the old identity's text and the next edit would persist it + // into the new identity's store. On identity change we replace the + // controller content with the new identity's own saved draft (or clear). + final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); + final draftRevision = useRef(0); + final draftIdentity = + '${ref.watch(relayConfigProvider).baseUrl}' + ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; + final focusNode = useFocusNode(); + useEffect( + () => + () => _dismissComposerKeyboard(focusNode), + [focusNode], + ); + final isComposerExpanded = useState(false); + final isEmojiPickerOpen = 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>([]); + _useOwnedAttachmentCleanup(attachments); + final uploadError = useState(null); + final uploadingCount = useState(0); + final uploadProgress = useState(0.0); + final uploadGeneration = useRef(0); + final activeUploadCancellation = useRef(null); + _useComposeDraftLifecycle( + ref: ref, + controller: controller, + draftKey: draftKey, + channelId: channelId, + threadHeadId: threadHeadId, + draftIdentity: draftIdentity, + draftRevision: draftRevision, + attachments: attachments, + uploadGeneration: uploadGeneration, + activeUploadCancellation: activeUploadCancellation, + uploadingCount: uploadingCount, + isSending: isSending, + attachmentSurface: attachmentSurface, + uploadError: uploadError, + iosAttachmentPopover: iosAttachmentPopover, + ); + final clipboardHasImage = useState(false); + final hasAttachments = attachments.value.isNotEmpty; + final customEmoji = ref.watch(customEmojiListProvider); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final composerExpansionController = useAnimationController( + initialValue: 0, + upperBound: 1.05, + ); + final composerExpansionValue = useAnimation(composerExpansionController); + final composerExpansionProgress = composerExpansionValue + .clamp(0.0, 1.0) + .toDouble(); + + void collapseComposer() { + if (!isComposerExpanded.value) return; + showFormatting.value = false; + isComposerExpanded.value = false; + } + + useEffect(() { + void collapseWhenUnfocused() { + if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { + collapseComposer(); + } + } + + focusNode.addListener(collapseWhenUnfocused); + return () => focusNode.removeListener(collapseWhenUnfocused); + }, [focusNode]); + + final appView = View.of(context); + useEffect(() { + final observer = _ComposerKeyboardMetricsObserver( + view: appView, + onKeyboardHidden: () { + collapseComposer(); + focusNode.unfocus(); + }, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [appView, focusNode]); + final resolvedHint = + hintText ?? + (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + useEffect(() { + final target = isComposerExpanded.value ? 1.0 : 0.0; + if (reducedMotion) { + composerExpansionController.value = target; + } else if ((composerExpansionController.value - target).abs() > 0.001) { + composerExpansionController.animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 220), + bounce: 0.08, + ), + composerExpansionController.value, + target, + 0, + snapToEnd: true, + ), + ); + } + return null; + }, [isComposerExpanded.value, reducedMotion]); + useEffect(() { + if (defaultTargetPlatform != TargetPlatform.iOS) return null; + + var disposed = false; + Future refreshClipboardAvailability() async { + final hasImage = await ref + .read(mediaUploadServiceProvider) + .clipboardHasImage(); + if (!disposed && context.mounted) { + clipboardHasImage.value = hasImage; + } + } + + void refreshWhenFocused() { + if (focusNode.hasFocus) refreshClipboardAvailability(); + } + + final lifecycleListener = AppLifecycleListener( + onResume: refreshClipboardAvailability, + ); + focusNode.addListener(refreshWhenFocused); + refreshClipboardAvailability(); + return () { + disposed = true; + focusNode.removeListener(refreshWhenFocused); + lifecycleListener.dispose(); + }; + }, [focusNode]); + + // Mention state -------------------------------------------------------- + final mentionQuery = useState(null); + final mentionStartIdx = useState(-1); + // Map of displayName → selected mention candidate built as the user selects + // mentions. Used to pass resolved pubkeys directly to onSend and to attach + // selected non-member agents before the message is published. + final mentionMap = useRef({}); + + // Channel autocomplete state ---------------------------------------------- + final channelQuery = useState(null); + final channelStartIdx = useState(-1); + final channelsAsync = ref.watch(channelsProvider); + + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final currentPubkey = ref.watch(currentPubkeyProvider); + final userCache = ref.watch(userCacheProvider); + final isDmChannel = + channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ?? + false; + + // Preload profiles for channel members, mentionable agents, and their + // owners so @mention suggestions show names ("managed by …" included). + final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; + final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + final agentMentionLabels = _agentMentionLabels( + candidates: mentionMap.value.values, + ); + final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( + '\u0000', + ); + useEffect(() { + controller.setAgentMentionNames(agentMentionLabels); + return null; + }, [controller, agentMentionLabelsKey]); + useEffect( + () { + final memberList = membersAsync.asData?.value ?? []; + final pubkeys = [ + ...memberList.map((m) => m.pubkey), + ...?relayAgents?.map((a) => a.pubkey), + ...?agentOwners?.values, + ]; + if (pubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(pubkeys); + } + return null; + }, + [ + membersAsync.asData?.value.length, + relayAgents?.length, + agentOwners?.length, + ], + ); + + // Typing indicator broadcast — throttled to one event per 3 seconds. + final lastTypingSentMs = useRef(0); + final isModifyingText = useRef(false); + + // Detect @mention query and broadcast typing on text / selection change. + useEffect(() { + void listener() { + if (isModifyingText.value) return; + final text = controller.text; + final sel = controller.selection; + + // Broadcast typing indicator (throttled). + if (text.isNotEmpty) { + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastTypingSentMs.value > _typingThrottleMs) { + lastTypingSentMs.value = now; + _sendTypingIndicator( + ref, + channelId: channelId, + threadHeadId: threadHeadId, + rootId: rootId, + ); + } + } + + if (!sel.isValid || !sel.isCollapsed) { + mentionQuery.value = null; + channelQuery.value = null; + return; + } + final cursor = sel.baseOffset; + if (cursor < 1) { + mentionQuery.value = null; + channelQuery.value = null; + return; + } + + // Walk backward from cursor looking for trigger characters. + // stopAtSpace: false — @mentions support multi-word display names. + final atPos = findTrigger(text, cursor, '@', stopAtSpace: false); + + if (atPos != null) { + mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase(); + mentionStartIdx.value = atPos; + channelQuery.value = null; + } else { + mentionQuery.value = null; + } + + // Channel autocomplete detection — only when no @mention is active. + if (mentionQuery.value == null) { + final hashPos = findTrigger(text, cursor, '#'); + if (hashPos != null) { + channelQuery.value = text + .substring(hashPos + 1, cursor) + .toLowerCase(); + channelStartIdx.value = hashPos; + } else { + channelQuery.value = null; + } + } else { + channelQuery.value = null; + } + } + + controller.addListener(listener); + return () => controller.removeListener(listener); + }, [controller]); + + // Ranked mention candidates (desktop-parity ordering + eligibility). + final suggestions = mentionQuery.value == null + ? const [] + : ref + .watch( + mentionCandidatesProvider(( + channelId: channelId, + query: mentionQuery.value!, + )), + ) + .take(_mentionSuggestionLimit) + .toList(); + + // Resolve owner names for the visible "managed by …" subtitles. + useEffect(() { + final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey]; + if (ownerPubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(ownerPubkeys); + } + return null; + }, [suggestions.length, mentionQuery.value]); + + // Filter channels against the query. + final channels = channelsAsync.asData?.value ?? []; + final channelSuggestions = filterChannels(channels, channelQuery.value); + + // Insert a selected mention into the text field. + void insertMention(MentionCandidate candidate) { + final name = candidate.label; + // Track the resolved candidate so we can pass its pubkey and prepare + // selected non-member agents at send time. + mentionMap.value[name] = candidate; + + final start = mentionStartIdx.value.clamp(0, controller.text.length); + spliceAndMoveCursor( + controller, + focusNode, + start: start, + replacement: '@$name ', + ); + mentionQuery.value = null; + } + + // Insert a selected channel into the text field. + void insertChannel(Channel channel) { + final start = channelStartIdx.value.clamp(0, controller.text.length); + spliceAndMoveCursor( + controller, + focusNode, + start: start, + replacement: '#${channel.name} ', + ); + channelQuery.value = null; + } + + // Insert `@` at the cursor to manually trigger mention mode. + void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@'); + + // Insert `#` at the cursor to manually trigger channel mode. + void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#'); + + // Insert a selected emoji at the cursor without replacing the draft. + void insertEmoji(String emoji) { + final text = controller.text; + final selection = controller.selection; + final cursor = selection.isValid + ? selection.baseOffset.clamp(0, text.length) + : text.length; + controller.value = TextEditingValue( + text: text.replaceRange(cursor, cursor, emoji), + selection: TextSelection.collapsed(offset: cursor + emoji.length), + ); + focusNode.requestFocus(); + } + + void clearComposer() { + draftRevision.value += 1; + controller.clear(); + attachments.value = []; + mentionMap.value.clear(); + mentionQuery.value = null; + channelQuery.value = null; + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = false; + uploadError.value = null; + focusNode.requestFocus(); + } + + void removeAttachment(int id) { + _removePendingAttachment(attachments, draftRevision, id); + } + + // Send the message. + Future send() async { + final text = controller.text.trim(); + if ((text.isEmpty && !hasAttachments) || + isSending.value || + uploadingCount.value > 0) { + return; + } + // Resolved before any await: see + // `_reportSendCancelledByCommunitySwitch`. + final messenger = ScaffoldMessenger.maybeOf(context); + + // Extract pubkeys for mentions present in the final text. + final selectedMentions = [ + for (final entry in mentionMap.value.entries) + if (hasMention(text, entry.key)) entry.value, + ]; + final pubkeys = LinkedHashSet.from( + selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); + final nonMemberAgentPubkeys = []; + final nonMemberHumans = []; + if (selectedMentions.isNotEmpty) { + final currentChannel = (await ref.read( + channelsProvider.future, + )).firstWhere((channel) => channel.id == channelId); + if (!currentChannel.isDm) { + final memberPubkeys = (await ref.read( + channelMembersProvider(channelId).future, + )).map((member) => member.pubkey.toLowerCase()).toSet(); + final seenNonMembers = {}; + for (final candidate in selectedMentions) { + final pk = candidate.pubkey.toLowerCase(); + if (memberPubkeys.contains(pk)) continue; + if (!seenNonMembers.add(pk)) continue; + if (candidate.isAgent) { + nonMemberAgentPubkeys.add(pk); + } else { + nonMemberHumans.add(candidate); + } + } + } + } + + // Mentioning humans outside the channel prompts "Invite" / "Do + // nothing" (send without inviting) — mirrors desktop's + // NonMemberMentionDialog. Agents keep the existing silent auto-add. + var mentionPubkeys = pubkeys; + final referenceMentionTags = >[]; + var inviteHumanPubkeys = const []; + if (nonMemberHumans.isNotEmpty) { + if (!context.mounted) return; + final choice = await _promptNonMemberMention( + context, + names: [for (final candidate in nonMemberHumans) candidate.label], + ); + switch (choice) { + case null: + return; // Dismissed — keep the draft, send nothing. + case _NonMemberMentionChoice.invite: + inviteHumanPubkeys = [ + for (final candidate in nonMemberHumans) + candidate.pubkey.toLowerCase(), + ]; + case _NonMemberMentionChoice.sendWithoutInviting: + // Strip their p-tags (no channel notification) but keep a + // `mention` reference tag so their name still renders — + // mirrors desktop's mergeOutgoingTagsWithReferenceMentions. + final excluded = { + for (final candidate in nonMemberHumans) + candidate.pubkey.toLowerCase(), + }; + mentionPubkeys = [ + for (final pk in pubkeys) + if (!excluded.contains(pk)) pk, + ]; + referenceMentionTags.addAll([ + for (final pk in excluded) ['mention', pk], + ]); + } + } + + final queuedAttachments = List<_PendingAttachment>.of(attachments.value); + final channelActions = ref.read(channelActionsProvider); + + Future addMentionedNonMembers() => _addMentionedNonMembers( + channelActions, + channelId: channelId, + agentPubkeys: nonMemberAgentPubkeys, + humanPubkeys: inviteHumanPubkeys, + ); + + isSending.value = true; + try { + if (queuedAttachments.isEmpty) { + try { + await addMentionedNonMembers(); + final payload = _ComposeDraftPayload.fromDraft( + text: text, + attachments: const [], + customEmoji: customEmoji, + ); + await onSend( + payload.content, + mentionPubkeys, + mediaTags: [...payload.mediaTags, ...referenceMentionTags], + ); + if (context.mounted) clearComposer(); + } on StateError { + _reportSendCancelledByCommunitySwitch(messenger); + } + return; + } + + final draftText = controller.value; + final draftAttachments = List<_PendingAttachment>.of(attachments.value); + final draftMentions = Map.of( + mentionMap.value, + ); + clearComposer(); + final clearedDraftRevision = draftRevision.value; + uploadingCount.value += 1; + uploadProgress.value = 0; + isSending.value = false; + final queueGeneration = uploadGeneration.value; + final cancellation = UploadCancellationToken(); + final uploadService = ref.read(mediaUploadServiceProvider); + activeUploadCancellation.value = cancellation; + final delivery = onSend; + unawaited(() async { + var retainedForRetry = false; + try { + final uploaded = []; + for (var index = 0; index < queuedAttachments.length; index++) { + final attachment = queuedAttachments[index]; + final descriptor = await _uploadPendingAttachment( + uploadService, + attachment, + onProgress: (progress) { + if (context.mounted) { + uploadProgress.value = + (index + progress) / queuedAttachments.length; + } + }, + cancellationToken: cancellation, + ); + if (queueGeneration != uploadGeneration.value) return; + uploaded.add(descriptor); + if (context.mounted) { + uploadProgress.value = (index + 1) / queuedAttachments.length; + } + } + final payload = _ComposeDraftPayload.fromDraft( + text: text, + attachments: uploaded, + customEmoji: customEmoji, + ); + if (queueGeneration != uploadGeneration.value) return; + await addMentionedNonMembers(); + if (queueGeneration != uploadGeneration.value) return; + await delivery( + payload.content, + mentionPubkeys, + mediaTags: [...payload.mediaTags, ...referenceMentionTags], + ); + } catch (error) { + if (cancellation.isCancelled) return; + if (context.mounted) uploadError.value = _formatUploadError(error); + if (context.mounted && + queueGeneration == uploadGeneration.value && + draftRevision.value == clearedDraftRevision) { + controller.value = draftText; + attachments.value = draftAttachments; + retainedForRetry = true; + mentionMap.value + ..clear() + ..addAll(draftMentions); + focusNode.requestFocus(); + } + } finally { + if (!retainedForRetry) { + await _deleteOwnedAttachments(queuedAttachments); + } + if (activeUploadCancellation.value == cancellation) { + activeUploadCancellation.value = null; + } + if (context.mounted && queueGeneration == uploadGeneration.value) { + uploadingCount.value = math.max(0, uploadingCount.value - 1); + } + } + }()); + } finally { + if (context.mounted && isSending.value) isSending.value = false; + } + } + + void queueAttachment( + XFile file, + _PendingAttachmentKind kind, { + bool deleteAfterUse = false, + }) { + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + _PendingAttachment( + file: file, + kind: kind, + deleteAfterUse: deleteAfterUse, + ), + ]; + } + + Future pickThenQueue({ + required Future Function() pick, + required _PendingAttachmentKind kind, + }) async { + uploadError.value = null; + try { + final picked = await pick(); + if (picked == null || !context.mounted) return; + queueAttachment(picked, kind); + } catch (error) { + if (context.mounted) { + uploadError.value = _formatUploadError(error); + } + } + } + + void queueImages(List images, {bool deleteAfterUse = false}) { + if (images.isEmpty) return; + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + for (final image in images) + _PendingAttachment( + file: image, + kind: _PendingAttachmentKind.image, + deleteAfterUse: deleteAfterUse, + ), + ]; + } + + Future retainAndQueueImages(List images) => + _retainAndQueueImages(context, images, queueImages); + + Widget buildContextMenu( + BuildContext context, + EditableTextState editableTextState, + ) { + void pasteImage() { + ContextMenuController.removeAny(); + unawaited(() async { + try { + final image = await ref + .read(mediaUploadServiceProvider) + .readClipboardImage(); + if (image != null && context.mounted) { + queueAttachment(image, _PendingAttachmentKind.image); + } else if (context.mounted) { + uploadError.value = 'Unable to read pasted image'; + } + } catch (error) { + if (context.mounted) uploadError.value = _formatUploadError(error); + } + }()); + } + + if (defaultTargetPlatform == TargetPlatform.iOS && + SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + items: [ + if (clipboardHasImage.value) + IOSSystemContextMenuItemCustom( + title: 'Paste Image', + onPressed: pasteImage, + ), + ...SystemContextMenu.getDefaultItems(editableTextState), + ], + ); + } + + final buttonItems = [...editableTextState.contextMenuButtonItems]; + if (defaultTargetPlatform == TargetPlatform.iOS && + clipboardHasImage.value) { + buttonItems.insert( + 0, + ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + + void uploadPastedImage(KeyboardInsertedContent content) { + final bytes = content.data; + if (bytes == null || bytes.isEmpty) { + uploadError.value = 'Unable to read pasted image'; + return; + } + + queueAttachment( + XFile.fromData(bytes, name: 'Pasted image'), + _PendingAttachmentKind.image, + ); + } + + // Wrap (or insert) markdown formatting around the current selection. + void applyFormat(String prefix, [String? suffix]) { + suffix ??= prefix; + final text = controller.text; + final sel = controller.selection; + if (!sel.isValid) return; + + isModifyingText.value = true; + try { + if (sel.isCollapsed) { + final offset = sel.baseOffset; + final updated = + '${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: offset + prefix.length, + ); + } else { + final selected = text.substring(sel.start, sel.end); + final updated = + '${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: sel.start + prefix.length + selected.length + suffix.length, + ); + } + } finally { + isModifyingText.value = false; + } + focusNode.requestFocus(); + } + + // ----- Widget tree ---------------------------------------------------- + + 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() { + 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; + } + + unawaited( + iosAttachmentPopover + .present( + sourceContext: triggerContext, + onCapture: (image) => retainAndQueueImages([image]), + onChoosePhotos: retainAndQueueImages, + onAllPhotos: () => chooseAttachment(() async { + final photos = await ref + .read(mediaUploadServiceProvider) + .pickGalleryImages(); + queueImages(photos); + }, errorMessage: 'Unable to open your photo library.'), + onVideo: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenQueue( + pick: service.pickGalleryVideo, + kind: _PendingAttachmentKind.video, + ); + }), + onFiles: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenQueue( + pick: service.pickAttachmentFile, + kind: _PendingAttachmentKind.file, + ); + }), + ) + .then((didPresent) { + if (!didPresent && context.mounted) { + focusNode.unfocus(); + toggleAttachments(); + } + }), + ); + } + + void openCamera() { + focusNode.unfocus(); + attachmentSurface.value = _AttachmentSurface.camera; + } + + final motionDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + attachmentSurface.value == _AttachmentSurface.camera || + attachmentSurface.value == _AttachmentSurface.photos + ? 320 + : 250, + ); + final suggestionOverlayController = useMemoized( + OverlayPortalController.new, + ); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) suggestionOverlayController.show(); + }); + return null; + }, [suggestionOverlayController]); + + void expandComposer() { + if (isComposerExpanded.value) return; + attachmentSurface.value = _AttachmentSurface.closed; + isComposerExpanded.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) focusNode.requestFocus(); + }); + } + + final suggestionPanel = channelSuggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('channel-suggestions'), + child: _ChannelSuggestions( + suggestions: channelSuggestions, + onSelect: insertChannel, + ), + ) + : suggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('mention-suggestions'), + child: _MentionSuggestions( + suggestions: suggestions, + userCache: userCache, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + onSelect: insertMention, + ), + ) + : const SizedBox.shrink(key: ValueKey('no-suggestions')); + 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 pickThenQueue( + pick: service.pickGalleryVideo, + kind: _PendingAttachmentKind.video, + ); + }), + onFiles: () => chooseAttachment(() { + final service = ref.read(mediaUploadServiceProvider); + return pickThenQueue( + pick: service.pickAttachmentFile, + kind: _PendingAttachmentKind.file, + ); + }), + onCapture: (image) async { + attachmentSurface.value = _AttachmentSurface.closed; + await retainAndQueueImages([image]); + }, + onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages, + onChoosePhotos: (photos) async { + attachmentSurface.value = _AttachmentSurface.closed; + await retainAndQueueImages(photos); + }, + onChooseAllPhotos: (photos) async { + attachmentSurface.value = _AttachmentSurface.closed; + queueImages(photos); + }, + ); + } + + // Suggestions and attachments live in the overlay so showing them cannot + // reflow the composer. Both stay anchored just above the capsule. + final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; + final hasPendingUploads = uploadingCount.value > 0; + return _ComposerDockFrame( + widthFactor: composerWidthFactor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _UploadProgressMotion( + visible: hasPendingUploads, + progress: uploadProgress.value, + reducedMotion: reducedMotion, + onCancel: () { + activeUploadCancellation.value?.cancel(); + uploadGeneration.value += 1; + uploadingCount.value = 0; + uploadProgress.value = 0; + }, + ), + _ComposerOverlayPortal( + controller: suggestionOverlayController, + attachmentSurface: attachmentSurface, + reducedMotion: reducedMotion, + buildOverlayPanel: buildOverlayPanel, + onDismissAttachmentSurface: () { + attachmentSurface.value = _AttachmentSurface.closed; + }, + child: _ComposeBarLayout( + attachments: attachments.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; + isEmojiPickerOpen.value = true; + _showComposerEmojiPicker(context, insertEmoji, () { + if (!context.mounted) return; + isEmojiPickerOpen.value = false; + focusNode.requestFocus(); + }); + }, + onOpenFormatting: () { + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = true; + }, + hasPendingUploads: hasPendingUploads, + canSend: controller.text.trim().isNotEmpty || hasAttachments, + isSending: isSending.value, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart new file mode 100644 index 000000000..88d1c8eda --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -0,0 +1,60 @@ +part of '../compose_bar.dart'; + +void _useComposeDraftLifecycle({ + required WidgetRef ref, + required _MarkdownEditingController controller, + required String draftKey, + required String channelId, + required String? threadHeadId, + required String draftIdentity, + required ObjectRef draftRevision, + required ValueNotifier> attachments, + required ObjectRef uploadGeneration, + required ObjectRef activeUploadCancellation, + required ValueNotifier uploadingCount, + required ValueNotifier isSending, + required ValueNotifier<_AttachmentSurface> attachmentSurface, + required ValueNotifier uploadError, + required _IOSAttachmentPopoverController iosAttachmentPopover, +}) { + final lastDraftIdentity = useRef(null); + useEffect(() { + final identityChanged = + lastDraftIdentity.value != null && + lastDraftIdentity.value != draftIdentity; + lastDraftIdentity.value = draftIdentity; + final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); + if (identityChanged) { + draftRevision.value += 1; + uploadGeneration.value += 1; + activeUploadCancellation.value?.cancel(); + activeUploadCancellation.value = null; + uploadingCount.value = 0; + isSending.value = false; + attachmentSurface.value = _AttachmentSurface.closed; + uploadError.value = null; + unawaited(iosAttachmentPopover.dispose()); + final staleAttachments = attachments.value; + attachments.value = const []; + unawaited(_deleteOwnedAttachments(staleAttachments)); + controller.text = saved ?? ''; + } else if (saved != null && controller.text.isEmpty) { + controller.text = saved; + } + + void persistDraft() { + draftRevision.value += 1; + ref + .read(composeDraftsProvider.notifier) + .save( + key: draftKey, + channelId: channelId, + threadHeadId: threadHeadId, + text: controller.text, + ); + } + + controller.addListener(persistDraft); + return () => controller.removeListener(persistDraft); + }, [controller, draftKey, draftIdentity]); +} diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index c09815538..c630accdf 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -219,3 +219,40 @@ void _sendTypingIndicator( // Fire-and-forget — typing indicator failure is non-fatal. } } + +/// Reports a send that was cancelled because the active community changed. +/// +/// The send path is fire-and-forget, so a `StateError` escaping it would be +/// silent. The composer's own error line cannot carry this message either: the +/// identity change that causes the failure also resets that state on the next +/// frame, so [messenger] must be resolved before the send's first `await`. +void _reportSendCancelledByCommunitySwitch(ScaffoldMessengerState? messenger) { + messenger?.showSnackBar( + const SnackBar(content: Text('Message not sent: the community changed')), + ); +} + +/// Adds mentioned non-members to the channel before a send. +/// +/// Agents are added silently with the `bot` role; humans are only passed here +/// after they have been explicitly invited from the mention prompt. +Future _addMentionedNonMembers( + ChannelActions channelActions, { + required String channelId, + required List agentPubkeys, + required List humanPubkeys, +}) async { + if (agentPubkeys.isNotEmpty) { + await channelActions.addMembers( + channelId: channelId, + pubkeys: agentPubkeys, + role: 'bot', + ); + } + if (humanPubkeys.isNotEmpty) { + await channelActions.addMembers( + channelId: channelId, + pubkeys: humanPubkeys, + ); + } +} 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 ade2bec94..0548d73fd 100644 --- a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -9,12 +9,14 @@ class _IOSInlinePhotoPicker extends HookWidget { final VoidCallback onBack; final Future> Function() onPickAllPhotos; final Future Function(List photos) onChoosePhotos; + final Future Function(List photos) onChooseAllPhotos; final Widget fallback; const _IOSInlinePhotoPicker({ required this.onBack, required this.onPickAllPhotos, required this.onChoosePhotos, + required this.onChooseAllPhotos, required this.fallback, }); @@ -97,7 +99,7 @@ class _IOSInlinePhotoPicker extends HookWidget { try { final photos = await onPickAllPhotos(); if (photos.isNotEmpty) { - await onChoosePhotos(photos); + await onChooseAllPhotos(photos); } } catch (_) { if (context.mounted) { diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index e0adb9621..029a4368e 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -1,9 +1,8 @@ part of '../compose_bar.dart'; class _ComposeBarLayout extends StatelessWidget { - final List attachments; - final int uploadingCount; - final ValueChanged onRemoveAttachment; + final List<_PendingAttachment> attachments; + final ValueChanged onRemoveAttachment; final String? uploadError; final bool isExpanded; final TextEditingController controller; @@ -31,7 +30,6 @@ class _ComposeBarLayout extends StatelessWidget { const _ComposeBarLayout({ required this.attachments, - required this.uploadingCount, required this.onRemoveAttachment, required this.uploadError, required this.isExpanded, @@ -85,10 +83,9 @@ class _ComposeBarLayout extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (attachments.isNotEmpty || hasPendingUploads) ...[ + if (attachments.isNotEmpty) ...[ _AttachmentStrip( attachments: attachments, - uploadingCount: uploadingCount, onRemove: onRemoveAttachment, ), const SizedBox(height: Grid.xxs), 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 598587406..d40fd93a2 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -4,11 +4,13 @@ class _PhotoGalleryPicker extends StatelessWidget { final VoidCallback onBack; final Future> Function() onPickAllPhotos; final Future Function(List photos) onChoosePhotos; + final Future Function(List photos) onChooseAllPhotos; const _PhotoGalleryPicker({ required this.onBack, required this.onPickAllPhotos, required this.onChoosePhotos, + required this.onChooseAllPhotos, }); @override @@ -17,12 +19,14 @@ class _PhotoGalleryPicker extends StatelessWidget { onBack: onBack, onPickAllPhotos: onPickAllPhotos, onChoosePhotos: onChoosePhotos, + onChooseAllPhotos: onChooseAllPhotos, ); if (defaultTargetPlatform != TargetPlatform.iOS) return fallback; return _IOSInlinePhotoPicker( onBack: onBack, onPickAllPhotos: onPickAllPhotos, onChoosePhotos: onChoosePhotos, + onChooseAllPhotos: onChooseAllPhotos, fallback: fallback, ); } @@ -32,11 +36,13 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { final VoidCallback onBack; final Future> Function() onPickAllPhotos; final Future Function(List photos) onChoosePhotos; + final Future Function(List photos) onChooseAllPhotos; const _RecentPhotoGalleryPicker({ required this.onBack, required this.onPickAllPhotos, required this.onChoosePhotos, + required this.onChooseAllPhotos, }); @override @@ -73,7 +79,9 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { .read(photoLibraryProvider) .resolveSelectedPhotos(selection.value); if (photos.isNotEmpty && context.mounted) { - await onChoosePhotos(photos); + await (selection.value.isEmpty ? onChooseAllPhotos : onChoosePhotos)( + photos, + ); } } catch (_) { if (context.mounted) { diff --git a/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart b/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart new file mode 100644 index 000000000..23f381b82 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart @@ -0,0 +1,197 @@ +part of '../compose_bar.dart'; + +class _UploadProgressMotion extends StatelessWidget { + final bool visible; + final double progress; + final bool reducedMotion; + final VoidCallback onCancel; + + const _UploadProgressMotion({ + required this.visible, + required this.progress, + required this.reducedMotion, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + key: const ValueKey('compose-upload-progress-motion'), + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 220), + reverseDuration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180), + transitionBuilder: (child, animation) { + final motion = CurvedAnimation( + parent: animation, + curve: Curves.easeInCubic, + reverseCurve: Curves.easeOutCubic, + ); + return ClipRect( + child: SlideTransition( + position: Tween( + begin: const Offset(0, 1.15), + end: Offset.zero, + ).animate(motion), + child: child, + ), + ); + }, + child: visible + ? Padding( + key: const ValueKey('compose-upload-progress-visible'), + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: _UploadProgressPill( + progress: progress, + reducedMotion: reducedMotion, + onCancel: onCancel, + ), + ) + : const SizedBox.shrink( + key: ValueKey('compose-upload-progress-hidden'), + ), + ); + } +} + +/// A lightweight post-send upload status that stays above the composer, so the +/// composer is immediately available for the next message. +class _UploadProgressPill extends HookConsumerWidget { + final double progress; + final bool reducedMotion; + final VoidCallback onCancel; + + const _UploadProgressPill({ + required this.progress, + required this.reducedMotion, + required this.onCancel, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cancelHovered = useState(false); + final clampedProgress = progress.clamp(0.0, 1.0).toDouble(); + final percentage = (clampedProgress * 100).round(); + return Semantics( + liveRegion: true, + label: 'Uploading $percentage%', + child: SizedBox( + key: const ValueKey('compose-upload-progress'), + width: 300, + height: 36, + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + border: Border.all(color: context.colors.outlineVariant), + borderRadius: BorderRadius.circular(Radii.full), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.centerLeft, + child: AnimatedFractionallySizedBox( + key: const ValueKey('compose-upload-progress-fill'), + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + widthFactor: clampedProgress, + heightFactor: 1, + child: ColoredBox( + color: context.colors.primary.withValues( + alpha: context.theme.brightness == Brightness.dark + ? 0.15 + : 0.12, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: Grid.twelve, + right: Grid.half, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Uploading', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox( + width: 36, + child: FittedBox( + alignment: Alignment.centerRight, + fit: BoxFit.scaleDown, + child: Text( + '$percentage%', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: Grid.xxs), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + child: MouseRegion( + onEnter: (_) => cancelHovered.value = true, + onExit: (_) => cancelHovered.value = false, + child: Material( + color: cancelHovered.value + ? context.colors.onSurface.withValues( + alpha: 0.08, + ) + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('compose-upload-cancel'), + onTap: onCancel, + borderRadius: BorderRadius.circular(Radii.full), + child: Padding( + key: const ValueKey( + 'compose-upload-cancel-padding', + ), + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.quarter, + ), + child: Text( + 'Cancel', + style: context.textTheme.labelMedium + ?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index f7e1af921..b5eb308c6 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:io'; 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:http/http.dart' as http; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:video_player/video_player.dart'; import '../../shared/relay/relay.dart'; @@ -17,6 +20,8 @@ export 'media_viewer_hero.dart'; part 'media_viewer_page/image_controls.dart'; part 'media_viewer_page/route_transition.dart'; +part 'media_viewer_page/video_controls.dart'; +part 'media_viewer_page/video_viewer.dart'; const _imageViewerPushDuration = Duration(milliseconds: 260); const _imageViewerPopDuration = Duration(milliseconds: 170); @@ -139,6 +144,7 @@ void openVideoViewer( BuildContext context, { required String videoUrl, String? posterUrl, + VoidCallback? onReply, }) { Navigator.of(context).push( PageRouteBuilder( @@ -149,7 +155,11 @@ void openVideoViewer( ? Duration.zero : _imageViewerPopDuration, pageBuilder: (context, animation, secondaryAnimation) => - MediaVideoViewerPage(videoUrl: videoUrl, posterUrl: posterUrl), + MediaVideoViewerPage( + videoUrl: videoUrl, + posterUrl: posterUrl, + onReply: onReply, + ), transitionsBuilder: (context, animation, secondaryAnimation, child) => _MediaViewerRouteTransition(animation: animation, child: child), ), @@ -654,212 +664,6 @@ class MediaImageViewerPage extends HookConsumerWidget { } } -// StatefulWidget retained: owns a VideoPlayerController with async init and -// disposal — kept imperative deliberately (allowed exception). -class MediaVideoViewerPage extends StatefulWidget { - final String videoUrl; - final String? posterUrl; - - const MediaVideoViewerPage({ - super.key, - required this.videoUrl, - this.posterUrl, - }); - - @override - State createState() => _MediaVideoViewerPageState(); -} - -class _MediaVideoViewerPageState extends State { - late final VideoPlayerController _controller; - late final Future _initializeFuture; - String? _error; - - @override - void initState() { - super.initState(); - _controller = VideoPlayerController.networkUrl( - Uri.parse(widget.videoUrl), - httpHeaders: mediaGetHeadersForContext(context, widget.videoUrl), - ); - _initializeFuture = _controller - .initialize() - .then((_) async { - await _controller.play(); - if (mounted) { - setState(() {}); - } - }) - .catchError((Object error) { - if (mounted) { - setState(() { - _error = error.toString(); - }); - } - }); - } - - @override - void dispose() { - unawaited(_controller.dispose()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - key: const ValueKey('message-media-video-viewer'), - backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: SafeArea( - child: Center( - child: FutureBuilder( - future: _initializeFuture, - builder: (context, snapshot) { - if (_error != null || snapshot.hasError) { - return const _MediaLoadFailure( - message: 'Failed to load video', - icon: LucideIcons.videoOff, - ); - } - - if (!_controller.value.isInitialized) { - return _VideoLoadingPoster(posterUrl: widget.posterUrl); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ), - const SizedBox(height: Grid.sm), - _VideoTransportBar(controller: _controller), - ], - ); - }, - ), - ), - ), - ), - 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, - ), - child: IconButton( - key: const ValueKey('message-media-video-viewer-close'), - onPressed: () => Navigator.of(context).maybePop(), - tooltip: 'Close video viewer', - icon: const Icon(LucideIcons.x, color: Colors.white), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _VideoLoadingPoster extends StatelessWidget { - final String? posterUrl; - - const _VideoLoadingPoster({required this.posterUrl}); - - @override - Widget build(BuildContext context) { - return AspectRatio( - aspectRatio: 16 / 9, - child: Stack( - fit: StackFit.expand, - children: [ - if (posterUrl != null) - MediaImage( - url: posterUrl!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _videoPlaceholder(context), - ) - else - _videoPlaceholder(context), - const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)), - const Center( - child: BuzzLoadingIndicator( - size: 44, - color: Colors.white, - semanticLabel: 'Loading video', - ), - ), - ], - ), - ); - } - - Widget _videoPlaceholder(BuildContext context) { - return ColoredBox( - color: context.colors.surfaceContainerHighest, - child: Icon( - LucideIcons.video, - size: 40, - color: context.colors.onSurfaceVariant, - ), - ); - } -} - -class _VideoTransportBar extends HookWidget { - final VideoPlayerController controller; - - const _VideoTransportBar({required this.controller}); - - @override - Widget build(BuildContext context) { - useListenable(controller); - final value = controller.value; - final durationMs = value.duration.inMilliseconds; - final positionMs = value.position.inMilliseconds.clamp(0, durationMs); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () { - if (value.isPlaying) { - controller.pause(); - } else { - controller.play(); - } - }, - tooltip: value.isPlaying ? 'Pause video' : 'Play video', - icon: Icon( - value.isPlaying ? LucideIcons.pause : LucideIcons.play, - color: Colors.white, - ), - ), - SizedBox( - width: 220, - child: Slider( - value: durationMs == 0 ? 0 : positionMs.toDouble(), - min: 0, - max: durationMs == 0 ? 1 : durationMs.toDouble(), - onChanged: durationMs == 0 - ? null - : (next) => - controller.seekTo(Duration(milliseconds: next.round())), - ), - ), - ], - ); - } -} - class _MediaLoadFailure extends StatelessWidget { final String message; final IconData icon; diff --git a/mobile/lib/features/channels/media_viewer_page/video_controls.dart b/mobile/lib/features/channels/media_viewer_page/video_controls.dart new file mode 100644 index 000000000..996d94ee4 --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page/video_controls.dart @@ -0,0 +1,140 @@ +part of '../media_viewer_page.dart'; + +/// Video transport and message actions, kept visually aligned with the image +/// viewer chrome while the native player owns decoding and playback. +class _VideoViewerBottomControls extends StatelessWidget { + final VideoPlayerController? controller; + final VoidCallback? onReply; + + const _VideoViewerBottomControls({ + required this.controller, + required this.onReply, + }); + + @override + Widget build(BuildContext context) { + final readyController = controller?.value.isInitialized == true + ? controller + : null; + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (readyController != null) + _VideoTransportBar(controller: readyController), + Row( + children: [ + _MediaViewerCircleButton( + key: const ValueKey('message-media-video-viewer-reply-thread'), + icon: LucideIcons.messageSquareReply, + tooltip: 'Reply in thread', + onPressed: onReply, + ), + const Spacer(), + ], + ), + ], + ), + ); + } +} + +class _VideoTransportBar extends HookConsumerWidget { + final VideoPlayerController controller; + + const _VideoTransportBar({required this.controller}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(controller); + final value = controller.value; + final durationMs = value.duration.inMilliseconds; + final positionMs = value.position.inMilliseconds.clamp(0, durationMs); + final hasDuration = durationMs > 0; + + return Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.42), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + borderRadius: BorderRadius.circular(Radii.full), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Row( + children: [ + IconButton( + key: const ValueKey('message-media-video-viewer-play-pause'), + onPressed: () { + if (value.isPlaying) { + controller.pause(); + } else { + controller.play(); + } + }, + tooltip: value.isPlaying ? 'Pause video' : 'Play video', + icon: Icon( + value.isPlaying ? LucideIcons.pause : LucideIcons.play, + color: Colors.white, + ), + ), + Text( + _formatVideoDuration(Duration(milliseconds: positionMs)), + style: context.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white.withValues(alpha: 0.28), + thumbColor: Colors.white, + overlayColor: Colors.white.withValues(alpha: 0.12), + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 5, + ), + ), + child: Slider( + value: hasDuration ? positionMs.toDouble() : 0, + min: 0, + max: hasDuration ? durationMs.toDouble() : 1, + onChanged: hasDuration + ? (next) => controller.seekTo( + Duration(milliseconds: next.round()), + ) + : null, + ), + ), + ), + Text( + _formatVideoDuration(value.duration), + style: context.textTheme.labelSmall?.copyWith( + color: Colors.white.withValues(alpha: 0.78), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: Grid.xxs), + ], + ), + ), + ), + ); + } +} + +String _formatVideoDuration(Duration duration) { + final totalSeconds = duration.inSeconds.clamp(0, 359999); + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + final paddedSeconds = seconds.toString().padLeft(2, '0'); + if (hours > 0) { + return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds'; + } + return '$minutes:$paddedSeconds'; +} diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart new file mode 100644 index 000000000..dc4a82b50 --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -0,0 +1,385 @@ +part of '../media_viewer_page.dart'; + +class MediaVideoViewerPage extends HookConsumerWidget { + final String videoUrl; + final String? posterUrl; + final VoidCallback? onReply; + + static const _dismissThreshold = 100.0; + static const _dismissVelocity = 700.0; + static const _backgroundFadeDivisor = 300.0; + + const MediaVideoViewerPage({ + super.key, + required this.videoUrl, + this.posterUrl, + this.onReply, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final videoFile = useRef(null); + final downloadRequestAbort = useRef?>(null); + final downloadSubscription = useRef>?>(null); + final downloadSink = useRef(null); + final initializeFuture = useState?>(null); + final error = useState(null); + final dragOffset = useState(0.0); + final isDragging = useState(false); + final snapBackController = useAnimationController( + duration: const Duration(milliseconds: 200), + ); + + Future deleteVideoFile() async { + final file = videoFile.value; + videoFile.value = null; + if (file == null) return; + try { + if (await file.exists()) await file.delete(); + } on FileSystemException { + // Temporary storage cleanup should not make closing the viewer fail. + } + } + + useEffect(() { + var disposed = false; + Future initializeVideo() async { + final auth = ref.read(mediaGetAuthServiceProvider); + final uri = Uri.parse(videoUrl); + + // ExoPlayer supports the request headers on every range request, so + // keep Android on its streaming path. iOS uses the authenticated local + // copy below because AVPlayer can drop those headers after the first + // request. + if (Platform.isAndroid) { + VideoPlayerController? streamingController; + try { + streamingController = VideoPlayerController.networkUrl( + uri, + httpHeaders: auth.headersFor(videoUrl), + ); + await streamingController.initialize(); + await streamingController.play(); + if (disposed) { + await streamingController.dispose(); + return; + } + controller.value = streamingController; + return; + } catch (_) { + if (streamingController != null) { + await streamingController.dispose(); + } + // Fall through to the authenticated local-file path only when the + // streaming controller cannot initialize. + } + } + + try { + final client = ref.read(mediaHttpClientProvider); + final requestAbort = Completer(); + downloadRequestAbort.value = requestAbort; + final request = http.AbortableStreamedRequest( + 'GET', + uri, + abortTrigger: requestAbort.future, + )..headers.addAll(auth.headersFor(videoUrl)); + late final http.StreamedResponse response; + try { + response = await client.send(request); + } finally { + if (downloadRequestAbort.value == requestAbort) { + downloadRequestAbort.value = null; + } + } + if (disposed) { + await _cancelVideoResponse(response); + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.drain(); + throw HttpException( + 'Video download failed (${response.statusCode})', + uri: uri, + ); + } + + final responseSubscription = response.stream.listen(null)..pause(); + downloadSubscription.value = responseSubscription; + final directory = await getTemporaryDirectory(); + if (disposed) { + await responseSubscription.cancel(); + if (downloadSubscription.value == responseSubscription) { + downloadSubscription.value = null; + } + return; + } + final file = File( + '${directory.path}${Platform.pathSeparator}' + 'buzz-video-${DateTime.now().microsecondsSinceEpoch}' + '${_videoFileExtension(uri)}', + ); + videoFile.value = file; + final sink = file.openWrite(); + downloadSink.value = sink; + final completed = Completer(); + responseSubscription + ..onData(sink.add) + ..onError((Object error, StackTrace stackTrace) async { + await sink.close(); + if (!completed.isCompleted) { + completed.completeError(error, stackTrace); + } + }) + ..onDone(() async { + await sink.close(); + if (!completed.isCompleted) completed.complete(); + }) + ..resume(); + await completed.future; + downloadSubscription.value = null; + downloadSink.value = null; + if (disposed) { + await deleteVideoFile(); + return; + } + + final localController = VideoPlayerController.file(file); + await localController.initialize(); + await localController.play(); + if (disposed) { + await localController.dispose(); + await deleteVideoFile(); + return; + } + controller.value = localController; + } catch (loadError) { + if (!disposed) error.value = loadError.toString(); + } + } + + initializeFuture.value = initializeVideo(); + return () { + disposed = true; + final activeRequestAbort = downloadRequestAbort.value; + if (activeRequestAbort != null && !activeRequestAbort.isCompleted) { + activeRequestAbort.complete(); + } + unawaited(downloadSubscription.value?.cancel() ?? Future.value()); + unawaited(downloadSink.value?.close() ?? Future.value()); + final activeController = controller.value; + if (activeController != null) unawaited(activeController.dispose()); + unawaited(deleteVideoFile()); + }; + }, [videoUrl]); + + void animateSnapBack() { + isDragging.value = false; + if (MediaQuery.disableAnimationsOf(context)) { + dragOffset.value = 0; + return; + } + 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), + ); + } + + Future replyInThread() async { + final callback = onReply; + if (callback == null) return; + final route = ModalRoute.of(context); + controller.value?.pause(); + await Navigator.of(context).maybePop(); + await route?.completed; + callback(); + } + + final viewportHeight = MediaQuery.sizeOf(context).height; + final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0); + final videoScale = 1 - (dragProgress * 0.1); + final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0); + return Scaffold( + key: const ValueKey('message-media-video-viewer'), + backgroundColor: Colors.black.withValues( + alpha: (1 - (dragOffset.value / _backgroundFadeDivisor)).clamp( + 0.3, + 1.0, + ), + ), + body: Stack( + children: [ + Positioned.fill( + child: Transform.translate( + offset: Offset(0, dragOffset.value), + child: Transform.scale( + scale: videoScale, + child: GestureDetector( + key: const ValueKey('message-media-video-viewer-gesture'), + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (_) { + snapBackController.stop(); + isDragging.value = true; + }, + onVerticalDragUpdate: (details) { + if (!isDragging.value) return; + dragOffset.value = (dragOffset.value + details.delta.dy) + .clamp(0.0, viewportHeight) + .toDouble(); + }, + onVerticalDragEnd: (details) { + isDragging.value = false; + final velocity = details.primaryVelocity ?? 0; + if (dragOffset.value > _dismissThreshold || + velocity > _dismissVelocity) { + controller.value?.pause(); + Navigator.of(context).maybePop(); + return; + } + animateSnapBack(); + }, + onVerticalDragCancel: animateSnapBack, + child: SafeArea( + child: Center( + child: FutureBuilder( + future: initializeFuture.value, + builder: (context, snapshot) { + if (error.value != null || snapshot.hasError) { + return const _MediaLoadFailure( + message: 'Failed to load video', + icon: LucideIcons.videoOff, + ); + } + + final videoController = controller.value; + if (videoController == null || + !videoController.value.isInitialized) { + return _VideoLoadingPoster(posterUrl: posterUrl); + } + + return AspectRatio( + aspectRatio: videoController.value.aspectRatio, + child: VideoPlayer(videoController), + ); + }, + ), + ), + ), + ), + ), + ), + ), + PositionedDirectional( + top: Grid.sm, + end: Grid.sm, + child: Opacity( + opacity: chromeOpacity, + child: SafeArea( + child: _MediaViewerCircleButton( + key: const ValueKey('message-media-video-viewer-close'), + icon: LucideIcons.x, + tooltip: 'Close video viewer', + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ), + ), + PositionedDirectional( + bottom: 0, + start: 0, + end: 0, + child: Opacity( + opacity: chromeOpacity, + child: SafeArea( + child: _VideoViewerBottomControls( + controller: controller.value, + onReply: onReply == null + ? null + : () => unawaited(replyInThread()), + ), + ), + ), + ), + ], + ), + ); + } +} + +Future _cancelVideoResponse(http.StreamedResponse response) { + return response.stream.listen((_) {}).cancel(); +} + +String _videoFileExtension(Uri uri) { + final path = uri.path; + final extensionStart = path.lastIndexOf('.'); + if (extensionStart < 0 || extensionStart == path.length - 1) return '.mp4'; + final extension = path.substring(extensionStart); + return RegExp(r'^\.[A-Za-z0-9]{1,10}$').hasMatch(extension) + ? extension + : '.mp4'; +} + +class _VideoLoadingPoster extends StatelessWidget { + final String? posterUrl; + + const _VideoLoadingPoster({required this.posterUrl}); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + if (posterUrl != null) + MediaImage( + url: posterUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _videoPlaceholder(context), + ) + else + _videoPlaceholder(context), + const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)), + const Center( + child: BuzzLoadingIndicator( + size: 44, + color: Colors.white, + semanticLabel: 'Loading video', + ), + ), + ], + ), + ); + } + + Widget _videoPlaceholder(BuildContext context) { + return ColoredBox( + color: context.colors.surfaceContainerHighest, + child: Icon( + LucideIcons.video, + size: 40, + color: context.colors.onSurfaceVariant, + ), + ); + } +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index df0480002..b2fbc266d 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -25,9 +25,9 @@ import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'reaction_row.dart'; import 'recent_emoji_provider.dart'; -import 'read_state/message_read_state.dart'; -import 'read_state/read_state_format.dart'; -import 'read_state/read_state_provider.dart'; +import '../../shared/read_state/message_read_state.dart'; +import '../../shared/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_provider.dart'; import 'thread_detail_page.dart'; import 'thread_follows/thread_follows_provider.dart'; import 'timeline_message.dart'; diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 465585d96..8f4281aeb 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; @@ -11,6 +12,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; @@ -25,6 +27,7 @@ import 'media_viewer_page.dart'; import 'message_media.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/video_preview.dart'; const _messageMediaMaxInlineWidth = 320.0; const _messageMediaMaxImageHeight = 240.0; @@ -306,7 +309,11 @@ class MessageContent extends HookConsumerWidget { Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); if (mediaKind == MessageMediaKind.video) { - return _MessageVideoPreview(url: imageUrl, imeta: imeta); + return _MessageVideoPreview( + url: imageUrl, + imeta: imeta, + onReply: onMediaReply, + ); } return _MessageImagePreview( url: imageUrl, @@ -449,82 +456,6 @@ class _MessageImagePreview extends HookConsumerWidget { } } -class _MessageVideoPreview extends StatelessWidget { - final String url; - final ImetaEntry? imeta; - - const _MessageVideoPreview({required this.url, required this.imeta}); - - @override - Widget build(BuildContext context) { - final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9); - final aspectRatio = rawAspectRatio.clamp(0.75, 1.91); - final posterUrl = imeta?.posterUrl; - - return Padding( - padding: const EdgeInsets.only(top: Grid.half), - child: GestureDetector( - onTap: () => - openVideoViewer(context, videoUrl: url, posterUrl: posterUrl), - child: _MessageMediaPreviewFrame( - previewKey: ValueKey('message-media-video-preview:$url'), - backgroundColor: Colors.black, - child: AspectRatio( - aspectRatio: aspectRatio.toDouble(), - child: Stack( - fit: StackFit.expand, - children: [ - if (posterUrl != null) - MediaImage( - url: posterUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const _MediaPreviewFallback( - icon: LucideIcons.video, - label: 'Video preview unavailable', - ), - ) - else - const _MediaPreviewFallback( - icon: LucideIcons.video, - label: 'Video attachment', - ), - const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)), - Center( - child: Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Color.fromRGBO(0, 0, 0, 0.6), - shape: BoxShape.circle, - ), - child: const Icon( - LucideIcons.play, - color: Colors.white, - size: 24, - ), - ), - ), - Positioned( - left: Grid.xxs, - right: Grid.xxs, - bottom: Grid.xxs, - child: Text( - 'Video', - style: context.textTheme.labelSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - class _MessageMediaPreviewFrame extends StatelessWidget { final Key previewKey; final Color backgroundColor; diff --git a/mobile/lib/features/channels/message_content/video_preview.dart b/mobile/lib/features/channels/message_content/video_preview.dart new file mode 100644 index 000000000..61d92cb2b --- /dev/null +++ b/mobile/lib/features/channels/message_content/video_preview.dart @@ -0,0 +1,228 @@ +part of '../message_content.dart'; + +/// A decoded, paused video surface used as a posterless message preview. +class LoadedVideoPreviewFrame { + /// Widget backed by the initialized native video texture. + final Widget child; + + /// Aspect ratio reported by the video decoder. + final double aspectRatio; + + final Future Function() _dispose; + + /// Creates a loaded preview frame with its resource cleanup callback. + const LoadedVideoPreviewFrame({ + required this.child, + required this.aspectRatio, + required Future Function() dispose, + }) : _dispose = dispose; + + /// Releases the native decoder and any downloaded temporary file. + Future dispose() => _dispose(); +} + +/// Loads a paused first-frame surface for a video URL. +typedef VideoPreviewFrameLoader = + Future Function(String url); + +/// Loader used when an older video event has no NIP-71 poster URL. +final videoPreviewFrameLoaderProvider = Provider(( + ref, +) { + final auth = ref.watch(mediaGetAuthServiceProvider); + return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url)); +}); + +class _MessageVideoPreview extends HookConsumerWidget { + final String url; + final ImetaEntry? imeta; + final VoidCallback? onReply; + + const _MessageVideoPreview({ + required this.url, + required this.imeta, + required this.onReply, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9); + final aspectRatio = rawAspectRatio.clamp(0.75, 1.91); + final posterUrl = imeta?.posterUrl; + final loadPreviewFrame = ref.watch(videoPreviewFrameLoaderProvider); + final previewFrameFuture = useMemoized( + () => posterUrl == null + ? loadPreviewFrame(url) + : Future.value(), + [loadPreviewFrame, posterUrl, url], + ); + + useEffect(() { + var disposed = false; + LoadedVideoPreviewFrame? loadedFrame; + unawaited( + previewFrameFuture.then((frame) { + if (disposed) { + if (frame != null) unawaited(frame.dispose()); + } else { + loadedFrame = frame; + } + }), + ); + return () { + disposed = true; + final frame = loadedFrame; + if (frame != null) unawaited(frame.dispose()); + }; + }, [previewFrameFuture]); + + return Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: GestureDetector( + onTap: () => openVideoViewer( + context, + videoUrl: url, + posterUrl: posterUrl, + onReply: onReply, + ), + child: _MessageMediaPreviewFrame( + previewKey: ValueKey('message-media-video-preview:$url'), + backgroundColor: Colors.black, + child: AspectRatio( + aspectRatio: aspectRatio.toDouble(), + child: Stack( + fit: StackFit.expand, + children: [ + if (posterUrl != null) + MediaImage( + url: posterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const _MediaPreviewFallback( + icon: LucideIcons.video, + label: 'Video preview unavailable', + ), + ) + else + FutureBuilder( + future: previewFrameFuture, + builder: (context, snapshot) { + final frame = snapshot.data; + if (frame == null) { + return const _MediaPreviewFallback( + icon: LucideIcons.video, + label: 'Video attachment', + ); + } + return ColoredBox( + key: ValueKey('message-media-video-first-frame:$url'), + color: Colors.black, + child: Center( + child: AspectRatio( + aspectRatio: frame.aspectRatio, + child: frame.child, + ), + ), + ); + }, + ), + const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)), + Center( + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.play, + color: Colors.white, + size: 24, + ), + ), + ), + Positioned( + left: Grid.xxs, + right: Grid.xxs, + bottom: Grid.xxs, + child: Text( + 'Video', + style: context.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +Future _loadVideoPreviewFrame( + String url, { + required Map headers, +}) async { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) return null; + + VideoPlayerController? controller; + Future disposeResources() async { + final currentController = controller; + controller = null; + if (currentController != null) await currentController.dispose(); + } + + Future initialize(VideoPlayerController candidate) async { + controller = candidate; + await candidate.initialize(); + final duration = candidate.value.duration; + if (duration > const Duration(milliseconds: 100)) { + await candidate.seekTo(const Duration(milliseconds: 100)); + } + await candidate.pause(); + return candidate.value.isInitialized; + } + + try { + final networkController = VideoPlayerController.networkUrl( + uri, + httpHeaders: headers, + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + ); + if (!await initialize(networkController)) { + await disposeResources(); + return null; + } + } on MissingPluginException { + await disposeResources(); + return null; + } on UnimplementedError { + await disposeResources(); + return null; + } catch (_) { + // A timeline preview must stay bounded: falling back to a local file here + // would fully download every posterless video encountered while scrolling. + // The full-screen viewer can use its authenticated fallback after a tap. + await disposeResources(); + return null; + } + + final initializedController = controller; + if (initializedController == null) return null; + var didDispose = false; + return LoadedVideoPreviewFrame( + aspectRatio: initializedController.value.aspectRatio > 0 + ? initializedController.value.aspectRatio + : 16 / 9, + child: VideoPlayer(initializedController), + dispose: () async { + if (didDispose) return; + didDispose = true; + await disposeResources(); + }, + ); +} diff --git a/mobile/lib/features/channels/message_media.dart b/mobile/lib/features/channels/message_media.dart index f46a6614e..40d0e3177 100644 --- a/mobile/lib/features/channels/message_media.dart +++ b/mobile/lib/features/channels/message_media.dart @@ -85,9 +85,11 @@ Map parseImetaTags(List> tags) { MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) { final mimeType = imeta?.mimeType; if (mimeType != null) { - if (mimeType == 'video/mp4') return MessageMediaKind.video; + // An imeta MIME type is authoritative. The native video player chooses + // whether the device can decode the specific codec/container; rejecting + // every non-MP4 video here prevents it from even trying. + if (mimeType.startsWith('video/')) return MessageMediaKind.video; if (mimeType.startsWith('image/')) return MessageMediaKind.image; - if (mimeType.startsWith('video/')) return null; } final path = (Uri.tryParse(url)?.path ?? url).toLowerCase(); diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4b..730546807 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -15,6 +15,7 @@ class SendMessage { final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; final void Function(String channelId, String eventId) _removeLocalMessage; + final bool Function()? _isDeliveryValid; SendMessage({ required SignedEventRelay signedEventRelay, @@ -25,12 +26,14 @@ class SendMessage { required void Function(String channelId, String eventId) completeLocalMessage, required void Function(String channelId, String eventId) removeLocalMessage, + bool Function()? isDeliveryValid, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, - _removeLocalMessage = removeLocalMessage; + _removeLocalMessage = removeLocalMessage, + _isDeliveryValid = isDeliveryValid; /// Send a text message to a channel. /// @@ -47,6 +50,7 @@ class SendMessage { List? mentionPubkeys, List> mediaTags = const [], }) async { + _ensureDeliveryValid(); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. final resolvedMentions = @@ -69,6 +73,7 @@ class SendMessage { ...mediaTags, ]; + _ensureDeliveryValid(); NostrEvent? localMessage; try { await _signedEventRelay.submit( @@ -89,6 +94,14 @@ class SendMessage { } } + void _ensureDeliveryValid() { + if (_isDeliveryValid?.call() == false) { + throw StateError( + 'Message delivery cancelled because the active community changed', + ); + } + } + /// Resolve @mentions to pubkeys, scoped to channel members. /// /// Fetches channel members from the relay and matches @names only @@ -179,5 +192,10 @@ final sendMessageProvider = Provider((ref) { removeLocalMessage: (channelId, eventId) => ref .read(channelMessagesProvider(channelId).notifier) .removeLocalMessage(eventId), + isDeliveryValid: () { + final currentConfig = ref.read(relayConfigProvider); + return currentConfig.baseUrl == config.baseUrl && + currentConfig.nsec == config.nsec; + }, ); }); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 53be4488b..fff7f5834 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -27,8 +27,8 @@ import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'reaction_row.dart'; -import 'read_state/read_state_format.dart'; -import 'read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_format.dart'; +import '../../shared/read_state/read_state_provider.dart'; import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; @@ -60,6 +60,7 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final composerDockHeight = useState(0.0); + final sendMessage = ref.read(sendMessageProvider); // Relay thread queries are keyed by the outermost root, even when this // page displays a nested branch. Query that root, then select this head's // direct children from the returned subtree below. @@ -533,16 +534,14 @@ class ThreadDetailPage extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - mediaTags: mediaTags, - ), + }) => sendMessage.call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), ), ], ), diff --git a/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart b/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart index b3b09cf7e..13210ca8e 100644 --- a/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart +++ b/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; -import '../read_state/read_state_time.dart'; +import '../../../shared/read_state/read_state_time.dart'; import 'thread_follows_storage.dart'; class ThreadFollowsState { diff --git a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart index f25957ad8..d6a402b4c 100644 --- a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart +++ b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart @@ -1,4 +1,4 @@ -import '../read_state/read_state_format.dart'; +import '../../../shared/read_state/read_state_format.dart'; class ObservedUnreadEvent { final String id; diff --git a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart index c967aa105..09e33ba95 100644 --- a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart +++ b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart @@ -1,8 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../channels_provider.dart'; -import '../read_state/read_state_provider.dart'; -import '../read_state/read_state_format.dart'; +import '../../../shared/read_state/read_state_provider.dart'; +import '../../../shared/read_state/read_state_format.dart'; import 'observed_unread_event.dart'; class UnreadBadgeState { diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index e3eed1f96..f11d0444b 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -33,6 +33,10 @@ class ForumPostsView extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final postsAsync = ref.watch(forumPostsProvider(channel.id)); final isComposing = useState(false); + // A queued attachment can finish after this view is popped. Capture the + // app-level provider container instead of retaining the route's WidgetRef. + final providerContainer = ProviderScope.containerOf(context, listen: false); + final forumDelivery = ForumEventDelivery.capture(providerContainer); // Periodic refresh (every 15s, matching desktop). useEffect(() { @@ -146,8 +150,7 @@ class ForumPostsView extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) async { - await createForumPost( - ref, + await forumDelivery.createPost( channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, diff --git a/mobile/lib/features/forum/forum_provider.dart b/mobile/lib/features/forum/forum_provider.dart index 710e16aa3..59cd72d07 100644 --- a/mobile/lib/features/forum/forum_provider.dart +++ b/mobile/lib/features/forum/forum_provider.dart @@ -55,73 +55,120 @@ final forumThreadProvider = ); }); -/// Creates a new forum post (kind:45001). -Future createForumPost( - WidgetRef ref, { - required String channelId, - required String content, - List mentionPubkeys = const [], - List> mediaTags = const [], -}) async { - final config = ref.read(relayConfigProvider); - final session = ref.read(relaySessionProvider.notifier); - final relay = SignedEventRelay(session: session, nsec: config.nsec); +/// A forum event delivery bound to the community where composition began. +/// +/// Attachment uploads can outlive their route. Capturing the relay identity, +/// signing key, and emoji palette prevents a queued draft from being delivered +/// to a different community after the user switches relays. +class ForumEventDelivery { + final ProviderContainer _container; + final String _relayUrl; + final String? _nsec; + final SignedEventRelay _relay; + final List _customEmoji; - final selfPubkey = relay.pubkey?.toLowerCase(); - final seen = {?selfPubkey}; - final normalizedMentions = [ - for (final pk in mentionPubkeys) - if (seen.add(pk.toLowerCase())) pk, - ]; + ForumEventDelivery._({ + required ProviderContainer container, + required String relayUrl, + required String? nsec, + required SignedEventRelay relay, + required List customEmoji, + }) : _container = container, + _relayUrl = relayUrl, + _nsec = nsec, + _relay = relay, + _customEmoji = customEmoji; - await relay.submit( - kind: EventKind.forumPost, - content: content, - tags: [ - ['h', channelId], - for (final pk in normalizedMentions) ['p', pk], - ...mediaTags, - ...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)), - ], - ); - ref.invalidate(forumPostsProvider(channelId)); -} + /// Captures the active community dependencies for a future delivery. + factory ForumEventDelivery.capture(ProviderContainer container) { + final config = container.read(relayConfigProvider); + return ForumEventDelivery._( + container: container, + relayUrl: config.baseUrl, + nsec: config.nsec, + relay: SignedEventRelay( + session: container.read(relaySessionProvider.notifier), + nsec: config.nsec, + ), + customEmoji: List.unmodifiable( + container.read(customEmojiListProvider), + ), + ); + } -/// Creates a reply to a forum post (kind:45003). -Future createForumReply( - WidgetRef ref, { - required String channelId, - required String parentEventId, - required String content, - List mentionPubkeys = const [], - List> mediaTags = const [], -}) async { - final config = ref.read(relayConfigProvider); - final session = ref.read(relaySessionProvider.notifier); - final relay = SignedEventRelay(session: session, nsec: config.nsec); + /// Creates a new forum post (kind:45001). + Future createPost({ + required String channelId, + required String content, + List mentionPubkeys = const [], + List> mediaTags = const [], + }) async { + await _submit( + kind: EventKind.forumPost, + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ); + _container.invalidate(forumPostsProvider(channelId)); + } - final selfPubkey = relay.pubkey?.toLowerCase(); - final seen = {?selfPubkey}; - final normalizedMentions = [ - for (final pk in mentionPubkeys) - if (seen.add(pk.toLowerCase())) pk, - ]; + /// Creates a reply to a forum post (kind:45003). + Future createReply({ + required String channelId, + required String parentEventId, + required String content, + List mentionPubkeys = const [], + List> mediaTags = const [], + }) async { + await _submit( + kind: EventKind.forumComment, + channelId: channelId, + parentEventId: parentEventId, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ); + _container.invalidate(forumPostsProvider(channelId)); + _container.invalidate( + forumThreadProvider((channelId: channelId, eventId: parentEventId)), + ); + } - await relay.submit( - kind: EventKind.forumComment, - content: content, - tags: [ - ['h', channelId], - ['e', parentEventId, '', 'reply'], - for (final pk in normalizedMentions) ['p', pk], - ...mediaTags, - ...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)), - ], - ); - ref.invalidate(forumPostsProvider(channelId)); - ref.invalidate( - forumThreadProvider((channelId: channelId, eventId: parentEventId)), - ); + Future _submit({ + required int kind, + required String channelId, + required String content, + String? parentEventId, + required List mentionPubkeys, + required List> mediaTags, + }) async { + final currentConfig = _container.read(relayConfigProvider); + if (currentConfig.baseUrl != _relayUrl || currentConfig.nsec != _nsec) { + throw StateError( + 'Forum delivery cancelled because the active community changed', + ); + } + + final selfPubkey = _relay.pubkey?.toLowerCase(); + final seen = {?selfPubkey}; + final normalizedMentions = [ + for (final pk in mentionPubkeys) + if (seen.add(pk.toLowerCase())) pk, + ]; + + await _relay.submit( + kind: kind, + content: content, + tags: [ + ['h', channelId], + if (parentEventId != null) ['e', parentEventId, '', 'reply'], + for (final pk in normalizedMentions) ['p', pk], + ...mediaTags, + ...buildCustomEmojiTags(content, _customEmoji), + ], + ); + } } /// Deletes a forum post or reply and invalidates relevant caches. diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 68d2562f6..f7859f16f 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -207,6 +207,9 @@ class _ThreadContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // Background media delivery may outlive this route's WidgetRef. + final providerContainer = ProviderScope.containerOf(context, listen: false); + final forumDelivery = ForumEventDelivery.capture(providerContainer); final post = thread.post; final replies = thread.replies; @@ -301,8 +304,7 @@ class _ThreadContent extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => createForumReply( - ref, + }) => forumDelivery.createReply( channelId: channelId, parentEventId: post.eventId, content: content, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index a40e31746..79ccf3199 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -8,15 +8,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/directional_transition_scope.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { - const HomePage({required this.settingsPageBuilder, super.key}); + const HomePage({ + required this.settingsPageBuilder, + required this.hasUnreadInbox, + super.key, + }); final WidgetBuilder settingsPageBuilder; + final bool hasUnreadInbox; static const double _tabBarHeight = mobileTabBarHeight; static const double _tabBarRadius = _tabBarHeight / 2; @@ -29,6 +35,12 @@ class HomePage extends HookConsumerWidget { static const double _tabIconSize = 22; static const double _fabClearance = _tabBarHeight + _tabBarBottomGap; static const Duration _tabIconWeightDuration = Duration(milliseconds: 120); + static const Duration _tabUnreadBadgeDuration = Duration(milliseconds: 220); + static const Duration _tabContentTransitionDuration = Duration( + milliseconds: 240, + ); + static const Curve _tabContentTransitionCurve = Cubic(0.22, 1, 0.36, 1); + static const double _tabContentTransitionDistance = 24; static const _destinations = [ _HomeDestination( @@ -51,6 +63,18 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); + final tabContentTransitionDirection = useRef(1.0); + final tabContentTransitionController = useAnimationController( + duration: _tabContentTransitionDuration, + initialValue: 1, + ); + final tabContentTransitionValue = useAnimation( + tabContentTransitionController, + ); + final reducedMotion = MediaQuery.of(context).disableAnimations; + final tabContentTransitionProgress = reducedMotion + ? 1.0 + : _tabContentTransitionCurve.transform(tabContentTransitionValue); final systemBottomInset = MediaQuery.paddingOf(context).bottom; final navigationBarWidth = _floatingTabBarWidth( MediaQuery.sizeOf(context).width, @@ -80,7 +104,16 @@ class HomePage extends HookConsumerWidget { context, HomePage._fabClearance, ), - child: IndexedStack(index: tabIndex.value, children: pages), + child: DirectionalTransitionScope( + horizontalOffset: + tabContentTransitionDirection.value * + _tabContentTransitionDistance * + (1 - tabContentTransitionProgress), + opacity: tabContentTransitionProgress, + child: ClipRect( + child: IndexedStack(index: tabIndex.value, children: pages), + ), + ), ), ), Align( @@ -106,10 +139,17 @@ class HomePage extends HookConsumerWidget { ), bottomNavigationBar: _FloatingTabBar( selectedIndex: tabIndex.value, + hasUnreadInbox: hasUnreadInbox, onDestinationSelected: (i) { if (i == tabIndex.value) return; + tabContentTransitionDirection.value = i > tabIndex.value ? 1 : -1; unawaited(HapticFeedback.selectionClick()); tabIndex.value = i; + if (reducedMotion) { + tabContentTransitionController.value = 1; + } else { + unawaited(tabContentTransitionController.forward(from: 0)); + } }, destinations: _destinations, ), @@ -165,11 +205,13 @@ class _HomeDestination { class _FloatingTabBar extends StatelessWidget { final int selectedIndex; + final bool hasUnreadInbox; final ValueChanged onDestinationSelected; final List<_HomeDestination> destinations; const _FloatingTabBar({ required this.selectedIndex, + required this.hasUnreadInbox, required this.onDestinationSelected, required this.destinations, }); @@ -271,6 +313,7 @@ class _FloatingTabBar extends StatelessWidget { child: _FloatingTabDestination( destination: destinations[i], selected: i == safeSelectedIndex, + showUnreadBadge: i == 1 && hasUnreadInbox, onTap: () => onDestinationSelected(i), ), ), @@ -292,27 +335,37 @@ class _FloatingTabBar extends StatelessWidget { class _FloatingTabDestination extends StatelessWidget { final _HomeDestination destination; final bool selected; + final bool showUnreadBadge; final VoidCallback onTap; const _FloatingTabDestination({ required this.destination, required this.selected, + required this.showUnreadBadge, required this.onTap, }); @override Widget build(BuildContext context) { final colorScheme = context.colors; + final isDark = context.theme.brightness == Brightness.dark; final reducedMotion = MediaQuery.of(context).disableAnimations; final foregroundColor = selected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant; final icon = selected ? destination.selectedIcon : destination.icon; + final badgeOutlineColor = selected + ? colorScheme.primaryContainer + : (isDark ? colorScheme.surfaceContainerHighest : colorScheme.surface); + + final showVisibleUnreadBadge = showUnreadBadge && !selected; return Semantics( button: true, selected: selected, - label: destination.label, + label: showVisibleUnreadBadge + ? '${destination.label}, unread' + : destination.label, child: Tooltip( message: destination.label, excludeFromSemantics: true, @@ -327,19 +380,60 @@ class _FloatingTabDestination extends StatelessWidget { ), borderRadius: BorderRadius.circular(HomePage._selectedTabRadius), child: Center( - child: AnimatedSwitcher( - duration: reducedMotion - ? Duration.zero - : HomePage._tabIconWeightDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: Icon( - icon, - key: ValueKey('${destination.label}-$icon'), - color: foregroundColor, - size: HomePage._tabIconSize, + child: SizedBox( + width: HomePage._tabIconSize + 8, + height: HomePage._tabIconSize + 8, + child: Stack( + clipBehavior: Clip.none, + children: [ + Center( + child: AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : HomePage._tabIconWeightDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: Icon( + icon, + key: ValueKey('${destination.label}-$icon'), + color: foregroundColor, + size: HomePage._tabIconSize, + ), + ), + ), + if (showUnreadBadge) + Positioned( + top: 0, + right: 0, + child: AnimatedScale( + key: const ValueKey('activity-tab-unread-dot-scale'), + scale: selected ? 0 : 1, + alignment: const Alignment(-0.5, 0.5), + duration: reducedMotion + ? Duration.zero + : HomePage._tabUnreadBadgeDuration, + curve: Curves.easeOutCubic, + child: Container( + key: const ValueKey('activity-tab-unread-dot'), + width: 12, + height: 12, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: badgeOutlineColor, + shape: BoxShape.circle, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ], ), ), ), diff --git a/mobile/lib/features/channels/read_state/deferred_read_state_update.dart b/mobile/lib/shared/read_state/deferred_read_state_update.dart similarity index 100% rename from mobile/lib/features/channels/read_state/deferred_read_state_update.dart rename to mobile/lib/shared/read_state/deferred_read_state_update.dart diff --git a/mobile/lib/features/channels/read_state/message_read_state.dart b/mobile/lib/shared/read_state/message_read_state.dart similarity index 100% rename from mobile/lib/features/channels/read_state/message_read_state.dart rename to mobile/lib/shared/read_state/message_read_state.dart diff --git a/mobile/lib/features/channels/read_state/read_state_format.dart b/mobile/lib/shared/read_state/read_state_format.dart similarity index 98% rename from mobile/lib/features/channels/read_state/read_state_format.dart rename to mobile/lib/shared/read_state/read_state_format.dart index fe99b4861..b21175853 100644 --- a/mobile/lib/features/channels/read_state/read_state_format.dart +++ b/mobile/lib/shared/read_state/read_state_format.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import '../../../shared/relay/nostr_models.dart'; +import '../relay/nostr_models.dart'; const readStateDTagPrefix = 'read-state:'; const readStateFetchLimit = 500; diff --git a/mobile/lib/features/channels/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart similarity index 99% rename from mobile/lib/features/channels/read_state/read_state_manager.dart rename to mobile/lib/shared/read_state/read_state_manager.dart index fe51a9473..65b52c7d3 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -6,8 +6,8 @@ import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../shared/crypto/nip44.dart'; -import '../../../shared/relay/relay.dart'; +import '../crypto/nip44.dart'; +import '../relay/relay.dart'; import 'read_state_format.dart'; import 'read_state_storage.dart'; import 'read_state_time.dart'; diff --git a/mobile/lib/features/channels/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart similarity index 97% rename from mobile/lib/features/channels/read_state/read_state_provider.dart rename to mobile/lib/shared/read_state/read_state_provider.dart index b82f1af5f..22375db95 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../shared/relay/relay.dart'; -import '../../../shared/theme/theme_provider.dart'; -import '../../../shared/community/community_provider.dart'; +import '../relay/relay.dart'; +import '../theme/theme_provider.dart'; +import '../community/community_provider.dart'; import 'read_state_manager.dart'; class ReadStateState { diff --git a/mobile/lib/features/channels/read_state/read_state_storage.dart b/mobile/lib/shared/read_state/read_state_storage.dart similarity index 100% rename from mobile/lib/features/channels/read_state/read_state_storage.dart rename to mobile/lib/shared/read_state/read_state_storage.dart diff --git a/mobile/lib/features/channels/read_state/read_state_time.dart b/mobile/lib/shared/read_state/read_state_time.dart similarity index 100% rename from mobile/lib/features/channels/read_state/read_state_time.dart rename to mobile/lib/shared/read_state/read_state_time.dart diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58c93979d..3161a7036 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; @@ -20,6 +22,7 @@ const _legacyMediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; +const _generateVideoPosterMethod = 'generateVideoPoster'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; const _requiresLegacyMediaStoragePermissionMethod = 'requiresLegacyMediaStoragePermission'; @@ -73,6 +76,9 @@ typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); + +/// Generates poster-frame bytes for the video at [filePath], when available. +typedef GenerateVideoPoster = Future Function(String filePath); typedef ReadClipboardImage = Future Function(); class MediaPolicyUploadException implements Exception { @@ -82,6 +88,28 @@ class MediaPolicyUploadException implements Exception { String toString() => _mediaPolicyUploadMessage; } +/// Cancels a single user-initiated media upload without closing the shared +/// HTTP client used by later uploads. +class UploadCancellationToken { + final Completer _cancelled = Completer(); + + /// Whether cancellation has been requested. + bool get isCancelled => _cancelled.isCompleted; + + /// Completes when cancellation is first requested. + Future get whenCancelled => _cancelled.future; + + /// Requests cancellation. Calling this more than once has no effect. + void cancel() { + if (!_cancelled.isCompleted) _cancelled.complete(); + } +} + +/// Indicates that a user cancelled a media upload before it completed. +class UploadCancelledException implements Exception { + const UploadCancelledException(); +} + @immutable class _PreparedUploadImage { final Uint8List bytes; @@ -146,6 +174,21 @@ class BlobDescriptor { filename: value, ); + /// Returns a descriptor with [value] as its NIP-71 video poster URL. + BlobDescriptor withImage(String value) => BlobDescriptor( + url: url, + sha256: sha256, + size: size, + type: type, + uploaded: uploaded, + dim: dim, + blurhash: blurhash, + thumb: thumb, + duration: duration, + image: value, + filename: filename, + ); + List toImetaTag() => [ 'imeta', 'url $url', @@ -181,6 +224,7 @@ class MediaUploadService { final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; + final GenerateVideoPoster _generateVideoPoster; final ReadClipboardImage _readClipboardImage; final DateTime Function() _now; final http.Client _http; @@ -196,6 +240,7 @@ class MediaUploadService { SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, + GenerateVideoPoster? generateVideoPoster, ReadClipboardImage? readClipboardImage, DateTime Function()? now, http.Client? httpClient, @@ -214,6 +259,7 @@ class MediaUploadService { _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, + _generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster, _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), @@ -234,11 +280,18 @@ class MediaUploadService { /// Opens the system picker with multi-selection enabled. Future> pickGalleryImages() => _pickGalleryImages(); - Future uploadImage(XFile image) async { + Future uploadImage( + XFile image, { + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { final preparedImage = await _prepareUploadImage(image); + _throwIfCancelled(cancellationToken); return _uploadPreparedBytes( preparedImage.bytes, mimeType: preparedImage.mimeType, + onProgress: onProgress, + cancellationToken: cancellationToken, ); } @@ -250,18 +303,28 @@ class MediaUploadService { } Future readAndUploadClipboardImage() async { + final image = await readClipboardImage(); + if (image == null) throw Exception('Unable to read pasted image'); + return uploadImage(image); + } + + /// Reads a clipboard image for composer preview before the user sends it. + Future readClipboardImage() async { final bytes = await _readClipboardImage(); - if (bytes == null || bytes.isEmpty) { - throw Exception('Unable to read pasted image'); - } - return uploadImage(XFile.fromData(bytes)); + if (bytes == null || bytes.isEmpty) return null; + return XFile.fromData(bytes, name: 'Pasted image'); } /// Opens the system gallery video picker. Future pickGalleryVideo() => _pickGalleryVideo(); /// Sanitizes and uploads [pickedVideo] as an MP4 attachment. - Future uploadVideo(XFile pickedVideo) async { + Future uploadVideo( + XFile pickedVideo, { + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _throwIfCancelled(cancellationToken); final length = await pickedVideo.length(); if (length > _maxVideoSizeBytes) { throw Exception( @@ -274,6 +337,7 @@ class MediaUploadService { String? transcodedPath; try { transcodedPath = await _transcodeVideoToMp4(pickedVideo.path); + _throwIfCancelled(cancellationToken); final transcodedFile = File(transcodedPath); final transcodedLength = await transcodedFile.length(); if (transcodedLength > _maxVideoSizeBytes) { @@ -282,7 +346,63 @@ class MediaUploadService { ); } final bytes = await transcodedFile.readAsBytes(); - return uploadBytes(bytes, mimeType: 'video/mp4'); + _throwIfCancelled(cancellationToken); + final video = await uploadBytes( + bytes, + mimeType: 'video/mp4', + onProgress: onProgress == null + ? null + : (progress) => onProgress(progress * 0.9), + cancellationToken: cancellationToken, + ); + + // Extract from the canonical output first so the poster matches the + // uploaded orientation. Some AVFoundation exports need a moment before + // their first frame is seekable; fall back to the picked source instead + // of silently sending a permanently gray video card. + Uint8List? posterBytes; + Object? posterExtractionError; + for (final sourcePath in {transcodedPath, pickedVideo.path}) { + try { + final candidate = await _generateVideoPoster(sourcePath); + if (candidate != null && candidate.isNotEmpty) { + posterBytes = candidate; + break; + } + } catch (error) { + posterExtractionError = error; + } + } + + if (posterBytes == null) { + if (posterExtractionError != null) { + debugPrint('Unable to generate video poster: $posterExtractionError'); + } + onProgress?.call(1); + return video; + } + + // Posters are best-effort: a video that passed the media policy should + // still send if the separate preview upload fails. + try { + _throwIfCancelled(cancellationToken); + final poster = await uploadImage( + XFile.fromData( + posterBytes, + mimeType: 'image/jpeg', + name: 'video-poster.jpg', + ), + onProgress: onProgress == null + ? null + : (progress) => onProgress(0.9 + (progress * 0.1)), + cancellationToken: cancellationToken, + ); + return video.withImage(poster.url); + } catch (error) { + debugPrint('Unable to upload video poster: $error'); + onProgress?.call(1); + return video; + } } finally { if (transcodedPath != null) { try { @@ -310,7 +430,12 @@ class MediaUploadService { } /// Uploads [pickedFile] as a size-limited generic attachment. - Future uploadFile(XFile pickedFile) async { + Future uploadFile( + XFile pickedFile, { + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _throwIfCancelled(cancellationToken); final length = await pickedFile.length(); if (length == 0) { throw Exception('File is empty.'); @@ -321,10 +446,13 @@ class MediaUploadService { ); } final bytes = await pickedFile.readAsBytes(); + _throwIfCancelled(cancellationToken); final descriptor = await _uploadPreparedBytes( bytes, mimeType: 'application/octet-stream', allowGenericFile: true, + onProgress: onProgress, + cancellationToken: cancellationToken, ); return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); } @@ -338,7 +466,10 @@ class MediaUploadService { Future uploadBytes( Uint8List bytes, { required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { + _throwIfCancelled(cancellationToken); if (mimeType == 'image/gif' || (mimeType == 'image/png' && _isAnimatedPng(bytes)) || (mimeType == 'image/webp' && _isAnimatedWebp(bytes))) { @@ -348,14 +479,22 @@ class MediaUploadService { throw Exception('failed to sanitize image for upload'); } } - return _uploadPreparedBytes(bytes, mimeType: mimeType); + return _uploadPreparedBytes( + bytes, + mimeType: mimeType, + onProgress: onProgress, + cancellationToken: cancellationToken, + ); } Future _uploadPreparedBytes( Uint8List bytes, { required String mimeType, bool allowGenericFile = false, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { + _throwIfCancelled(cancellationToken); if (!allowGenericFile && !_allowedImageMimeTypes.contains(mimeType) && !_allowedVideoMimeTypes.contains(mimeType)) { @@ -363,25 +502,24 @@ class MediaUploadService { } final sha256 = _sha256Hex(bytes); - var request = _buildUploadRequest( + var response = await _sendUploadRequest( bytes: bytes, mimeType: mimeType, sha256: sha256, path: _mediaUploadPath, + onProgress: onProgress, + cancellationToken: cancellationToken, ); - - var streamed = await _http.send(request); - var response = await http.Response.fromStream(streamed); if (response.statusCode == HttpStatus.notFound || response.statusCode == HttpStatus.methodNotAllowed) { - request = _buildUploadRequest( + response = await _sendUploadRequest( bytes: bytes, mimeType: mimeType, sha256: sha256, path: _legacyMediaUploadPath, + onProgress: onProgress, + cancellationToken: cancellationToken, ); - streamed = await _http.send(request); - response = await http.Response.fromStream(streamed); } if (response.statusCode < 200 || response.statusCode >= 300) { if (_allowedImageMimeTypes.contains(mimeType) && @@ -399,18 +537,37 @@ class MediaUploadService { ); } - http.Request _buildUploadRequest({ + Future _sendUploadRequest({ required Uint8List bytes, required String mimeType, required String sha256, required String path, - }) { - final request = http.Request('PUT', Uri.parse(_baseUrl).resolve(path)); - request.bodyBytes = bytes; + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _throwIfCancelled(cancellationToken); + final request = http.AbortableStreamedRequest( + 'PUT', + Uri.parse(_baseUrl).resolve(path), + abortTrigger: cancellationToken?.whenCancelled, + ); + request.contentLength = bytes.length; request.headers.addAll( _buildUploadHeaders(mimeType: mimeType, sha256: sha256), ); - return request; + final writeRequest = request.sink + .addStream(_uploadByteStream(bytes, onProgress)) + .whenComplete(request.sink.close); + final response = await _http.send(request); + await writeRequest; + _throwIfCancelled(cancellationToken); + return http.Response.fromStream(response); + } + + void _throwIfCancelled(UploadCancellationToken? cancellationToken) { + if (cancellationToken?.isCancelled ?? false) { + throw const UploadCancelledException(); + } } Map _buildUploadHeaders({ @@ -547,6 +704,23 @@ String _safeAttachmentFilename(String filename) { return safeBasename.isEmpty ? 'file' : safeBasename; } +Stream> _uploadByteStream( + Uint8List bytes, + ValueChanged? onProgress, +) async* { + const chunkSize = 64 * 1024; + onProgress?.call(0); + if (bytes.isEmpty) { + onProgress?.call(1); + return; + } + for (var start = 0; start < bytes.length; start += chunkSize) { + final end = math.min(start + chunkSize, bytes.length); + yield Uint8List.sublistView(bytes, start, end); + onProgress?.call(end / bytes.length); + } +} + String _sha256Hex(Uint8List bytes) { final digest = SHA256Digest().process(bytes); return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); @@ -730,15 +904,19 @@ int _readUint32LittleEndian(Uint8List bytes, int offset) { (bytes[offset + 3] << 24); } -/// Always returns `video/mp4` — the relay only accepts MP4 and does its own -/// magic-byte validation. Most iPhone `.mov` files are ftyp-isom containers -/// that the relay accepts as MP4. Future _readPlatformClipboardImage() async { return _mediaUploadPlatformChannel.invokeMethod( _readClipboardImageMethod, ); } +Future _generatePickedVideoPoster(String filePath) { + return _mediaUploadPlatformChannel.invokeMethod( + _generateVideoPosterMethod, + filePath, + ); +} + Future _transcodePickedVideoToMp4(String filePath) async { final result = await _mediaUploadPlatformChannel.invokeMethod( _transcodeVideoToMp4Method, diff --git a/mobile/lib/shared/widgets/directional_transition_scope.dart b/mobile/lib/shared/widgets/directional_transition_scope.dart new file mode 100644 index 000000000..5e4e5971a --- /dev/null +++ b/mobile/lib/shared/widgets/directional_transition_scope.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// Supplies a shared directional entrance to separate foreground surfaces. +/// +/// Descendants opt in with [DirectionalTransitionMotion], which lets a page +/// move its body and app-bar content together while leaving decorative +/// backgrounds stationary. +class DirectionalTransitionScope extends InheritedWidget { + /// Horizontal displacement remaining in the transition. + final double horizontalOffset; + + /// Current foreground opacity, from zero to one. + final double opacity; + + /// Creates a directional transition scope. + const DirectionalTransitionScope({ + super.key, + required this.horizontalOffset, + required this.opacity, + required super.child, + }); + + /// Returns the closest transition, or null outside a transitioning surface. + static DirectionalTransitionScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(DirectionalTransitionScope oldWidget) => + horizontalOffset != oldWidget.horizontalOffset || + opacity != oldWidget.opacity; +} + +/// Applies the nearest [DirectionalTransitionScope] to one foreground layer. +class DirectionalTransitionMotion extends StatelessWidget { + /// Foreground content that participates in the shared transition. + final Widget child; + + /// Optional key for inspecting the composited translation layer. + final Key? transformKey; + + /// Optional key for inspecting the composited opacity layer. + final Key? opacityKey; + + /// Creates a foreground transition participant. + const DirectionalTransitionMotion({ + super.key, + required this.child, + this.transformKey, + this.opacityKey, + }); + + @override + Widget build(BuildContext context) { + final transition = DirectionalTransitionScope.maybeOf(context); + if (transition == null) return child; + + return Transform.translate( + key: transformKey, + offset: Offset(transition.horizontalOffset, 0), + child: Opacity( + key: opacityKey, + opacity: transition.opacity, + child: child, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 6a0bd2729..8e8a8b1ce 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/theme.dart'; +import 'directional_transition_scope.dart'; /// Minimum height of the frosted app bar content area below the safe area. const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48 @@ -144,6 +145,7 @@ class FrostedAppBar extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( + key: const ValueKey('frosted-app-bar-background'), padding: EdgeInsets.only(top: topPadding), decoration: BoxDecoration( // A gradient and a color cannot both paint, so the gradient @@ -159,48 +161,58 @@ class FrostedAppBar extends StatelessWidget { ), ), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: barContentHeight, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalInset), - child: IconTheme.merge( - data: IconThemeData(color: iconColor), - child: Row( - children: [ - ?effectiveLeading, - if (title != null) - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: effectiveLeading != null - ? 0 - : Grid.gutter - Grid.quarter, - right: actions.isEmpty - ? Grid.gutter - Grid.quarter - : 0, + child: DirectionalTransitionMotion( + transformKey: const ValueKey( + 'frosted-app-bar-content-transition-transform', + ), + opacityKey: const ValueKey( + 'frosted-app-bar-content-transition-opacity', + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: barContentHeight, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalInset, + ), + child: IconTheme.merge( + data: IconThemeData(color: iconColor), + child: Row( + children: [ + ?effectiveLeading, + if (title != null) + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null + ? 0 + : Grid.gutter - Grid.quarter, + right: actions.isEmpty + ? Grid.gutter - Grid.quarter + : 0, + ), + child: DefaultTextStyle.merge( + style: effectiveTitleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: title!, + ), ), - child: DefaultTextStyle.merge( - style: effectiveTitleStyle, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, - ), - ), - ) - else - const Spacer(), - ...actions, - ], + ) + else + const Spacer(), + ...actions, + ], + ), ), ), ), - ), - if (bottom != null) - SizedBox(height: bottomHeight, child: bottom), - ], + if (bottom != null) + SizedBox(height: bottomHeight, child: bottom), + ], + ), ), ), ), diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index fc0e2fe50..9772fd024 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'directional_transition_scope.dart'; import 'frosted_app_bar.dart'; /// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body. @@ -40,7 +41,20 @@ class FrostedScaffold extends StatelessWidget { backgroundColor: backgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, floatingActionButton: floatingActionButton, - body: Stack(children: [body, appBar]), + body: Stack( + children: [ + DirectionalTransitionMotion( + transformKey: const ValueKey( + 'frosted-scaffold-body-transition-transform', + ), + opacityKey: const ValueKey( + 'frosted-scaffold-body-transition-opacity', + ), + child: body, + ), + appBar, + ], + ), ); } } diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index aecb3303f..7e5aa0f4d 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -9,7 +9,7 @@ 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/shared/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'; @@ -17,8 +17,10 @@ import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -601,12 +603,157 @@ void main() { await tester.longPress(find.byKey(const ValueKey('inbox-row-m1'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Mark unread')); + await tester.tap( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Mark unread'), + ), + ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget); }); + testWidgets('swiping an inbox row reveals and runs its row actions', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable( + readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey(const ValueKey('inbox-row-m1')); + final originalLeft = tester.getTopLeft(row).dx; + await tester.drag(row, const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(row).dx, lessThan(originalLeft)); + expect(find.byKey(const ValueKey('inbox-swipe-read-m1')), findsOneWidget); + expect(find.byKey(const ValueKey('inbox-swipe-open-m1')), findsNothing); + expect( + find.byKey(const ValueKey('inbox-swipe-background-m1')), + findsOneWidget, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('inbox-swipe-background-m1'))) + .height, + tester.getSize(row).height, + ); + final markUnreadAction = find.byKey(const ValueKey('inbox-swipe-read-m1')); + final markUnreadMaterial = tester.widget( + find.descendant(of: markUnreadAction, matching: find.byType(Material)), + ); + expect(markUnreadMaterial.color, tester.element(row).colors.primary); + expect(markUnreadMaterial.borderRadius, BorderRadius.circular(Radii.full)); + + await tester.tap(find.byKey(const ValueKey('inbox-swipe-read-m1'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget); + + await tester.drag(row, const Offset(-200, 0)); + await tester.pumpAndSettle(); + final markReadAction = find.byKey(const ValueKey('inbox-swipe-read-m1')); + final markReadMaterial = tester.widget( + find.descendant(of: markReadAction, matching: find.byType(Material)), + ); + expect(markReadMaterial.color, tester.element(row).appColors.success); + expect(markReadMaterial.color, isNot(markUnreadMaterial.color)); + }); + + testWidgets('swipe action reveals its label with one threshold haptic', ( + tester, + ) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + hapticCalls.add(call); + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + await buildTestable( + readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey(const ValueKey('inbox-row-m1')); + final gesture = await tester.startGesture(tester.getCenter(row)); + for (var step = 1; step <= 7; step++) { + await gesture.moveBy( + const Offset(-10, 0), + timeStamp: Duration(milliseconds: step * 50), + ); + await tester.pump(); + } + + final action = find.byKey(const ValueKey('inbox-swipe-read-m1')); + expect(action, findsOneWidget); + expect( + find.descendant(of: action, matching: find.byIcon(LucideIcons.mail)), + findsOneWidget, + ); + expect(find.text('Mark unread'), findsNothing); + expect(hapticCalls, isEmpty); + + for (var step = 8; step <= 10; step++) { + await gesture.moveBy( + const Offset(-10, 0), + timeStamp: Duration(milliseconds: step * 50), + ); + await tester.pump(); + } + expect(find.text('Mark unread'), findsOneWidget); + expect(hapticCalls, hasLength(1)); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick'); + + await gesture.moveBy( + const Offset(20, 0), + timeStamp: const Duration(milliseconds: 550), + ); + await tester.pump(); + await gesture.moveBy( + const Offset(-20, 0), + timeStamp: const Duration(milliseconds: 600), + ); + await tester.pump(); + expect(hapticCalls, hasLength(1)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('commits the inbox read-state action past the swipe threshold', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable( + readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now}, + ), + ); + await tester.pumpAndSettle(); + + final row = find.byKey(const ValueKey('inbox-row-m1')); + await tester.drag(row, const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget); + expect( + find.byKey(const ValueKey('inbox-swipe-background-m1')), + findsNothing, + ); + }); + testWidgets('falls back to short pubkey when user not cached', ( tester, ) async { diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 0d927df86..351e08b4e 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -7,10 +7,17 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -/// Records every DM history query (`#h` filter) so tests can assert whether -/// the DM source was fetched. All queries resolve to empty lists. +/// Records subscriptions and DM history queries for Activity projection tests. class _RecordingSessionNotifier extends RelaySessionNotifier { final List> dmQueries = []; + final List _history = []; + final List<({NostrFilter filter, void Function(NostrEvent) onEvent})> + _subscriptions = []; + Completer? mentionFetchGate; + bool failNextMentionFetch = false; + int mentionFetchCount = 0; + int activeMentionFetches = 0; + int maxActiveMentionFetches = 0; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -22,7 +29,63 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { }) async { final h = filter.tags['#h']; if (h != null) dmQueries.add(h); - return const []; + final isMentionFetch = + filter.tags.containsKey('#p') && filter.kinds.contains(40002); + if (isMentionFetch) { + mentionFetchCount += 1; + activeMentionFetches += 1; + if (activeMentionFetches > maxActiveMentionFetches) { + maxActiveMentionFetches = activeMentionFetches; + } + try { + final gate = mentionFetchGate; + if (gate != null) await gate.future; + if (failNextMentionFetch) { + failNextMentionFetch = false; + throw StateError('transient mention history failure'); + } + } finally { + activeMentionFetches -= 1; + } + } + return _history.where((event) => _matches(filter, event)).toList(); + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + final subscription = (filter: filter, onEvent: onEvent); + _subscriptions.add(subscription); + return () => _subscriptions.remove(subscription); + } + + void emit(NostrEvent event) { + _history.add(event); + for (final subscription in List.of(_subscriptions)) { + if (_matches(subscription.filter, event)) { + subscription.onEvent(event); + } + } + } + + void seed(NostrEvent event) => _history.add(event); + + bool _matches(NostrFilter filter, NostrEvent event) { + if (!filter.kinds.contains(event.kind)) return false; + for (final entry in filter.tags.entries) { + final tagName = entry.key.startsWith('#') + ? entry.key.substring(1) + : entry.key; + final matchesTag = event.tags.any( + (tag) => + tag.length > 1 && tag[0] == tagName && entry.value.contains(tag[1]), + ); + if (!matchesTag) return false; + } + return true; } } @@ -55,6 +118,27 @@ Channel _dmChannel(String id) => Channel( isMember: true, ); +NostrEvent _mentionEvent(String id, int createdAt) => NostrEvent( + id: id, + pubkey: 'other_pk', + createdAt: createdAt, + kind: 40002, + tags: const [ + ['p', 'me_pk'], + ['h', 'channel-1'], + ], + content: 'Hello from the live relay', + sig: '', +); + +Future _waitFor(bool Function() predicate) async { + for (var attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Condition was not reached before timeout'); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -105,4 +189,159 @@ void main() { expect(session.dmQueries, isEmpty); }); + + test( + 'refreshes the inbox projection when addressed activity arrives', + () async { + final session = _RecordingSessionNotifier(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(const Duration(milliseconds: 10)); + expect(container.read(inboxItemsProvider), isEmpty); + + session.emit( + const NostrEvent( + id: 'live-mention', + pubkey: 'other_pk', + createdAt: 1_700_000_000, + kind: 40002, + tags: [ + ['p', 'me_pk'], + ['h', 'channel-1'], + ], + content: 'Hello from the live relay', + sig: '', + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(container.read(inboxItemsProvider).single.id, 'live-mention'); + }, + ); + + test( + 'serializes live refreshes and catches up events queued mid-fetch', + () async { + final session = _RecordingSessionNotifier(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(const Duration(milliseconds: 10)); + + session.mentionFetchGate = Completer(); + session.emit(_mentionEvent('live-one', 1_700_000_001)); + await _waitFor(() => session.activeMentionFetches == 1); + + session.emit(_mentionEvent('live-two', 1_700_000_002)); + await Future.delayed(const Duration(milliseconds: 100)); + expect(session.maxActiveMentionFetches, 1); + + session.mentionFetchGate!.complete(); + await _waitFor(() => session.mentionFetchCount >= 3); + await _waitFor(() => container.read(inboxItemsProvider).length == 2); + + expect(session.maxActiveMentionFetches, 1); + expect( + container.read(inboxItemsProvider).map((item) => item.id), + containsAll(['live-one', 'live-two']), + ); + }, + ); + + test('serializes manual and live inbox refreshes', () async { + final session = _RecordingSessionNotifier(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(const Duration(milliseconds: 10)); + + session.mentionFetchGate = Completer(); + final manualRefresh = container.read(activityProvider.notifier).refresh(); + await _waitFor(() => session.activeMentionFetches == 1); + + session.emit(_mentionEvent('live-during-manual', 1_700_000_003)); + await Future.delayed(const Duration(milliseconds: 100)); + expect(session.maxActiveMentionFetches, 1); + + session.mentionFetchGate!.complete(); + await manualRefresh; + await _waitFor(() => session.mentionFetchCount >= 3); + await _waitFor( + () => + container.read(inboxItemsProvider).single.id == 'live-during-manual', + ); + + expect(session.maxActiveMentionFetches, 1); + }); + + test('retains the loaded inbox when a live refresh fails', () async { + final session = _RecordingSessionNotifier() + ..seed(_mentionEvent('existing', 1_700_000_001)); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + await Future.delayed(const Duration(milliseconds: 10)); + expect(container.read(inboxItemsProvider).single.id, 'existing'); + + session.failNextMentionFetch = true; + session.emit(_mentionEvent('newer', 1_700_000_002)); + await _waitFor(() => session.mentionFetchCount >= 2); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(container.read(inboxItemsProvider).single.id, 'existing'); + }); +} + +class _FixedChannelsNotifier extends ChannelsNotifier { + final List channels; + + _FixedChannelsNotifier(this.channels); + + @override + Future> build() async => channels; } diff --git a/mobile/test/features/channels/camera_capture_cleanup_test.dart b/mobile/test/features/channels/camera_capture_cleanup_test.dart index 17d96b0d5..0716af4d9 100644 --- a/mobile/test/features/channels/camera_capture_cleanup_test.dart +++ b/mobile/test/features/channels/camera_capture_cleanup_test.dart @@ -51,4 +51,21 @@ void main() { expect(await file.exists(), isFalse); } }); + + test('retains a composer-owned copy before native cleanup', () async { + final source = File( + '${Directory.systemTemp.path}/buzz-native-${DateTime.now().microsecondsSinceEpoch}.png', + ); + await source.writeAsBytes([4, 5, 6]); + late XFile retained; + + await processCapturedImage(XFile(source.path), (image) async { + retained = (await retainTemporaryImages([image])).single; + }); + addTearDown(() => File(retained.path).delete()); + + expect(await source.exists(), isFalse); + expect(await File(retained.path).exists(), isTrue); + expect(await retained.readAsBytes(), [4, 5, 6]); + }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f9f8fa3b7..912233aba 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -21,7 +21,7 @@ import 'package:buzz/features/channels/thread_detail_page.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 30f8815d7..77dc65fbc 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -12,7 +12,7 @@ import 'package:buzz/features/channels/channel_sections/channel_sections_provide import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; @@ -1312,7 +1312,7 @@ void main() { expect(find.text('Retry'), findsOneWidget); }); - testWidgets('renders and clears unread dot indicator', (tester) async { + testWidgets('bolds and clears unread channel labels', (tester) async { final channels = [ Channel( id: '1', @@ -1356,15 +1356,21 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('channel-unread-dot-1')), findsOneWidget); + expect( + tester.widget(find.text('general')).style?.fontWeight, + FontWeight.w700, + ); readState.markContextRead('1', 20); await tester.pump(); - expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing); + expect( + tester.widget(find.text('general')).style?.fontWeight, + FontWeight.w400, + ); }); - testWidgets('renders numeric unread count for counted events', ( + testWidgets('bolds channels with unread thread activity without a badge', ( tester, ) async { final channels = [ @@ -1423,9 +1429,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('channel-unread-1')), findsOneWidget); - expect(find.text('2'), findsOneWidget); - expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing); + expect( + tester.widget(find.text('general')).style?.fontWeight, + FontWeight.w700, + ); }); testWidgets('seeds first loaded channels as read', (tester) async { @@ -1467,7 +1474,10 @@ void main() { expect(readState.seededContexts, {'1': 20}); expect(readState.markedContexts, isEmpty); - expect(find.byKey(const Key('channel-unread-1')), findsNothing); + expect( + tester.widget(find.text('general')).style?.fontWeight, + FontWeight.w400, + ); }); testWidgets('waits for read-state readiness before initial seeding', ( diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 7dd2c2b60..11bf30682 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -292,6 +292,40 @@ class _EmptyPhotoLibrary implements PhotoLibrary { const []; } +class _FakeVideoUploadService extends MediaUploadService { + final XFile video; + XFile? uploadedVideo; + + _FakeVideoUploadService(this.video) + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + @override + Future pickGalleryVideo() async => video; + + @override + Future uploadVideo( + XFile pickedVideo, { + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadedVideo = pickedVideo; + onProgress?.call(1); + return const BlobDescriptor( + url: 'https://relay.example/media/test.mp4', + sha256: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + size: 32, + type: 'video/mp4', + uploaded: 1, + ); + } +} + class _FakePhotoLibrary implements PhotoLibrary { final List photos; @@ -323,14 +357,22 @@ class _RecordingRelaySocket extends RelaySocket { final List> events; final void Function(List message) handleMessage; - _RecordingRelaySocket(this.events, this.handleMessage) - : super( - wsUrl: 'ws://localhost', - nsec: null, - onMessage: handleMessage, - onConnected: () {}, - onDisconnected: (_) {}, - ); + /// Invoked after an event has been recorded and acknowledged, before the + /// caller's `await` resumes. Lets a test interleave state changes (such as + /// a community switch) between two relay round trips. + final void Function(Map event)? onEventAcknowledged; + + _RecordingRelaySocket( + this.events, + this.handleMessage, { + this.onEventAcknowledged, + }) : super( + wsUrl: 'ws://localhost', + nsec: null, + onMessage: handleMessage, + onConnected: () {}, + onDisconnected: (_) {}, + ); @override SocketState get state => SocketState.connected; @@ -341,6 +383,7 @@ class _RecordingRelaySocket extends RelaySocket { events.add(event); final id = event['id'] as String; super.debugHandleOkForTest(['OK', id, true, '']); + onEventAcknowledged?.call(event); } } @@ -548,7 +591,13 @@ void main() { await tester.pumpWidget( _buildComposeBar( - uploadService: _testUploadService(keysA.nsec), + uploadService: MediaUploadService( + baseUrl: relayUrl, + nsec: keysA.nsec, + pickGalleryImage: () async => + XFile.fromData(_pngBytes, name: 'identity-a.png'), + pickGalleryVideo: () async => null, + ), relayConfig: () => _SwitchableRelayConfigNotifier( RelayConfig(baseUrl: relayUrl, nsec: keysA.nsec), ), @@ -576,6 +625,9 @@ void main() { await tester.enterText(find.byType(TextField), 'identity A secret draft'); await tester.pump(); expect(storedText(keysA), 'identity A secret draft'); + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + expect(find.byTooltip('Remove attachment'), findsOneWidget); // Switch identity in place while the composer stays mounted. final container = ProviderScope.containerOf( @@ -587,9 +639,15 @@ void main() { await tester.pumpAndSettle(); // The mounted composer must not carry identity A's text forward. + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); expect(textField.controller!.text, isEmpty); expect(storedText(keysB), isNull); + expect(find.byTooltip('Remove attachment'), findsNothing); + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); // Identity B's edits persist only into B's store; A's is untouched. await tester.enterText(find.byType(TextField), 'identity B text'); @@ -1392,7 +1450,7 @@ void main() { ]); }); - testWidgets('bounds concurrent system-selected photo uploads', ( + testWidgets('defers system-selected photo uploads until send', ( tester, ) async { final releaseFirstBatch = Completer(); @@ -1444,19 +1502,8 @@ 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)); + expect(requestsStarted, 0); + expect(peakActiveRequests, 0); }); testWidgets('numbers recent photo selection and returns to the menu', ( @@ -1806,63 +1853,344 @@ void main() { ); }); - 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'), - ], - ); + testWidgets( + 'shows post-send upload progress above the composer and cancels', + (tester) async { + final uploadResponse = Completer(); + var sent = false; + 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 { + sent = true; + }, + ), + ); + + await _openSystemPhotoPicker(tester); + await tester.pump(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); + + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsOneWidget, + ); + expect(find.text('Uploading'), findsOneWidget); + expect(find.text('100%'), findsOneWidget); + expect( + find.byKey(const ValueKey('compose-upload-cancel')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('compose-upload-progress-fill')), + ) + .widthFactor, + 1, + ); + final progressFill = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('compose-upload-progress-fill')), + matching: find.byType(ColoredBox), + ), + ); + expect(progressFill.color.a, closeTo(0.12, 0.001)); + expect( + tester + .widget( + find.byKey(const ValueKey('compose-upload-progress-fill')), + ) + .heightFactor, + 1, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('compose-upload-cancel-padding')), + ) + .padding, + const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.quarter, + ), + ); + + await tester.pump(const Duration(milliseconds: 220)); + await tester.tap(find.byKey(const ValueKey('compose-upload-cancel'))); + await tester.pump(); + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); + + 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( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); + expect(sent, isFalse); + }, + ); + + testWidgets('surfaces an error and keeps the draft when a community switch ' + 'cancels a text-only send', (tester) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + var sendCount = 0; await tester.pumpWidget( _buildComposeBar( - uploadService: uploadService, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) async {}, + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayAgents: [_testAgent(agentPubkey)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + onSend: (_, _, {mediaTags = const >[]}) async { + sendCount += 1; + }, ), ); - await _openSystemPhotoPicker(tester); - await tester.pump(); - - expect( - find.byKey(const ValueKey('compose-upload-progress')), - findsOneWidget, + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), ); - 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, + final session = container.read(relaySessionProvider.notifier); + // Switch community the moment the agent's kind:9000 add is + // acknowledged. That is the only window in which the guard can fire: + // everything before the first relay round trip runs in the same + // microtask as the ChannelActions read. + session.debugAttachSocketForTest( + _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + onEventAcknowledged: (event) { + if (event['kind'] != 9000) return; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example', nsec: signer.nsec); + }, ), ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); + // The cancelled send must not reach the relay. + expect(sendCount, 0); + // The escaped StateError itself is pinned by flutter_test's own + // unhandled-exception reporting, which fails this test if the error + // is not caught. Deliberately no `takeException()` row: the framework + // has already consumed the error by this point, so such a row reads + // null whether or not the error escaped, and would never fail. + // + // What needs pinning is the user-visible half. The composer's own + // error line cannot carry it, because the same identity change resets + // that state on the very next frame, so the surface must outlive the + // switch. expect( - find.byKey(const ValueKey('compose-upload-progress')), - findsNothing, + find.widgetWithText( + SnackBar, + 'Message not sent: the community changed', + ), + findsOneWidget, + ); + // Characterization, not a fix: the unsent text is already retained in + // the originating community's own draft store (the send path never + // reaches `clearComposer`, and the persist listener wrote it while + // typing), so it is waiting when the user switches back. This row + // holds without the catch above and exists to keep it that way. + final storedDrafts = _testPrefs.getString( + 'compose_drafts_v1:https://relay.example:${signer.public}', + ); + expect(storedDrafts, isNotNull); + expect( + (jsonDecode(storedDrafts!) as List) + .map((d) => (d as Map)['text']) + .toList(), + contains('hello @Helper Bot'), ); }); + testWidgets( + 'stale channel actions cannot invite after a community switch', + (tester) async { + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + session.debugAttachSocketForTest( + _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ), + ); + final staleActions = container.read(channelActionsProvider); + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other-community.example', nsec: null); + await tester.pump(); + + await expectLater( + staleActions.addMembers( + channelId: 'channel-1', + pubkeys: ['c' * 64], + role: 'bot', + ), + throwsA(isA()), + ); + expect(publishedEvents, isEmpty); + }, + ); + + testWidgets( + 'does not add a mentioned non-member when media upload is cancelled', + (tester) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + final uploadResponse = Completer(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.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, + currentPubkey: signer.public, + relayAgents: [_testAgent(agentPubkey)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + final socket = _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ); + session.debugAttachSocketForTest(socket); + + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + + expect( + publishedEvents.where((event) => event['kind'] == 9000), + isEmpty, + ); + + await tester.pump(const Duration(milliseconds: 220)); + await tester.tap(find.byKey(const ValueKey('compose-upload-cancel'))); + 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( + publishedEvents.where((event) => event['kind'] == 9000), + isEmpty, + ); + }, + ); + testWidgets('renders markdown formatting without visible delimiters', ( tester, ) async { @@ -2034,21 +2362,20 @@ void main() { await tester.pumpAndSettle(); expect(galleryPickerCalled, isFalse); - expect(uploadedBytes, _pngBytes); - expect(uploadedMimeType, 'image/png'); + expect(uploadedBytes, isNull); + expect(find.byTooltip('Remove attachment'), findsOneWidget); expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/pasted.png', - ), + find.byWidgetPredicate( + (widget) => widget is Image && widget.image is MemoryImage, ), findsOneWidget, ); - expect(find.byTooltip('Remove attachment'), findsOneWidget); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); + expect(uploadedBytes, _pngBytes); + expect(uploadedMimeType, 'image/png'); expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); expect(sentMediaTags, hasLength(1)); expect( @@ -2117,14 +2444,7 @@ void main() { pasteImage.onPressed(); await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/ios-native-paste.png', - ), - ), - findsOneWidget, - ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } @@ -2246,14 +2566,7 @@ void main() { pasteImage.onPressed!(); await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/ios-paste.png', - ), - ), - findsOneWidget, - ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } @@ -2422,18 +2735,20 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); - final attachmentFinder = find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/test.png', - ), - ); final removeButtonFinder = find.byTooltip('Remove attachment'); - expect(attachmentFinder, findsOneWidget); expect(removeButtonFinder, findsOneWidget); - final attachmentTopRight = tester.getTopRight(attachmentFinder); - final attachmentTopLeft = tester.getTopLeft(attachmentFinder); + final attachmentTopRight = tester.getTopRight( + find + .ancestor(of: removeButtonFinder, matching: find.byType(Container)) + .first, + ); + final attachmentTopLeft = tester.getTopLeft( + find + .ancestor(of: removeButtonFinder, matching: find.byType(Container)) + .first, + ); final removeButtonCenter = tester.getCenter(removeButtonFinder); expect( @@ -2476,8 +2791,14 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'Keep this draft'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); + expect(find.text('Keep this draft'), findsOneWidget); + expect(find.byTooltip('Remove attachment'), findsOneWidget); }); for (final statusCode in [ @@ -2516,6 +2837,9 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); expect( find.text("We couldn't prepare this image for upload."), @@ -2565,14 +2889,7 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/animated.gif', - ), - ), - findsOneWidget, - ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); }); testWidgets('adds a selected non-member agent as a bot before sending', ( @@ -2860,95 +3177,52 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/animated.png', - ), - ), - findsOneWidget, - ); + expect(find.byTooltip('Remove attachment'), findsOneWidget); }); - // Skip: video upload relies on native platform bridging - // (transcodeVideoToMp4) that can't be fully mocked in widget tests. - testWidgets('taps Video in chooser sheet and uploads video', skip: true, ( + testWidgets('taps Video in chooser sheet and uploads video', ( tester, ) async { - final keychain = nostr.Keys.generate(); - final nsec = keychain.nsec; + final pickedVideo = XFile.fromData( + Uint8List.fromList([0, 1, 2, 3]), + mimeType: 'video/mp4', + name: 'clip.mp4', + ); + final uploadService = _FakeVideoUploadService(pickedVideo); - // Build a temp file with a valid MP4 ftyp header (isom brand). - final mp4Bytes = Uint8List(32); - mp4Bytes[3] = 32; - mp4Bytes[4] = 0x66; // f - mp4Bytes[5] = 0x74; // t - mp4Bytes[6] = 0x79; // y - mp4Bytes[7] = 0x70; // p - mp4Bytes[8] = 0x69; // i - mp4Bytes[9] = 0x73; // s - mp4Bytes[10] = 0x6F; // o - mp4Bytes[11] = 0x6D; // m - final tempDir = await Directory.systemTemp.createTemp('compose_video_'); - final tempFile = File('${tempDir.path}/clip.mp4'); - await tempFile.writeAsBytes(mp4Bytes); + String? sentContent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + }, + ), + ); - try { - final uploadService = MediaUploadService( - baseUrl: 'https://relay.example', - nsec: nsec, - httpClient: http_testing.MockClient((request) async { - return http.Response( - jsonEncode({ - 'url': 'https://relay.example/media/test.mp4', - 'sha256': - '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - 'size': 1024, - 'type': 'video/mp4', - 'uploaded': 1, - }), - 200, - ); - }), - pickGalleryVideo: () async => XFile(tempFile.path), - pickGalleryImage: () async => null, - ); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Video')); + await tester.pumpAndSettle(); - String? sentContent; - await tester.pumpWidget( - _buildComposeBar( - uploadService: uploadService, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) async { - sentContent = content; - }, - ), - ); + expect(find.byIcon(LucideIcons.video), findsOneWidget); - await _openAttachmentMenu(tester); - await tester.tap(find.text('Video')); - // Pump enough frames for the async file read + upload to complete. - // Can't use pumpAndSettle here — the upload spinner's animation - // prevents settling while the async upload is in-flight. - for (var i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } + final sendButton = find + .ancestor( + of: find.byIcon(LucideIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(); + expect(sendButton, findsOneWidget); + await tester.tap(sendButton); + await tester.pumpAndSettle(); - // Video attachment should show a video icon (not a broken image). - expect(find.byIcon(LucideIcons.video), findsOneWidget); - - await tester.tap(find.byIcon(LucideIcons.arrowUp)); - await tester.pump(); - await tester.pumpAndSettle(); - - expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); - } finally { - await tempDir.delete(recursive: true); - } + expect(uploadService.uploadedVideo, same(pickedVideo)); + expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); }); }); diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 03d7951b0..c95df3446 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,5 +1,5 @@ import 'package:buzz/features/channels/message_actions.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/reminders/reminder_service.dart'; diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 9d8adb69b..7c84a7471 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -14,9 +14,15 @@ Widget _testable( Widget child, { List overrides = const [], bool disableAnimations = false, + VideoPreviewFrameLoader? videoPreviewFrameLoader, }) { return ProviderScope( - overrides: overrides, + overrides: [ + videoPreviewFrameLoaderProvider.overrideWithValue( + videoPreviewFrameLoader ?? (_) async => null, + ), + ...overrides, + ], child: MaterialApp( theme: AppTheme.light(), home: Builder( @@ -1075,6 +1081,52 @@ Photos expect(find.byIcon(LucideIcons.play), findsOneWidget); }); + testWidgets('derives a first frame for a posterless video event', ( + tester, + ) async { + const videoUrl = 'https://example.com/media/legacy-video.mp4'; + var disposed = false; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![video]($videoUrl)', + tags: [ + ['imeta', 'url $videoUrl', 'm video/mp4', 'dim 1080x1920'], + ], + ), + videoPreviewFrameLoader: (url) async { + expect(url, videoUrl); + return LoadedVideoPreviewFrame( + aspectRatio: 9 / 16, + child: const ColoredBox( + key: ValueKey('derived-video-frame'), + color: Colors.blue, + ), + dispose: () async => disposed = true, + ); + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey('message-media-video-first-frame:$videoUrl'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('derived-video-frame')), + findsOneWidget, + ); + expect(find.text('Video attachment'), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(disposed, isTrue); + }); + testWidgets( 'tapping video preview opens overlay viewer with close button', (tester) async { @@ -1114,6 +1166,15 @@ Photos ); expect(viewer.backgroundColor, Colors.black); expect(viewer.appBar, isNull); + expect( + find.descendant( + of: find.byKey( + const ValueKey('message-media-video-viewer-reply-thread'), + ), + matching: find.byType(IconButton), + ), + findsNothing, + ); // Close button is present expect( @@ -1134,6 +1195,46 @@ Photos }, ); + testWidgets('swiping down on the video dismisses its viewer', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![video](https://example.com/media/clip.mp4)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/clip.mp4', + 'm video/mp4', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mp4', + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ValueKey('message-media-video-viewer-gesture')), + const Offset(0, 140), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-media-video-viewer')), + findsNothing, + ); + }); + testWidgets('treats only mp4 fallback URLs as videos', (tester) async { await tester.pumpWidget( _testable( @@ -1172,6 +1273,43 @@ Photos findsOneWidget, ); }); + + testWidgets('renders an explicitly tagged non-mp4 video preview', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '![video](https://example.com/media/clip.mov)', + tags: [ + [ + 'imeta', + 'url https://example.com/media/clip.mov', + 'm video/quicktime', + ], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey( + 'message-media-video-preview:https://example.com/media/clip.mov', + ), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/media/clip.mov', + ), + ), + findsNothing, + ); + }); }); group('blockquotes', () { diff --git a/mobile/test/features/channels/message_media_test.dart b/mobile/test/features/channels/message_media_test.dart index b5ddf0ee7..8e61a71dd 100644 --- a/mobile/test/features/channels/message_media_test.dart +++ b/mobile/test/features/channels/message_media_test.dart @@ -12,7 +12,7 @@ void main() { expect(classifyMediaUrl('https://example.com/media/clip.webm'), isNull); }); - test('does not treat non-mp4 video mimetypes as video UI', () { + test('uses explicit video mimetypes for the video UI', () { expect( classifyMediaUrl( 'https://example.com/media/clip.mov', @@ -21,7 +21,7 @@ void main() { mimeType: 'video/quicktime', ), ), - isNull, + MessageMediaKind.video, ); expect( classifyMediaUrl( diff --git a/mobile/test/features/channels/read_state/message_read_state_test.dart b/mobile/test/features/channels/read_state/message_read_state_test.dart index 97d1317ac..0415546a7 100644 --- a/mobile/test/features/channels/read_state/message_read_state_test.dart +++ b/mobile/test/features/channels/read_state/message_read_state_test.dart @@ -1,5 +1,5 @@ -import 'package:buzz/features/channels/read_state/message_read_state.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/message_read_state.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:flutter_test/flutter_test.dart'; ReadStateState _state( diff --git a/mobile/test/features/channels/read_state/read_state_format_test.dart b/mobile/test/features/channels/read_state/read_state_format_test.dart index d8aa65739..57aa10d4c 100644 --- a/mobile/test/features/channels/read_state/read_state_format_test.dart +++ b/mobile/test/features/channels/read_state/read_state_format_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/features/channels/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; void main() { diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index 953d2ff10..9f3301408 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -4,8 +4,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:buzz/features/channels/read_state/read_state_format.dart'; -import 'package:buzz/features/channels/read_state/read_state_manager.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_manager.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { diff --git a/mobile/test/features/channels/read_state/read_state_provider_test.dart b/mobile/test/features/channels/read_state/read_state_provider_test.dart index 40cd06362..f01aa2e81 100644 --- a/mobile/test/features/channels/read_state/read_state_provider_test.dart +++ b/mobile/test/features/channels/read_state/read_state_provider_test.dart @@ -1,5 +1,5 @@ -import 'package:buzz/features/channels/read_state/read_state_format.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; diff --git a/mobile/test/features/channels/read_state/read_state_time_test.dart b/mobile/test/features/channels/read_state/read_state_time_test.dart index f6875aafe..f56e97014 100644 --- a/mobile/test/features/channels/read_state/read_state_time_test.dart +++ b/mobile/test/features/channels/read_state/read_state_time_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/features/channels/read_state/read_state_time.dart'; +import 'package:buzz/shared/read_state/read_state_time.dart'; void main() { test('dateTimeToUnixSeconds converts DateTime values and nulls', () { diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index f91ce87b2..273336f28 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -66,6 +67,30 @@ void main() { expect(completedIds, isEmpty); expect(removedIds, [localMessages.single.id]); }); + + test('cancels delivery after the active community changes', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://first.example'); + final send = container.read(sendMessageProvider); + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://second.example'); + + await expectLater( + send(channelId: _channelId, content: 'old community draft'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('active community changed'), + ), + ), + ); + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; diff --git a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart index 987bd96b0..6c385bfd4 100644 --- a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart +++ b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart'; diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 7f83a33c8..f834bda64 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -160,6 +160,30 @@ void main() { _testPrefs = await SharedPreferences.getInstance(); }); + test('cancels a captured forum delivery after the community changes', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://first.example'); + final delivery = ForumEventDelivery.capture(container); + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://second.example'); + + expect( + delivery.createPost(channelId: _channelId, content: 'Queued post'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('active community changed'), + ), + ), + ); + }); + group('ForumPostCard', () { testWidgets('renders author name and content', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index cf688ce72..f22c25856 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -8,14 +8,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - Future buildHome() async { + Future buildHome({ + int unreadInboxCount = 0, + bool disableAnimations = false, + }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); return ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], child: MaterialApp( theme: AppTheme.light(), - home: const HomePage(settingsPageBuilder: _buildSettingsPage), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: child!, + ), + home: HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: unreadInboxCount > 0, + ), ), ); } @@ -95,6 +107,166 @@ void main() { expect(hapticCalls, hasLength(2)); }); + testWidgets('gives a light impact when the Home quick action is pressed', ( + tester, + ) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + hapticCalls.add(call); + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget(await buildHome()); + await tester.pump(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + + expect(hapticCalls, hasLength(1)); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact'); + }); + + testWidgets('badges the Inbox tab when it has unread rows', (tester) async { + await tester.pumpWidget(await buildHome(unreadInboxCount: 1)); + await tester.pump(); + + expect( + find.byKey(const ValueKey('activity-tab-unread-dot')), + findsOneWidget, + ); + final badge = tester.widget( + find.byKey(const ValueKey('activity-tab-unread-dot')), + ); + expect(badge.constraints?.maxWidth, 12); + expect(badge.constraints?.maxHeight, 12); + expect(find.bySemanticsLabel('Activity, unread'), findsOneWidget); + AnimatedScale unreadDotScale() => tester.widget( + find.byKey(const ValueKey('activity-tab-unread-dot-scale')), + ); + expect(unreadDotScale().scale, 1); + expect(unreadDotScale().alignment, const Alignment(-0.5, 0.5)); + expect(unreadDotScale().duration, const Duration(milliseconds: 220)); + + await tester.tap(find.byTooltip('Activity')); + await tester.pump(); + + expect( + find.byKey(const ValueKey('activity-tab-unread-dot')), + findsOneWidget, + ); + expect(unreadDotScale().scale, 0); + expect(find.bySemanticsLabel('Activity, unread'), findsNothing); + + await tester.tap(find.byTooltip('Home')); + await tester.pump(); + + expect(unreadDotScale().scale, 1); + }); + + testWidgets('fades and slides tab content in the selected direction', ( + tester, + ) async { + await tester.pumpWidget(await buildHome()); + await tester.pump(); + + Transform bodyTransform() => tester.widget( + find.byKey(const ValueKey('frosted-scaffold-body-transition-transform')), + ); + Opacity bodyOpacity() => tester.widget( + find.byKey(const ValueKey('frosted-scaffold-body-transition-opacity')), + ); + Transform appBarTransform() => tester.widget( + find.byKey( + const ValueKey('frosted-app-bar-content-transition-transform'), + ), + ); + Opacity appBarOpacity() => tester.widget( + find.byKey(const ValueKey('frosted-app-bar-content-transition-opacity')), + ); + double bodyOffset() => bodyTransform().transform.getTranslation().x; + double appBarOffset() => appBarTransform().transform.getTranslation().x; + + expect(bodyOffset(), closeTo(0, 0.001)); + expect(appBarOffset(), closeTo(0, 0.001)); + expect(bodyOpacity().opacity, closeTo(1, 0.001)); + expect(appBarOpacity().opacity, closeTo(1, 0.001)); + + await tester.tap(find.byTooltip('Activity')); + await tester.pump(); + + expect(bodyOffset(), closeTo(24, 0.001)); + expect(appBarOffset(), closeTo(24, 0.001)); + expect(bodyOpacity().opacity, closeTo(0, 0.001)); + expect(appBarOpacity().opacity, closeTo(0, 0.001)); + expect( + find.descendant( + of: find.byKey(const ValueKey('frosted-app-bar-background')), + matching: find.byKey( + const ValueKey('frosted-app-bar-content-transition-transform'), + ), + ), + findsOneWidget, + ); + + await tester.pump(const Duration(milliseconds: 120)); + + expect(bodyOffset(), inExclusiveRange(0, 24)); + expect(appBarOffset(), inExclusiveRange(0, 24)); + expect(bodyOpacity().opacity, inExclusiveRange(0, 1)); + expect(appBarOpacity().opacity, inExclusiveRange(0, 1)); + + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Home')); + await tester.pump(); + + expect(bodyOffset(), closeTo(-24, 0.001)); + expect(appBarOffset(), closeTo(-24, 0.001)); + expect(bodyOpacity().opacity, closeTo(0, 0.001)); + expect(appBarOpacity().opacity, closeTo(0, 0.001)); + + await tester.pumpAndSettle(); + expect(bodyOffset(), closeTo(0, 0.001)); + expect(appBarOffset(), closeTo(0, 0.001)); + expect(bodyOpacity().opacity, closeTo(1, 0.001)); + expect(appBarOpacity().opacity, closeTo(1, 0.001)); + }); + + testWidgets('switches tab content instantly with reduced motion', ( + tester, + ) async { + await tester.pumpWidget(await buildHome(disableAnimations: true)); + await tester.pump(); + + await tester.tap(find.byTooltip('Activity')); + await tester.pump(); + + final bodyTransform = tester.widget( + find.byKey(const ValueKey('frosted-scaffold-body-transition-transform')), + ); + final bodyOpacity = tester.widget( + find.byKey(const ValueKey('frosted-scaffold-body-transition-opacity')), + ); + final appBarTransform = tester.widget( + find.byKey( + const ValueKey('frosted-app-bar-content-transition-transform'), + ), + ); + final appBarOpacity = tester.widget( + find.byKey(const ValueKey('frosted-app-bar-content-transition-opacity')), + ); + expect(bodyTransform.transform.getTranslation().x, closeTo(0, 0.001)); + expect(appBarTransform.transform.getTranslation().x, closeTo(0, 0.001)); + expect(bodyOpacity.opacity, closeTo(1, 0.001)); + expect(appBarOpacity.opacity, closeTo(1, 0.001)); + }); + testWidgets('scales and fades the quick action as tabs change', ( tester, ) async { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index dcd04359b..815f41c53 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -1340,6 +1341,184 @@ void main() { } }); + test( + 'stops before reading a transcode completed after cancellation', + () async { + final transcodeStarted = Completer(); + final transcodeFinished = Completer(); + final cancellationToken = UploadCancellationToken(); + var uploadRequested = false; + final (xfile, sourceFile) = await writeTempVideo( + buildFtypHeader('qt '), + 'clip.mov', + ); + final outputDirectory = await Directory.systemTemp.createTemp( + 'cancelled_transcode_', + ); + final outputFile = File('${outputDirectory.path}/out.mp4'); + final outputHandle = await outputFile.open(mode: FileMode.write); + await outputHandle.truncate(101 * 1024 * 1024); + await outputHandle.close(); + + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((_) async { + uploadRequested = true; + return http.Response('', HttpStatus.internalServerError); + }), + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (_) { + transcodeStarted.complete(); + return transcodeFinished.future; + }, + ); + + final upload = service.uploadVideo( + xfile, + cancellationToken: cancellationToken, + ); + final expectation = expectLater( + upload, + throwsA(isA()), + ); + await transcodeStarted.future; + cancellationToken.cancel(); + transcodeFinished.complete(outputFile.path); + await expectation; + + expect(uploadRequested, isFalse); + expect(await outputFile.exists(), isFalse); + } finally { + await sourceFile.parent.delete(recursive: true); + await outputDirectory.delete(recursive: true); + } + }, + ); + + test( + 'uploads a generated poster and links it from the video imeta', + () async { + final keychain = nostr.Keys.generate(); + final requestTypes = []; + final client = http_testing.MockClient((request) async { + final type = request.headers['Content-Type']!; + requestTypes.add(type); + final isPoster = type == 'image/jpeg'; + return http.Response( + jsonEncode({ + 'url': isPoster + ? 'https://relay.example/media/poster.jpg' + : 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 32, + 'type': type, + 'uploaded': 1, + }), + 200, + ); + }); + + final (xfile, tempFile) = await writeTempVideo( + buildFtypHeader('qt '), + 'clip.mov', + ); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + final outDir = await Directory.systemTemp.createTemp( + 'transcode_', + ); + final outFile = File('${outDir.path}/out.mp4'); + await outFile.writeAsBytes(buildFtypHeader('isom')); + return outFile.path; + }, + generateVideoPoster: (_) async => _jpegBytes, + sanitizeImageBytes: (bytes, _) async => bytes, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadVideo(); + + expect(descriptor?.image, 'https://relay.example/media/poster.jpg'); + expect( + descriptor?.toImetaTag(), + contains('image https://relay.example/media/poster.jpg'), + ); + expect(requestTypes, ['video/mp4', 'image/jpeg']); + } finally { + await tempFile.parent.delete(recursive: true); + } + }, + ); + + test( + 'falls back to the picked video when the exported poster fails', + () async { + final sourcePaths = []; + final client = http_testing.MockClient((request) async { + final type = request.headers['Content-Type']!; + return http.Response( + jsonEncode({ + 'url': type == 'image/jpeg' + ? 'https://relay.example/media/poster.jpg' + : 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': request.bodyBytes.length, + 'type': type, + 'uploaded': 1, + }), + 200, + ); + }); + + final (xfile, tempFile) = await writeTempVideo( + buildFtypHeader('qt '), + 'clip.mov', + ); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + final outDir = await Directory.systemTemp.createTemp( + 'transcode_', + ); + final outFile = File('${outDir.path}/out.mp4'); + await outFile.writeAsBytes(buildFtypHeader('isom')); + return outFile.path; + }, + generateVideoPoster: (path) async { + sourcePaths.add(path); + if (sourcePaths.length == 1) throw Exception('frame unavailable'); + return _jpegBytes; + }, + sanitizeImageBytes: (bytes, _) async => bytes, + ); + + final descriptor = await service.pickAndUploadVideo(); + + expect(sourcePaths, hasLength(2)); + expect(sourcePaths.last, xfile.path); + expect(descriptor?.image, 'https://relay.example/media/poster.jpg'); + } finally { + await tempFile.parent.delete(recursive: true); + } + }, + ); + test('returns null when video picker is cancelled', () async { final service = MediaUploadService( baseUrl: 'https://relay.example', From ddcf0aef9f1b3c81ec5a9b709dd62d2fcc773996 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Tue, 4 Aug 2026 19:20:29 +0530 Subject: [PATCH 02/30] fix(desktop): stop clipping focus ring on channel intro action cards (#2392) (#4374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fixes #2392 — the action cards in the empty-channel intro ("Create agent", "Add people") had their `focus-visible` ring clipped by the surrounding scroll container. ## Root cause The cards sit in a `flex ... overflow-x-auto pb-1` row. Setting `overflow-x` (without `overflow-y`) makes the browser compute `overflow-y: auto` as well, so the container clips anything painted outside its padding box — including the cards' `focus-visible:ring-2` box-shadow. With only `pb-1` padding, the top/left/right of the ring were cut off when Tabbing to a card. ## Change `desktop/src/features/messages/ui/ChannelIntroBlock.tsx` — `pb-1` → `p-1` on the action-cards scroll container, reserving 4px on all four sides so the focus ring renders fully inside the scroll container's padding box. - 1 file, 1 line. No behavior change for mouse users or layout. ## Verification - `pnpm typecheck` — clean - `pnpm exec biome check src/features/messages/ui/ChannelIntroBlock.tsx` — clean - `pnpm check:file-sizes` — clean - Desktop unit suite — **3906/3906 pass** Signed-off-by: Sarthak Singh Signed-off-by: Sarthak Singh --- desktop/src/features/messages/ui/ChannelIntroBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index 98300acb3..76d1960b9 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -63,7 +63,7 @@ export function ChannelIntroBlock({

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); From f18a9cb10688deaa3f618869170bfe9303c4be62 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 14:59:43 +0100 Subject: [PATCH 03/30] Defer desktop media uploads until send (#4522) ## Summary - send desktop messages immediately while media uploads continue in background state across channel navigation - show immediate progress above the composer and keep Jump to latest above it - report the real media stages as Preparing, Processing, Converting, Uploading, and Finishing - use Buzz's shared spinner during local media work, then switch to the real percentage when byte transfer begins - animate phase-label and status-suffix changes without overlap or layout jumps - keep cancel, progress fill, message publication, and community-reset behavior coordinated with the background task - use raw Tauri IPC for large browser files so renderer-side byte serialization does not block initial feedback ## Why Desktop previously blocked sending while attachments uploaded in the composer. Large videos could also pause the renderer before progress appeared, and the progress pill said Uploading while native media processing was still underway. This makes the initial response immediate and describes the work actually happening. ## Validation - `cd desktop && pnpm check` - `cd desktop && pnpm typecheck` - `cd desktop && pnpm test` (3,931 passed) - `cd desktop && pnpm exec vite build --mode e2e` - `cd desktop && pnpm exec playwright test tests/e2e/file-attachment.spec.ts --project=smoke` (11 passed) - focused native media tests (80 passed) - native Clippy with all targets and features - pre-push native suite (2,107 passed, 14 ignored; 3 diagnostics passed) Updated phase snapshots are included in the PR comments. Split from #4512 so the desktop and mobile changes can be reviewed independently. --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/commands/media.rs | 148 ++++---- desktop/src-tauri/src/commands/media_raw.rs | 96 +++++ .../src-tauri/src/commands/media_transcode.rs | 94 +++-- .../src/commands/media_upload_progress.rs | 126 +++++++ desktop/src-tauri/src/commands/mod.rs | 3 + desktop/src-tauri/src/lib.rs | 4 +- .../src/features/channels/ui/ChannelPane.tsx | 22 +- .../channels/useChannelPaneHandlers.ts | 16 +- .../features/communities/useCommunityInit.ts | 2 + desktop/src/features/home/useHomeDrafts.ts | 4 +- .../lib/backgroundMediaUploadPhase.test.mjs | 49 +++ .../lib/backgroundMediaUploadPhase.ts | 60 +++ .../lib/backgroundMediaUploadStore.ts | 350 ++++++++++++++++++ .../features/messages/lib/useDrafts.test.mjs | 14 + .../src/features/messages/lib/useDrafts.ts | 28 +- .../features/messages/lib/useMediaUpload.ts | 192 +++++++++- .../messages/ui/ComposerAttachments.tsx | 100 ++++- .../messages/ui/ComposerReplyEditBanner.tsx | 3 + .../ui/ComposerUploadProgressOverlay.tsx | 21 ++ .../ui/ComposerUploadProgressPill.tsx | 171 +++++++++ .../features/messages/ui/MessageComposer.tsx | 266 ++++++------- .../messages/ui/MessageComposer.types.ts | 5 + .../MessageComposerDraftImagePersist.test.mjs | 83 +++++ .../features/messages/ui/MessageTimeline.tsx | 5 +- .../features/messages/ui/NewMessageScreen.tsx | 7 +- .../features/messages/ui/submitMessageEdit.ts | 135 +++++++ .../messages/ui/useComposerHeightPadding.ts | 12 +- .../messages/ui/useDraftPersistSnapshot.ts | 39 ++ .../messages/ui/useMentionSendFlow.helpers.ts | 76 ++++ .../messages/ui/useMentionSendFlow.ts | 293 +++++++++------ desktop/src/shared/api/tauri.ts | 8 +- desktop/src/shared/api/tauriMedia.ts | 39 ++ desktop/src/testing/e2eBridge.ts | 29 +- desktop/tests/e2e/file-attachment.spec.ts | 231 +++++++++++- desktop/tests/helpers/bridge.ts | 2 + 35 files changed, 2330 insertions(+), 403 deletions(-) create mode 100644 desktop/src-tauri/src/commands/media_raw.rs create mode 100644 desktop/src-tauri/src/commands/media_upload_progress.rs create mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs create mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts create mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadStore.ts create mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx create mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx create mode 100644 desktop/src/features/messages/ui/submitMessageEdit.ts create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b34023..86a91a984 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -3,17 +3,17 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; -use crate::relay::{ - classify_request_error, parse_json_response, relay_api_base_url_with_override, - relay_error_message, -}; +use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, - transcode_heic_path_to_jpeg_bytes, + transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, + transcode_heic_path_to_jpeg_bytes_with_cancellation, }; +use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -410,51 +410,6 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { ) } -async fn send_upload_attempt( - state: &AppState, - url: String, - auth_header: &str, - mime: &str, - sha256: &str, - body: bytes::Bytes, - progress: Option<&(tauri::AppHandle, String)>, -) -> Result { - let req = state - .http_client - .put(url) - .header("Authorization", auth_header) - .header("Content-Type", mime) - .header("X-SHA-256", sha256); - - let response = if let Some((app, progress_id)) = progress { - use tauri::Emitter; - let app = app.clone(); - let progress_id = progress_id.clone(); - let total = body.len() as u64; - let chunk_size = 64 * 1024; - let chunk_count = body.len().div_ceil(chunk_size); - let mut sent: u64 = 0; - let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { - let start = i * chunk_size; - let end = usize::min(start + chunk_size, body.len()); - let chunk = body.slice(start..end); - sent += chunk.len() as u64; - let _ = app.emit( - "media-upload-progress", - serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), - ); - Ok::(chunk) - })); - req.header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send() - .await - } else { - req.body(body).send().await - }; - response.map_err(|error| classify_request_error(&error)) -} - pub(crate) async fn upload_image_bytes( body: Vec, state: &AppState, @@ -464,7 +419,7 @@ pub(crate) async fn upload_image_bytes( return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None).await + do_upload(body, &mime, state, None, None).await } async fn do_upload( @@ -472,6 +427,7 @@ async fn do_upload( mime: &str, state: &AppState, progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -494,25 +450,34 @@ async fn do_upload( URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); let body = bytes::Bytes::from(body); + if let Some((app, progress_id)) = progress.as_ref() { + emit_media_upload_phase(app, Some(progress_id.as_str()), "uploading"); + } let mut resp = send_upload_attempt( state, - format!("{base_url}/upload"), - &auth_header, - mime, - &sha256, - body.clone(), - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body: body.clone(), + progress: progress.as_ref(), + cancellation, + }, ) .await?; if should_retry_legacy_upload(resp.status()) { resp = send_upload_attempt( state, - format!("{base_url}/media/upload"), - &auth_header, - mime, - &sha256, - body, - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/media/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body, + progress: progress.as_ref(), + cancellation, + }, ) .await?; } @@ -559,7 +524,7 @@ pub async fn upload_media( let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None).await + do_upload(body, &mime, &state, None, None).await } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -573,6 +538,7 @@ async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, + progress: Option<(tauri::AppHandle, String)>, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -639,10 +605,9 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, None).await?; - + let mut descriptor = do_upload(body, &mime, state, progress, None).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None).await { + match do_upload(poster, "image/jpeg", state, None, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -675,6 +640,7 @@ async fn process_picked_path( #[tauri::command] pub async fn pick_and_upload_media( app: tauri::AppHandle, + progress_id: Option, state: State<'_, AppState>, ) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -694,7 +660,8 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, false).await?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; descriptors.push(descriptor); } @@ -735,30 +702,37 @@ pub async fn pick_and_upload_image( }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true).await?; + let descriptor = process_picked_path(path, &state, true, None).await?; Ok(Some(descriptor)) } -/// Upload raw bytes directly (for paste and drag-drop). -/// -/// The renderer already has the bytes in memory from the clipboard/drag event. -/// If the bytes are a video, they're written to a temp file, transcoded via -/// ffmpeg, and the transcoded output is uploaded instead. -#[tauri::command] -pub async fn upload_media_bytes( +pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, progress_id: Option, app: tauri::AppHandle, state: State<'_, AppState>, + cancellation: Option<&CancellationToken>, ) -> Result { if data.is_empty() { return Err("empty upload".to_string()); } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + + emit_media_upload_phase(&app, progress_id.as_deref(), "preparing"); + + let heic_by_extension = filename + .as_deref() + .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let (body, poster_bytes) = if is_video_file(&data) { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video"); // Video: write to temp → transcode + extract poster → read results. // All blocking I/O runs off the async runtime via spawn_blocking. + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -766,17 +740,19 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_and_extract_poster(&tmp_input) + transcode_and_extract_poster_with_cancellation(&tmp_input, cancellation.as_ref()) })(); let _ = std::fs::remove_file(&tmp_input); result }) .await .map_err(|e| format!("transcode task failed: {e}"))?? - } else if is_heic_file(&data) { + } else if is_heic_file(&data) || heic_by_extension { + emit_media_upload_phase(&app, progress_id.as_deref(), "converting-image"); // HEIC/HEIF still pasted/dropped: no filename here, so detection is // magic-bytes only. ffmpeg needs a path, so write to temp, transcode // to JPEG, and clean up. (Mirrors mobile's pre-upload transcode.) + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -784,7 +760,11 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_heic_path_to_jpeg_bytes(&tmp_input).map(|jpeg| (jpeg, None)) + transcode_heic_path_to_jpeg_bytes_with_cancellation( + &tmp_input, + cancellation.as_ref(), + ) + .map(|jpeg| (jpeg, None)) })(); let _ = std::fs::remove_file(&tmp_input); result @@ -799,11 +779,15 @@ pub async fn upload_media_bytes( let body = sanitize_image_for_upload(body, &mime)?; // Upload video first, then poster (best-effort). - let progress = progress_id.map(|id| (app, id)); - let mut descriptor = do_upload(body, &mime, &state, progress).await?; + let progress = progress_id.as_ref().map(|id| (app.clone(), id.clone())); + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?; + emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None).await { + match do_upload(poster, "image/jpeg", &state, None, cancellation).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs new file mode 100644 index 000000000..a74ccd4df --- /dev/null +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -0,0 +1,96 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use tauri::{ + ipc::{InvokeBody, Request}, + State, +}; + +use crate::app_state::AppState; + +use super::{ + media::{upload_media_bytes_inner, BlobDescriptor}, + media_upload_progress::{ + begin_media_upload, cancel_media_upload as cancel_registered_media_upload, + finish_media_upload, + }, +}; + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// If the bytes are a video, they're written to a temp file, transcoded via +/// ffmpeg, and the transcoded output is uploaded instead. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + filename: Option, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + upload_media_bytes_inner(data, filename, progress_id, app, state, None).await +} + +fn decode_raw_upload_header(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|error| format!("invalid raw upload header: {error}"))?; + String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}")) +} + +fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result, String> { + request + .headers() + .get(name) + .map(|value| { + value + .to_str() + .map_err(|error| format!("invalid {name} header: {error}")) + .and_then(decode_raw_upload_header) + }) + .transpose() +} + +/// Cancel the native upload associated with a background progress ID. +#[tauri::command] +pub fn cancel_media_upload(progress_id: String) { + cancel_registered_media_upload(&progress_id); +} + +/// Upload raw IPC bytes without expanding a large browser File into JSON. +#[tauri::command] +pub async fn upload_media_bytes_raw( + request: Request<'_>, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + }; + let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; + let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; + + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_raw_upload_header_preserves_unicode() { + let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); + assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 46a5decaa..3fb7eda5f 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -6,6 +6,7 @@ //! `validate_video_file()`) and to produce a JPEG poster frame. use crate::managed_agents::resolve_command; +use tokio_util::sync::CancellationToken; /// Build an ffmpeg command without inheriting user-controlled process knobs. /// @@ -121,7 +122,7 @@ pub(super) fn has_heic_extension(path: &std::path::Path) -> bool { /// blocking a Tokio worker thread indefinitely. const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); -/// Run an ffmpeg command with a wall-clock timeout. +/// Run an ffmpeg command with a wall-clock timeout and optional cancellation. /// /// Spawns the child process, polls `try_wait()` every 500ms, and kills it /// if the deadline is exceeded. Returns the same `Output` as `Command::output()`. @@ -131,10 +132,14 @@ const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); /// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB), /// the child blocks on write() and never exits — causing a false timeout. /// `-loglevel error` suppresses progress spam, keeping stderr small. -pub(super) fn run_ffmpeg_with_timeout( +fn run_ffmpeg_with_cancellation( cmd: &mut std::process::Command, timeout: std::time::Duration, + cancellation: Option<&CancellationToken>, ) -> Result { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; @@ -162,6 +167,11 @@ pub(super) fn run_ffmpeg_with_timeout( } Ok(None) => { // Still running — check deadline. + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = child.kill(); + let _ = child.wait(); + return Err("upload cancelled".to_string()); + } if std::time::Instant::now() > deadline { let _ = child.kill(); let _ = child.wait(); // reap zombie @@ -181,14 +191,15 @@ pub(super) fn run_ffmpeg_with_timeout( /// relay's `validate_video_file()`. /// /// Returns the path to a temp file. Caller must clean up. -pub(super) fn transcode_to_mp4( +fn transcode_to_mp4_with_cancellation( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4())); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -240,7 +251,11 @@ pub(super) fn transcode_to_mp4( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), FFMPEG_TIMEOUT, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -265,9 +280,10 @@ pub(super) fn transcode_to_mp4( /// Uses `-frames:v 1` so multi-image HEIF containers (Live Photos, bursts) /// yield a single still, and `-q:v 2` for high JPEG quality. Returns the path /// to a temp file. Caller must clean up. -pub(super) fn transcode_heic_to_jpeg( +fn transcode_heic_to_jpeg( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-heic-{}.jpg", uuid::Uuid::new_v4())); @@ -275,7 +291,7 @@ pub(super) fn transcode_heic_to_jpeg( // Single-frame image decode — 60s is generous even for large HEICs. let heic_timeout = std::time::Duration::from_secs(60); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -301,7 +317,11 @@ pub(super) fn transcode_heic_to_jpeg( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), heic_timeout, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -323,9 +343,16 @@ pub(super) fn transcode_heic_to_jpeg( /// file. Mirrors `transcode_and_extract_poster` but for images (no poster). pub(super) fn transcode_heic_path_to_jpeg_bytes( source: &std::path::Path, +) -> Result, String> { + transcode_heic_path_to_jpeg_bytes_with_cancellation(source, None) +} + +pub(super) fn transcode_heic_path_to_jpeg_bytes_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result, String> { let ffmpeg_path = find_ffmpeg()?; - let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path)?; + let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path, cancellation)?; let bytes = std::fs::read(&jpeg_path).map_err(|e| format!("failed to read transcoded HEIC: {e}")); let _ = std::fs::remove_file(&jpeg_path); @@ -340,9 +367,10 @@ pub(super) fn transcode_heic_path_to_jpeg_bytes( /// /// Best-effort: returns `Err` on failure — callers should log and continue /// without a poster rather than failing the entire video upload. -pub(super) fn extract_poster_frame( +fn extract_poster_frame_with_cancellation( mp4_path: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { let output = std::env::temp_dir().join(format!("buzz-poster-{}.jpg", uuid::Uuid::new_v4())); @@ -350,7 +378,7 @@ pub(super) fn extract_poster_frame( let poster_timeout = std::time::Duration::from_secs(30); // Try seeking to 1s first (avoids black first frames from fade-ins). - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -369,6 +397,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; // If seek to 1s failed (video shorter than 1s), retry from first frame. @@ -381,7 +410,7 @@ pub(super) fn extract_poster_frame( eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}"); } let _ = std::fs::remove_file(&output); - let fallback = run_ffmpeg_with_timeout( + let fallback = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -398,6 +427,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; if !fallback.status.success() || !output.exists() { @@ -417,22 +447,35 @@ pub(super) fn extract_poster_frame( /// and the video bytes are still valid. All temp files are cleaned up. pub(super) fn transcode_and_extract_poster( source: &std::path::Path, +) -> Result<(Vec, Option>), String> { + transcode_and_extract_poster_with_cancellation(source, None) +} + +pub(super) fn transcode_and_extract_poster_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result<(Vec, Option>), String> { let ffmpeg_path = find_ffmpeg()?; - let transcoded = transcode_to_mp4(source, &ffmpeg_path)?; + let transcoded = transcode_to_mp4_with_cancellation(source, &ffmpeg_path, cancellation)?; // Extract poster from the transcoded file (not the original — guarantees decodability). - let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) { - Ok(poster_path) => { - let bytes = std::fs::read(&poster_path).ok(); - let _ = std::fs::remove_file(&poster_path); - bytes - } - Err(e) => { - eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); - None - } - }; + let poster_bytes = + match extract_poster_frame_with_cancellation(&transcoded, &ffmpeg_path, cancellation) { + Ok(poster_path) => { + let bytes = std::fs::read(&poster_path).ok(); + let _ = std::fs::remove_file(&poster_path); + bytes + } + Err(e) => { + eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); + None + } + }; + + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = std::fs::remove_file(&transcoded); + return Err("upload cancelled".to_string()); + } let video_bytes = std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}")); @@ -599,7 +642,8 @@ mod tests { return; } - let output = transcode_to_mp4(&source, &ffmpeg).expect("transcode fixture"); + let output = + transcode_to_mp4_with_cancellation(&source, &ffmpeg, None).expect("transcode fixture"); let bytes = std::fs::read(&output).expect("read transcoded video"); let _ = std::fs::remove_file(&source); let _ = std::fs::remove_file(&output); diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs new file mode 100644 index 000000000..850afe1b1 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -0,0 +1,126 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tauri::Emitter; +use tokio_util::sync::CancellationToken; + +use crate::{app_state::AppState, relay::classify_request_error}; + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { + let progress_id = progress_id?; + let cancel = CancellationToken::new(); + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.insert(progress_id.to_string(), cancel.clone()); + } + Some(cancel) +} + +pub(super) fn cancel_media_upload(progress_id: &str) { + if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + if let Some(cancel) = uploads.get(progress_id) { + cancel.cancel(); + } + } +} + +pub(super) fn finish_media_upload(progress_id: Option<&str>) { + let Some(progress_id) = progress_id else { + return; + }; + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.remove(progress_id); + } +} + +pub(super) struct UploadAttempt<'a> { + pub url: String, + pub auth_header: &'a str, + pub mime: &'a str, + pub sha256: &'a str, + pub body: bytes::Bytes, + pub progress: Option<&'a (tauri::AppHandle, String)>, + pub cancellation: Option<&'a CancellationToken>, +} + +pub(super) async fn send_upload_attempt( + state: &AppState, + attempt: UploadAttempt<'_>, +) -> Result { + let UploadAttempt { + url, + auth_header, + mime, + sha256, + body, + progress, + cancellation, + } = attempt; + let req = state + .http_client + .put(url) + .header("Authorization", auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", sha256); + + let response = if let Some((app, progress_id)) = progress { + let app = app.clone(); + let progress_id = progress_id.clone(); + let total = body.len() as u64; + let chunk_size = 64 * 1024; + let chunk_count = body.len().div_ceil(chunk_size); + let mut sent: u64 = 0; + let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { + let start = i * chunk_size; + let end = usize::min(start + chunk_size, body.len()); + let chunk = body.slice(start..end); + sent += chunk.len() as u64; + let _ = app.emit( + "media-upload-progress", + serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), + ); + Ok::(chunk) + })); + let request = req + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)) + .send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + } else { + let request = req.body(body).send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + }; + response.map_err(|error| classify_request_error(&error)) +} + +pub(super) fn emit_media_upload_phase( + app: &tauri::AppHandle, + progress_id: Option<&str>, + phase: &'static str, +) { + let Some(id) = progress_id else { + return; + }; + let _ = app.emit( + "media-upload-phase", + serde_json::json!({ "id": id, "phase": phase }), + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17..237bc06e8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,8 +28,10 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_raw; mod media_snapshot_png; mod media_transcode; +mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; mod messages; @@ -85,6 +87,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a67264760..d59936946 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -596,7 +596,6 @@ pub fn run() { } }); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -724,6 +723,8 @@ pub fn run() { pick_and_upload_media, pick_and_upload_image, upload_media_bytes, + upload_media_bytes_raw, + cancel_media_upload, download_image, save_png_data_url, download_file, @@ -898,7 +899,6 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); #[cfg(unix)] diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 54c0dcd5d..9e5152edf 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; +import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -63,12 +64,10 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; - const HUDDLE_TRANSCRIPT_ROOT_STYLE = { "--buzz-channel-content-top-padding": "0rem", "--channel-top-chrome-height": "0.25rem", } as React.CSSProperties; - export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -179,7 +178,9 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, ); - const mainComposerMedia = useMediaUpload(); + const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const [isMainDeferredEditPending, setMainDeferredEditPending] = + React.useState(false); const isNonMemberView = activeChannel !== null && !activeChannel.isMember && @@ -230,15 +231,12 @@ export const ChannelPane = React.memo(function ChannelPane({ welcomeComposerHideTimerRef.current = null; } }, []); - React.useEffect( () => () => clearWelcomeComposerDismissTimer(), [clearWelcomeComposerDismissTimer], ); - React.useEffect(() => { clearWelcomeComposerDismissTimer(); - if ( activeChannelId && isActiveWelcomeChannel && @@ -247,14 +245,12 @@ export const ChannelPane = React.memo(function ChannelPane({ setWelcomeComposerBannerState("hidden"); return; } - setWelcomeComposerBannerState("prompt"); }, [ activeChannelId, clearWelcomeComposerDismissTimer, isActiveWelcomeChannel, ]); - const isEditInThread = editTarget != null && threadHeadMessage != null && @@ -262,7 +258,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages.some((entry) => entry.message.id === editTarget.id)); const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; - const findLastOwnEditable = React.useCallback( (candidates: TimelineMessage[]): TimelineMessage | null => { if (!onEdit || !currentPubkey) return null; @@ -283,7 +278,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { const target = findLastOwnEditable(messages); if (!target || !onEdit) return false; @@ -399,7 +393,10 @@ export const ChannelPane = React.memo(function ChannelPane({ ], ); const canDropInMainColumn = - hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; + hasMainComposerOverlay && + !isComposerDisabled && + !isMainDeferredEditPending && + !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; // Unified working set for the composer bar: observer-derived turns primary, // bot typing fallback (both folded together by agentWorkingSignal). This is @@ -733,6 +730,7 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > +
{ - const eventId = editTargetIdRef.current; + const eventId = capturedEventId ?? editTargetIdRef.current; if (!eventId) { return; } @@ -175,15 +173,19 @@ export function useChannelPaneHandlers({ return; } - await editMutateRef.current({ + await editMessageMutation.mutateAsync({ eventId, content, mediaTags, mentionPubkeys, }); - setEditTargetId(null); + setEditTargetId((current) => (current === eventId ? null : current)); }, - [onRequestEmptyEditDelete, setEditTargetId], + [ + editMessageMutation.mutateAsync, + onRequestEmptyEditDelete, + setEditTargetId, + ], ); const handleOpenThread = React.useCallback( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913..1bd1e090a 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -19,6 +19,7 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; +import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -67,6 +68,7 @@ function resetCommunityState({ resetMediaCaches(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); + resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } diff --git a/desktop/src/features/home/useHomeDrafts.ts b/desktop/src/features/home/useHomeDrafts.ts index 8d9accda6..4b4f4afa6 100644 --- a/desktop/src/features/home/useHomeDrafts.ts +++ b/desktop/src/features/home/useHomeDrafts.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { clearDraftEntry } from "@/features/messages/lib/useDrafts"; +import { deleteDraftEntry } from "@/features/messages/lib/useDrafts"; import { useActiveDraftCount, useDraftViewItems, @@ -62,7 +62,7 @@ export function useHomeDrafts({ const deleteDraft = React.useCallback( (draftKey: string) => { - clearDraftEntry(draftKey); + deleteDraftEntry(draftKey); if (selectedKey === draftKey) { setSelectedKey(null); } diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs new file mode 100644 index 000000000..ed4ab860f --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + backgroundMediaUploadPhaseLabel, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase.ts"; + +test("upload phase labels describe the real work in plain language", () => { + assert.equal(backgroundMediaUploadPhaseLabel("preparing"), "Preparing"); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-video"), + "Processing", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("converting-image"), + "Converting", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-files"), + "Processing", + ); + assert.equal(backgroundMediaUploadPhaseLabel("uploading"), "Uploading"); + assert.equal(backgroundMediaUploadPhaseLabel("finishing"), "Finishing"); +}); + +test("upload phase validation accepts only phases emitted by native code", () => { + assert.equal(isNativeMediaUploadPhase("processing-video"), true); + assert.equal(isNativeMediaUploadPhase("processing-files"), false); + assert.equal(isNativeMediaUploadPhase("transcribing"), false); + assert.equal(isNativeMediaUploadPhase(null), false); +}); + +test("upload phase aggregation favors active transfer and combines mixed work", () => { + assert.equal(resolveBackgroundMediaUploadPhase([]), "preparing"); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "uploading"]), + "uploading", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "preparing"]), + "processing-files", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["finishing", "finishing"]), + "finishing", + ); +}); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts new file mode 100644 index 000000000..0d8a98f2f --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts @@ -0,0 +1,60 @@ +export type BackgroundMediaUploadPhase = + | "preparing" + | "processing-video" + | "converting-image" + | "processing-files" + | "uploading" + | "finishing"; + +export type NativeMediaUploadPhase = Exclude< + BackgroundMediaUploadPhase, + "processing-files" +>; + +const NATIVE_PHASES = new Set([ + "preparing", + "processing-video", + "converting-image", + "uploading", + "finishing", +]); + +export function isNativeMediaUploadPhase( + value: unknown, +): value is NativeMediaUploadPhase { + return ( + typeof value === "string" && + NATIVE_PHASES.has(value as NativeMediaUploadPhase) + ); +} + +export function resolveBackgroundMediaUploadPhase( + phases: BackgroundMediaUploadPhase[], +): BackgroundMediaUploadPhase { + if (phases.length === 0) return "preparing"; + if (phases.includes("uploading")) return "uploading"; + + const activePhases = new Set(phases.filter((phase) => phase !== "finishing")); + if (activePhases.size === 0) return "finishing"; + if (activePhases.size > 1) return "processing-files"; + return activePhases.values().next().value ?? "preparing"; +} + +export function backgroundMediaUploadPhaseLabel( + phase: BackgroundMediaUploadPhase, +): string { + switch (phase) { + case "processing-video": + return "Processing"; + case "converting-image": + return "Converting"; + case "processing-files": + return "Processing"; + case "uploading": + return "Uploading"; + case "finishing": + return "Finishing"; + default: + return "Preparing"; + } +} diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts new file mode 100644 index 000000000..ace711985 --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -0,0 +1,350 @@ +import * as React from "react"; + +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { + type BackgroundMediaUploadPhase, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase"; + +export type QueuedMediaAttachment = { + file: File; + id: number; + previewUrl?: string; + spoilered: boolean; +}; + +type BackgroundUploadTask = { + abortController: AbortController; + canceled: boolean; + filePhases: BackgroundMediaUploadPhase[]; + fileProgress: Array<{ sent: number; total: number }>; + id: number; + isCompleting: boolean; + onCancel?: () => void; +}; + +type BackgroundUploadSnapshot = { + canCancel: boolean; + isUploading: boolean; + phase: BackgroundMediaUploadPhase; + percentage: number; +}; + +type EnqueueBackgroundUploadOptions = { + attachments: QueuedMediaAttachment[]; + onCancel?: () => void; + onComplete: ( + descriptors: BlobDescriptor[], + signal: AbortSignal, + ) => Promise; + onError: (error: unknown) => void; +}; + +type StartBackgroundUploadOptions = Omit< + EnqueueBackgroundUploadOptions, + "attachments" +>; + +export type PreparedBackgroundMediaUpload = { + cancel: () => void; + start: (options: StartBackgroundUploadOptions) => boolean; +}; + +const tasks = new Map(); +const queuedAttachmentsByDraftKey = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let snapshot: BackgroundUploadSnapshot = { + canCancel: false, + isUploading: false, + phase: "preparing", + percentage: 0, +}; +let stopUploadListeners: (() => void)[] = []; +let uploadListenersPromise: Promise | null = null; + +function progressId(taskId: number, fileIndex: number): string { + return `background-media-upload-${taskId}-${fileIndex}`; +} + +function rebuildSnapshot(): void { + const allTasks = [...tasks.values()]; + const allProgress = allTasks.flatMap((task) => task.fileProgress); + const totalBytes = allProgress.reduce( + (total, progress) => total + progress.total, + 0, + ); + const sentBytes = allProgress.reduce( + (total, progress) => total + progress.sent, + 0, + ); + snapshot = { + canCancel: allTasks.some((task) => !task.isCompleting), + isUploading: allTasks.length > 0, + phase: resolveBackgroundMediaUploadPhase( + allTasks.flatMap((task) => task.filePhases), + ), + percentage: + totalBytes === 0 ? 0 : Math.round((sentBytes / totalBytes) * 100), + }; + for (const listener of listeners) listener(); +} + +async function ensureUploadListeners(): Promise { + if (stopUploadListeners.length > 0) return; + if (uploadListenersPromise) { + await uploadListenersPromise; + return; + } + uploadListenersPromise = (async () => { + const disposers: (() => void)[] = []; + try { + const { listen } = await import("@tauri-apps/api/event"); + disposers.push( + await listen<{ + id: string; + sent: number; + total: number; + }>("media-upload-progress", (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || event.payload.total <= 0) return; + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.fileProgress.length) return; + + task.filePhases[fileIndex] = "uploading"; + task.fileProgress[fileIndex] = { + sent: Math.min( + event.payload.total, + Math.max(0, event.payload.sent), + ), + total: event.payload.total, + }; + rebuildSnapshot(); + }), + ); + disposers.push( + await listen<{ id: string; phase: unknown }>( + "media-upload-phase", + (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || !isNativeMediaUploadPhase(event.payload.phase)) { + return; + } + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.filePhases.length) return; + + task.filePhases[fileIndex] = event.payload.phase; + rebuildSnapshot(); + }, + ), + ); + if (tasks.size === 0) { + for (const dispose of disposers) dispose(); + } else { + stopUploadListeners = disposers; + } + } catch { + for (const dispose of disposers) dispose(); + // Browser and E2E runtimes do not emit native upload state. + } finally { + uploadListenersPromise = null; + } + })(); + await uploadListenersPromise; +} + +function finishTask(taskId: number): void { + tasks.delete(taskId); + rebuildSnapshot(); + if (tasks.size === 0) { + for (const dispose of stopUploadListeners) dispose(); + stopUploadListeners = []; + } +} + +function cancelTask( + task: BackgroundUploadTask, + { force = false, notify = true }: { force?: boolean; notify?: boolean } = {}, +): void { + if (task.canceled || (task.isCompleting && !force)) return; + task.canceled = true; + task.abortController.abort(); + if (notify) task.onCancel?.(); + for (let index = 0; index < task.fileProgress.length; index += 1) { + void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); + } + finishTask(task.id); +} + +function yieldForUploadFeedback(): Promise { + if ( + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + document.visibilityState === "hidden" + ) { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + return new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); + }); +} + +export function prepareBackgroundMediaUpload( + attachments: QueuedMediaAttachment[], +): PreparedBackgroundMediaUpload { + if (attachments.length === 0) { + let started = false; + return { + cancel: () => undefined, + start: ({ onComplete, onError }) => { + if (started) return false; + started = true; + void onComplete([], new AbortController().signal).catch(onError); + return true; + }, + }; + } + + const taskId = nextTaskId; + nextTaskId += 1; + const task: BackgroundUploadTask = { + abortController: new AbortController(), + canceled: false, + filePhases: attachments.map(() => "preparing"), + fileProgress: attachments.map((attachment) => ({ + sent: 0, + total: attachment.file.size, + })), + id: taskId, + isCompleting: false, + }; + let started = false; + tasks.set(taskId, task); + rebuildSnapshot(); + + return { + cancel: () => { + cancelTask(task); + }, + start: ({ onCancel, onComplete, onError }) => { + if (started || task.canceled) return false; + started = true; + task.onCancel = onCancel; + + void (async () => { + try { + await ensureUploadListeners(); + // Let React commit and paint the 0% task before file reads or native + // IPC begin, so large attachments never hide the initial feedback. + await yieldForUploadFeedback(); + const descriptors: BlobDescriptor[] = []; + for (let index = 0; index < attachments.length; index += 1) { + if (task.canceled) return; + const attachment = attachments[index]; + const descriptor = await uploadMediaFile( + attachment.file, + progressId(taskId, index), + task.abortController.signal, + ); + if (task.canceled) return; + task.filePhases[index] = "finishing"; + task.fileProgress[index] = { + sent: task.fileProgress[index].total, + total: task.fileProgress[index].total, + }; + rebuildSnapshot(); + descriptors.push(descriptor); + } + + if (!task.canceled) { + task.isCompleting = true; + task.filePhases.fill("finishing"); + rebuildSnapshot(); + await onComplete(descriptors, task.abortController.signal); + } + } catch (error) { + if (!task.canceled) onError(error); + } finally { + finishTask(taskId); + } + })(); + return true; + }, + }; +} + +export function enqueueBackgroundMediaUpload({ + attachments, + onCancel, + onComplete, + onError, +}: EnqueueBackgroundUploadOptions): PreparedBackgroundMediaUpload { + const preparedUpload = prepareBackgroundMediaUpload(attachments); + preparedUpload.start({ onCancel, onComplete, onError }); + return preparedUpload; +} + +export function cancelBackgroundMediaUploads(): void { + for (const task of [...tasks.values()].reverse()) { + if (!task.isCompleting) { + cancelTask(task); + return; + } + } +} + +export function resetBackgroundMediaUploads(): void { + for (const task of [...tasks.values()]) { + cancelTask(task, { force: true, notify: false }); + } + queuedAttachmentsByDraftKey.clear(); +} + +/** + * Retain local files that cannot be serialized with a draft while a deferred + * upload recovers after the user has left its channel. + */ +export function saveQueuedAttachmentsForDraft( + draftKey: string, + attachments: QueuedMediaAttachment[], +): void { + queuedAttachmentsByDraftKey.set(draftKey, attachments); +} + +/** Remove local files retained for a draft without restoring them. */ +export function discardQueuedAttachmentsForDraft(draftKey: string): void { + queuedAttachmentsByDraftKey.delete(draftKey); +} + +/** Return and remove the local files retained for a recovered draft. */ +export function takeQueuedAttachmentsForDraft( + draftKey: string, +): QueuedMediaAttachment[] { + const attachments = queuedAttachmentsByDraftKey.get(draftKey) ?? []; + queuedAttachmentsByDraftKey.delete(draftKey); + return attachments; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundUploadSnapshot { + return snapshot; +} + +export function useBackgroundMediaUpload(): BackgroundUploadSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index c66c607b9..e4741baa8 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -488,6 +488,20 @@ test("markDraftSent_new_active_draft_after_send_is_independent", () => { assert.equal(getSentDraftEntries().length, 0, "no sent records"); }); +test("markDraftSent_keeps_a_newer_draft_with_the_same_key", () => { + setup("pubkey-sent-race"); + persistDraftEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + // A background upload is still in flight when the user starts the next + // message in this channel. Its completion must not clear this newer entry. + persistDraftEntry("chan-race", "next draft", "chan-race", [IMG_B], []); + + markDraftSentEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + + const draft = loadDraftEntry("chan-race"); + assert.equal(draft?.content, "next draft"); + assert.deepEqual(draft?.pendingImeta, [IMG_B]); +}); + test("getActiveDraftEntries_excludes_cleared_drafts", () => { setup("pubkey-active"); persistDraftEntry("chan-active", "active draft", "chan-active", [], []); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 1e9fee6e5..2a78e8813 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import { discardQueuedAttachmentsForDraft } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; @@ -296,6 +297,11 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined { return readStore().get(draftKey); } +export function deleteDraftEntry(draftKey: string): void { + discardQueuedAttachmentsForDraft(draftKey); + clearDraftEntry(draftKey); +} + export function clearDraftEntry(draftKey: string): void { const map = readStore(); if (map.has(draftKey)) { @@ -495,12 +501,24 @@ export function getSentDraftEntries(): Array<{ */ export function markDraftSentEntry( draftKey: string, - _content: string, - _channelId: string, - _pendingImeta: ImetaMedia[], - _spoileredAttachmentUrls: string[], + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], ): void { - clearDraftEntry(draftKey); + const draft = loadDraftEntry(draftKey); + // A background upload can finish after the user has started the next draft + // in this same channel. Clear only the exact submitted snapshot rather than + // deleting whichever newer entry currently owns the key. + if ( + draft?.content === content && + draft.channelId === channelId && + JSON.stringify(draft.pendingImeta) === JSON.stringify(pendingImeta) && + JSON.stringify(draft.spoileredAttachmentUrls) === + JSON.stringify(spoileredAttachmentUrls) + ) { + clearDraftEntry(draftKey); + } } // ── Reactive hooks ──────────────────────────────────────────────────────────── diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b627633be..b4c3cae44 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,6 +5,7 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; +import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; /** * First 4 hex chars of the sha256 — used as a short display name. @@ -30,6 +31,7 @@ export type UploadingAttachmentPreview = { * (e.g. video transcoding before the HTTP upload starts). */ progress?: number | null; slotIndex?: number; + spoilered?: boolean; type?: string; }; @@ -133,7 +135,22 @@ async function captureVideoPosterFrame( } } -export function useMediaUpload() { +type UseMediaUploadOptions = { + /** Keep newly selected files local until the message is submitted. */ + deferUploadsUntilSend?: boolean; +}; + +export function useMediaUpload({ + deferUploadsUntilSend = false, +}: UseMediaUploadOptions = {}) { + const e2eConfig = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { deferredComposerUploads?: boolean } }; + } + ).__BUZZ_E2E__; + const queueUntilSend = + deferUploadsUntilSend && + (!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true); const [uploadState, setUploadState] = React.useState({ status: "idle", }); @@ -144,6 +161,11 @@ export function useMediaUpload() { >([]); const uploadingPreviewsRef = React.useRef(uploadingPreviews); uploadingPreviewsRef.current = uploadingPreviews; + const [queuedAttachments, setQueuedAttachmentsState] = React.useState< + QueuedMediaAttachment[] + >([]); + const queuedAttachmentsRef = React.useRef(queuedAttachments); + queuedAttachmentsRef.current = queuedAttachments; React.useEffect(() => { let unlisten: (() => void) | null = null; let cancelled = false; @@ -248,6 +270,99 @@ export function useMediaUpload() { * before React flushes the state update. */ const nextSlotRef = React.useRef(0); const nextUploadingPreviewIdRef = React.useRef(0); + const nextQueuedAttachmentIdRef = React.useRef(0); + + const updateQueuedVideoPoster = React.useCallback( + (id: number, posterUrl: string) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, previewUrl: posterUrl } + : attachment, + ), + ); + }, + [], + ); + + const queueFiles = React.useCallback( + (files: File[]) => { + if (files.length === 0) return; + + const attachments = files.map((file) => { + const id = nextQueuedAttachmentIdRef.current; + nextQueuedAttachmentIdRef.current += 1; + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined; + if (file.type.startsWith("video/")) { + void captureVideoPosterFrame(file).then((poster) => { + if (poster) updateQueuedVideoPoster(id, poster.posterUrl); + }); + } + return { file, id, previewUrl, spoilered: false }; + }); + + setQueuedAttachmentsState((current) => [...current, ...attachments]); + }, + [updateQueuedVideoPoster], + ); + + const removeQueuedAttachment = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => { + const removed = current.find((attachment) => attachment.id === id); + if (removed?.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(removed.previewUrl); + } + return current.filter((attachment) => attachment.id !== id); + }); + }, []); + + const clearQueuedAttachments = React.useCallback(() => { + setQueuedAttachmentsState((current) => { + for (const attachment of current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return []; + }); + }, []); + + const restoreQueuedAttachments = React.useCallback( + (attachments: QueuedMediaAttachment[]) => { + clearQueuedAttachments(); + queueFiles(attachments.map((attachment) => attachment.file)); + setQueuedAttachmentsState((current) => + current.map((attachment, index) => ({ + ...attachment, + spoilered: attachments[index]?.spoilered ?? false, + })), + ); + }, + [clearQueuedAttachments, queueFiles], + ); + + const toggleQueuedAttachmentSpoiler = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, spoilered: !attachment.spoilered } + : attachment, + ), + ); + }, []); + + React.useEffect( + () => () => { + for (const attachment of queuedAttachmentsRef.current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + }, + [], + ); const isUploadCanceled = React.useCallback( (previewId?: number) => @@ -367,6 +482,19 @@ export function useMediaUpload() { ); const handlePaperclip = React.useCallback(async () => { + if (queueUntilSend) { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.addEventListener( + "change", + () => queueFiles(Array.from(input.files ?? [])), + { once: true }, + ); + input.click(); + return; + } + // Hold a single pending tick while the native picker is open + uploads // run in Rust. We don't know the file count until the dialog returns, // and uploads are already complete by then, so we just append each @@ -374,7 +502,7 @@ export function useMediaUpload() { const previewId = reserveUploadingPreview(); setUploadingCount((c) => c + 1); try { - const descriptors = await pickAndUploadMedia(); + const descriptors = await pickAndUploadMedia(uploadProgressId(previewId)); if (isUploadCanceled(previewId)) return; finishUpload(previewId); for (const descriptor of descriptors) { @@ -385,7 +513,14 @@ export function useMediaUpload() { if (isUploadCanceled(previewId)) return; onUploadError(err, previewId); } - }, [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview]); + }, [ + queueUntilSend, + finishUpload, + isUploadCanceled, + onUploadError, + queueFiles, + reserveUploadingPreview, + ]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -399,6 +534,11 @@ export function useMediaUpload() { // (active-content + executables) and size caps; everything else uploads. const validFiles = files; + if (queueUntilSend) { + queueFiles(validFiles); + return; + } + setUploadingCount((c) => c + validFiles.length); const baseIndex = reserveSlots(validFiles.length); @@ -425,9 +565,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -497,6 +639,11 @@ export function useMediaUpload() { event.preventDefault(); + if (queueUntilSend) { + queueFiles(mediaFiles); + return; + } + setUploadingCount((c) => c + mediaFiles.length); const baseIndex = reserveSlots(mediaFiles.length); @@ -522,9 +669,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -532,6 +681,10 @@ export function useMediaUpload() { /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */ const uploadFile = React.useCallback( async (file: File) => { + if (queueUntilSend) { + queueFiles([file]); + return; + } const previewId = reserveUploadingPreview(file); setUploadingCount((c) => c + 1); try { @@ -547,7 +700,14 @@ export function useMediaUpload() { onUploadError(err, previewId); } }, - [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], + [ + queueUntilSend, + isUploadCanceled, + onUploaded, + onUploadError, + queueFiles, + reserveUploadingPreview, + ], ); /** @@ -642,10 +802,22 @@ export function useMediaUpload() { ); const isUploading = uploadingCount > 0; + const queuedPreviews = React.useMemo( + () => + queuedAttachments.map((attachment) => ({ + filename: attachment.file.name, + id: attachment.id, + posterUrl: attachment.previewUrl, + spoilered: attachment.spoilered, + type: attachment.file.type, + })), + [queuedAttachments], + ); return React.useMemo( () => ({ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -657,10 +829,16 @@ export function useMediaUpload() { originalUrlByUrl, pendingImeta, pendingImetaRef, + queuedAttachments, + queuedAttachmentsRef, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, setUploadState, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, @@ -669,6 +847,7 @@ export function useMediaUpload() { }), [ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -679,9 +858,14 @@ export function useMediaUpload() { isUploading, originalUrlByUrl, pendingImeta, + queuedAttachments, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index bee38e321..8578e4c08 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -61,6 +61,12 @@ type ComposerAttachmentsProps = { attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; + /** Remove a local attachment that has not started uploading yet. */ + onRemoveQueued?: (previewId: number) => void; + /** Toggle spoiler state for a local attachment before it receives a URL. */ + onToggleQueuedSpoiler?: (previewId: number) => void; + /** Local previews that are queued for upload when the message is sent. */ + queuedPreviews?: UploadingAttachmentPreview[]; uploadingCount?: number; uploadingPreviews?: UploadingAttachmentPreview[]; /** Upload annotated bytes as a replacement for the attachment at `url`. */ @@ -511,6 +517,9 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onRemoveQueued, + onToggleQueuedSpoiler, + queuedPreviews = [], onEditSave, onRemove, onRevert, @@ -518,7 +527,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { - if (attachments.length === 0 && !isUploading) return null; + if (attachments.length === 0 && queuedPreviews.length === 0 && !isUploading) + return null; const uploadPlaceholders: UploadingAttachmentPreview[] = uploadingPreviews.length > 0 @@ -609,6 +619,94 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ /> ); })} + {queuedPreviews.map((preview) => { + const isMedia = + preview.type?.startsWith("image/") || + preview.type?.startsWith("video/"); + return ( + + {isMedia ? ( +
+
+ {preview.posterUrl ? ( + {preview.filename + ) : ( +
+ +
+ )} +
+ {preview.spoilered ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ + + {preview.filename ?? "Attachment"} + +
+ )} + {onRemoveQueued ? ( + + + + + Remove attachment + + ) : null} + {isMedia && onToggleQueuedSpoiler ? ( + + + + onToggleQueuedSpoiler(preview.id) + } + pressed={preview.spoilered} + type="button" + > + + + + + {preview.spoilered ? "Remove spoiler" : "Mark as spoiler"} + + + ) : null} +
+ ); + })} {isUploading && uploadPlaceholders.map((preview) => ( void; onCancelReply?: () => void; @@ -39,6 +41,7 @@ export function ComposerReplyEditBanner({
+ ); +} diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx new file mode 100644 index 000000000..288b32f8f --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -0,0 +1,171 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { + type BackgroundMediaUploadPhase, + backgroundMediaUploadPhaseLabel, +} from "@/features/messages/lib/backgroundMediaUploadPhase"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; + +export function ComposerUploadProgressPill({ + canCancel, + isUploading, + onCancel, + phase, + percentage, +}: { + canCancel: boolean; + isUploading: boolean; + onCancel: () => void; + phase: BackgroundMediaUploadPhase; + percentage: number; +}) { + const reducedMotion = useReducedMotion(); + const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const isTransferring = phase === "uploading"; + const phaseTransition = reducedMotion + ? { duration: 0 } + : { duration: 0.18, ease: [0.77, 0, 0.175, 1] as const }; + + return ( + + {isUploading ? ( + +
+ +
+ + + + + {phaseLabel} + + + + + + {isTransferring ? ( + + {percentage}% + + ) : ( + + + )} + + + + + {canCancel ? ( + + ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b..6f79daa60 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; @@ -10,20 +9,21 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; - import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { + cancelBackgroundMediaUploads, + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, + useBackgroundMediaUpload, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import { @@ -50,14 +50,14 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - +import { submitMessageEdit } from "./submitMessageEdit"; import type { MessageComposerProps } from "./MessageComposer.types"; - function MessageComposerImpl({ audienceContext = null, channelId = null, @@ -71,6 +71,7 @@ function MessageComposerImpl({ onAutoSubmitComplete, editTarget = null, isSending = false, + onDeferredEditPendingChange, onCancelEdit, onCancelReply, onCaptureSendContext, @@ -83,6 +84,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, + showBackgroundUploadProgress = true, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -103,12 +105,10 @@ function MessageComposerImpl({ >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; - const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); }, []); - const drafts = useDrafts(); const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; @@ -124,10 +124,10 @@ function MessageComposerImpl({ : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; - // Snapshot composer state before edit mode so cancel can restore it. const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; + queuedAttachments: ReturnType["queuedAttachments"]; spoileredAttachmentUrls: Set; } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { @@ -141,16 +141,20 @@ function MessageComposerImpl({ typingParentEventId, typingRootEventId, ); - - // We pass a custom setter that both updates React state AND inserts - // markdown into the Tiptap editor when media upload completes. - const internalMedia = useMediaUpload(); + const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; + const [isDeferredEditPending, setDeferredEditPending] = React.useState(false); + const composerDisabled = disabled || isDeferredEditPending; + const isEditSubmissionLocked = + isSending || media.isUploading || isDeferredEditPending; + const canRestoreEditDraftRef = React.useRef(false); + canRestoreEditDraftRef.current = + contentRef.current.trim().length === 0 && + media.pendingImetaRef.current.length === 0 && + media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; - - // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on - // key change, and persist the outgoing draft in the cleanup. The StrictMode - // fix lives inside this hook — see useDraftPersistSnapshot.ts. + const backgroundUpload = useBackgroundMediaUpload(); + // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -160,6 +164,11 @@ function MessageComposerImpl({ restoreMentionRefs: mentions.restoreDraftMentionRefs, livePendingImeta: media.pendingImeta, setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent: (content) => { setComposerContent(content); richText.setContent(content); @@ -179,7 +188,6 @@ function MessageComposerImpl({ channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); }, [effectiveDraftKey]); - const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); @@ -198,16 +206,13 @@ function MessageComposerImpl({ editTargetRef.current = editTarget; extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; ownerPubkeyRef.current = ownerPubkey; - const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || emojiAutocomplete.isEmojiAutocompleteOpen; - const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler // delegates through this ref to break the hook ordering cycle (the editor // needs `onEditLink`, but the link editor needs the editor's `richText`). @@ -218,7 +223,6 @@ function MessageComposerImpl({ ((info: LinkSelectionInfo | null) => void) | null >(null); const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null); - const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { const scrollElement = composerScrollRef.current; @@ -226,17 +230,15 @@ function MessageComposerImpl({ scrollElement.scrollTop = scrollElement.scrollHeight; }); }, []); - const computedPlaceholder = editTarget ? "Edit your message" : (placeholder ?? (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); - const richText = useRichTextEditor({ placeholder: computedPlaceholder, - editable: !disabled, + editable: !composerDisabled, mentionNames: mentions.knownNames, agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, @@ -255,19 +257,15 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); - mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); - persistentMentionHydrationRef.current?.reconcile(text); - if (text.trim().length > 0) { notifyTyping(); } }, }); - const linkEditor = useLinkEditor(richText); syncContentRefFromEditorRef.current = () => { const markdown = richText.getMarkdown(); @@ -278,7 +276,6 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentMentionHydration = usePersistentAgentMentionHydration({ audienceScope, hydrationKey: effectiveDraftKey, @@ -292,7 +289,6 @@ function MessageComposerImpl({ persistentMentionHydration, ); persistentMentionHydrationRef.current = persistentMentionHydration; - const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -308,6 +304,11 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + hasUnsavedMedia: () => + media.pendingImetaRef.current.length > 0 || + media.queuedAttachmentsRef.current.length > 0, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -322,16 +323,18 @@ function MessageComposerImpl({ : undefined, resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, }); - + React.useEffect(() => { + onDeferredEditPendingChange?.(isDeferredEditPending); + return () => onDeferredEditPendingChange?.(false); + }, [isDeferredEditPending, onDeferredEditPendingChange]); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { - // Snapshot the current draft (text + attachments) so the user's - // in-flight work survives the edit-mode hijack and is restored on - // edit-cancel/exit. + // Preserve the user's in-flight draft while editing another message. preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), pendingImeta: [...media.pendingImetaRef.current], + queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; // Strip the trailing `![image|video](url)` lines that correspond to @@ -348,6 +351,7 @@ function MessageComposerImpl({ // attachments so they show up in `ComposerAttachments` and the user // can remove existing ones / add new ones before saving. media.setPendingImeta(editableImeta); + media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); @@ -363,6 +367,7 @@ function MessageComposerImpl({ const { content: restoredContent, pendingImeta: restoredImeta, + queuedAttachments: restoredQueuedAttachments, spoileredAttachmentUrls: restoredSpoileredAttachmentUrls, } = preEditSnapshotRef.current; preEditSnapshotRef.current = null; @@ -371,21 +376,19 @@ function MessageComposerImpl({ ? richText.setContent(restoredContent) : richText.clearContent(); media.setPendingImeta(restoredImeta); + media.restoreQueuedAttachments(restoredQueuedAttachments); setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); - // ── Focus on reply ────────────────────────────────────────────────── // Use focusPreserve so that re-renders (e.g. new messages arriving in // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { - if (!replyTarget || disabled) return; + if (!replyTarget || composerDisabled) return; richText.focusPreserve(); - }, [disabled, replyTarget, richText.focusPreserve]); - + }, [composerDisabled, replyTarget, richText.focusPreserve]); // ── Autofocus on mount / channel switch ───────────────────────────── - useComposerAutofocus(richText.focus, effectiveDraftKey, disabled); - + useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). @@ -400,7 +403,6 @@ function MessageComposerImpl({ }, [richText.replacePlainTextRange], ); - const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -412,7 +414,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -424,7 +425,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyEmojiInsert = React.useCallback( (suggestion: EmojiSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -436,7 +436,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -473,12 +472,10 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); - // Check if there's already an @-query in progress const beforeCursor = text.slice(0, cursor); if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { @@ -486,14 +483,12 @@ function MessageComposerImpl({ richText.focus(); return; } - // Insert @ at cursor const previousChar = text.slice(0, cursor).slice(-1); const prefix = cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; richText.editor.chain().focus().insertContent(prefix).run(); setIsEmojiPickerOpen(false); - // Trigger mention detection after inserting @ const { text: updatedText, cursor: updatedCursor } = richText.getPlainTextAndCursor(); @@ -504,90 +499,66 @@ function MessageComposerImpl({ richText.focus, mentions.updateMentionQuery, ]); - // ── Submit message ────────────────────────────────────────────────── const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); - // Edit mode if (editTargetRef.current && onEditSaveRef.current) { - if (isSendingRef.current || isUploadingRef.current) return; - const currentPendingImeta = media.pendingImetaRef.current; + if (isEditSubmissionLocked) return; // No empty-edit guard here: clearing an edit to empty (no text, no // attachments) flows through to onEditSave as empty content, which // deletes the message instead of publishing it (see handleEditSave). - - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, + await submitMessageEdit({ + content: trimmed, + editTargetId: editTargetRef.current.id, + customEmoji, + originalContent: editTargetRef.current.body, + ownerPubkey: ownerPubkeyRef.current, + getMentionRefs: mentions.getDraftMentionRefs, + pendingImeta: media.pendingImetaRef.current, + queuedAttachments: media.queuedAttachmentsRef.current, spoileredAttachmentUrls, - ); - - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); - - const savedContent = trimmed; - const savedImeta = [...currentPendingImeta]; - const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); - setComposerContent(""); - richText.clearContent(); - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - - try { - await onEditSaveRef.current( - finalContent, - outgoingTags, - addedMentionPubkeys, - ); - } catch { - setComposerContent(savedContent); - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); - } + extractMentionPubkeys: extractMentionPubkeysRef.current, + save: onEditSaveRef.current, + clearComposer: () => { + setComposerContent(""); + richText.clearContent(); + media.setPendingImeta([]); + media.clearQueuedAttachments(); + setSpoileredAttachmentUrls(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, + restoreComposer: (draft) => { + setComposerContent(draft.content); + richText.setContent(draft.content); + media.setPendingImeta(draft.pendingImeta); + media.restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls); + }, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + shouldRestoreComposer: () => canRestoreEditDraftRef.current, + setDeferredUploadPending: setDeferredEditPending, + setUploadError: (message) => + media.setUploadState({ status: "error", message }), + }); return; } - // Normal send const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; + const currentQueuedAttachments = media.queuedAttachmentsRef.current; + const hasMedia = + currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0; if ( (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || - isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; } - const capturedThreadContext = onCaptureSendContext?.() ?? null; if ( capturedThreadContext !== null && @@ -595,7 +566,6 @@ function MessageComposerImpl({ ) { return; } - onPreparingMentionSendChange?.(true); persistentMentionHydration.beginSubmit(); try { @@ -603,10 +573,12 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, + queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, ), + recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, audienceGeneration: persistentAudience.generation, @@ -622,8 +594,12 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + media.clearQueuedAttachments, media.pendingImetaRef, + media.queuedAttachmentsRef, + media.restoreQueuedAttachments, media.setPendingImeta, + media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, mentions.clearMentions, @@ -638,9 +614,12 @@ function MessageComposerImpl({ persistentMentionHydration, persistentAudience.generation, persistentAudience.revision, + isEditSubmissionLocked, + effectiveDraftKey, + mentions.getDraftMentionRefs, + mentions.restoreDraftMentionRefs, ]); submitMessageRef.current = submitMessage; - // ── Auto-submit on draft send ──────────────────────────────────────────── // When `autoSubmitDraftKey` is set (the user clicked "Send message" in the // Drafts panel and confirmed), fire `submitMessage` once after mount so the @@ -654,7 +633,6 @@ function MessageComposerImpl({ // runs, preventing re-fire on re-render or back-navigation. const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete); onAutoSubmitCompleteRef.current = onAutoSubmitComplete; - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only React.useEffect(() => { if ( @@ -677,7 +655,6 @@ function MessageComposerImpl({ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount-only - const handleSubmit = React.useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -685,7 +662,6 @@ function MessageComposerImpl({ }, [submitMessage], ); - // ── Keyboard handling ─────────────────────────────────────────────── // Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively. // Plain Enter → submit is now handled inside the Tiptap `submitOnEnter` @@ -701,7 +677,6 @@ function MessageComposerImpl({ } return; } - const channelResult = channelLinks.handleChannelKeyDown(event); if (channelResult.handled) { if (channelResult.suggestion) { @@ -709,7 +684,6 @@ function MessageComposerImpl({ } return; } - const { handled, suggestion } = mentions.handleMentionKeyDown(event); if (handled) { if (suggestion) { @@ -717,7 +691,6 @@ function MessageComposerImpl({ } return; } - if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { event.preventDefault(); if (!linkEditor.focusCardFirstControl()) { @@ -727,7 +700,12 @@ function MessageComposerImpl({ } // Escape in edit mode - if (event.key === "Escape" && editTargetRef.current && onCancelEdit) { + if ( + event.key === "Escape" && + !isDeferredEditPending && + editTargetRef.current && + onCancelEdit + ) { event.preventDefault(); onCancelEdit(); return; @@ -742,6 +720,7 @@ function MessageComposerImpl({ applyMentionInsert, linkEditor.isCardOpen, linkEditor.focusCardFirstControl, + isDeferredEditPending, onCancelEdit, ], ); @@ -824,22 +803,24 @@ function MessageComposerImpl({ // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( () => - disabled || - media.isUploading || + composerDisabled || + (editTarget !== null && media.isUploading) || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0), [ - disabled, + composerDisabled, + editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => { - // No-op for Tiptap — selection is managed by ProseMirror. - }, []); + const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -893,10 +874,20 @@ function MessageComposerImpl({
+ {showBackgroundUploadProgress ? ( + + ) : null}
{ + if (isDeferredEditPending) { + e.preventDefault(); + return; + } void media.handleDrop(e); } : undefined @@ -956,12 +951,17 @@ function MessageComposerImpl({
) : null} - {(media.pendingImeta.length > 0 || media.isUploading) && ( + {(media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading) && (
void; onCancelEdit?: () => void; onCancelReply?: () => void; /** @@ -66,6 +67,8 @@ export type MessageComposerProps = { content: string, mediaTags?: string[][], mentionPubkeys?: string[], + /** Target captured when the edit was submitted; avoids a later ref swap. */ + eventId?: string, ) => Promise; /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { @@ -92,6 +95,8 @@ export type MessageComposerProps = { id: string; } | null; showTopBorder?: boolean; + /** Render the app-wide upload queue above this composer dock. */ + showBackgroundUploadProgress?: boolean; toolbarExtraActions?: ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index f6bfff384..46b5d91d5 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -237,10 +237,15 @@ import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; // Real storage functions — the test uses them, not a replica. import { clearAllDrafts, + deleteDraftEntry, initDraftStore, loadDraftEntry, persistDraftEntry, } from "../lib/useDrafts.ts"; +import { + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, +} from "../lib/backgroundMediaUploadStore.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -579,3 +584,81 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); + +test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { + setupStore("pubkey-switch-files"); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 7, + spoilered: false, + }; + let draftKey = "chan-a"; + let editorContent = ""; + let queuedAttachments = []; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + return null; + } + + saveQueuedAttachmentsForDraft("chan-a", [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + + draftKey = "chan-b"; + await handle.rerender(); + assert.deepEqual(queuedAttachments, [], "B must not inherit A's local files"); + + draftKey = "chan-a"; + await handle.rerender(); + assert.equal( + queuedAttachments[0]?.file.name, + "report.pdf", + "A's attachment-only draft survives a full A → B → A switch", + ); + + await handle.unmount(); +}); + +test("discarding_a_draft_drops_its_retained_local_files", () => { + const retainedFile = { + file: new File(["private"], "private.pdf", { + type: "application/pdf", + }), + id: 8, + spoilered: false, + }; + + saveQueuedAttachmentsForDraft("chan-deleted", [retainedFile]); + deleteDraftEntry("chan-deleted"); + + assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); +}); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 40e235d2d..0e73c39e2 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -854,8 +854,9 @@ const MessageTimelineBase = React.forwardRef< {!isAtBottom ? (
diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts new file mode 100644 index 000000000..8edeea615 --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -0,0 +1,135 @@ +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +type EditDraft = { + content: string; + mentionRefs: DraftMentionRef[]; + pendingImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + spoileredAttachmentUrls: Set; +}; + +type SubmitMessageEditOptions = Omit & { + clearComposer: () => void; + customEmoji: ReadonlyArray; + extractMentionPubkeys: (content: string) => string[]; + getMentionRefs: (content: string) => DraftMentionRef[]; + editTargetId: string; + originalContent: string; + ownerPubkey: string | null; + restoreComposer: (draft: EditDraft) => void; + restoreMentionRefs: (refs: DraftMentionRef[]) => void; + shouldRestoreComposer: () => boolean; + setDeferredUploadPending: (isPending: boolean) => void; + save: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + eventId?: string, + ) => Promise; + setUploadError: (message: string) => void; +}; + +/** Clear an edited message immediately, then upload and save captured state. */ +export async function submitMessageEdit({ + clearComposer, + content, + customEmoji, + editTargetId, + extractMentionPubkeys, + getMentionRefs, + originalContent, + ownerPubkey, + pendingImeta, + queuedAttachments, + restoreComposer, + restoreMentionRefs, + setDeferredUploadPending, + shouldRestoreComposer, + save, + setUploadError, + spoileredAttachmentUrls, +}: SubmitMessageEditOptions): Promise { + const draft: EditDraft = { + content, + mentionRefs: getMentionRefs(content), + pendingImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; + const restoreDraft = () => { + if (shouldRestoreComposer()) { + restoreComposer(draft); + restoreMentionRefs(draft.mentionRefs); + } + }; + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeys(originalContent), + extractMentionPubkeys(content), + ownerPubkey ?? "", + ); + const hasQueuedAttachments = draft.queuedAttachments.length > 0; + if (hasQueuedAttachments) setDeferredUploadPending(true); + clearComposer(); + + const finishEdit = async (uploaded: ImetaMedia[], signal?: AbortSignal) => { + // An explicit empty media tag set tells edit receivers to wipe attachments. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + content, + [...draft.pendingImeta, ...uploaded], + new Set([ + ...draft.spoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] ? [uploaded[index].url] : [], + ), + ]), + ); + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ) ?? []; + if (signal?.aborted) return; + await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); + }; + + if (hasQueuedAttachments) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded, signal) => { + try { + await finishEdit(uploaded, signal); + } catch { + restoreDraft(); + } finally { + setDeferredUploadPending(false); + } + }, + onError: (error) => { + restoreDraft(); + setUploadError(String(error)); + setDeferredUploadPending(false); + }, + onCancel: () => { + restoreDraft(); + setDeferredUploadPending(false); + }, + }); + return; + } + + try { + await finishEdit([]); + } catch { + restoreDraft(); + } +} diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index 9990ce506..6035ed736 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -40,6 +40,11 @@ export function useComposerHeightPadding( return; } + // In CSS-variable mode the timeline controls are siblings of the scroll + // element. Set the measurement on their shared parent so both the virtual + // trailing spacer and floating controls inherit the same live height. + const cssVariableTarget = scrollEl.parentElement ?? scrollEl; + const getScrollElement = (): HTMLElement => mode === "css-variable" ? (scrollEl.querySelector( @@ -79,7 +84,10 @@ export function useComposerHeightPadding( const wasAtBottom = isNearBottom(); if (mode === "css-variable") { - scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`); + cssVariableTarget.style.setProperty( + "--composer-overlay-height", + `${padding}px`, + ); } else { scrollEl.style.paddingBottom = `${padding}px`; } @@ -116,7 +124,7 @@ export function useComposerHeightPadding( cancelAnimationFrame(followBottomFrame); } if (mode === "css-variable") { - scrollEl.style.removeProperty("--composer-overlay-height"); + cssVariableTarget.style.removeProperty("--composer-overlay-height"); } else { scrollEl.style.paddingBottom = ""; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index e2c4134bd..14dae33ad 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { DraftMentionRef, DraftState, @@ -28,6 +29,19 @@ type UseDraftPersistLifecycleParams = { livePendingImeta: ImetaMedia[]; /** Async setter for pendingImeta — called after the synchronous snapshot. */ setPendingImeta: (imeta: ImetaMedia[]) => void; + /** Snapshot the local files owned by the outgoing draft key. */ + getQueuedAttachments?: () => QueuedMediaAttachment[]; + /** Retain local files in memory under their draft key. */ + saveQueuedAttachmentsForDraft?: ( + draftKey: string, + attachments: QueuedMediaAttachment[], + ) => void; + /** Local files cannot be persisted, so clear them at a draft-key boundary. */ + clearQueuedAttachments?: () => void; + /** Restore local files retained while a deferred upload was off-channel. */ + restoreQueuedAttachments?: (attachments: QueuedMediaAttachment[]) => void; + /** Read and remove local files retained for a recovered draft. */ + takeQueuedAttachmentsForDraft?: (draftKey: string) => QueuedMediaAttachment[]; /** Set the rich-text editor content from a draft string. */ setContent: (content: string) => void; /** Clear the rich-text editor content (no-draft path). */ @@ -80,6 +94,11 @@ export function useDraftPersistLifecycle({ restoreMentionRefs, livePendingImeta, setPendingImeta, + getQueuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments, + restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent, clearContent, setSpoileredAttachmentUrls, @@ -87,6 +106,12 @@ export function useDraftPersistLifecycle({ syncComposerContentFromEditor, }: UseDraftPersistLifecycleParams): void { const pendingImetaForPersistRef = React.useRef([]); + const restoredQueuedAttachmentsRef = React.useRef( + [], + ); + const restoredQueuedAttachmentsDraftKeyRef = React.useRef( + null, + ); // Render-time update: keep the ref in sync with committed state so the // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; @@ -99,6 +124,16 @@ export function useDraftPersistLifecycle({ // already reflects the incoming channel, which would corrupt the outgoing // draft's channelId metadata. + // Files cannot be serialized into localStorage. Replace the outgoing + // queue (retained by the cleanup below) with the incoming draft's queue. + clearQueuedAttachments?.(); + if (effectiveDraftKey !== restoredQueuedAttachmentsDraftKeyRef.current) { + restoredQueuedAttachmentsDraftKeyRef.current = effectiveDraftKey ?? null; + restoredQueuedAttachmentsRef.current = effectiveDraftKey + ? (takeQueuedAttachmentsForDraft?.(effectiveDraftKey) ?? []) + : []; + } + restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; if (saved) { setContent(saved.content); @@ -121,6 +156,10 @@ export function useDraftPersistLifecycle({ return () => { if (effectiveDraftKey) { + const queuedAttachments = getQueuedAttachments?.() ?? []; + if (queuedAttachments.length > 0) { + saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); + } const content = syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts new file mode 100644 index 000000000..76503bfab --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -0,0 +1,76 @@ +import type { ManagedAgent } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; + +export { MENTION_REFERENCE_TAG }; + +export type PendingNonMemberMentionSend = { + capturedChannelId: string | null; + capturedThreadContext: { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + trimmed: string; + mentionPubkeys: string[]; + nonMemberPubkeys: string[]; + outgoingTags?: string[][]; + preparedManagedAgents?: ManagedAgent[]; + readyAgentPubkeys?: string[]; + savedContent: string; + savedImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + savedSpoileredAttachmentUrls: Set; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + savedMentionRefs: DraftMentionRef[]; + audienceGeneration: number; + audienceRevision: number | null; + explicitAgentPubkeys: string[]; +}; + +export type SendMessageWithMentionFlowInput = { + capturedChannelId: string | null; + capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; + pendingImeta: ImetaMedia[]; + queuedAttachments?: QueuedMediaAttachment[]; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + spoileredAttachmentUrls?: ReadonlySet; + trimmed: string; + audienceGeneration?: number; + audienceRevision?: number | null; +}; + +export function mergeOutgoingTagsWithReferenceMentions( + outgoingTags: string[][] | undefined, + pubkeys: Iterable, +) { + const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); + if (normalizedPubkeys.length === 0) { + return outgoingTags; + } + + return [ + ...(outgoingTags ?? []), + ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), + ]; +} + +export function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +export function uniqueNormalizedPubkeys(pubkeys: Iterable) { + return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); +} + +export function isManagedAgentRunning(agent: ManagedAgent) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function isProviderBackedAgent(agent: ManagedAgent) { + return agent.backend.type === "provider"; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5e9ef2792..6ba9f6905 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,6 +1,5 @@ import * as React from "react"; import { toast } from "sonner"; - import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, @@ -13,6 +12,11 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import { + prepareBackgroundMediaUpload, + saveQueuedAttachmentsForDraft, + type QueuedMediaAttachment, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -27,54 +31,24 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; - -type PendingNonMemberMentionSend = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - finalContent: string; - mentionPubkeys: string[]; - nonMemberPubkeys: string[]; - outgoingTags?: string[][]; - preparedManagedAgents?: ManagedAgent[]; - readyAgentPubkeys?: string[]; - savedContent: string; - savedImeta: ImetaMedia[]; - savedSpoileredAttachmentUrls: Set; - sentDraftKey: string | null | undefined; - audienceGeneration: number; - audienceRevision: number | null; - /** Agent mentions explicitly authored in this draft (never inferred). */ - explicitAgentPubkeys: string[]; -}; - -type SendMessageWithMentionFlowInput = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - pendingImeta: ImetaMedia[]; - sentDraftKey: string | null | undefined; - spoileredAttachmentUrls?: ReadonlySet; - trimmed: string; - audienceGeneration?: number; - audienceRevision?: number | null; -}; - +import { + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + MENTION_REFERENCE_TAG, + mergeOutgoingTagsWithReferenceMentions, + type PendingNonMemberMentionSend, + type SendMessageWithMentionFlowInput, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; type UseMentionSendFlowOptions = { channelId: string | null; channelLinks: Pick; channelType: ChannelType | null; contentRef: React.MutableRefObject; customEmoji: CustomEmoji[]; - drafts: Pick; + drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; onPrepareSendChannel?: ( @@ -99,6 +73,9 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -110,43 +87,10 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; - -function mergeOutgoingTagsWithReferenceMentions( - outgoingTags: string[][] | undefined, - pubkeys: Iterable, -) { - const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); - if (normalizedPubkeys.length === 0) { - return outgoingTags; - } - - return [ - ...(outgoingTags ?? []), - ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), - ]; -} - -function getErrorMessage(error: unknown, fallback: string) { - return error instanceof Error && error.message ? error.message : fallback; -} - -function uniqueNormalizedPubkeys(pubkeys: Iterable) { - return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); -} - -function isManagedAgentRunning(agent: ManagedAgent) { - return agent.status === "running" || agent.status === "deployed"; -} - -function isProviderBackedAgent(agent: ManagedAgent) { - return agent.backend.type === "provider"; -} - const DM_THREAD_AGENT_MENTION_ERROR = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; const DM_THREAD_MEMBERS_LOADING_ERROR = "Checking conversation members. Try again in a moment."; - export function useMentionSendFlow({ channelId, channelLinks, @@ -162,6 +106,9 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + hasUnsavedMedia, + clearQueuedAttachments, + restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -188,7 +135,6 @@ export function useMentionSendFlow({ isMountedRef.current = false; }; }, []); - const addMembersMutation = useAddChannelMembersMutation(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = @@ -198,18 +144,15 @@ export function useMentionSendFlow({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const startAgentMutation = useStartManagedAgentMutation(); - const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = managedAgentsQuery.data ?? (await managedAgentsQuery.refetch()).data ?? []; - return new Map( agents.map((agent) => [normalizePubkey(agent.pubkey), agent]), ); }, [managedAgentsQuery.data, managedAgentsQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< AcpRuntime[] > => { @@ -217,7 +160,6 @@ export function useMentionSendFlow({ if (cached.length > 0 || !availableRuntimesQuery.isLoading) { return cached; } - const refetched = await availableRuntimesQuery.refetch(); return (refetched.data ?? []).filter( (runtime): runtime is AcpRuntime => @@ -230,7 +172,6 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -399,6 +340,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -416,6 +358,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -442,12 +385,33 @@ export function useMentionSendFlow({ isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); + const preparedUpload = + draft.queuedAttachments.length > 0 + ? prepareBackgroundMediaUpload(draft.queuedAttachments) + : null; + const persistPreflightDraft = () => { + if (!draft.recoveryDraftKey) return; + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, + ); + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + }; + let uploadStarted = false; try { const readyAgentPubkeys = new Set( (draft.readyAgentPubkeys ?? []).map(normalizePubkey), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); if (!isMountedRef.current) { + persistPreflightDraft(); return; } for (const agent of draft.preparedManagedAgents ?? []) { @@ -473,6 +437,7 @@ export function useMentionSendFlow({ return; } if (!isMountedRef.current) { + persistPreflightDraft(); return; } } @@ -486,6 +451,7 @@ export function useMentionSendFlow({ [...managedAgentsByPubkey.values()], ); if (!isMountedRef.current) { + persistPreflightDraft(); return; } if (agentReadiness.errors.length > 0) { @@ -523,23 +489,87 @@ export function useMentionSendFlow({ mentionPubkeys, ); - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. - if (draft.capturedChannelId === channelIdRef.current) { - clearComposer( - resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + const send = onSendRef.current; + const persistCanceledDraft = () => { + if (!draft.recoveryDraftKey) return; + const existing = drafts.loadDraft(draft.recoveryDraftKey); + if ( + existing && + (existing.content !== draft.savedContent || + existing.channelId !== + (draft.capturedChannelId ?? draft.recoveryDraftKey) || + JSON.stringify(existing.pendingImeta) !== + JSON.stringify(draft.savedImeta) || + JSON.stringify(existing.spoileredAttachmentUrls) !== + JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + ) { + return; + } + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, ); - } - - try { - await onSendRef.current( - draft.finalContent, + }; + const restoreComposerAfterFailure = () => { + persistCanceledDraft(); + const canRestoreCurrentComposer = + isMountedRef.current && + (draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null) && + contentRef.current.trim().length === 0 && + !hasUnsavedMedia(); + if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + } + if (!canRestoreCurrentComposer) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + mentions.restoreDraftMentionRefs(draft.savedMentionRefs); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + const finishSend = async ( + uploaded: ImetaMedia[], + signal?: AbortSignal, + ) => { + const { content: finalContent, mediaTags } = buildOutgoingMessage( + draft.trimmed, + [...draft.savedImeta, ...uploaded], + new Set([ + ...draft.savedSpoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] + ? [uploaded[index].url] + : [], + ), + ]), + ); + const finalOutgoingTags = mergeOutgoingTags( + mediaTags, + outgoingTags ?? [], + ); + if (signal?.aborted) return; + await send( + finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); + if (signal?.aborted) return; if (effectiveExplicitAgentPubkeys.length > 0) { // Promote only explicitly authored agents that remained effective // for this successful send. "Send without inviting" removes its @@ -555,25 +585,57 @@ export function useMentionSendFlow({ drafts.markDraftSent( draft.sentDraftKey, draft.savedContent, - sendChannelId ?? draft.sentDraftKey, + draft.capturedChannelId ?? draft.sentDraftKey, draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], ); } - } catch { - // Only restore the composer content if the user is still on the - // channel that originated the send. - if (draft.capturedChannelId === channelIdRef.current) { - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); + }; + if (preparedUpload) { + uploadStarted = preparedUpload.start({ + onComplete: async (uploaded, signal) => { + try { + await finishSend(uploaded, signal); + } catch { + restoreComposerAfterFailure(); + } + }, + onError: (error) => { + restoreComposerAfterFailure(); + toast.error( + `Upload failed: ${getErrorMessage(error, "Unknown error")}`, + ); + }, + onCancel: () => { + restoreComposerAfterFailure(); + }, + }); + if (!uploadStarted) { + return; + } + } + + // Replace the sent body directly with its final post-send state before + // the async network send starts. This avoids an intermediate blank frame + // for persistent audiences while preserving the ordinary empty state. + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + clearComposer( + resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + ); + } + + if (!preparedUpload) { + try { + await finishSend([]); + } catch { + restoreComposerAfterFailure(); } } } finally { + if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { setIsCompleteSendPending(false); @@ -594,7 +656,10 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + restoreQueuedAttachments, setSpoileredAttachmentUrls, + hasUnsavedMedia, + mentions.restoreDraftMentionRefs, ], ); @@ -660,7 +725,9 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, + queuedAttachments = [], sentDraftKey, + recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, audienceGeneration = 0, @@ -721,15 +788,7 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - pendingImeta, - spoileredAttachmentUrls, - ); - const outgoingTags = mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ); + const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => @@ -752,7 +811,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - finalContent, + trimmed, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -763,8 +822,11 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, + recoveryDraftKey, + savedMentionRefs: mentions.getDraftMentionRefs(trimmed), audienceGeneration, audienceRevision, explicitAgentPubkeys, @@ -793,6 +855,7 @@ export function useMentionSendFlow({ mentions.extractMentionPubkeys, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, + mentions.getDraftMentionRefs, onPrepareSendChannel, ], ); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index bb56bc18e..62a262e4f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -596,11 +596,11 @@ export async function uploadMedia( isTemp, }); } - -export async function pickAndUploadMedia(): Promise { - return invokeTauri("pick_and_upload_media", {}); +export async function pickAndUploadMedia( + progressId?: string, +): Promise { + return invokeTauri("pick_and_upload_media", { progressId }); } - export async function uploadMediaBytes( data: number[], filename?: string, diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 1dab9b772..60205a45f 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,5 +1,44 @@ +import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; +function encodeRawIpcHeader(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return window + .btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */ +export async function uploadMediaFile( + file: File, + progressId?: string, + signal?: AbortSignal, +): Promise { + const headers: Record = { + "x-buzz-filename": encodeRawIpcHeader(file.name), + }; + if (progressId) { + headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId); + } + + if (signal?.aborted) throw new Error("upload cancelled"); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (signal?.aborted) throw new Error("upload cancelled"); + + return invokeTauriRaw("upload_media_bytes_raw", bytes, { + headers, + }); +} + +/** Stop the native HTTP request associated with a background media upload. */ +export async function cancelMediaUpload(progressId: string): Promise { + await invokeTauri("cancel_media_upload", { progressId }); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b15358d9d..33b1fb38f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -369,6 +369,8 @@ type E2eConfig = { // (e.g. a generic PDF) without a real upload pipeline. See // tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors. uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; @@ -1066,6 +1068,15 @@ declare global { command: string; payload: unknown; }>; + __BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?: (input: { + id: string; + phase: string; + }) => Promise; + __BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?: (input: { + id: string; + sent: number; + total: number; + }) => Promise; __BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__?: (payload: { pubkey: string | null; level: number; @@ -8806,7 +8817,7 @@ async function resolveMockUploadDescriptors( } async function resolveMockUploadDescriptorForBytes( - args: { data: number[]; filename?: string | null }, + args: { data: number[] | Uint8Array; filename?: string | null }, config: E2eConfig | undefined, ): Promise { const configured = config?.mock?.uploadDescriptors; @@ -9820,6 +9831,12 @@ export function maybeInstallE2eTauriMocks() { emit("huddle-tts-speaker-level", payload); window.__BUZZ_E2E_SIGNED_EVENTS__ = []; window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1; + window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__ = async (input) => { + await emit("media-upload-phase", input); + }; + window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__ = async (input) => { + await emit("media-upload-progress", input); + }; window.__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__ = async ({ members, transcriptionEnabled, @@ -10160,6 +10177,9 @@ export function maybeInstallE2eTauriMocks() { const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); const loggedPayload = (() => { + if (payload instanceof Uint8Array) { + return { rawByteLength: payload.byteLength }; + } try { return JSON.parse(JSON.stringify(payload ?? null)); } catch { @@ -12294,6 +12314,13 @@ export function maybeInstallE2eTauriMocks() { payload as { data: number[]; filename?: string | null }, activeConfig, ); + case "upload_media_bytes_raw": + return resolveMockUploadDescriptorForBytes( + { + data: payload as Uint8Array, + }, + 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/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 403d26c13..699e71198 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -11,6 +13,7 @@ import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; test.beforeEach(async ({ page }) => { await installMockBridge(page, { + deferredComposerUploads: true, uploadDescriptors: [ { url: `https://mock.relay/media/${"a".repeat(64)}.pdf`, @@ -24,13 +27,37 @@ test.beforeEach(async ({ page }) => { }); }); +async function chooseQuarterlyReport(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("quarterly report"), + mimeType: "application/pdf", + name: "quarterly-report.pdf", + }); +} + +async function chooseLargeVideo(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.alloc(16 * 1024 * 1024, 1), + mimeType: "video/mp4", + name: "large-video.mp4", + }); +} + test("upload a file and see a FileCard in the timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // Paperclip → mocked pick_and_upload_media returns the PDF descriptor. - await page.getByRole("button", { name: "Attach image" }).click(); + // The paperclip queues the local file without starting its upload. + await chooseQuarterlyReport(page); // The composer shows a chip with the original filename. await expect(page.getByTestId("message-composer")).toContainText( @@ -63,6 +90,206 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { .toContain("download_file"); }); +test("sends immediately and keeps upload progress across channels", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-composer")).not.toContainText( + "quarterly-report.pdf", + ); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0, { + timeout: 5_000, + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("file-card").last()).toContainText( + "quarterly-report.pdf", + ); +}); + +test("shows upload feedback before transferring a large file", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 5_000; + }); + await page.getByTestId("channel-general").click(); + await chooseLargeVideo(page); + + const progress = page.getByTestId("composer-upload-progress"); + await Promise.all([ + page.getByTestId("send-message").click(), + expect(progress).toBeVisible({ timeout: 800 }), + ]); + await expect(progress).toHaveAttribute("aria-label", "Preparing"); + await expect(page.getByTestId("composer-upload-spinner")).toBeVisible(); + await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: { rawByteLength?: number } | null; + }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "upload_media_bytes_raw", + payload: { rawByteLength: 16 * 1024 * 1024 }, + }); + + const uploadId = "background-media-upload-0-0"; + await page.evaluate(async (id) => { + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({ + id, + phase: "processing-video", + }); + }, uploadId); + await expect(progress).toHaveAttribute("aria-label", "Processing"); + await waitForAnimations(page); + const processingPhaseBox = await page + .getByTestId("composer-upload-phase") + .boundingBox(); + const processingStatusBox = await page + .getByTestId("composer-upload-status") + .boundingBox(); + expect(processingPhaseBox).not.toBeNull(); + expect(processingStatusBox).not.toBeNull(); + expect( + (processingStatusBox?.x ?? 0) - + ((processingPhaseBox?.x ?? 0) + (processingPhaseBox?.width ?? 0)), + ).toBeGreaterThanOrEqual(3); + await expect(page.getByTestId("composer-upload-spinner")).toBeVisible(); + await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0); + + await page.evaluate(async (id) => { + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({ + id, + phase: "uploading", + }); + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?.({ + id, + sent: 42, + total: 100, + }); + }, uploadId); + await expect(progress).toHaveAttribute("aria-label", "Uploading 42%"); + await waitForAnimations(page); + await expect(page.getByTestId("composer-upload-spinner")).toHaveCount(0); + await expect(page.getByTestId("composer-upload-percentage")).toHaveText( + "42%", + ); + + await page.getByTestId("composer-upload-cancel").click(); +}); + +test("canceling a background upload prevents the message from publishing", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + + await page.getByTestId("composer-upload-cancel").click(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.waitForTimeout(1_100); + await expect(page.getByTestId("file-card")).toHaveCount(0); +}); + +test("upload progress floats above the dock and lifts Jump to latest", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 2_000; + }); + await page.getByTestId("channel-deep-history").click(); + + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + const jumpToLatest = page.getByTestId("message-scroll-to-latest"); + await expect(jumpToLatest).toBeVisible(); + const restingBox = await jumpToLatest.boundingBox(); + + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + const uploadMotion = page.getByTestId("composer-upload-progress-motion"); + await expect(uploadMotion).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect(jumpToLatest).toBeVisible(); + await page.waitForTimeout(250); + + const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([ + uploadMotion.boundingBox(), + page.getByTestId("composer-dock-backdrop").boundingBox(), + jumpToLatest.boundingBox(), + ]); + expect(restingBox).not.toBeNull(); + expect(uploadBox).not.toBeNull(); + expect(dockBackdropBox).not.toBeNull(); + expect(liftedBox).not.toBeNull(); + expect((dockBackdropBox?.y ?? 0) + 1).toBeGreaterThanOrEqual( + (uploadBox?.y ?? 0) + (uploadBox?.height ?? 0), + ); + expect((liftedBox?.y ?? 0) + (liftedBox?.height ?? 0)).toBeLessThanOrEqual( + uploadBox?.y ?? 0, + ); + expect(liftedBox?.y ?? 0).toBeLessThan((restingBox?.y ?? 0) - 10); + + await page.getByTestId("composer-upload-cancel").click(); +}); + test("dropping a file on the channel column attaches it to the composer", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8622e3132..d9e2c68e9 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -360,6 +360,8 @@ type MockBridgeOptions = { * explicit `[]` is honoured (models a picker cancel / no files selected). */ uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; From a5bf3c5ae1e2f3b9a1783cd90b859d027fc92b9a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 15:11:16 +0100 Subject: [PATCH 04/30] Refine desktop timeline activity presentation (#4582) ## Summary - Make channel join/leave activity use the selected inline avatar-stack treatment. - Group related membership activity for one hour and preserve profile/overflow-name interactions. - Restore the virtualized day-divider handoff and align the sticky date behavior with the message timeline. ## Validation - `pnpm check` - `pnpm test` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` - Visual desktop screenshot captured with seeded membership activity --------- Signed-off-by: kenny lopez --- .../messages/lib/rowHeightEstimate.test.mjs | 4 +- .../messages/lib/rowHeightEstimate.ts | 2 +- .../messages/lib/systemEventCopy.test.mjs | 6 + .../features/messages/lib/systemEventCopy.ts | 9 + .../messages/lib/timelineItems.test.mjs | 56 +- .../features/messages/lib/timelineItems.ts | 41 +- .../lib/virtualizedTimelineItems.test.mjs | 2 +- .../messages/lib/virtualizedTimelineItems.ts | 2 +- .../src/features/messages/ui/DayDivider.tsx | 24 +- .../features/messages/ui/MessageTimeline.tsx | 8 +- .../messages/ui/SystemMessageAvatars.tsx | 196 +++++++ .../features/messages/ui/SystemMessageRow.tsx | 502 +++++++++--------- .../messages/ui/TimelineMessageList.tsx | 429 +++++++-------- .../messages/ui/TimelineMessageRow.tsx | 228 ++++++++ .../features/messages/ui/TimelineRowShell.tsx | 27 + .../profile/ui/UserProfilePopover.tsx | 5 + desktop/src/shared/layout/chromeLayout.ts | 3 + desktop/tests/e2e/channels.spec.ts | 106 ++-- desktop/tests/e2e/mentions.spec.ts | 111 ++-- desktop/tests/e2e/messaging.spec.ts | 8 - desktop/tests/e2e/unread-pill.spec.ts | 20 + 21 files changed, 1132 insertions(+), 657 deletions(-) create mode 100644 desktop/src/features/messages/ui/SystemMessageAvatars.tsx create mode 100644 desktop/src/features/messages/ui/TimelineMessageRow.tsx create mode 100644 desktop/src/features/messages/ui/TimelineRowShell.tsx diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index f17a53c66..1fe6f66cd 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -106,11 +106,11 @@ test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); }); -test("timelineRowReserveStyle: divider is short fixed height", () => { +test("timelineRowReserveStyle: divider reserves its visual breathing room", () => { const style = timelineRowReserveStyle({ kind: "day-divider", key: "k", headingTimestamp: 0, }); - assert.equal(style.containIntrinsicSize, "auto 32px"); + assert.equal(style.containIntrinsicSize, "auto 56px"); }); diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index acefae95d..196aabbec 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -156,7 +156,7 @@ export function estimateRowHeight( // Dividers are short, fixed-height rows; reserving their true height keeps the // estimate honest without a content scan. -const DIVIDER_HEIGHT = 32; +const DIVIDER_HEIGHT = 56; const SYSTEM_GROUP_HEIGHT = 80; /** diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs index eeed9d543..417685d47 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.test.mjs +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -2,10 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "./systemEventCopy.ts"; +test("an add to the reader uses passive wording", () => { + assert.equal(addedByActionPrefix(true), "were added by"); + assert.equal(addedByActionPrefix(false), "added by"); +}); + test("a set topic is quoted verbatim", () => { assert.equal( describeChannelTextFieldChange("topic", "Release planning"), diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts index bae6abb09..99f731616 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.ts +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -14,6 +14,15 @@ const CLOSE_QUOTE = "”"; export type ChannelTextField = "topic" | "purpose"; +/** + * The reader is the recipient of an add, while every other member is the + * subject of one. Keep that distinction in the caption: "You were added by" + * rather than the ungrammatical "You added by". + */ +export function addedByActionPrefix(isCurrentUser: boolean): string { + return isCurrentUser ? "were added by" : "added by"; +} + /** * Caption for a channel topic or purpose change. * diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 4677fe7b4..2b87b5ebc 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -48,6 +48,15 @@ function memberJoinedEntry({ createdAt, id, target }) { return memberAddedEntry({ actor: target, createdAt, id, target }); } +function memberLeftEntry({ createdAt, id, target }) { + return entry({ + id, + createdAt, + kind: KIND_SYSTEM_MESSAGE, + body: JSON.stringify({ type: "member_left", actor: target }), + }); +} + function kinds(items) { return items.map((item) => item.kind); } @@ -103,12 +112,12 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); -test("buildTimelineItems: member additions by one actor group within five minutes", () => { +test("buildTimelineItems: contiguous member additions by one actor group", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 60 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 300 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_600 }), ]; const { items } = buildTimelineItems(entries, null); @@ -121,7 +130,7 @@ test("buildTimelineItems: member additions by one actor group within five minute assert.equal(group?.key, "c"); }); -test("buildTimelineItems: self-joins group across different members within five minutes", () => { +test("buildTimelineItems: contiguous self-joins group across different members", () => { const start = dayAt(2026, 6, 14); const entries = [ memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -133,7 +142,7 @@ test("buildTimelineItems: self-joins group across different members within five memberJoinedEntry({ id: "c", target: "target-c", - createdAt: start + 300, + createdAt: start + 3_600, }), ]; @@ -149,9 +158,9 @@ test("buildTimelineItems: self-joins group across different members within five test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => { const start = dayAt(2026, 6, 14); const loaded = [ - memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }), - entry({ id: "message", createdAt: start + 600 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_500 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), + entry({ id: "message", createdAt: start + 3_700 }), ]; const prepended = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -164,23 +173,28 @@ test("buildTimelineItems: prepending membership history preserves the loaded suf const prependedKeys = prependedItems.slice(1).map((item) => item.key); assert.deepEqual(loadedKeys, ["c", "message"]); - assert.deepEqual(prependedKeys, ["a", "c", "message"]); + assert.deepEqual(prependedKeys, ["c", "message"]); assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys); }); -test("buildTimelineItems: member-add window is fixed from the newest addition", () => { +test("buildTimelineItems: contiguous member additions extend a group outside one hour", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), - memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 301 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_599 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), ]; const { items } = buildTimelineItems(entries, null); - assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["a", "b", "c"], + ); }); -test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => { +test("buildTimelineItems: incompatible arrivals remain separate", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -216,6 +230,22 @@ test("buildTimelineItems: actor changes and intervening rows break member-add gr ]); }); +test("buildTimelineItems: a member joining then leaving is one lifecycle group", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "joined", target: "member-a", createdAt: start }), + memberLeftEntry({ id: "left", target: "member-a", createdAt: start + 90 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["joined", "left"], + ); +}); + test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => { const entries = [ entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 72b83f0a0..c28387102 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -62,13 +62,10 @@ function entryRenderKey(entry: MainTimelineEntry): string { return entry.message.renderKey ?? entry.message.id; } -const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60; - -type MembershipChangePayload = { - actor: string | null; - mode: "added" | "joined"; - target: string; -}; +type MembershipChangePayload = + | { mode: "self-arrival"; target: string } + | { actor: string; mode: "addition"; target: string } + | { mode: "departure"; target: string }; function parseMembershipChangePayload( entry: MainTimelineEntry, @@ -81,6 +78,10 @@ function parseMembershipChangePayload( actor?: unknown; target?: unknown; }; + if (payload.type === "member_left" && typeof payload.actor === "string") { + const target = payload.actor.trim().toLowerCase(); + return target ? { mode: "departure", target } : null; + } if ( payload.type !== "member_joined" || typeof payload.actor !== "string" || @@ -92,10 +93,9 @@ function parseMembershipChangePayload( const actor = payload.actor.trim().toLowerCase(); const target = payload.target.trim().toLowerCase(); if (!actor || !target) return null; - return actor === target - ? { actor: null, mode: "joined", target } - : { actor, mode: "added", target }; + ? { mode: "self-arrival", target } + : { actor, mode: "addition", target }; } catch { return null; } @@ -105,9 +105,16 @@ function membershipChangesCanGroup( first: MembershipChangePayload, second: MembershipChangePayload, ): boolean { + if (first.mode === "self-arrival") { + return ( + second.mode === "self-arrival" || + (second.mode === "departure" && first.target === second.target) + ); + } return ( - first.mode === second.mode && - (first.mode === "joined" || first.actor === second.actor) + first.mode === "addition" && + second.mode === "addition" && + first.actor === second.actor ); } @@ -116,6 +123,12 @@ function membershipChangesCanGroup( * history cannot repartition the rows that are already loaded. Their key is * likewise the newest entry's key: extending the oldest visible group changes * its contents, but not its identity or the virtual list's existing key suffix. + * + * Compatible membership activities stay together while they are contiguous. + * Self-joins and additions from one administrator each form their own summary; + * a self-join immediately followed by that member leaving becomes a single + * lifecycle summary. Each adjacent event must fall within the one-hour activity + * window, so uninterrupted activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], @@ -134,14 +147,14 @@ function buildMembershipGroups( let start = end; while (start > 0) { const candidate = entries[start - 1]; + const nextEntry = entries[start]; const candidatePayload = parseMembershipChangePayload(candidate); if ( barrierIndexes.has(start) || !candidatePayload || !membershipChangesCanGroup(candidatePayload, newestPayload) || newestEntry.message.createdAt < candidate.message.createdAt || - newestEntry.message.createdAt - candidate.message.createdAt > - MEMBERSHIP_GROUP_WINDOW_SECONDS + nextEntry.message.createdAt - candidate.message.createdAt > 60 * 60 ) { break; } diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs index fbfad321e..daae41a78 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs @@ -221,7 +221,7 @@ test("virtualized rows preserve their heterogeneous height estimates", () => { ); const estimates = items.map(estimateVirtualizedTimelineItemHeight); - assert.equal(estimates[0], 32); + assert.equal(estimates[0], 56); assert.ok(estimates[2] > estimates[1] + 200); assert.equal(estimates.at(-1), 96); }); diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts index e9195128c..1ab68af18 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts @@ -32,7 +32,7 @@ export function estimateVirtualizedTimelineItemHeight( ): number { if (item.kind === "bottom-spacer") return 96; if (item.kind === "leading-content") return 60; - if (item.kind === "day-divider") return 32; + if (item.kind === "day-divider") return 56; return estimateTimelineItemHeight(item.item); } diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index 8dff4b7a0..72a396d50 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -1,9 +1,27 @@ -export function DayDivider({ label }: { label: string }) { +import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; + +export function DayDivider({ + label, + sticky = true, + testId = "message-timeline-day-divider", +}: { + label: string; + sticky?: boolean; + testId?: string; +}) { return (

diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 0e73c39e2..6d9c49b75 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -689,8 +689,8 @@ const MessageTimelineBase = React.forwardRef< {showUnreadPill ? (

diff --git a/desktop/src/features/messages/ui/SystemMessageAvatars.tsx b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx new file mode 100644 index 000000000..e3792259b --- /dev/null +++ b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx @@ -0,0 +1,196 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MAX_MEMBERSHIP_AVATARS = 5; + +function resolveAvatarUrl( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string | null { + if (!pubkey || !profiles) return null; + return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null; +} + +function isKnownAgentPubkey( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, + personaLookup?: Map, + agentPubkeys?: ReadonlySet, +) { + if (!pubkey) return false; + const normalizedPubkey = normalizePubkey(pubkey); + return ( + agentPubkeys?.has(normalizedPubkey) === true || + profiles?.[normalizedPubkey]?.isAgent === true || + personaLookup?.has(normalizedPubkey) === true + ); +} + +export function SystemMessageAvatar({ + actorPubkey, + agentPubkeys, + currentPubkey, + personaLookup, + profiles, + targetPubkey, +}: { + actorPubkey: string | undefined; + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; + targetPubkey: string | undefined; +}) { + const hasActorAndTarget = + actorPubkey && targetPubkey && actorPubkey !== targetPubkey; + const actorLabel = actorPubkey + ? resolveUserLabel({ + pubkey: actorPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : "Someone"; + const singlePubkey = actorPubkey ?? targetPubkey; + + if (!hasActorAndTarget) { + const isSingleAgent = isKnownAgentPubkey( + singlePubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const avatar = ( + + ); + if (singlePubkey) { + return ( + + + + ); + } + return avatar; + } + + const isActorAgent = isKnownAgentPubkey( + actorPubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const targetLabel = resolveUserLabel({ + pubkey: targetPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const dualAvatar = ( +
+ + +
+ ); + return ( + + + + ); +} + +export function MembershipAvatarStack({ + currentPubkey, + profiles, + pubkeys, +}: { + currentPubkey: string | undefined; + profiles: UserProfileLookup | undefined; + pubkeys: readonly string[]; +}) { + const visiblePubkeys = pubkeys.slice(0, MAX_MEMBERSHIP_AVATARS); + if (visiblePubkeys.length === 0) return null; + return ( +
+ {visiblePubkeys.map((pubkey, index) => { + const label = resolveUserLabel({ + pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + return ( +
0 && "-ml-1")} + data-testid="system-message-avatar" + key={pubkey} + style={{ zIndex: index + 1 }} + > + + + +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index c4637d282..1da16e437 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -29,12 +29,17 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { + MembershipAvatarStack, + SystemMessageAvatar, +} from "./SystemMessageAvatars"; const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0"; const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4"; @@ -77,34 +82,29 @@ function buildGroupedMembershipPayload( if (messages.length < 2) return null; const payloads = messages.map(parseSystemMessagePayload); - const firstPayload = payloads[0]; - const actor = firstPayload?.actor - ? normalizePubkey(firstPayload.actor) - : null; - const firstTarget = firstPayload?.target - ? normalizePubkey(firstPayload.target) - : null; - if (!actor || !firstTarget) return null; - const isSelfJoinGroup = actor === firstTarget; + const joinedThenLeft = buildJoinedThenLeftPayload(payloads); + if (joinedThenLeft) return joinedThenLeft; - const targets: string[] = []; - for (const payload of payloads) { + const arrivals = payloads.map((payload) => { const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; const payloadTarget = payload?.target ? normalizePubkey(payload.target) : null; - if ( - payload?.type !== "member_joined" || - !payloadActor || - !payloadTarget || - (isSelfJoinGroup - ? payloadActor !== payloadTarget - : payloadActor !== actor || payloadActor === payloadTarget) - ) { + if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) { return null; } - targets.push(payloadTarget); - } + return { actor: payloadActor, target: payloadTarget }; + }); + if (arrivals.some((arrival) => !arrival)) return null; + + const membershipArrivals = arrivals as { + actor: string; + target: string; + }[]; + const targets = membershipArrivals.map(({ target }) => target); + const isSelfJoinGroup = membershipArrivals.every( + ({ actor, target }) => actor === target, + ); if (isSelfJoinGroup) { return { @@ -114,6 +114,15 @@ function buildGroupedMembershipPayload( }; } + const actor = membershipArrivals[0].actor; + const isSameAdderGroup = membershipArrivals.every( + ({ actor: candidateActor, target }) => + candidateActor === actor && candidateActor !== target, + ); + if (!isSameAdderGroup) { + return null; + } + return { type: "members_added", actor, @@ -122,6 +131,32 @@ function buildGroupedMembershipPayload( }; } +function buildJoinedThenLeftPayload( + payloads: readonly (SystemMessagePayload | null)[], +): SystemMessagePayload | null { + if (payloads.length !== 2) return null; + + const [arrival, departure] = payloads; + const arrivalTarget = arrival?.target + ? normalizePubkey(arrival.target) + : null; + const departureActor = departure?.actor + ? normalizePubkey(departure.actor) + : null; + if ( + arrival?.type !== "member_joined" || + departure?.type !== "member_left" || + !arrival.actor || + !arrivalTarget || + normalizePubkey(arrival.actor) !== arrivalTarget || + arrivalTarget !== departureActor + ) { + return null; + } + + return { type: "member_joined_then_left", target: arrival.target }; +} + function aggregateGroupedReactions( messages: readonly TimelineMessage[], ): TimelineReaction[] { @@ -277,116 +312,17 @@ function ProfileName({ ); } -function SystemMessageAvatar({ - actorPubkey, - agentPubkeys, - currentPubkey, - personaLookup, - profiles, - targetPubkey, -}: { - actorPubkey: string | undefined; - agentPubkeys?: ReadonlySet; - currentPubkey: string | undefined; - personaLookup?: Map; - profiles: UserProfileLookup | undefined; - targetPubkey: string | undefined; -}) { - const hasActorAndTarget = - actorPubkey && targetPubkey && actorPubkey !== targetPubkey; - const actorLabel = actorPubkey - ? resolveUserLabel({ - pubkey: actorPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }) - : "Someone"; +function membershipActivityPubkeys(payload: SystemMessagePayload): string[] { + const pubkeys = + payload.type === "members_added" || payload.type === "members_joined" + ? (payload.targets ?? []) + : payload.type === "member_removed" + ? [payload.target ?? payload.actor] + : [payload.target ?? payload.actor]; - const singlePubkey = actorPubkey ?? targetPubkey; - - if (!hasActorAndTarget) { - const isSingleAgent = isKnownAgentPubkey( - singlePubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const avatar = ( - - ); - - if (singlePubkey) { - return ( - - - - ); - } - - return avatar; - } - - const isActorAgent = isKnownAgentPubkey( - actorPubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const targetLabel = resolveUserLabel({ - pubkey: targetPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - - const dualAvatar = ( -
- - -
- ); - - return ( - - - - ); + return [ + ...new Set(pubkeys.filter((pubkey): pubkey is string => Boolean(pubkey))), + ]; } function MembershipPersonName({ @@ -508,6 +444,10 @@ function describeSystemEvent( personaLookup?: Map, agentPubkeys?: ReadonlySet, ): SystemMessageDescription | null { + const isTargetCurrentUser = + currentPubkey !== undefined && + payload.target !== undefined && + normalizePubkey(payload.target) === normalizePubkey(currentPubkey); const isTargetAgent = isKnownAgentPubkey( payload.target, profiles, @@ -554,7 +494,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -590,6 +530,12 @@ function describeSystemEvent( ), }; + case "member_joined_then_left": + if (!payload.target) return null; + return { + title: membershipTitle, + action: "joined, then left the channel", + }; case "member_joined": { if (!payload.actor || !payload.target) return null; if (normalizePubkey(payload.actor) === normalizePubkey(payload.target)) { @@ -602,7 +548,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -766,6 +712,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ payload.type === "member_joined" || payload.type === "members_added" || payload.type === "members_joined"; + const isMembershipActivity = + isMembershipArrival || + payload.type === "member_joined_then_left" || + payload.type === "member_left" || + payload.type === "member_removed"; + const membershipPubkeys = isMembershipActivity + ? membershipActivityPubkeys(payload) + : []; const displayedIdentityPubkey = isMembershipArrival ? payload.target : payload.actor; @@ -799,140 +753,172 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser, ); + const reactionsContent = ( +
+ { + setBadgeBurstEmoji((current) => (current === emoji ? null : current)); + }} + onSelect={(emoji) => { + void handleReactionSelect(emoji); + }} + /> + {reactionErrorMessage ? ( +

+ {reactionErrorMessage} +

+ ) : null} +
+ ); + + const reactionPicker = canToggleReactions ? ( +
+
+ + + + + + + + React + + + {reactionErrorMessage ? ( +
+

+ {reactionErrorMessage} +

+
+ ) : null} + { + if ( + !reactionPending && + wouldAddReaction(value) && + isPositiveEmojiParticle(value) + ) { + setBadgeBurstEmoji(value); + } + void handleReactionSelect(value) + .then(() => { + recordQuickReactionEmoji(value); + }) + .catch(() => {}) + .finally(() => { + setIsReactionPickerOpen(false); + }); + }} + /> +
+
+
+
+ ) : null; + return (
-
- -
- - - {description.title} - - {displayedIdentityIsAgent ? ( - +
+
+ - ) : null} - - -

- {description.action} -

-
- { - setBadgeBurstEmoji((current) => - current === emoji ? null : current, - ); - }} - onSelect={(emoji) => { - void handleReactionSelect(emoji); - }} - /> - {reactionErrorMessage ? ( -

- {reactionErrorMessage} +

+ {description.title} {description.action}

- ) : null} +
+
+
{reactionsContent}
+
+ ) : ( +
+ +
+ + + {description.title} + + {displayedIdentityIsAgent ? ( + + ) : null} + + +

+ {description.action} +

+ {reactionsContent}
-
- {canToggleReactions ? ( -
-
- - - - - - - - React - - - {reactionErrorMessage ? ( -
-

- {reactionErrorMessage} -

-
- ) : null} - { - if ( - !reactionPending && - wouldAddReaction(value) && - isPositiveEmojiParticle(value) - ) { - setBadgeBurstEmoji(value); - } - void handleReactionSelect(value) - .then(() => { - recordQuickReactionEmoji(value); - }) - .catch(() => {}) - .finally(() => { - setIsReactionPickerOpen(false); - }); - }} - /> -
-
-
-
- ) : null} -
+ )} +
+ {reactionPicker}
); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 9c5b143db..bf2da03f4 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -3,7 +3,6 @@ import { VList } from "virtua"; import type { VListHandle } from "virtua"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; -import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; import { buildTimelineDayGroups, buildTimelineItems, @@ -18,21 +17,18 @@ import { type VirtualizedTimelineItem, virtualizedItemKey, } from "@/features/messages/lib/virtualizedTimelineItems"; -import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; -import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; -import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; import { DayDivider } from "./DayDivider"; -import { MessageRow } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; -import { SystemMessageRow } from "./SystemMessageRow"; +import { MessageRowItem, SystemRow } from "./TimelineMessageRow"; +import { TimelineRowShell } from "./TimelineRowShell"; import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; @@ -345,7 +341,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ "relative flex flex-col", !hideDayDividers && group.headingTimestamp !== null && - "before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']", + "before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']", )} data-day-label={ group.headingTimestamp === null @@ -369,10 +365,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); -function timelineItemMessageId(item: TimelineNonDayItem): string | null { +function timelineItemMessageIds(item: TimelineNonDayItem): string[] { + if (item.kind === "system-group") { + return item.entries.map((entry) => entry.message.id); + } return item.kind === "message" || item.kind === "system" - ? item.entry.message.id - : null; + ? [item.entry.message.id] + : []; } type VirtualizedTimelineRowsProps = { @@ -437,6 +436,8 @@ function VirtualizedTimelineRows({ typeof window === "undefined" ? 1_000 : window.innerHeight, ); const hasInitialPositionedRef = React.useRef(false); + const pinnedDayLabelRef = React.useRef(null); + const pinnedDayTranslateYRef = React.useRef(0); const estimateCallCountRef = React.useRef(0); const estimateItemSize = React.useCallback( (item: VirtualizedTimelineItem) => { @@ -462,6 +463,17 @@ function VirtualizedTimelineRows({ [dayGroups, hideDayDividers, historyExhausted, leadingContent], ); const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); + const dayDividerItems = React.useMemo( + () => + items.flatMap((item, index) => + item.kind === "day-divider" ? [{ index, item }] : [], + ), + [items], + ); + const [pinnedDay, setPinnedDay] = React.useState<{ + label: string | null; + incomingLabel: string | null; + }>({ label: null, incomingLabel: null }); itemsLengthRef.current = items.length; const previousKeysRef = React.useRef([]); const [prependShiftEpoch, clearPrependShift] = React.useReducer( @@ -475,6 +487,128 @@ function VirtualizedTimelineRows({ cancelBottomSettle, ); + const updatePinnedDayLabel = React.useCallback( + (offset: number) => { + const list = listRef.current; + const scroller = hostRef.current?.firstElementChild; + const pinnedLabel = pinnedDayLabelRef.current; + if (!list || !(scroller instanceof HTMLDivElement) || !pinnedLabel) { + return; + } + + const pinnedTop = + pinnedLabel.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + pinnedDayTranslateYRef.current; + const [pinnedPill, incomingPinnedPill] = + pinnedLabel.querySelectorAll("p"); + const pinnedPillHeight = pinnedPill?.offsetHeight ?? 0; + if (pinnedPillHeight === 0) return; + const renderedDividerPillTop = ( + divider: (typeof dayDividerItems)[number], + ) => { + const label = formatDayHeading(divider.item.headingTimestamp); + const source = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"]', + ), + ].find((element) => element.dataset.dayLabel === label); + const pill = source?.querySelector("p"); + return pill + ? pill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + : null; + }; + const sourcePills = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"] p', + ), + ]; + // Source dividers are normally visible in the feed. Only hide the one + // that physically overlaps the floating chip at the handoff point. + for (const pill of sourcePills) { + pill.style.removeProperty("visibility"); + } + + let activeDividerIndex = -1; + for (const [index, divider] of dayDividerItems.entries()) { + if (list.getItemOffset(divider.index) > offset + pinnedTop) break; + activeDividerIndex = index; + } + const candidateDivider = dayDividerItems[activeDividerIndex]; + // Retain the previous date while the next in-flow divider is still + // above the sticky slot. This avoids changing the label before the + // moving chip reaches its handoff point. + if ( + activeDividerIndex > 0 && + candidateDivider && + (renderedDividerPillTop(candidateDivider) ?? -Infinity) > pinnedTop + ) { + activeDividerIndex -= 1; + } + const activeDivider = dayDividerItems[activeDividerIndex]; + const nextDivider = dayDividerItems[activeDividerIndex + 1]; + const nextDividerTop = nextDivider + ? (renderedDividerPillTop(nextDivider) ?? + list.getItemOffset(nextDivider.index) - offset) + : null; + const nextTranslateY = + nextDividerTop === null + ? 0 + : Math.max( + -pinnedPillHeight, + Math.min(0, nextDividerTop - pinnedTop - pinnedPillHeight), + ); + if (pinnedDayTranslateYRef.current !== nextTranslateY) { + pinnedDayTranslateYRef.current = nextTranslateY; + pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`; + } + const nextLabel = activeDivider + ? formatDayHeading(activeDivider.item.headingTimestamp) + : null; + const incomingLabel = + nextDivider && nextTranslateY < 0 + ? formatDayHeading(nextDivider.item.headingTimestamp) + : null; + const activeSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === nextLabel, + ); + if (activeSourcePill) { + const sourceTop = + activeSourcePill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + const overlayTop = pinnedTop; + const sourceBottom = sourceTop + activeSourcePill.offsetHeight; + const overlayBottom = overlayTop + pinnedPillHeight; + if (sourceBottom > overlayTop && sourceTop < overlayBottom) { + activeSourcePill.style.visibility = "hidden"; + } + } + const incomingSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === incomingLabel, + ); + if (incomingSourcePill) { + incomingSourcePill.style.visibility = "hidden"; + } + if (pinnedPill) { + pinnedPill.textContent = nextLabel ?? ""; + pinnedPill.style.visibility = nextLabel ? "visible" : "hidden"; + } + if (incomingPinnedPill) { + incomingPinnedPill.textContent = incomingLabel ?? ""; + incomingPinnedPill.style.visibility = incomingLabel + ? "visible" + : "hidden"; + } + setPinnedDay((current) => + current.label === nextLabel && current.incomingLabel === incomingLabel + ? current + : { label: nextLabel, incomingLabel }, + ); + }, + [dayDividerItems], + ); + React.useEffect( () => () => { cancelBottomSettle(); @@ -502,8 +636,9 @@ function VirtualizedTimelineRows({ const byId = new Map(); items.forEach((item, index) => { if (item.kind !== "timeline-item") return; - const messageId = timelineItemMessageId(item.item); - if (messageId) byId.set(messageId, index); + for (const messageId of timelineItemMessageIds(item.item)) { + byId.set(messageId, index); + } }); return byId; }, [items]); @@ -523,6 +658,10 @@ function VirtualizedTimelineRows({ return () => onVirtualizerScrollerChange?.(null); }, [onVirtualizerScrollerChange]); + React.useLayoutEffect(() => { + updatePinnedDayLabel(listRef.current?.scrollOffset ?? 0); + }, [updatePinnedDayLabel]); + React.useLayoutEffect(() => { if (!onVirtualizerApiChange) return; const api: TimelineVirtualizerApi = { @@ -578,6 +717,7 @@ function VirtualizedTimelineRows({ // channel above its newest message. The settle hook's wheel, pointer, // touch, and key listeners are the authoritative user-interaction gate. onAtBottomStateChange?.(distanceFromBottom <= 32); + updatePinnedDayLabel(offset); if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. armUpwardMomentum(onStartReached?.() ?? false); @@ -588,11 +728,12 @@ function VirtualizedTimelineRows({ onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, + updatePinnedDayLabel, ], ); return ( -
+
- -
+
); } @@ -653,236 +786,34 @@ function VirtualizedTimelineRows({ }}
-
- ); -} - -function TimelineRowShell({ - children, - item, - useContentVisibility = true, -}: { - children: React.ReactNode; - item: TimelineNonDayItem; - useContentVisibility?: boolean; -}) { - return ( -
- {children} -
- ); -} - -function SystemRow({ - currentPubkey, - entries, - entry, - footer, - onToggleReaction, - profiles, - ownerProfiles, -}: { - currentPubkey?: string; - entries?: MainTimelineEntry[]; - entry?: MainTimelineEntry; - footer: React.ReactNode; - onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; - profiles?: UserProfileLookup; - ownerProfiles?: UserProfileLookup; -}) { - const systemEntries = entries ?? (entry ? [entry] : []); - const firstEntry = systemEntries[0]; - const groupedMessages = React.useMemo( - () => entries?.map((systemEntry) => systemEntry.message), - [entries], - ); - if (!firstEntry) return null; - - return ( -
- - {footer} -
- ); -} - -type MessageRowItemProps = Pick< - TimelineMessageListProps, - | "channelId" - | "currentPubkey" - | "followThreadById" - | "highlightedMessageId" - | "huddleMemberPubkeys" - | "huddleMemberPubkeysPending" - | "hideAgentAccessBadges" - | "isFollowingThreadById" - | "onDelete" - | "onEdit" - | "onMarkUnread" - | "onMarkRead" - | "onReply" - | "onOpenThread" - | "onToggleReaction" - | "profiles" - | "searchActiveMessageId" - | "searchMatchingMessageIds" - | "searchQuery" - | "threadUnreadCounts" - | "unfollowThreadById" -> & { - entry: MainTimelineEntry; - footer: React.ReactNode; - isContinuation?: boolean; - isFollowedByContinuation?: boolean; - isUnread?: boolean; - playEntrance?: boolean; - onEntranceComplete?: (messageId: string) => void; - videoReviewContext: ReturnType; -}; - -function MessageRowItem({ - channelId, - currentPubkey, - entry, - followThreadById, - footer, - highlightedMessageId, - huddleMemberPubkeys, - huddleMemberPubkeysPending, - hideAgentAccessBadges, - isContinuation = false, - isFollowedByContinuation = false, - isFollowingThreadById, - isUnread, - playEntrance = false, - onEntranceComplete, - onDelete, - onEdit, - onMarkUnread, - onMarkRead, - onReply, - onOpenThread, - onToggleReaction, - profiles, - searchActiveMessageId, - searchMatchingMessageIds, - searchQuery, - threadUnreadCounts, - unfollowThreadById, - videoReviewContext, -}: MessageRowItemProps) { - const { message, summary } = entry; - const canManage = canManageMessageForCurrentUser( - message, - currentPubkey, - profiles, - ); - const canDelete = canManage && onDelete ? onDelete : undefined; - const canEdit = canManage && onEdit ? onEdit : undefined; - - if (summary && onOpenThread) { - const isHighlighted = message.id === highlightedMessageId; - return (
- followThreadById(message.id) : undefined - } - onMarkRead={onMarkRead} - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - showDepthGuides={false} - videoReviewContext={videoReviewContext} - /> - - {footer} +
+ +
+
+ + +
- ); - } - - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - - return ( -
- - {footer}
); } diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx new file mode 100644 index 000000000..283760fb0 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -0,0 +1,228 @@ +import * as React from "react"; + +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; +import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { MessageRow } from "./MessageRow"; +import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { SystemMessageRow } from "./SystemMessageRow"; + +type ToggleReaction = ( + message: TimelineMessage, + emoji: string, + remove: boolean, +) => Promise; + +type SystemRowProps = { + currentPubkey?: string; + entries?: MainTimelineEntry[]; + entry?: MainTimelineEntry; + footer: React.ReactNode; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + ownerProfiles?: UserProfileLookup; +}; + +export function SystemRow({ + currentPubkey, + entries, + entry, + footer, + onToggleReaction, + profiles, + ownerProfiles, +}: SystemRowProps) { + const systemEntries = entries ?? (entry ? [entry] : []); + const firstEntry = systemEntries[0]; + const groupedMessages = React.useMemo( + () => entries?.map((systemEntry) => systemEntry.message), + [entries], + ); + if (!firstEntry) return null; + + return ( +
+ + {footer} +
+ ); +} + +type MessageRowItemProps = { + channelId?: string | null; + currentPubkey?: string; + entry: MainTimelineEntry; + followThreadById?: (rootId: string) => void; + footer: React.ReactNode; + highlightedMessageId?: string | null; + huddleMemberPubkeys?: readonly string[]; + huddleMemberPubkeysPending?: boolean; + hideAgentAccessBadges?: boolean; + isContinuation?: boolean; + isFollowedByContinuation?: boolean; + isFollowingThreadById?: (rootId: string) => boolean; + isUnread?: boolean; + playEntrance?: boolean; + onEntranceComplete?: (messageId: string) => void; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; + onMarkRead?: (message: TimelineMessage) => void; + onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + searchActiveMessageId?: string | null; + searchMatchingMessageIds?: Set; + searchQuery?: string; + threadUnreadCounts?: ReadonlyMap; + unfollowThreadById?: (rootId: string) => void; + videoReviewContext: ReturnType; +}; + +export function MessageRowItem({ + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId, + huddleMemberPubkeys, + huddleMemberPubkeysPending, + hideAgentAccessBadges, + isContinuation = false, + isFollowedByContinuation = false, + isFollowingThreadById, + isUnread, + playEntrance = false, + onEntranceComplete, + onDelete, + onEdit, + onMarkUnread, + onMarkRead, + onReply, + onOpenThread, + onToggleReaction, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + threadUnreadCounts, + unfollowThreadById, + videoReviewContext, +}: MessageRowItemProps) { + const { message, summary } = entry; + const canManage = canManageMessageForCurrentUser( + message, + currentPubkey, + profiles, + ); + const canDelete = canManage && onDelete ? onDelete : undefined; + const canEdit = canManage && onEdit ? onEdit : undefined; + + if (summary && onOpenThread) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkRead={onMarkRead} + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + showDepthGuides={false} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); + } + + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} diff --git a/desktop/src/features/messages/ui/TimelineRowShell.tsx b/desktop/src/features/messages/ui/TimelineRowShell.tsx new file mode 100644 index 000000000..f69f461c8 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineRowShell.tsx @@ -0,0 +1,27 @@ +import type * as React from "react"; +import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; +import { + getTimelineItemKey, + type TimelineNonDayItem, +} from "@/features/messages/lib/timelineItems"; +import { cn } from "@/shared/lib/cn"; + +export function TimelineRowShell({ + children, + item, + useContentVisibility = true, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; + useContentVisibility?: boolean; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index da51ab1b8..d3e0e34dc 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -593,6 +593,11 @@ export function UserProfilePopover({ data-testid="user-profile-popover" onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave} + // This is a hover card: moving focus into its first button on open + // makes the profile header look keyboard-selected before the user has + // interacted with it. Keep focus on the trigger; Tab still enters the + // card and shows its normal focus treatment when needed. + onOpenAutoFocus={(event) => event.preventDefault()} side="top" sideOffset={8} > diff --git a/desktop/src/shared/layout/chromeLayout.ts b/desktop/src/shared/layout/chromeLayout.ts index 2c9fe627f..7a28e0a90 100644 --- a/desktop/src/shared/layout/chromeLayout.ts +++ b/desktop/src/shared/layout/chromeLayout.ts @@ -53,6 +53,9 @@ export const channelChrome = { contentPadding: "pt-(--buzz-channel-content-top-padding,5.75rem)", /** Absolute/fixed top offset below the measured channel header chrome. */ top: "top-(--buzz-channel-content-top-padding,5.75rem)", + /** Sticky timeline controls sit slightly below the channel navigation. */ + stickyTimelineTop: + "top-[calc(var(--buzz-channel-content-top-padding,5.75rem)+0.5rem)]", /** Height matching the measured channel header chrome. */ headerHeight: "h-(--buzz-channel-content-top-padding,5.75rem)", /** Negative margin for overlaid channel chrome that should not affect flow. */ diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index e9c5b4263..e84a61306 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1826,59 +1826,80 @@ test("channel date divider keeps the date sticky while the separator rule scroll const timeline = page.getByTestId("message-timeline"); await timeline.evaluate((element) => { - const firstGroup = element.querySelector( - '[data-testid="message-timeline-day-group"]', - ); - if (!firstGroup) { - throw new Error("missing first day group"); - } - const groupRect = firstGroup.getBoundingClientRect(); - const stickyTop = Number.parseFloat( - getComputedStyle( - firstGroup.querySelector( - '[data-testid="message-timeline-day-divider"]', - ) ?? firstGroup, - ).top, - ); - element.scrollTop += - groupRect.top - (element.getBoundingClientRect().top + stickyTop - 32); + element.scrollTop = element.scrollHeight * 0.2; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); - await page.waitForTimeout(50); + + const [headerBox, stickyPillBox] = await Promise.all([ + page.getByTestId("chat-header").boundingBox(), + page + .getByTestId("message-timeline-sticky-day-divider-content") + .locator("p") + .first() + .boundingBox(), + ]); + if (!headerBox || !stickyPillBox) { + throw new Error("missing channel header or sticky day divider"); + } + expect( + Math.abs(stickyPillBox.y - (headerBox.y + headerBox.height) - 8), + ).toBeLessThanOrEqual(1); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveCSS("opacity", "1"); + await expect( + page.getByTestId("message-timeline-day-divider").last().locator("p"), + ).toHaveCSS("visibility", "visible"); const metrics = await timeline.evaluate((element) => { - const firstGroup = element.querySelector( - '[data-testid="message-timeline-day-group"]', + const pinnedDivider = element.parentElement?.querySelector( + '[data-testid="message-timeline-sticky-day-divider"]', ); - const firstDivider = firstGroup?.querySelector( - '[data-testid="message-timeline-day-divider"]', + const pinnedPill = pinnedDivider?.querySelector( + '[data-testid="message-timeline-sticky-day-divider-content"] p', ); - const firstDividerPill = firstDivider?.querySelector("p"); - if (!firstGroup || !firstDivider || !firstDividerPill) { - throw new Error("missing day group or divider"); + if (!pinnedDivider || !pinnedPill) { + throw new Error("missing sticky day divider"); } - const groupRect = firstGroup.getBoundingClientRect(); - const dividerRect = firstDivider.getBoundingClientRect(); - const groupBefore = getComputedStyle(firstGroup, "::before"); - const dividerBefore = getComputedStyle(firstDivider, "::before"); - return { - dividerBeforeContent: dividerBefore.content, - dividerPillBackground: getComputedStyle(firstDividerPill).backgroundColor, - dividerPillShadow: getComputedStyle(firstDividerPill).boxShadow, - dividerPosition: getComputedStyle(firstDivider).position, - dividerTop: dividerRect.top, - dividerZIndex: getComputedStyle(firstDivider).zIndex, - groupBeforeContent: groupBefore.content, - groupBeforePosition: groupBefore.position, - groupTop: groupRect.top, - ruleTop: groupRect.top + Number.parseFloat(groupBefore.top), + dividerPillBackground: getComputedStyle(pinnedPill).backgroundColor, + dividerPillShadow: getComputedStyle(pinnedPill).boxShadow, + dividerZIndex: getComputedStyle(pinnedDivider).zIndex, }; }); - expect(metrics.dividerPosition).toBe("sticky"); expect(Number.parseInt(metrics.dividerZIndex, 10)).toBeGreaterThan(10); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveCSS("overflow", "visible"); + + const dividerAlignment = await timeline.evaluate((element) => { + const group = [ + ...element.querySelectorAll( + '[data-testid="message-timeline-day-group"]', + ), + ].find((candidate) => { + const pill = candidate.querySelector("p"); + return pill && getComputedStyle(pill).visibility === "visible"; + }); + const pill = group?.querySelector("p"); + if (!group || !pill) throw new Error("missing visible day divider"); + + const rule = getComputedStyle(group, "::before"); + const groupBox = group.getBoundingClientRect(); + const pillBox = pill.getBoundingClientRect(); + return { + chipCenter: pillBox.top + pillBox.height / 2, + ruleCenter: + groupBox.top + + Number.parseFloat(rule.top) + + Number.parseFloat(rule.height) / 2, + }; + }); + expect( + Math.abs(dividerAlignment.chipCenter - dividerAlignment.ruleCenter), + ).toBeLessThanOrEqual(0.5); await expect .poll(async () => { const headerZIndex = await page @@ -1931,11 +1952,6 @@ test("channel date divider keeps the date sticky while the separator rule scroll expect(metrics.dividerPillBackground).not.toBe("rgba(0, 0, 0, 0)"); expect(metrics.dividerPillBackground).not.toBe("transparent"); expect(metrics.dividerPillShadow).toBe("none"); - expect(metrics.dividerBeforeContent).toBe("none"); - expect(metrics.groupBeforePosition).toBe("absolute"); - expect(metrics.groupBeforeContent).not.toBe("none"); - expect(metrics.groupTop).toBeLessThan(metrics.dividerTop - 8); - expect(metrics.ruleTop).toBeLessThan(metrics.dividerTop - 8); }); test("shows and clears activity indicators for active channel agents", async ({ diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5e31235a1..d9dcffa2e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1129,7 +1129,7 @@ test("system add rows use plain names while remove rows retain agent mention sty ).toHaveText("portal"); }); -test("groups member additions and joins with hidden names in the standard tooltip", async ({ +test("groups contiguous arrival activity with hidden names in the standard tooltip", async ({ page, }) => { const actor = { @@ -1178,20 +1178,25 @@ test("groups member additions and joins with hidden names in the standard toolti const groupedRow = page .getByTestId("system-message-row") - .filter({ hasText: "added by Alice Chen" }); + .filter({ hasText: "added by Alice Chen, along with" }); for (const visibleName of [ "Erica Chapman", "Peter Griffin", "Marcia Thomas", - "Jordan Lee", ]) { await expect(groupedRow).toContainText(visibleName); } await expect( - groupedRow.locator("p").filter({ hasText: "added by" }), + groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }), ).toContainText( - "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + "Erica Chapman added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", ); + const avatarStack = groupedRow.getByTestId("system-message-avatar-stack"); + await expect(avatarStack).toHaveCount(1); + await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(5); + await expect( + groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }), + ).toHaveCSS("text-align", "left"); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); const visibleName = groupedRow.getByText("Peter Griffin", { exact: true }); @@ -1213,51 +1218,7 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(tooltip).toContainText("Olivia Park"); await expect(tooltip).toContainText("Sam Rivera"); - await page.evaluate( - ({ addedTargets, kind }) => { - const createdAt = Math.floor(Date.now() / 1_000) + 60; - for (const [index, target] of addedTargets.entries()) { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", - content: JSON.stringify({ - type: "member_joined", - actor: target.pubkey, - target: target.pubkey, - }), - createdAt: createdAt + index, - kind, - }); - } - }, - { addedTargets: targets, kind: SYSTEM_MESSAGE_KIND }, - ); - await waitForTimelineSettled(page); - - const joinedRow = page - .getByTestId("system-message-row") - .filter({ hasText: "joined the channel" }) - .filter({ hasText: "Erica Chapman" }); - await expect( - joinedRow.locator("p").filter({ hasText: "joined the channel" }), - ).toContainText( - "joined the channel along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", - ); - await expect(joinedRow.locator("[data-mention]")).toHaveCount(0); - - const joinedOthersTrigger = joinedRow.getByRole("button", { - name: "2 others", - }); - await page.mouse.move(0, 0); - await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none"); - await joinedOthersTrigger.hover(); - // Scope to the *open* tooltip: the first row's tooltip stays mounted with - // data-state="closed" while it animates out, so a bare role=tooltip lookup - // matches two elements and trips strict mode. - const joinedTooltip = page.locator( - '[role="tooltip"]:not([data-state="closed"])', - ); - await expect(joinedTooltip).toContainText("Olivia Park"); - await expect(joinedTooltip).toContainText("Sam Rivera"); + await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center"); }); test("system agent profile exposes owned agent actions", async ({ page }) => { @@ -1305,7 +1266,7 @@ test("system agent profile exposes owned agent actions", async ({ page }) => { ); }); -test("system agent avatar exposes owned agent actions", async ({ page }) => { +test("system agent activity avatar stack is decorative", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -1334,15 +1295,49 @@ test("system agent avatar exposes owned agent actions", async ({ page }) => { .getByTestId("system-message-row") .filter({ hasText: "mira" }) .filter({ hasText: "joined the channel" }); - await joinedRow.getByTestId("system-message-avatar").hover(); + const avatarStack = joinedRow.getByTestId("system-message-avatar-stack"); + await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(1); + await expect(avatarStack.locator("button")).toHaveCount(0); +}); - const profilePopover = page.locator( - '[data-testid="user-profile-popover"][data-state="open"]', +test("membership activity folds a member joining then leaving", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); + + await page.evaluate( + ({ alicePubkey, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ + type: "member_joined", + actor: alicePubkey, + target: alicePubkey, + }), + createdAt, + kind, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ type: "member_left", actor: alicePubkey }), + createdAt: createdAt + 1, + kind, + }); + }, + { alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND }, ); - await expect(profilePopover).toBeVisible(); - await expectOwnedAgentProfileActions( - profilePopover, - PROFILE_ONLY_AGENT_PUBKEY, + await waitForTimelineSettled(page); + const lifecycleRow = page + .getByTestId("system-message-row") + .filter({ hasText: "alice" }) + .filter({ hasText: "joined, then left the channel" }); + await expect(lifecycleRow).toBeVisible(); + await expect(lifecycleRow.getByTestId("system-message-avatar")).toHaveCount( + 1, ); }); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c6f5aefb9..5d833f771 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -130,14 +130,6 @@ test("agent owner label identifies the agent and owner", async ({ page }) => { await expect(ownerTreatment.locator(".sr-only")).toHaveText( "Agent managed by", ); - - const joinedRow = page - .getByTestId("system-message-row") - .filter({ hasText: "alice" }) - .filter({ hasText: "joined the channel" }); - await expect(joinedRow.getByTestId("message-agent-owner")).toContainText( - "managed bybob", - ); }); test("send a message and see it in timeline", async ({ page }) => { diff --git a/desktop/tests/e2e/unread-pill.spec.ts b/desktop/tests/e2e/unread-pill.spec.ts index 88169d963..07ad69a58 100644 --- a/desktop/tests/e2e/unread-pill.spec.ts +++ b/desktop/tests/e2e/unread-pill.spec.ts @@ -125,6 +125,26 @@ test.describe("unread pill & divider", () => { const pill = page.getByTestId("message-unread-pill"); await expect(pill).toBeVisible(); await expect(pill).toContainText("20 new messages"); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveAttribute("data-day-label", /.+/); + + const { pillTop, stickyDayTop } = await page.evaluate(() => { + const pill = document.querySelector( + '[data-testid="message-unread-pill"]', + ); + const stickyDay = document.querySelector( + '[data-testid="message-timeline-sticky-day-divider"]', + ); + if (!pill || !stickyDay) { + throw new Error("missing top timeline affordance"); + } + return { + pillTop: pill.getBoundingClientRect().top, + stickyDayTop: stickyDay.getBoundingClientRect().top, + }; + }); + expect(Math.abs(pillTop - stickyDayTop)).toBeLessThanOrEqual(1); }); test("02-unread-divider-visible", async ({ page }) => { From d0af845a1d489ab3fce6a73adbb0e82ebb4b0fa1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 15:14:09 +0100 Subject: [PATCH 05/30] Remove blur from Welcome composer guidance (#4691) ## Summary - Keep the Welcome composer prompt above the dock blur so it stays readable. - Remove blur from the prompt and persona-motion paths. - Cover the crisp, correctly layered banner in the onboarding browser test. ## Validation - `pnpm -C desktop exec biome check src/features/channels/ui/WelcomeComposerBanner.tsx tests/e2e/onboarding.spec.ts` - `pnpm -C desktop build:e2e` - `pnpm -C desktop exec playwright test tests/e2e/onboarding.spec.ts --grep "finishing onboarding creates starter channels and focuses welcome-everyone for a new member" --project=integration` --------- Signed-off-by: kenny lopez --- .../channels/ui/WelcomeComposerBanner.tsx | 13 ++----------- desktop/tests/e2e/onboarding.spec.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx index 2d5782b28..8d492c918 100644 --- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx +++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx @@ -13,14 +13,12 @@ const WELCOME_PERSONA_EXIT_DURATION_SECONDS = 0.432; const WELCOME_PERSONA_ENTER_STAGGER_SECONDS = 0.018; const WELCOME_PERSONA_EXIT_STAGGER_SECONDS = 0.011; const WELCOME_PERSONA_Y_OFFSET_PX = 9; -const WELCOME_PERSONA_BLUR_PX = 6; const WELCOME_COMPOSER_BANNER_CONTENT_EXIT_DURATION_SECONDS = 0.18; const WELCOME_COMPOSER_BANNER_CONTENT_Y_OFFSET_PX = 4; const WELCOME_COMPOSER_BANNER_SUCCESS_ENTER_DURATION_SECONDS = 0.32; const WELCOME_COMPOSER_BANNER_SUCCESS_COPY_DELAY_SECONDS = 0.06; const WELCOME_COMPOSER_BANNER_SUCCESS_Y_OFFSET_PX = 6; export const WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS = 0.25; -const WELCOME_COMPOSER_BANNER_DISMISS_BLUR_PX = 3; export const WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS = 50; const WELCOME_COMPOSER_BANNER_DISMISS_Y_OFFSET_PX = 48; export const WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS = Math.round( @@ -52,7 +50,6 @@ const welcomePersonaPhraseVariants = { const welcomePersonaCharacterVariants = { animate: { - filter: "blur(0px)", opacity: 1, transition: { duration: WELCOME_PERSONA_ENTER_DURATION_SECONDS, @@ -61,7 +58,6 @@ const welcomePersonaCharacterVariants = { y: 0, }, exit: { - filter: `blur(${WELCOME_PERSONA_BLUR_PX}px)`, opacity: 0, transition: { duration: WELCOME_PERSONA_EXIT_DURATION_SECONDS, @@ -70,7 +66,6 @@ const welcomePersonaCharacterVariants = { y: -WELCOME_PERSONA_Y_OFFSET_PX, }, initial: { - filter: `blur(${WELCOME_PERSONA_BLUR_PX}px)`, opacity: 0, y: WELCOME_PERSONA_Y_OFFSET_PX, }, @@ -272,7 +267,7 @@ function WelcomeComposerPersonaMention() { > {activeMentionCharacters.map(({ character, key }) => ( { const styles = window.getComputedStyle(element); return { + backdropFilter: styles.backdropFilter, bottomLeft: styles.borderBottomLeftRadius, bottomRight: styles.borderBottomRightRadius, + filter: styles.filter, + zIndex: styles.zIndex, topLeft: styles.borderTopLeftRadius, topRight: styles.borderTopRightRadius, + transform: styles.transform, + willChange: styles.willChange, }; }); expect(radii.topLeft).toBe(radii.topRight); expect(radii.bottomLeft).toBe("0px"); expect(radii.bottomRight).toBe("0px"); + expect(radii.backdropFilter).toBe("none"); + expect(radii.filter).toBe("none"); + expect(radii.transform).toBe("none"); + expect(radii.willChange).toBe("auto"); + expect(radii.zIndex).toBe("1"); expect(personaMentionBox.width).toBeGreaterThan(0); } @@ -250,6 +260,12 @@ async function expectWelcomePersonaMention(page: Page) { .getByTestId("welcome-composer-persona-character") .count(), ).toBeGreaterThanOrEqual(4); + expect( + await personaMention + .getByTestId("welcome-composer-persona-character") + .first() + .evaluate((element) => window.getComputedStyle(element).filter), + ).toBe("none"); const transition = await personaMention.evaluate((element) => { const styles = window.getComputedStyle(element); From 0542bc8b955756a62b4133aa70f84441d93616ee Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 4 Aug 2026 10:40:15 -0400 Subject: [PATCH 06/30] =?UTF-8?q?docs(nip-am):=20normative=20amendment=20?= =?UTF-8?q?=E2=80=94=20cache=20SHOULD/MUST=20+=20pricingIdentity=20+=20con?= =?UTF-8?q?sumer=20cost=20guidance=20(#4632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends `docs/nips/NIP-AM.md` with three normative publisher-behavior changes per the cleared Usage v2 plan (plan v3, D4 + D2'). ## Changes ### 1. Cache emission semantics (D4) Replaces the unconditional `MAY` with qualified obligations: - Publishers SHOULD emit `cacheReadTokens` / `cacheWriteTokens` when the provider exposes a cache component. - Publishers MUST preserve an explicit zero when the provider reports zero. - Publishers MUST omit the field (never null or fabricated zero) when that component is unavailable to the publisher — including when the provider supports it but the harness does not surface it. An explicit carve-out in both the JSON comment block and the Numeric-validity prose exempts these fields from the payload-wide null guidance. Omission is the only valid representation for an unavailable cache component. ### 2. Optional `pricingIdentity` field (D2') Adds an optional, non-nullable `pricingIdentity` object (`authority`, `model`, `cacheClass`), defined as billing authority — distinct from the transport `Provider` enum. - `authority` is a registered billing-namespace identifier: exact lowercase hostname, no scheme, no path, no trailing slash. Registered values: `api.anthropic.com`, `api.openai.com`, `openrouter.ai`. The set extends only by NIP amendment. Pricing lookup is an exact string match on `(authority, model)`. - Present only when the publisher can prove applicability: direct official-endpoint connections prove via the actually-requested resolved model; other routes MUST receive response-supplied authoritative billing identity. - MUST omit for custom/overridden base URLs, gateways (unless the gateway is the named billing authority), unresolved aliases, and turns where usage contributions carry more than one billing identity (including identity-bearing mixed with unresolved). - `cacheClass` is omitted (not null) when not applicable. - `pricingIdentity` is optional but not nullable — omission is the only absence representation. - The existing `model` field retains its non-billing semantics (configured/session model) and is never overloaded. - Consumers MUST treat omission as "price unknown" and MUST NOT infer a price from the session `model` field. ### 3. Consumer cost guidance (D4) - Consumers MAY recompute cost estimates using the billing identity and a pricing manifest. - Consumers MUST retain the provenance of any cost value (e.g. `manifest-estimated`, `wire-reported`). - Consumers MUST NOT merge manifest-estimated and wire-reported costs into an unlabeled total. Manifest-vs-wire display preference is application policy and deliberately excluded from this NIP. ## Scope Doc-only. Single file: `docs/nips/NIP-AM.md`. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- docs/nips/NIP-AM.md | 63 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/nips/NIP-AM.md b/docs/nips/NIP-AM.md index ff636fb80..794a6722d 100644 --- a/docs/nips/NIP-AM.md +++ b/docs/nips/NIP-AM.md @@ -88,6 +88,8 @@ The `content` field decrypts to a UTF-8 JSON object: // Usage for THIS turn (computed delta). Fields are null when the harness // does not report them — a null MUST NOT be recorded or summed as zero. + // Exception: cache fields (cacheReadTokens, cacheWriteTokens) MUST be + // omitted rather than null when unavailable — see "Numeric validity" below. "turn": { "inputTokens": 1234 | null, "outputTokens": 567 | null, @@ -108,13 +110,28 @@ The `content` field decrypts to a UTF-8 JSON object: // "turn" object unreliable for this event. "deltaReliable": true, + // Billing identity, present only when the publisher can prove applicability + // from the actual endpoint (official provider API) and the actually-requested + // model for the usage represented. Omit this field when applicability cannot + // be proven — it is never inferred from the configured/session "model" field. + // Consumers MUST treat omission as "price unknown"; they MUST NOT infer a + // price from the session "model" field. pricingIdentity is OPTIONAL but NOT + // nullable: when present, "authority" and "model" MUST be non-null strings; + // "cacheClass" is omitted (not null) when it is not applicable. + "pricingIdentity": { // OPTIONAL; omit entirely if unproven + "authority": "api.anthropic.com", // billing authority (not transport provider) + "model": "claude-sonnet-4-5", // actually-requested billable model id + "cacheClass": "ephemeral" // cache-write class; omit when not applicable + }, + "stopReason": "end_turn" // optional } ``` `harness` and `timestamp` are REQUIRED. All other fields are OPTIONAL or -nullable, except as constrained below. Consumers MUST ignore unknown fields -(forward compatibility). +nullable, except as constrained below: `pricingIdentity` is optional but not +nullable (omit it entirely rather than set it to null). Consumers MUST ignore +unknown fields (forward compatibility). ### Ordering and delta recomputation @@ -151,14 +168,48 @@ when no total is reported, `totalTokens` is null. `inputTokens` is the inclusive input-side total: where the provider reports cache reads/writes separately (e.g. Anthropic `cache_read_input_tokens` / `cache_creation_input_tokens`), the publisher folds them into `inputTokens`. -Publishers MAY additionally report the cache components in optional -`cacheReadTokens` / `cacheWriteTokens` fields inside `turn` and `cumulative`; -when present these are informational subsets of `inputTokens`, not additions -to it. +Where the provider exposes a cache component, publishers SHOULD report it in +the optional `cacheReadTokens` / `cacheWriteTokens` fields inside `turn` and +`cumulative`; these are informational subsets of `inputTokens`, not additions +to it. Publishers MUST preserve an explicit zero when the provider reports +zero and MUST omit the field (never null or fabricated zero) when that +component is unavailable to the publisher — including when the provider +supports the component but the harness does not surface it. Treating an +unreported category as zero is incorrect. Note: the payload-wide null +guidance above does not apply to these cache fields; omission is the only +valid representation for an unavailable cache component. `costUsd` values are estimates (provider list prices at publish time, or a harness-reported estimate). They are advisory, not billing records. +`pricingIdentity`, when present, identifies the billing authority and +actually-requested model for the usage represented by this event. `authority` +is a registered billing-namespace identifier: exact lowercase hostname, no +scheme, no path, no trailing slash (registered values: `api.anthropic.com`, +`api.openai.com`, `openrouter.ai`; the set extends only by amendment to this +NIP). Pricing lookup is an exact string match on `(authority, model)` — any +deviation loses the price. It is a billing namespace, distinct from the +runtime transport provider. `model` is the +billable model identifier as resolved at the point the request was made, not +the configured/session model. `cacheClass` +is the cache-write class when applicable (e.g. `ephemeral`). Publishers MUST +omit `pricingIdentity` when any of the following apply: the request was routed +through a custom or overridden base URL, a gateway (unless the gateway is the +named billing authority), or an unresolved alias; the billed model identity +cannot be confirmed — for direct connections to an official allowlisted +endpoint, proof is the actually-requested resolved model; for all other routes, +the response MUST supply authoritative billing identity; or the usage +represented within this turn contains contributions from more than one billing +identity (including a mix of identity-bearing and unresolved contributions). +The existing `model` field +retains its non-billing semantics (configured/session model) and is never +overloaded by `pricingIdentity`. Consumers MUST treat omission of +`pricingIdentity` as "price unknown". Consumers MAY recompute cost estimates +using the billing identity and a pricing manifest; they MUST retain +the provenance of any cost value (e.g. `manifest-estimated`, `wire-reported`). +Consumers MUST NOT merge manifest-estimated and wire-reported costs into an +unlabeled total. + `stopReason`, when present, MUST be one of `end_turn`, `max_tokens`, `cancelled`, `error`, `unknown`. Consumers MUST treat unrecognized `stopReason` values as `unknown`; the token counts remain valid. From 56003ebf98c22367fb6357f295494e26efbd8ae6 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 4 Aug 2026 10:50:27 -0400 Subject: [PATCH 07/30] docs(acp): explain per-channel session model in base prompt (#4729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Agents running in Buzz have no built-in awareness that each channel is an isolated conversation context. When a human mentions work "you" are doing in another channel, the current session can misread this as its own active context and try to coordinate, re-plan, or take ownership of it — causing confusion and wasted turns. ## What changed Added a `## Session Model` section to `crates/buzz-acp/src/base_prompt.md`, inserted immediately after the opening paragraph and before `## Buzz CLI`. The section explains: - Each channel is a separate session; multiple sessions of the same agent identity may be active simultaneously. - Sessions share core memory, workspace, and relay — but not conversation context or in-flight reasoning. - Cross-channel work belongs to the owning session by default; the current session may take it over only when the human explicitly requests it. No runtime code changes. Base prompt only. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-acp/src/base_prompt.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d2498..12e5c4290 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,5 +1,11 @@ You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session. +## Session Model + +You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state. + +When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. + ## Buzz CLI The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON. From d0d4acd4fa02893ad2460b447d7e13da00506be3 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 4 Aug 2026 11:36:42 -0400 Subject: [PATCH 08/30] fix(desktop): show cached display names on startup (#3317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Buzz restores cached channels and messages before profile lookups complete. On launch, that briefly exposes pubkey-derived labels in place of familiar display names. ## What - Persist a bounded, relay-scoped cache of last-known display names, NIP-01 names, and NIP-05 handles - Seed batch profile queries from those labels immediately, while keeping them stale so the existing relay request revalidates them - Keep cached data presentation-only: avatars and ownership metadata are not persisted or used to seed profile-detail caches - Remove cleared or missing profiles, purge a relay's labels when its community is removed, and include the cache in local-storage quota recovery - Add unit coverage for parsing, bounds, eviction, malformed data, and cleared profiles - Add an E2E regression that delays the relay profile response and verifies the cached name is rendered first ## Risk Assessment Low. The cache is disposable, capped at 1,000 entries per relay, scoped by normalized relay URL, and always revalidated. It contains only public label fields and does not restore avatars, agent ownership, or authorization state. ## Verification - `just ci` - `pnpm typecheck` - `pnpm test` — 3,727 passed - `pnpm exec playwright test tests/e2e/channels.spec.ts --grep "cached profile labels"` — passed Generated with Codex --- .../features/communities/useCommunities.tsx | 2 + desktop/src/features/profile/hooks.ts | 22 +- .../profile/lib/userLabelStorage.test.mjs | 225 ++++++++++++++++++ .../features/profile/lib/userLabelStorage.ts | 176 ++++++++++++++ .../src/shared/lib/localStorageQuota.test.mjs | 4 +- desktop/src/shared/lib/localStorageQuota.ts | 1 + desktop/tests/e2e/channels.spec.ts | 45 +++- 7 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/profile/lib/userLabelStorage.test.mjs create mode 100644 desktop/src/features/profile/lib/userLabelStorage.ts diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index df019519b..9051552ad 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -17,6 +17,7 @@ import { saveCommunities, } from "./communityStorage"; import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; +import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage"; import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; @@ -213,6 +214,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn { const removed = communities.find((w) => w.id === id); if (removed) { removeSelfProfileCachesForRelay(removed.relayUrl); + removeUserLabelCacheForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); removeMessageSnapshotsForRelay(removed.relayUrl); clearSavedCommunitySnapshot(id); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 04546804e..7a456fb25 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -5,7 +5,6 @@ import type { } from "@tanstack/react-query"; import * as React from "react"; import { - keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -41,6 +40,10 @@ import { shouldFetchAvatar, resolveAvatarDataUrl, } from "@/features/profile/lib/selfProfileStorage"; +import { + resolveUserLabelPlaceholderData, + writeCachedUserLabels, +} from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; @@ -317,6 +320,8 @@ export function useUsersBatchQuery( }, ) { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? ""; const normalizedPubkeys = [ ...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())), ] @@ -352,6 +357,9 @@ export function useUsersBatchQuery( } if (toFetch.length > 0) { const fresh = await getUsersBatch(toFetch); + if (relayUrl) { + writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); + } for (const pubkey of toFetch) { const summary = fresh.profiles[pubkey] ?? null; queryClient.setQueryData( @@ -367,7 +375,12 @@ export function useUsersBatchQuery( // Loading older messages grows the pubkey set, which changes this query's // key entirely. Without this, already-resolved authors would flash back // to their raw pubkey while the larger batch refetches. - placeholderData: keepPreviousData, + placeholderData: (previousData) => + resolveUserLabelPlaceholderData( + previousData, + relayUrl, + normalizedPubkeys, + ), staleTime: 60_000, gcTime: 5 * 60 * 1_000, }); @@ -375,6 +388,9 @@ export function useUsersBatchQuery( // Seed individual "user-profile" cache entries so avatar clicks are instant // cache hits instead of fresh network requests. React.useEffect(() => { + // Persisted labels are intentionally presentation-only. Wait for a relay + // result before seeding profile-detail caches that also carry ownership. + if (query.dataUpdatedAt === 0) return; const profiles = query.data?.profiles; if (!profiles) return; for (const [pubkey, summary] of Object.entries(profiles)) { @@ -391,7 +407,7 @@ export function useUsersBatchQuery( }, ); } - }, [query.data, queryClient]); + }, [query.data, query.dataUpdatedAt, queryClient]); return query; } diff --git a/desktop/src/features/profile/lib/userLabelStorage.test.mjs b/desktop/src/features/profile/lib/userLabelStorage.test.mjs new file mode 100644 index 000000000..c791b1f73 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadSubject() { + try { + return await import("./userLabelStorage.ts"); + } catch { + return {}; + } +} + +function installLocalStorage() { + const values = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }, + }; + globalThis.localStorage = globalThis.window.localStorage; + return values; +} + +test("reads cached labels as safe stale profile summaries", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + updatedAt: 100, + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + nip05Handle: "alice@example.com", + updatedAt: 100, + }, + }, + }), + ); + + assert.deepEqual( + subject.readCachedUserLabels("WSS://Relay.Example/", ["ABCDEF", "missing"]), + { + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: "alice@example.com", + ownerPubkey: null, + }, + }, + missing: [], + }, + ); +}); + +test("keeps previous full profiles ahead of persisted label placeholders", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.resolveUserLabelPlaceholderData, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + profiles: { + abcdef: { + displayName: "Cached Alice", + name: "alice", + nip05Handle: null, + updatedAt: 100, + }, + }, + }), + ); + const previous = { + profiles: { + abcdef: { + displayName: "Fresh Alice", + name: "alice", + avatarUrl: "https://relay.example/alice.png", + nip05Handle: null, + ownerPubkey: "owner", + }, + }, + missing: [], + }; + + assert.equal( + subject.resolveUserLabelPlaceholderData(previous, "wss://relay.example", [ + "abcdef", + ]), + previous, + ); +}); + +test("writes merge with existing labels and remain bounded", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + existing: { + displayName: "Existing", + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels( + "wss://relay.example", + Object.fromEntries( + Array.from({ length: 1_005 }, (_, index) => [ + `pubkey-${index}`, + { + displayName: `Person ${index}`, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + ]), + ), + ); + + const stored = JSON.parse( + window.localStorage.getItem( + subject.userLabelCacheKey("wss://relay.example"), + ), + ); + assert.equal(Object.keys(stored.profiles).length, 1_000); + assert.equal(stored.version, 1); + assert.equal(stored.updatedAt, undefined); +}); + +test("removes a stale label when the fresh profile clears all names", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: null, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes stale labels for profiles the relay reports missing", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", {}, ["ABCDEF"]); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes only the selected relay cache", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.removeUserLabelCacheForRelay, "function"); + installLocalStorage(); + const first = subject.userLabelCacheKey("wss://one.example"); + const second = subject.userLabelCacheKey("wss://two.example"); + window.localStorage.setItem(first, "{}"); + window.localStorage.setItem(second, "{}"); + + subject.removeUserLabelCacheForRelay("wss://one.example"); + + assert.equal(window.localStorage.getItem(first), null); + assert.equal(window.localStorage.getItem(second), "{}"); +}); + +test("ignores malformed cache payloads", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ version: 1, profiles: { abc: { displayName: 42 } } }), + ); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abc"]), + undefined, + ); +}); diff --git a/desktop/src/features/profile/lib/userLabelStorage.ts b/desktop/src/features/profile/lib/userLabelStorage.ts new file mode 100644 index 000000000..b28c17da9 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.ts @@ -0,0 +1,176 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { + UserProfileSummary, + UsersBatchResponse, +} from "@/shared/api/types"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_KEY_PREFIX = "buzz-user-labels.v1"; +const MAX_CACHED_LABELS = 1_000; + +type CachedUserLabel = { + displayName: string | null; + name: string | null; + nip05Handle: string | null; + updatedAt: number; +}; + +type UserLabelCache = { + version: 1; + profiles: Record; +}; + +export function userLabelCacheKey(relayUrl: string): string { + return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}`; +} + +function nullableString(value: unknown): string | null | undefined { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : undefined; +} + +function parseCachedUserLabel(value: unknown): CachedUserLabel | null { + if (typeof value !== "object" || value === null) return null; + const raw = value as Record; + const displayName = nullableString(raw.displayName); + const name = nullableString(raw.name); + const nip05Handle = nullableString(raw.nip05Handle); + if ( + displayName === undefined || + name === undefined || + nip05Handle === undefined + ) { + return null; + } + if (![displayName, name, nip05Handle].some((label) => label?.trim())) { + return null; + } + return { + displayName, + name, + nip05Handle, + updatedAt: + typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0, + }; +} + +function readCache(relayUrl: string): UserLabelCache | null { + try { + const raw = window.localStorage.getItem(userLabelCacheKey(relayUrl)); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) return null; + const payload = parsed as Record; + if ( + payload.version !== 1 || + typeof payload.profiles !== "object" || + payload.profiles === null + ) { + return null; + } + + const profiles: Record = {}; + for (const [pubkey, value] of Object.entries( + payload.profiles as Record, + )) { + const label = parseCachedUserLabel(value); + if (label) profiles[pubkey.toLowerCase()] = label; + } + return { + version: 1, + profiles, + }; + } catch { + return null; + } +} + +export function readCachedUserLabels( + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + const cache = readCache(relayUrl); + if (!cache) return undefined; + + const profiles: UsersBatchResponse["profiles"] = {}; + for (const pubkey of pubkeys) { + const normalizedPubkey = pubkey.toLowerCase(); + const cached = cache.profiles[normalizedPubkey]; + if (!cached) continue; + profiles[normalizedPubkey] = { + displayName: cached.displayName, + name: cached.name, + avatarUrl: null, + nip05Handle: cached.nip05Handle, + ownerPubkey: null, + }; + } + + return Object.keys(profiles).length > 0 + ? { profiles, missing: [] } + : undefined; +} + +export function resolveUserLabelPlaceholderData( + previousData: UsersBatchResponse | undefined, + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + return ( + previousData ?? + (relayUrl ? readCachedUserLabels(relayUrl, pubkeys) : undefined) + ); +} + +export function writeCachedUserLabels( + relayUrl: string, + profiles: Record, + missing: string[] = [], +): void { + try { + const now = Date.now(); + const merged = { ...(readCache(relayUrl)?.profiles ?? {}) }; + for (const [pubkey, profile] of Object.entries(profiles)) { + const label = parseCachedUserLabel({ + displayName: profile.displayName, + name: profile.name, + nip05Handle: profile.nip05Handle, + updatedAt: now, + }); + const normalizedPubkey = pubkey.toLowerCase(); + if (label) { + merged[normalizedPubkey] = label; + } else { + delete merged[normalizedPubkey]; + } + } + for (const pubkey of missing) { + delete merged[pubkey.toLowerCase()]; + } + + const boundedProfiles = Object.fromEntries( + Object.entries(merged) + .sort(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, MAX_CACHED_LABELS), + ); + setLocalStorageItemWithRecovery( + userLabelCacheKey(relayUrl), + JSON.stringify({ + version: 1, + profiles: boundedProfiles, + } satisfies UserLabelCache), + ); + } catch { + // Storage access failures are non-fatal. + } +} + +export function removeUserLabelCacheForRelay(relayUrl: string): void { + try { + window.localStorage.removeItem(userLabelCacheKey(relayUrl)); + } catch { + // Storage access failures are non-fatal. + } +} diff --git a/desktop/src/shared/lib/localStorageQuota.test.mjs b/desktop/src/shared/lib/localStorageQuota.test.mjs index 64e351113..192944807 100644 --- a/desktop/src/shared/lib/localStorageQuota.test.mjs +++ b/desktop/src/shared/lib/localStorageQuota.test.mjs @@ -34,12 +34,13 @@ function install(ls) { } test("startup recovery removes disposable caches but preserves user state", () => { - const ls = makeQuotaLocalStorage({ maxEntries: 5 }); + const ls = makeQuotaLocalStorage({ maxEntries: 6 }); install(ls); ls.store.set("buzz-channel-messages.v1:relay:chan", "big"); ls.store.set("buzz-channels.v1:relay", "big"); ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small"); ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small"); + ls.store.set("buzz-user-labels.v1:relay", "small"); ls.store.set("buzz-communities", "keep"); recoverLocalStorageQuotaOnStartup(); @@ -51,6 +52,7 @@ test("startup recovery removes disposable caches but preserves user state", () = ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"), null, ); + assert.equal(ls.getItem("buzz-user-labels.v1:relay"), null); assert.equal(ls.getItem("buzz-communities"), "keep"); assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1"); }); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 538003ef6..e05307a26 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -12,6 +12,7 @@ const PURE_CACHE_KEY_PREFIXES = [ "buzz-channels.v1:", "buzz-sidebar-skeleton-shape.v1:", "buzz-timeline-skeleton-shape.v1:", + "buzz-user-labels.v1:", ]; const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1"; diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index e84a61306..868763cf5 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -16,6 +16,7 @@ import { const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const CACHED_PROFILE_LABELS_TAG = "@cached-profile-labels"; // Relay-only agent owned by the mock viewer (see e2eBridge.ts // OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and // owned-by-viewer via its mockProfiles owner_pubkey, so the sidebar @@ -487,8 +488,13 @@ async function expectIntroActionsShareRow( } } -test.beforeEach(async ({ page }) => { - await installMockBridge(page); +test.beforeEach(async ({ page }, testInfo) => { + await installMockBridge( + page, + testInfo.tags.includes(CACHED_PROFILE_LABELS_TAG) + ? { usersBatchDelayMs: 10_000 } + : undefined, + ); }); test("sidebar shows all channel types", async ({ page }) => { @@ -515,6 +521,41 @@ test("sidebar shows all channel types", async ({ page }) => { await expect(dmList).toContainText("bob-tyler"); }); +test("shows cached profile labels while relay profiles revalidate", { + tag: CACHED_PROFILE_LABELS_TAG, +}, async ({ page }) => { + await page.addInitScript( + ({ alicePubkey }) => { + window.localStorage.setItem( + "buzz-user-labels.v1:ws://localhost:3000", + JSON.stringify({ + version: 1, + updatedAt: Date.now(), + profiles: { + [alicePubkey]: { + displayName: "Cached Alice", + name: "alice", + nip05Handle: null, + }, + }, + }), + ); + }, + { alicePubkey: TEST_IDENTITIES.alice.pubkey }, + ); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const aliceMessage = page + .getByTestId("message-row") + .filter({ hasText: "Hey team — checking in." }); + await expect(aliceMessage.getByTestId("message-author")).toHaveText( + "Cached Alice", + { timeout: 1_000 }, + ); +}); + test("shows presence in sidebar, DM header, and member list", async ({ page, }) => { From 540b58920cef205b838da8be8442aae62bceaaa5 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 16:39:45 +0100 Subject: [PATCH 09/30] Polish sidebar unread hierarchy (#4573) ## Summary - Keep selected sidebar rows regular by default; manually unread rows become bold immediately. - Apply a clearer dark-mode hierarchy: standard inactive rows at 75%, muted rows at 45%, and unread rows at full emphasis. - Keep hover text color stable while retaining the selected-row and unread cues. ## Validation - `pnpm typecheck` - `pnpm build:e2e` - Playwright: sidebar badge and channel-mute coverage ## Screenshots Posted in the PR comments. --------- Signed-off-by: kenny lopez --- desktop/src/app/AppShell.tsx | 4 +- .../features/channels/useUnreadChannels.ts | 9 +- .../src/features/notifications/hooks.test.mjs | 10 +- .../features/notifications/lib/homeBadge.ts | 14 +- .../useOffscreenActivityChannelIds.test.mjs | 38 +++ .../lib/useOffscreenActivityChannelIds.ts | 53 ++++ .../sidebar/lib/useSidebarActivityOverflow.ts | 45 +++ .../src/features/sidebar/ui/AppSidebar.tsx | 15 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 26 +- .../sidebar/ui/ChannelActivityPopover.tsx | 31 +- .../features/sidebar/ui/MoreUnreadButton.tsx | 5 +- .../features/sidebar/ui/SidebarSection.tsx | 37 ++- desktop/src/shared/styles/globals.css | 3 + desktop/src/shared/ui/UnreadPill.tsx | 11 +- desktop/tests/e2e/badge.spec.ts | 291 +++++++++++++++++- .../e2e/channel-activity-popover.spec.ts | 24 +- desktop/tests/e2e/channel-mute.spec.ts | 25 +- desktop/tests/e2e/channels.spec.ts | 4 +- desktop/tests/e2e/unread-pill.spec.ts | 2 +- 19 files changed, 569 insertions(+), 78 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs create mode 100644 desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts create mode 100644 desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5acf68301..41bcf04bf 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -632,8 +632,7 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links only from the main window. The - // companion is dedicated to its active Huddle route. + // Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route. useMessageDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), @@ -929,6 +928,7 @@ export function AppShell() { selectedChannelId={selectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} + previewActivityChannelIds={unreadThreadChannelIds} unreadChannelCounts={unreadChannelCounts} mutedChannelIds={mutedChannelIds} onMuteChannel={muteChannel} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 256077d64..96e78eddd 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -827,9 +827,9 @@ export function useUnreadChannels( relayClient, ]); - // Unread = channels (excluding active) that have either been manually - // marked unread this session, or whose observed latest external trigger - // timestamp is strictly newer than their NIP-RS read marker. + // Unread = inactive channels, plus any channel manually marked unread this + // session. A manually marked active channel must remain visible as unread + // until the user explicitly marks it read again. // High-priority unread = DMs or channels with a mention/broadcast newer // than the read marker. Forced-unread channels are dot tier only (not // high-priority). Both sets share identical deps and always invalidate @@ -854,12 +854,11 @@ export function useUnreadChannels( let unreadChannelNotificationCount = 0; for (const channel of channels) { - if (channel.id === activeChannelId) continue; - const isForcedUnread = Object.hasOwn( forcedUnreadRef.current, channel.id, ); + if (channel.id === activeChannelId && !isForcedUnread) continue; const observedEvents = observedUnreadEventsByChannelRef.current.get( channel.id, diff --git a/desktop/src/features/notifications/hooks.test.mjs b/desktop/src/features/notifications/hooks.test.mjs index e5b90f3ac..26fc4fedb 100644 --- a/desktop/src/features/notifications/hooks.test.mjs +++ b/desktop/src/features/notifications/hooks.test.mjs @@ -37,7 +37,7 @@ const homeFeed = (feed) => ({ meta: { since: 0, total: 0, generatedAt: 0 }, }); -test("home badge items include locally unread activity and agent rows", () => { +test("home badge excludes thread activity already shown in a channel preview", () => { const items = buildHomeBadgeFeedItems( homeFeed({ mentions: [feedItem("mention", "mention")], @@ -51,7 +51,12 @@ test("home badge items include locally unread activity and agent rows", () => { feedItem("read-agent", "agent_activity"), ], }), - [feedItem("thread-activity")], + [ + { + ...feedItem("thread-activity"), + tags: ROOT_TAGS, + }, + ], new Set(["locally-unread-activity", "locally-unread-agent"]), ); @@ -60,7 +65,6 @@ test("home badge items include locally unread activity and agent rows", () => { [ "mention", "needs-action", - "thread-activity", "locally-unread-activity", "locally-unread-agent", ], diff --git a/desktop/src/features/notifications/lib/homeBadge.ts b/desktop/src/features/notifications/lib/homeBadge.ts index deac9b73a..b98db88e0 100644 --- a/desktop/src/features/notifications/lib/homeBadge.ts +++ b/desktop/src/features/notifications/lib/homeBadge.ts @@ -24,9 +24,19 @@ export function buildHomeBadgeFeedItems( extraInboxItems: readonly FeedItem[], localUnreadFeedIds: ReadonlySet, ): FeedItem[] { + // Thread activity is surfaced directly on its channel's hover preview. It + // should not also inflate the Inbox numeral, which is reserved for the + // Inbox's own high-priority activity. + const nonThreadExtraInboxItems = extraInboxItems.filter( + (item) => !isThreadReply(item.tags), + ); const items = feed - ? [...feed.feed.mentions, ...feed.feed.needsAction, ...extraInboxItems] - : [...extraInboxItems]; + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...nonThreadExtraInboxItems, + ] + : [...nonThreadExtraInboxItems]; if (feed && localUnreadFeedIds.size > 0) { items.push( diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs new file mode 100644 index 000000000..d548b7e50 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; +import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts"; + +test("keeps every unread channel navigable while adding working activity", () => { + const activity = getOffscreenActivityChannelIds({ + activeWorkingByChannelId: new Map([["working", {}]]), + previewActivityChannelIds: new Set(["preview"]), + unreadChannelIds: new Set(["dm", "forum", "stream"]), + }); + + assert.deepEqual([...activity.messageChannelIds].sort(), [ + "dm", + "forum", + "preview", + "stream", + ]); + assert.deepEqual([...activity.channelIds].sort(), [ + "dm", + "forum", + "preview", + "stream", + "working", + ]); +}); + +test("uses an activity-neutral overflow label when work contributes", () => { + assert.equal( + getSidebarActivityOverflowLabel({ activityCount: 2, messageCount: 1 }), + "2 new activity", + ); + assert.equal( + getSidebarActivityOverflowLabel({ activityCount: 1, messageCount: 1 }), + undefined, + ); +}); diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts new file mode 100644 index 000000000..aef953eff --- /dev/null +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +type OffscreenActivityChannelIds = { + messageChannelIds: ReadonlySet; + channelIds: ReadonlySet; +}; + +export function getOffscreenActivityChannelIds({ + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, +}: { + activeWorkingByChannelId: ReadonlyMap; + previewActivityChannelIds: ReadonlySet; + unreadChannelIds: ReadonlySet; +}): OffscreenActivityChannelIds { + // Every unread row must remain navigable, including top-level stream and + // forum unreads that do not have thread-preview activity. + const messageChannelIds = new Set([ + ...unreadChannelIds, + ...previewActivityChannelIds, + ]); + + return { + messageChannelIds, + channelIds: new Set([ + ...messageChannelIds, + ...activeWorkingByChannelId.keys(), + ]), + }; +} + +export function useOffscreenActivityChannelIds(args: { + activeWorkingByChannelId: ReadonlyMap; + previewActivityChannelIds: ReadonlySet; + unreadChannelIds: ReadonlySet; +}) { + const { + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, + } = args; + + return React.useMemo( + () => + getOffscreenActivityChannelIds({ + activeWorkingByChannelId, + previewActivityChannelIds, + unreadChannelIds, + }), + [activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds], + ); +} diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts new file mode 100644 index 000000000..c194ed9cf --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts @@ -0,0 +1,45 @@ +import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds"; +import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; + +type ActivityOptions = Parameters[0]; +type ScrollRef = Parameters[0]["scrollRef"]; + +export function getSidebarActivityOverflowLabel({ + activityCount, + messageCount, +}: { + activityCount: number; + messageCount: number; +}) { + return activityCount === messageCount + ? undefined + : `${activityCount} new activity`; +} + +export function useSidebarActivityOverflow({ + scrollRef, + ...activityOptions +}: ActivityOptions & { scrollRef: ScrollRef }) { + const { channelIds, messageChannelIds } = + useOffscreenActivityChannelIds(activityOptions); + const activityOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: channelIds, + }); + const messageOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: messageChannelIds, + }); + + return { + ...activityOverflow, + unreadAboveLabel: getSidebarActivityOverflowLabel({ + activityCount: activityOverflow.unreadAboveCount, + messageCount: messageOverflow.unreadAboveCount, + }), + unreadBelowLabel: getSidebarActivityOverflowLabel({ + activityCount: activityOverflow.unreadBelowCount, + messageCount: messageOverflow.unreadBelowCount, + }), + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index d4ca4f286..7d9472f70 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -22,7 +22,7 @@ import { import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; -import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; +import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow"; import { CreateSectionDialog, DeleteSectionAlertDialog, @@ -105,6 +105,7 @@ type AppSidebarProps = { | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; communities: Community[]; onAddCommunity: (community: Community) => void; onAddCommunityOpenChange?: (open: boolean) => void; @@ -194,6 +195,7 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, + previewActivityChannelIds, communities, onAddCommunity, onAddCommunityOpenChange, @@ -250,6 +252,8 @@ export function AppSidebar({ const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); + // biome-ignore format: keep compact to stay within file size limit + const { scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -503,13 +507,6 @@ export function AppSidebar({ profile?.displayName?.trim() || fallbackDisplayName?.trim() || "Current identity"; - const { - scrollToNextAbove, - scrollToNextBelow, - unreadAboveCount, - unreadBelowCount, - } = useUnreadOverflow({ scrollRef, unreadChannelIds }); - const isCreatingAny = createDialogKind === "stream" ? isCreatingChannel @@ -591,6 +588,7 @@ export function AppSidebar({ {unreadAboveCount > 0 ? ( - - Inbox + + + Inbox + {homeBadgeCount > 0 ? ( - - Agents + + + Agents + diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1e50ef72e..1c86ac4c3 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -21,6 +21,10 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; +const ACTIVITY_POPOVER_MOTION_STYLE = { + "--tw-enter-scale": "1", + "--tw-exit-scale": "1", +} as React.CSSProperties; function buildChannelActivityFeed(items: FeedItem[]): HomeFeedResponse { return { @@ -365,19 +369,17 @@ export function ChannelActivityPopover({ return ( - - {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while this wrapper supplies the popover anchor box. */} -
setOpen(false)} - onFocus={openImmediately} - onMouseEnter={openWithDelay} - onMouseLeave={closeWithDelay} - > - {children} -
-
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while the wrapper keeps the preview interactive. */} +
setOpen(false)} + onFocus={openImmediately} + onMouseEnter={openWithDelay} + onMouseLeave={closeWithDelay} + > + {children} +
event.preventDefault()} side="right" - sideOffset={8} + sideOffset={0} + style={ACTIVITY_POPOVER_MOTION_STYLE} >
void; position: "top" | "bottom"; testId: string; @@ -23,7 +25,8 @@ export function MoreUnreadButton({ > diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 797e907ba..56e12146b 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -214,10 +214,12 @@ function DmChannelIcon({ function SidebarChannelIcon({ channel, + className, dmParticipants, presenceStatus, }: { channel: Channel; + className?: string; dmParticipants?: SidebarDmParticipant[]; presenceStatus?: PresenceStatus; }) { @@ -238,14 +240,14 @@ function SidebarChannelIcon({ } if (channel.visibility === "private") { - return ; + return ; } if (channel.channelType === "forum") { - return ; + return ; } - return ; + return ; } export function ChannelMenuButton({ @@ -288,21 +290,24 @@ export function ChannelMenuButton({ (hasSidebarUnreadProjections ? unreadThreadChannelIds.has(channel.id) : hasUnread); + const inactiveContentOpacity = cn( + !isActive && !hasTopLevelUnread && !isMuted && "opacity-80", + !isActive && + isMuted && + !hasTopLevelUnread && + !hasThreadUnread && + "opacity-50 dark:opacity-45", + ); const button = ( - + {resolvedLabel} {ephemeralDisplay ? ( diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 704f6e542..c45bee413 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -30,3 +30,6 @@ Must stay below every `@import`: CSS requires `@import` to precede other at-rules, so placing this above them silently drops the rest of the sheet. */ @custom-variant hover (&:hover); +/* The app persists its selected theme on the root `.dark` class. Use that + class, rather than the system preference, for every `dark:` utility. */ +@custom-variant dark (&:where(.dark, .dark *)); diff --git a/desktop/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index 153e61478..15c054073 100644 --- a/desktop/src/shared/ui/UnreadPill.tsx +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -1,9 +1,12 @@ import { ArrowDown, ArrowUp } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; const UNREAD_PILL_CLASS = "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"; +const PRIMARY_UNREAD_PILL_CLASS = + "pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4"; export function unreadCountLabel(count: number) { return `${count} new message${count === 1 ? "" : "s"}`; @@ -11,11 +14,13 @@ export function unreadCountLabel(count: number) { export function UnreadPill({ direction, + emphasis = "default", label, onClick, testId, }: { direction: "up" | "down"; + emphasis?: "default" | "primary"; label: string; onClick: () => void; testId: string; @@ -23,12 +28,14 @@ export function UnreadPill({ const Arrow = direction === "up" ? ArrowUp : ArrowDown; return (
); diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 606d2b788..62a4169fc 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,11 +1,6 @@ import * as React from "react"; -import { - AlertTriangle, - ChevronDown, - ChevronRight, - RefreshCw, -} from "lucide-react"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -27,6 +22,7 @@ import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastE import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { PubKey } from "@/shared/ui/PubKey"; import { SubsectionLabel } from "@/shared/ui/PageHeader"; +import { RestartDiffBadge } from "./RestartDiffBadge"; export function ManagedAgentRow({ agent, @@ -100,7 +96,7 @@ export function ManagedAgentRow({ "overflow-hidden transition-colors", isLogSelected ? "bg-primary/5" : "hover:bg-muted/20", )} - data-testid={`managed-agent-${agent.pubkey}`} + data-testid={`managed-agent-row-${agent.pubkey}`} >
{isLocal ? ( @@ -158,7 +154,16 @@ export function ManagedAgentRow({
)} + {/* B4: restart badge is a sibling of the expansion button — never + inside it. TooltipTrigger renders as a (non-interactive), + so no nested interactive elements are introduced here. */}
+ {agent.needsRestart ? ( + + ) : null}
) : null} diff --git a/desktop/src/shared/api/restartDiff.ts b/desktop/src/shared/api/restartDiff.ts new file mode 100644 index 000000000..1ea46f488 --- /dev/null +++ b/desktop/src/shared/api/restartDiff.ts @@ -0,0 +1,34 @@ +/** + * Wire types for the restart-required config diff. + * + * These are camelCase / TS-idiomatic mirrors of the Rust `RestartDiffEntry` + * serialized shape. The `field` is a dotted path derived from serde field + * names (e.g. `"model"`, `"env.FOO"`); display labels are produced by + * humanising the path — there is no per-field label map. + */ + +/** A JSON-compatible value. Arrays are atomic leaves in the diff. */ +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * One change in a restart diff. The `kind` discriminant is a closed set of + * five values; unknown `field` paths must render gracefully in the UI. + */ +export type RestartChange = + | { kind: "value"; before: JsonValue; after: JsonValue } + | { kind: "text"; before_chars: number | null; after_chars: number | null } + | { kind: "masked"; before: string | null; after: string | null } + | { kind: "added" } + | { kind: "removed" }; + +export type RestartDiffEntry = { + /** Dotted path derived from serde field names, e.g. "model", "env.FOO". */ + field: string; + change: RestartChange; +}; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 62a262e4f..c44fd3b1c 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -118,6 +118,8 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; + +import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; export type RawManagedAgent = { pubkey: string; name: string; @@ -143,6 +145,7 @@ export type RawManagedAgent = { persona_out_of_date: boolean; persona_orphaned: boolean; needs_restart: boolean; + restart_diff?: RawRestartDiffEntry[]; env_vars?: Record; status: ManagedAgent["status"]; pid: number | null; @@ -158,8 +161,7 @@ export type RawManagedAgent = { auto_restart_on_config_change?: boolean; backend: ManagedAgentBackend; backend_agent_id: string | null; - // Optional: pre-feature mock fixtures may omit these. Mapped to - // `"owner-only"` / `[]` in `fromRawManagedAgent`. + // Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent. respond_to?: ManagedAgent["respondTo"]; respond_to_allowlist?: string[]; }; @@ -707,11 +709,11 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { avatarUrl: agent.avatar_url ?? null, model: agent.model, modelSource: agent.model_source ?? null, - // Fallbacks for pre-feature mocks/fixtures. Real records always carry them. provider: agent.provider ?? null, personaOutOfDate: agent.persona_out_of_date ?? false, personaOrphaned: agent.persona_orphaned ?? false, needsRestart: agent.needs_restart ?? false, + restartDiff: agent.restart_diff ?? [], envVars: agent.env_vars ?? {}, status: agent.status, pid: agent.pid, @@ -727,8 +729,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, backend: agent.backend, backendAgentId: agent.backend_agent_id, - // Fallbacks for pre-feature mocks/fixtures that don't carry these fields. - // Real agent records always include them (defaulted server-side). respondTo: agent.respond_to ?? "owner-only", respondToAllowlist: agent.respond_to_allowlist ?? [], }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index fd2c71bce..78f5d1aa3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -304,6 +304,8 @@ export type ManagedAgentBackend = | { type: "local" } | { type: "provider"; id: string; config: Record }; +import type { RestartDiffEntry } from "./restartDiff"; +export type { JsonValue, RestartChange, RestartDiffEntry } from "./restartDiff"; export type ManagedAgent = { pubkey: string; name: string; @@ -357,6 +359,8 @@ export type ManagedAgent = { * Always `false` for stopped agents. */ needsRestart: boolean; + /** Non-empty iff `needsRestart` is true. Empty when Rust omits the field. */ + restartDiff: RestartDiffEntry[]; /** Per-agent env vars. Layered on top of persona envVars. */ envVars: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -382,11 +386,7 @@ export type ManagedAgent = { respondToAllowlist: string[]; }; -/** - * Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag. - * `"nobody"` is supported by the harness but not surfaced through this API — - * it's a heartbeat-only mode without a meaningful GUI use case. - */ +/** Inbound author gate mode. Mirrors buzz-acp's --respond-to CLI flag. */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; export type BackendProviderCandidate = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33b1fb38f..e430549d4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -88,6 +88,7 @@ export type MockManagedAgentSeed = { lastError?: string | null; lastErrorCode?: number | null; needsRestart?: boolean; + restartDiff?: Array<{ field: string; change: unknown }>; autoRestartOnConfigChange?: boolean; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; @@ -826,6 +827,7 @@ type RawManagedAgent = { last_error: string | null; last_error_code: number | null; needs_restart?: boolean; + restart_diff?: Array<{ field: string; change: unknown }>; log_path: string; start_on_app_launch: boolean; auto_restart_on_config_change?: boolean; @@ -1627,6 +1629,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { last_error: agent.last_error, last_error_code: agent.last_error_code, needs_restart: agent.needs_restart ?? false, + restart_diff: agent.restart_diff ? [...agent.restart_diff] : [], log_path: agent.log_path, start_on_app_launch: agent.start_on_app_launch, auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true, @@ -2180,6 +2183,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { last_error: seed.lastError ?? null, last_error_code: seed.lastErrorCode ?? null, needs_restart: seed.needsRestart ?? false, + restart_diff: seed.restartDiff ?? [], log_path: `/tmp/mock-agent-${seed.pubkey}.log`, start_on_app_launch: true, auto_restart_on_config_change: seed.autoRestartOnConfigChange ?? true, diff --git a/desktop/tests/e2e/needs-restart-screenshots.spec.ts b/desktop/tests/e2e/needs-restart-screenshots.spec.ts index 7c836974f..1a695844f 100644 --- a/desktop/tests/e2e/needs-restart-screenshots.spec.ts +++ b/desktop/tests/e2e/needs-restart-screenshots.spec.ts @@ -1,11 +1,14 @@ /** - * Screenshot spec for the needsRestart badge and banner (PR #1853). + * Screenshot spec for the needsRestart badge and config diff (PR #1853 + diff + * overlay work). * - * Exercises two surfaces: - * - Agent grid card: warning badge ("Restart required") on standalone and - * persona-backed cards when `needsRestart: true`, absent when false. - * - Profile panel Runtime tab: amber banner with copy branched on the - * per-agent auto-restart toggle. + * Exercises: + * - Agent grid card and list-row badges at all three badge sites. + * - Hover tooltip with itemised before→after diff (capped at 6 + "and N more"). + * - Runtime-tab banner with full uncapped diff list. + * - Side-panel badge visible on the default (Info) tab — not only Runtime. + * - DOM validity: tooltip trigger has no
+
+ + + Or, copy a link + +
+
-

- Link settings -

diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 05b468728..84232f26d 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -1,4 +1,5 @@ -import { Check, ChevronDown, Link2 } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { toast } from "sonner"; @@ -12,7 +13,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Separator } from "@/shared/ui/separator"; +import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; const TTL_OPTIONS: { label: string; value: number }[] = [ @@ -34,13 +35,14 @@ const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [ export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; type CopyStatus = "idle" | "copying" | "copied"; +type GenerationStatus = "idle" | "generating" | "failed"; /** * Share-with-link footer for the community invite dialog. * - * Each copy action mints a fresh database-backed invite code and places its - * shareable landing-page URL on the clipboard. Invites may be unlimited or - * capped to a caller-selected number of successful joins. + * A database-backed invite link is minted when this section opens and whenever + * its settings change. Invites may be unlimited or capped to a caller-selected + * number of successful joins. */ export function InviteLinkSection({ onTtlSecsChange, @@ -50,18 +52,40 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [generationStatus, setGenerationStatus] = + React.useState("generating"); + const [inviteUrl, setInviteUrl] = React.useState(""); const [maxUses, setMaxUses] = React.useState(null); + const generationRequestId = React.useRef(0); + // React StrictMode replays effects in development. Keep one in-flight mint + // per setting set so the replay observes the original request instead of + // creating a second durable invite. + const inviteRequests = React.useRef( + new Map>(), + ); + const shouldReduceMotion = useReducedMotion(); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; const maxUsesLabel = MAX_USE_OPTIONS.find((option) => option.value === maxUses)?.label ?? "No limit"; - const copyLabel = - copyStatus === "copying" - ? "Copying…" - : copyStatus === "copied" - ? "Copied" - : "Copy link"; + const isGenerating = generationStatus === "generating"; + const hasGenerationFailed = generationStatus === "failed"; + const inviteSettingsKey = `${ttlSecs}:${maxUses ?? "no-limit"}`; + const isWorking = isGenerating || copyStatus === "copying"; + const copyLabel = hasGenerationFailed + ? "Retry" + : copyStatus === "copied" + ? "Copied" + : "Copy link"; + const copyButtonWidth = isWorking + ? "6.25rem" + : copyStatus === "copied" + ? "5.25rem" + : "4.5rem"; + const copyButtonTransition = shouldReduceMotion + ? { duration: 0 } + : { duration: 0.12, ease: [0.77, 0, 0.175, 1] as const }; React.useEffect(() => { if (copyStatus !== "copied") return; @@ -69,12 +93,55 @@ export function InviteLinkSection({ return () => window.clearTimeout(resetTimer); }, [copyStatus]); + const generateInviteLink = React.useCallback(async () => { + const requestId = generationRequestId.current + 1; + generationRequestId.current = requestId; + setGenerationStatus("generating"); + setInviteUrl(""); + setCopyStatus("idle"); + const existingRequest = inviteRequests.current.get(inviteSettingsKey); + const inviteRequest = existingRequest ?? mintInvite({ ttlSecs, maxUses }); + if (!existingRequest) { + inviteRequests.current.set(inviteSettingsKey, inviteRequest); + } + + try { + const invite = await inviteRequest; + if (inviteRequests.current.get(inviteSettingsKey) === inviteRequest) { + inviteRequests.current.delete(inviteSettingsKey); + } + if (generationRequestId.current === requestId) { + setInviteUrl(invite.url); + setGenerationStatus("idle"); + } + } catch { + if (inviteRequests.current.get(inviteSettingsKey) === inviteRequest) { + inviteRequests.current.delete(inviteSettingsKey); + } + if (generationRequestId.current === requestId) { + setGenerationStatus("failed"); + toast.error("Couldn’t create an invite link."); + } + } + }, [inviteSettingsKey, maxUses, ttlSecs]); + + React.useEffect(() => { + void generateInviteLink(); + return () => { + generationRequestId.current += 1; + }; + }, [generateInviteLink]); + + function retryInviteGeneration() { + if (!hasGenerationFailed) return; + void generateInviteLink(); + } + async function handleCopy() { - if (copyStatus === "copying") return; + if (!inviteUrl || isGenerating || copyStatus === "copying") return; setCopyStatus("copying"); try { - const invite = await mintInvite({ ttlSecs, maxUses }); - await writeTextToClipboard(invite.url); + await writeTextToClipboard(inviteUrl); setCopyStatus("copied"); toast.success("Invite link copied"); } catch { @@ -85,7 +152,60 @@ export function InviteLinkSection({ return (
-
+
+ + {inviteUrl ? ( + + ) : null} + + + +
+ +
Expires after @@ -94,7 +214,7 @@ export function InviteLinkSection({ aria-label="Choose invite expiry" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-ttl-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -129,7 +249,7 @@ export function InviteLinkSection({ aria-label="Choose maximum invite uses" className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground" data-testid="invite-link-max-uses-trigger" - disabled={copyStatus === "copying"} + disabled={isGenerating || copyStatus === "copying"} size="sm" type="button" variant="ghost" @@ -159,28 +279,6 @@ export function InviteLinkSection({
- -
- -
); } diff --git a/desktop/tests/e2e/invite-link-copy.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts index 20df59a00..353793ec4 100644 --- a/desktop/tests/e2e/invite-link-copy.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -27,7 +27,7 @@ test.beforeEach(async ({ page }) => { }); }); -test("copies a freshly minted invite link without showing a URL or QR code", async ({ +test("copies a freshly minted invite link from the link field", async ({ page, }) => { await page.goto("/"); @@ -38,7 +38,9 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy await expect(page.getByTestId("member-pubkey-input")).toBeVisible(); await expect(page.getByTestId("member-role")).toHaveCount(0); await expect(page.getByTestId("confirm-add-member")).toHaveCount(0); - await expect(page.getByTestId("invite-link-url")).toHaveCount(0); + await expect(page.getByTestId("invite-link-url")).toHaveValue( + "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", + ); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( "No limit", @@ -80,9 +82,82 @@ test("sets a selected invite-use limit", async ({ page }) => { ).toBeVisible(); await page.getByTestId("invite-link-max-uses-10").click(); await expect(maxUsesTrigger).toHaveText("10 uses"); + await expect + .poll(() => invitePayloads.at(-1)) + .toEqual({ + max_uses: 10, + ttl_secs: 3 * 24 * 60 * 60, + }); await page.getByTestId("copy-invite-link").click(); await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); - expect(invitePayloads).toEqual([ - { max_uses: 10, ttl_secs: 3 * 24 * 60 * 60 }, - ]); +}); + +test("retries a failed invite-link generation", async ({ page }) => { + let attempts = 0; + await page.route("**/api/invites", async (route) => { + attempts += 1; + if (attempts === 1) { + await route.fulfill({ status: 500 }); + return; + } + + await route.fulfill({ + contentType: "application/json", + json: { + code: "retry-test", + expires_at: Math.floor(Date.now() / 1000) + 86_400, + url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test", + }, + status: 200, + }); + }); + + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + const linkField = page.getByTestId("invite-link-url"); + const copyButton = page.getByTestId("copy-invite-link"); + await expect(linkField).toHaveAttribute( + "placeholder", + "Couldn’t create invite link", + ); + await expect(copyButton).toHaveText("Retry"); + await expect(copyButton).toBeEnabled(); + + await copyButton.click(); + await expect(linkField).toHaveValue( + "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=retry-test", + ); + await expect(copyButton).toHaveText("Copy link"); + expect(attempts).toBe(2); +}); + +test("reopens with the default expiry before generating a new link", async ({ + page, +}) => { + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + await expect + .poll(() => invitePayloads) + .toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]); + await page.getByTestId("invite-link-ttl-trigger").click(); + await page.getByTestId("invite-link-ttl-604800").click(); + await expect + .poll(() => invitePayloads) + .toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }, { ttl_secs: 7 * 24 * 60 * 60 }]); + + const dialog = page.getByTestId("community-invite-dialog"); + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toHaveCount(0); + await page.getByTestId("community-invite-dialog-trigger").click(); + await expect + .poll(() => invitePayloads) + .toEqual([ + { ttl_secs: 3 * 24 * 60 * 60 }, + { ttl_secs: 7 * 24 * 60 * 60 }, + { ttl_secs: 3 * 24 * 60 * 60 }, + ]); }); diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts index e0856ac40..5421fa642 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -104,15 +104,19 @@ test("capture: share-style community invite dialog", async ({ page }) => { await expect( dialog.getByRole("heading", { name: "Add someone", exact: true }), ).toHaveCount(0); + await expect(dialog.getByTestId("invite-options-divider")).toBeVisible(); await expect( - dialog.getByText("Or share a link", { exact: true }), - ).toHaveCount(0); - await expect( - dialog.getByText("Link settings", { exact: true }), + dialog.getByText("Or, copy a link", { exact: true }), ).toBeVisible(); + await expect(dialog.getByText("Link settings", { exact: true })).toHaveCount( + 0, + ); await expect(page.getByTestId("member-pubkey-input")).toBeVisible(); await expect(page.getByTestId("member-role")).toHaveCount(0); await expect(page.getByTestId("confirm-add-member")).toHaveCount(0); + await expect(page.getByTestId("invite-link-url")).toHaveValue( + "https://alpha.example.com/invite/community-email-test", + ); await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link"); await expect(page.getByTestId("invite-link-ttl-trigger")).toHaveText( "3 days", From 0c33a8a55f0aa0763f8d65ad90dc8af56215d2e8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 4 Aug 2026 13:52:35 -0400 Subject: [PATCH 13/30] fix(agents): canonicalize stale persona harness pins (#4631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stale `agent_command_override` drop logic in `apply_persona_snapshot` with a three-tier canonical command resolver. ## What this fixes The old code dropped a create-time harness pin when the persona switched to a different runtime, but it had two failure modes: 1. **Preset harnesses invisible.** `known_acp_runtime_exact()` only searches `KNOWN_ACP_RUNTIMES` (builtins). Preset harnesses such as OpenClaw live in `PRESET_HARNESSES`, so the destination lookup returned `None` and the outer `if let` branch never executed — a Goose→OpenClaw persona switch left the stale Goose override in place, keeping the agent running Goose instead of OpenClaw. 2. **Pin-side canonical resolution incomplete.** The pin was resolved by `known_acp_runtime()`, which searches by id/command/alias and returns a `&KnownAcpRuntime` entry correctly. However, if the *pin* named an alias (e.g. `claude-code-acp`) and the *destination* was a preset harness absent from builtins, the outer guard still failed for the same reason as (1). The alias regression test pins the requirement that the canonical resolver must handle both sides: alias pins must be recognised and drops must fire when the destination is a known preset. ## How it works now `canonical_harness_command(input)` accepts any form a stored override can take — bare command, alias, path prefix, or runtime id — and resolves it to the harness primary command through three tiers: 1. **Builtins** — `KNOWN_ACP_RUNTIMES`, matched by id/command/alias. 2. **Static presets** — `PRESET_HARNESSES`, matched by id or normalised command. 3. **Loaded registry** — custom/preset definitions loaded at runtime. `command_for_runtime_id` (id-only input, same three tiers) replaces the two-step `known_acp_runtime_exact`/`lookup_loaded_harness_by_id` pattern in `record_agent_command`, `effective_agent_command`, and `try_record_agent_command`, adding the static preset tier so preset harnesses resolve correctly even without a warm registry. ## Changed files - `discovery/presets.rs` — `preset_command_for_id`, `command_for_runtime_id`, `canonical_harness_command` - `discovery.rs` — re-export new functions; make `normalize_command_identity` `pub(crate)`; refactor three command-resolution functions to use `command_for_runtime_id` - `custom_harnesses.rs` — `loaded_harness_registry` visibility `fn` → `pub(super)` (needed by `canonical_harness_command`) - `persona_events.rs` — replace two-step `known_acp_runtime_exact`/`known_acp_runtime` + pointer comparison with canonical-command comparison - `persona_events/stale_pin_tests.rs` (new) — four regression tests: Goose→OpenClaw drop, OpenClaw→Goose drop, claude-code-acp alias→OpenClaw drop, same-harness path keep - `persona_events/tests.rs` — `sample_record`/`sample_persona` exposed as `pub(super)` for the new test module Signed-off-by: Will Pfleger Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> --- .../src/managed_agents/custom_harnesses.rs | 2 +- .../src-tauri/src/managed_agents/discovery.rs | 64 +++++------ .../src/managed_agents/discovery/presets.rs | 70 ++++++++++++ .../src/managed_agents/persona_events.rs | 37 +++++-- .../persona_events/stale_pin_tests.rs | 101 ++++++++++++++++++ .../managed_agents/persona_events/tests.rs | 4 +- 6 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index e6bc09496..ba0448bea 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -268,7 +268,7 @@ pub(crate) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> { /// Thread-safe registry of non-builtin (preset + custom) harness definitions, /// populated on every `discover_acp_runtimes_from` call and queried at spawn time. -fn loaded_harness_registry() -> &'static RwLock>> { +pub(super) fn loaded_harness_registry() -> &'static RwLock>> { use std::sync::OnceLock; static REGISTRY: OnceLock>>> = OnceLock::new(); REGISTRY.get_or_init(|| RwLock::new(Vec::new())) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2cccccb95..fafcb2589 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -13,8 +13,11 @@ mod presets; mod runtime_metadata; #[macro_use] mod windows_install; +pub(crate) use presets::{ + canonical_harness_command, command_for_runtime_id, preset_harness_definitions, + preset_harness_ids, +}; use presets::{preset_catalog_entry, PRESET_HARNESSES}; -pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -233,7 +236,7 @@ fn executable_basename(command: &str) -> String { } } -fn normalize_command_identity(command: &str) -> String { +pub(crate) fn normalize_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); let lower = basename @@ -295,9 +298,10 @@ pub fn default_agent_command() -> String { /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the record's own `runtime` id mapped to its primary command — -/// records materialize their runtime at create/migration time; -/// checks both static builtins AND the loaded preset/custom registry; +/// 2. the record's own `runtime` id mapped to its primary command via the +/// authoritative three-tier lookup (static builtins → static preset list +/// → loaded registry) — preset harnesses (e.g. openclaw) resolve +/// correctly even with a cold registry; /// 3. legacy fallback: the linked persona's `runtime` (records created /// before the unified model carry `persona_id` but no `runtime`); /// 4. `default_agent_command()`. @@ -315,15 +319,11 @@ pub fn record_agent_command( } if let Some(id) = record.runtime.as_deref() { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Fall back to loaded registry for preset/custom harnesses. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + // Using the shared resolver ensures preset harnesses (e.g. openclaw) + // resolve correctly even without a warm registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -336,8 +336,9 @@ pub fn record_agent_command( /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the linked persona's `runtime` id mapped to its primary command -/// (checks builtins then loaded preset/custom registry); +/// 2. the linked persona's `runtime` id mapped to its primary command via +/// the authoritative three-tier lookup (static builtins → static preset +/// list → loaded registry); /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, @@ -356,15 +357,9 @@ pub fn effective_agent_command( .and_then(|persona| persona.runtime.as_deref()); if let Some(id) = runtime_id { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Check loaded preset/custom registry. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -423,12 +418,8 @@ pub fn try_record_agent_command( // Record-level runtime id: if set but unresolvable → typed error. if let Some(id) = record.runtime.as_deref() { - if let Some(cmd) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { - return Ok(cmd.to_string()); - } - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -437,15 +428,8 @@ pub fn try_record_agent_command( if let Some(persona_id) = record.persona_id.as_deref() { if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { if let Some(id) = persona.runtime.as_deref() { - if let Some(cmd) = - known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return Ok(cmd.to_string()); - } - if let Some(def) = - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index bcc428800..b2d8a14ef 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -202,6 +202,76 @@ pub(crate) fn preset_harness_ids() -> &'static [&'static str] { .as_slice() } +/// Return the primary command for a preset harness by id, or `None` if the id +/// is not a known preset. +/// +/// Returns a `&'static str` so callers can use it without allocation. +pub(super) fn preset_command_for_id(id: &str) -> Option<&'static str> { + PRESET_HARNESSES + .iter() + .find(|p| p.id == id) + .map(|p| p.command) +} + +/// Return the primary harness command for a given runtime id, or `None`. +/// +/// Checks static builtins, then the static preset list (always available, +/// no registry warm-up required — covers openclaw, devin, cursor, etc.), +/// then the loaded preset/custom registry. +pub(crate) fn command_for_runtime_id(id: &str) -> Option { + super::known_acp_runtime_exact(id) + .and_then(|r| r.commands.first().copied()) + .map(str::to_string) + .or_else(|| preset_command_for_id(id).map(str::to_string)) + .or_else(|| { + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + .map(|d| d.command.clone()) + }) +} + +/// Resolve a harness to its canonical command accepting either a runtime id or +/// a command string (including path prefixes and aliases). +/// +/// This is the pin-classification resolver for `apply_persona_snapshot`: the +/// create-time override in `record.agent_command_override` can hold any of the +/// forms a user or the harness selector might have stored — bare command +/// ("goose"), alias ("claude-code-acp"), path ("/usr/local/bin/goose"), or the +/// runtime id directly ("claude"). All three tiers are searched: +/// +/// 1. **Builtins** — `known_acp_runtime(input)` matches by id, command, or +/// alias in `KNOWN_ACP_RUNTIMES`; returns its first primary command. +/// 2. **Static presets** — searched by id or by normalised command. +/// 3. **Loaded registry** — searched by id or by normalised command. +/// +/// Returns `None` for inputs that do not resolve to any known harness; those +/// pins are treated as custom/unknown and always kept. +pub(crate) fn canonical_harness_command(input: &str) -> Option { + let normalized = super::normalize_command_identity(input); + + // Tier 1: builtins — matched by id, command, or alias. + if let Some(rt) = super::known_acp_runtime(&normalized) { + if let Some(cmd) = rt.commands.first() { + return Some(cmd.to_string()); + } + } + + // Tier 2: static presets — matched by id or by normalized command. + if let Some(p) = PRESET_HARNESSES + .iter() + .find(|p| p.id == normalized || super::normalize_command_identity(p.command) == normalized) + { + return Some(p.command.to_string()); + } + + // Tier 3: loaded registry — matched by id or by normalized command. + let reg = crate::managed_agents::custom_harnesses::loaded_harness_registry() + .read() + .unwrap_or_else(|e| e.into_inner()); + reg.iter() + .find(|d| d.id == normalized || super::normalize_command_identity(&d.command) == normalized) + .map(|d| d.command.clone()) +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 17b7416b3..de396f45c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -464,23 +464,42 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; - // Drop a stale create-time harness pin when the definition names a - // different known runtime; custom commands stay pinned. - if let Some(def_runtime) = persona + // Drop a stale create-time harness pin when the definition switches to a + // different known runtime (builtin, static preset, or loaded custom). A pin + // that names an unknown/custom command is always kept. + // + // Both sides are resolved through the canonical harness-identity resolver + // (`canonical_harness_command`) which accepts either a runtime id OR a + // command string — covering aliases (e.g. "claude-code-acp"), path prefixes + // ("/usr/local/bin/goose"), and harnesses whose id ≠ command. The persona + // runtime side is resolved via `command_for_runtime_id` (id-only input is + // sufficient there since persona.runtime is always an authoritative id). + // + // Comparison is on canonical primary commands so "goose", "/usr/local/bin/goose", + // and runtime id "goose" all represent the same harness; the stale pin is + // dropped only when the canonical commands differ. + if let Some(new_cmd) = persona .runtime .as_deref() .map(str::trim) .filter(|r| !r.is_empty()) - .and_then(crate::managed_agents::known_acp_runtime_exact) + .and_then(super::command_for_runtime_id) { - if let Some(pin_runtime) = record + if let Some(pin) = record .agent_command_override .as_deref() - .and_then(crate::managed_agents::known_acp_runtime) + .map(str::trim) + .filter(|v| !v.is_empty()) { - if !std::ptr::eq(pin_runtime, def_runtime) { - record.agent_command_override = None; + // Resolve the pin via the canonical resolver (accepts id OR command). + if let Some(pin_cmd) = super::canonical_harness_command(pin) { + if pin_cmd != new_cmd { + // Known harness switched to a different known harness — drop stale pin. + record.agent_command_override = None; + } + // Same harness: keep the pin (e.g. explicit path override for same runtime). } + // Custom/unknown pin: always keep. } } // env_vars stay overrides-only. Self-heal records written before the env @@ -523,4 +542,6 @@ pub fn preview_prospective_persona_snapshot( preview } #[cfg(test)] +mod stale_pin_tests; +#[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs new file mode 100644 index 000000000..c34ab1739 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -0,0 +1,101 @@ +//! Stale-pin drop tests for `apply_persona_snapshot`. +//! +//! Covers the `canonical_harness_command` resolver used to classify a +//! create-time `agent_command_override` before deciding whether it should be +//! dropped when the persona switches to a different harness. + +use super::tests::{sample_persona, sample_record}; +use crate::managed_agents::persona_events::apply_persona_snapshot; +use crate::managed_agents::types::AgentDefinition; + +// ── Stale-pin drop: OpenClaw↔Goose (preset↔builtin) ───────────────────────── + +/// Persona→OpenClaw: stale Goose override dropped. +/// Regression for the original preset stale-pin fix. +#[test] +fn apply_persona_snapshot_goose_to_openclaw_drops_stale_goose_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to openclaw" + ); +} + +/// Persona→Goose: stale OpenClaw override dropped. +#[test] +fn apply_persona_snapshot_openclaw_to_goose_drops_stale_openclaw_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("openclaw".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale openclaw pin must be dropped when persona switches to goose" + ); +} + +// ── Stale-pin drop: alias pin (command ≠ id) ───────────────────────────────── + +/// Persona→OpenClaw; record has a stale `claude-code-acp` alias pin (id="claude", +/// command="claude-agent-acp"). The canonical resolver must recognise the alias +/// as the Claude harness and drop it when the persona switches to a different +/// harness (OpenClaw). +/// +/// This is the correctness case that motivated the `canonical_harness_command` +/// resolver: the old pointer-comparison code treated the alias as a +/// custom/unknown pin and kept it — the agent kept running Claude instead of +/// OpenClaw. +#[test] +fn apply_persona_snapshot_claude_alias_pin_to_openclaw_drops_stale_alias() { + let mut record = sample_record(); + // "claude-code-acp" is an alias of the Claude runtime (id="claude"). + record.agent_command_override = Some("claude-code-acp".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale claude-code-acp alias pin must be dropped when persona switches to openclaw" + ); +} + +// ── Stale-pin keep: same harness, path/alias override ─────────────────────── + +/// Same-harness case: record has an explicit path override pointing at the same +/// harness as the new persona runtime. The pin must NOT be dropped — it is a +/// deliberate per-instance configuration (e.g. a specific goose binary path). +#[test] +fn apply_persona_snapshot_same_harness_path_pin_is_kept() { + let mut record = sample_record(); + // Explicit path override for goose — same harness as the persona runtime. + record.agent_command_override = Some("/usr/local/bin/goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override.as_deref(), + Some("/usr/local/bin/goose"), + "same-harness path override must NOT be dropped" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a8..0580b12ce 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -3,7 +3,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// A linked instance record with no persona-derived fields set yet — the /// state right after creation, before any snapshot apply. -fn sample_record() -> ManagedAgentRecord { +pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), name: "agent".into(), @@ -139,7 +139,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); } -fn sample_persona() -> AgentDefinition { +pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { id: "test-persona".to_string(), display_name: "Test Persona".to_string(), From bc9e6528a7ba6007c5a25f6a0aca9c05d72e9d2c Mon Sep 17 00:00:00 2001 From: Jemiah Westerman Date: Tue, 4 Aug 2026 10:59:54 -0700 Subject: [PATCH 14/30] perf(relay): index channel-id lookups and skip trace-only reads (#4647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `SELECT id, community_id FROM channels WHERE id = ANY($1) AND deleted_at IS NULL` is the top **Load by waits (AAS)** on the Buzz Postgres writer. Two independent causes compound, and both are fixed here. ### 1. No index can serve it `channels` is `PRIMARY KEY (community_id, id)`, and every secondary index leads with `community_id`: | Index | Columns | |---|---| | *(primary key)* | `(community_id, id)` | | `idx_channels_nip29_group` | `(community_id, nip29_group_id)` | | `idx_channels_dm_hash` | `(community_id, participant_hash)` | | `idx_channels_community_type` | `(community_id, channel_type)` | | `idx_channels_community_visibility` | `(community_id, visibility)` | | `idx_channels_created_by` | `(community_id, created_by)` | | `idx_channels_ttl_expiry` | `(ttl_deadline)` *(partial)* | The two tenant-independent lookups carry **no `community_id` predicate** — deliberately: - `Db::communities_of_channels` — `WHERE id = ANY($1) AND deleted_at IS NULL` - `Db::community_of_channel` — `WHERE id = $1 AND deleted_at IS NULL` That independence is load-bearing, not an oversight: projecting a row's *true* owning community regardless of the fetch query's `WHERE` clause is what makes `Inv_NonInterference` non-vacuous. If the fetch ever dropped its tenant scoping, this lookup would still report the real label and the checker would catch the mismatch. But a composite btree is only usable when its leading column is constrained, so neither query can use the primary key, and nothing else leads with `id`. **Both sequentially scan `channels` on every call.** ### 2. In production the result is discarded Both call sites feed `record_read_message_rows` / `record_read_by_id_rows`, which call `tracer.record(...)`. Production binds `NoopTracer` (`crates/buzz-relay/src/state.rs`), whose `record` body is empty. The existing guard tests `trace_state`, which is `Some` for every well-formed request — it only goes `None` on malformed pubkey bytes. So the scan ran on the hot read path and its output was dropped. This is the classic eager-argument bug: `log.debug("..." + expensiveCall())` with no `isDebugEnabled()` check. ### 3. Multiplied per filter The non-search call site sits **inside the phase-3 per-filter loop**, so a `REQ` carrying N filters performed N sequential scans of `channels` before responding. ## Changes **`Tracer::enabled()`** — a capability check on the trait (the `isDebugEnabled()` of this seam), defaulting to `true`. `NoopTracer` overrides it to `false`, and both emitters in `req.rs` now gate on it, skipping the trace-only DB read entirely in production. **`migrations/0027_channels_id_lookup_index.sql`** ```sql CREATE INDEX IF NOT EXISTS idx_channels_id_live ON channels (id) INCLUDE (community_id) WHERE deleted_at IS NULL; ``` - `INCLUDE (community_id)` — both queries select exactly `(id, community_id)`, so this is covering and can be served index-only. - Partial on `deleted_at IS NULL` — matches both predicates exactly, excludes soft-deleted history, and lets Postgres skip the recheck. - **Not `UNIQUE`.** `id` alone is *not* unique in this table — `command_executor.rs` documents that `community_of_channel(channel_id)` is ambiguous because the same channel id can appear under more than one community. A unique index would encode a false constraint and fail to build on any database already holding such a pair. Worth keeping the index even though fix #1 removes the production caller: it still runs under conformance, and `community_of_channel` has the same problem on its own paths. **`schema/schema.sql`** — mirrored, since a test asserts desired-state parity. ## Conformance is unchanged This is the part worth reviewing closely. Under a real tracer `enabled()` returns `true` and **every emit happens exactly as before** — the gate only skips *building* emit inputs when nothing observes them, never an emit that would otherwise have been made. The coverage-breach guard stays non-vacuous. `CountingTracer` forwards `enabled()` to its inner tracer rather than inheriting the `true` default. Both directions matter and both fail silently: - inheriting `true` over a `NoopTracer` would keep the overhead this PR removes; - hardcoding `false` over a live tracer would suppress the emits whose absence `EmitGuard` reports as `ImplBug` — masking real breaches behind expected ones. Covered by a new regression test, `counting_tracer_delegates_enabled_to_inner`, which asserts delegation in both directions. ## Verification - `cargo check -p buzz-conformance -p buzz-relay` — clean - `cargo clippy --all-targets` — clean, zero warnings - `cargo test -p buzz-conformance` — 6/6 - `cargo test -p buzz-relay --lib conformance` — 11/11 - `cargo test -p buzz-db --lib migration` — 7/7 - `just test-unit` (pre-push) — green Migration-count assertions in `crates/buzz-db/src/migration.rs` were bumped 26 → 27, with content assertions for 0027 following the existing per-migration pattern (including a guard that it never becomes `UNIQUE`). ## Open questions for reviewers 1. **Lock strategy.** Built *without* `CONCURRENTLY`, following migration 0004's precedent, because sqlx runs each migration inside a transaction and `CREATE INDEX CONCURRENTLY` cannot run in one. This takes a brief `SHARE` lock on `channels` (blocks writes, not reads) — small relative to `events`, but an operator preferring zero write-blocking can pre-build it by hand and `IF NOT EXISTS` makes the migration a no-op. I could not confirm whether sqlx 0.9 supports a `-- no-transaction` directive; if it does, that may be preferable. 2. **Diagnosis is static.** This comes from reading the source, not from `EXPLAIN` against the live database. Worth confirming with `EXPLAIN (ANALYZE, BUFFERS)` on the writer before/after — that also sizes the win by revealing the real table size and row counts. 3. **Expected impact** scales with average filters-per-`REQ`, which I did not measure. `pg_stat_statements` ordered by `total_exec_time` would confirm this query drops off the top and show whether anything else is scanning the same way. Signed-off-by: Jemiah Westerman --- crates/buzz-conformance/src/lib.rs | 26 +++++++++ crates/buzz-db/src/migration.rs | 27 ++++++++- crates/buzz-relay/src/conformance/mod.rs | 56 +++++++++++++++++++ crates/buzz-relay/src/conformance/tracers.rs | 6 ++ crates/buzz-relay/src/handlers/req.rs | 11 +++- migrations/0027_channels_id_lookup_index.sql | 58 ++++++++++++++++++++ schema/schema.sql | 6 ++ 7 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 migrations/0027_channels_id_lookup_index.sql diff --git a/crates/buzz-conformance/src/lib.rs b/crates/buzz-conformance/src/lib.rs index 3e1cfe13e..b8e3f933d 100644 --- a/crates/buzz-conformance/src/lib.rs +++ b/crates/buzz-conformance/src/lib.rs @@ -315,6 +315,27 @@ pub trait Tracer: Send + Sync { /// Record one trace step. Implementations MAY be no-ops in production /// builds and write to JSONL in tests. fn record(&self, step: TraceStep); + + /// Whether recorded steps are actually observed. + /// + /// Emitters on hot paths MUST consult this before doing work whose + /// *only* consumer is the trace — most importantly extra database + /// reads that project row labels independently of the fetch query + /// (the read-seam's `communities_of_channels` lookup). With a + /// discarding tracer that work is pure overhead. + /// + /// This is the `log.isDebugEnabled()` of the trace seam. It exists to + /// let callers skip *building emit inputs*, never to let them skip an + /// emit they would otherwise have made: when this returns `true` + /// every seam must behave exactly as it did before the gate existed, + /// so the coverage-breach guard stays non-vacuous. + /// + /// Defaults to `true` — a new tracer is assumed to observe steps until + /// it says otherwise. Wrappers that delegate to an inner tracer MUST + /// forward this method rather than inherit the default. + fn enabled(&self) -> bool { + true + } } /// A no-op tracer for production. Zero cost: the build can omit emission @@ -324,4 +345,9 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs. + fn enabled(&self) -> bool { + false + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bb..65ca15672 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + assert_eq!(migrations.len(), 27); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,27 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); + + // Channel-id lookup index (0027): serves the tenant-independent + // `channels` lookups that carry no community_id predicate, which no + // community_id-leading index can satisfy. Covering + partial so the + // planner can go index-only; asserted NOT UNIQUE because `id` alone is + // not unique in this table (the same channel id may exist under more + // than one community), so a unique index would encode a false + // constraint and fail to build on such a database. + assert_eq!(migrations[26].version, 27); + let channel_id_index = migrations[26].sql.as_str(); + assert!(channel_id_index.contains("idx_channels_id_live")); + assert!(channel_id_index.contains("INCLUDE (community_id)")); + assert!(channel_id_index.contains("WHERE deleted_at IS NULL")); + assert!( + !channel_id_index.contains("CREATE UNIQUE INDEX"), + "channels.id is not unique across communities — index must not be UNIQUE", + ); + assert!( + desired_schema.contains("idx_channels_id_live"), + "desired-state schema must carry the channel-id lookup index", + ); } #[test] @@ -1161,7 +1182,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(26)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(27)); } #[tokio::test] diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 323d0aca0..93ebe5de9 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -370,6 +370,16 @@ impl Tracer for CountingTracer { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.inner.record(step); } + + /// Delegate, never inherit the `true` default. This wrapper is + /// transparent: whether emits are observed is a property of the + /// tracer underneath it. Returning `true` over a `NoopTracer` would + /// reintroduce the overhead the gate exists to remove; returning + /// `false` over a real tracer would suppress the emits whose absence + /// the `EmitGuard` reports as a coverage breach. + fn enabled(&self) -> bool { + self.inner.enabled() + } } impl EmitGuard { @@ -455,6 +465,52 @@ mod tests { } } + /// Discarding tracer that reports `enabled() == false`, standing in + /// for the production `NoopTracer`. + #[derive(Debug, Default)] + struct DisabledTracer; + + impl Tracer for DisabledTracer { + fn record(&self, _step: TraceStep) {} + fn enabled(&self) -> bool { + false + } + } + + /// `CountingTracer` must forward `enabled()` to the tracer it wraps + /// rather than inherit the trait's `true` default. Both directions + /// matter, and getting either wrong is silent: + /// + /// - over a disabled tracer, answering `true` would keep the hot-path + /// read-seam `channels` lookup running in production — the overhead + /// the gate exists to remove; + /// - over a live tracer, answering `false` would make gated emitters + /// skip emits during conformance runs, so the `EmitGuard` would + /// report `ImplBug` for seams that are in fact correct (or, worse, + /// mask a real breach behind an expected one). + #[test] + fn counting_tracer_delegates_enabled_to_inner() { + let (_guard, counting) = EmitGuard::arm( + Arc::new(DisabledTracer), + dummy_state(), + "delegates_disabled", + ); + assert!( + !counting.enabled(), + "CountingTracer must report disabled when wrapping a discarding tracer" + ); + + let (_guard, counting) = EmitGuard::arm( + Arc::new(VecTracer::default()), + dummy_state(), + "delegates_live", + ); + assert!( + counting.enabled(), + "CountingTracer must report enabled when wrapping an observing tracer" + ); + } + fn dummy_state() -> AbstractState { AbstractState { resolved_community: CommunityLabel::from_uuid(Uuid::from_u128(0xA)), diff --git a/crates/buzz-relay/src/conformance/tracers.rs b/crates/buzz-relay/src/conformance/tracers.rs index 682c1714e..36c978935 100644 --- a/crates/buzz-relay/src/conformance/tracers.rs +++ b/crates/buzz-relay/src/conformance/tracers.rs @@ -17,6 +17,12 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs — + /// including the read-seam's per-request `channels` lookup. + fn enabled(&self) -> bool { + false + } } /// JSONL-to-file tracer for tests + the CI replay job. Each `record` call diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2aed12cd7..fd7deadf5 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -334,7 +334,12 @@ pub async fn handle_req( // (B) projection strategy and the missing-lookup ImplBug // guard-rail. Skipped silently if `trace_state` is `None` (only // happens on malformed pubkey, a separate failure path). - if let Some(state_snap) = trace_state.as_ref() { + // `tracer.enabled()` short-circuits the whole block on the production + // `NoopTracer`: the `communities_of_channels` lookup below is a + // `channels` read whose only consumer is `record_read_message_rows`, + // and this emit runs once PER FILTER. Gating on `trace_state` alone was + // not enough — that is `Some` for every well-formed request. + if let Some(state_snap) = trace_state.as_ref().filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { @@ -659,7 +664,9 @@ async fn handle_search_req( // level isn't bound to a single channel filter, the // per-row `channel_id` carries the channel identity // honestly. - if let Some(state_snap) = trace_state { + // Same `enabled()` gate as the non-search lane: skip the + // trace-only `channels` lookup when nothing observes the emit. + if let Some(state_snap) = trace_state.filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { diff --git a/migrations/0027_channels_id_lookup_index.sql b/migrations/0027_channels_id_lookup_index.sql new file mode 100644 index 000000000..eaf99dfca --- /dev/null +++ b/migrations/0027_channels_id_lookup_index.sql @@ -0,0 +1,58 @@ +-- ── Covering index for channel-id → community lookups ─────────────────────── +-- `channels` is keyed PRIMARY KEY (community_id, id), and every secondary index +-- leads with community_id: +-- +-- idx_channels_nip29_group (community_id, nip29_group_id) +-- idx_channels_dm_hash (community_id, participant_hash) +-- idx_channels_community_type (community_id, channel_type) +-- idx_channels_community_visibility (community_id, visibility) +-- idx_channels_created_by (community_id, created_by) +-- +-- The tenant-independent lookups in buzz-db resolve a channel's owning +-- community *without* a community_id predicate — that independence is the +-- point (buzz-db/src/lib.rs: the read-seam projects a row's true label +-- regardless of the fetch query's WHERE clause, which is what makes +-- Inv_NonInterference non-vacuous): +-- +-- Db::communities_of_channels SELECT id, community_id FROM channels +-- WHERE id = ANY($1) AND deleted_at IS NULL +-- Db::community_of_channel SELECT community_id FROM channels +-- WHERE id = $1 AND deleted_at IS NULL +-- +-- A composite btree is only usable when its leading column is constrained, so +-- neither query can use the primary key and no other index leads with `id`. +-- Both therefore sequentially scan `channels` on every call. Observed as the +-- top "Load by waits (AAS)" on the staging writer (db.r8g.8xlarge, ~53% CPU). +-- +-- INCLUDE (community_id): both queries select only (id, community_id), so the +-- index is covering and the planner can serve them index-only, with no heap +-- fetch for visible rows. +-- +-- Partial on deleted_at IS NULL: matches both predicates exactly, keeps the +-- index off soft-deleted history, and lets Postgres skip re-checking the +-- predicate. +-- +-- NOT UNIQUE, deliberately. `id` alone is not unique in this table — +-- handlers/command_executor.rs documents that community_of_channel(channel_id) +-- is ambiguous because the same channel id can appear under more than one +-- community. A unique index would encode a false constraint and would fail to +-- build on any database that already holds such a pair. +-- +-- Lock note: built without CONCURRENTLY, matching migration 0004's precedent — +-- sqlx runs each migration inside a transaction and CREATE INDEX CONCURRENTLY +-- cannot run in one. This takes a SHARE lock on `channels` (blocking writes, +-- not reads) for the duration of the build. `channels` is a small table +-- relative to `events`, so this is expected to be brief, but on a large +-- brownfield database an operator may prefer to pre-build it by hand: +-- +-- CREATE INDEX CONCURRENTLY idx_channels_id_live +-- ON channels (id) INCLUDE (community_id) +-- WHERE deleted_at IS NULL; +-- +-- IF NOT EXISTS then makes this migration a no-op on that database. +-- +-- Additive migration: previously applied files must not change checksum. + +CREATE INDEX IF NOT EXISTS idx_channels_id_live + ON channels (id) INCLUDE (community_id) + WHERE deleted_at IS NULL; diff --git a/schema/schema.sql b/schema/schema.sql index 3c6472936..9f3449b06 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -112,6 +112,12 @@ CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibi CREATE INDEX idx_channels_created_by ON channels (community_id, created_by); CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline) WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL; +-- Tenant-independent channel-id → community lookups (Db::communities_of_channels, +-- Db::community_of_channel) carry no community_id predicate, so no +-- community_id-leading index can serve them. Covering + partial: index-only scan. +-- Not UNIQUE — the same channel id may exist under more than one community. +CREATE INDEX idx_channels_id_live ON channels (id) INCLUDE (community_id) + WHERE deleted_at IS NULL; -- channels.community_id is immutable: a channel can never be re-tenanted. -- (Conformance: "Migration lint forbids channel re-tenanting except through an From cb4a73e17d0760eba6c3c01811da07e1d3a6b85e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 12:10:47 -0600 Subject: [PATCH 15/30] Dock Buzz Term within channel workspace (#4724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - replace Buzz Term's full-app takeover with a resizable bottom dock inside the channel content surface - add a discoverable channel-header button plus hide and maximize/restore controls - create PTYs lazily and keep separate, persistent terminal workspaces per channel - capture immutable channel/thread context on every terminal session ## Multiple-channel behavior The dock is a single surface, but its tabs are partitioned by channel. Switching channels swaps to that channel's sessions without terminating background PTYs; returning restores them. New tabs capture the currently visible channel/thread context. ## Verification At commit `7ca087f8e08c80528387684364a65bf4ccd6315f`: - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 4,129 passed - pre-push repository hooks — desktop check/test, Tauri checks, terminal Rust suites all passed --------- Signed-off-by: Wes Signed-off-by: kenny lopez Co-authored-by: Carl Co-authored-by: kenny lopez --- desktop/src/app/AppHuddleShell.tsx | 8 - desktop/src/app/AppShell.tsx | 2 +- desktop/src/app/AppShellChannelSurface.tsx | 4 +- desktop/src/app/BuzzThemeSurfaces.tsx | 9 +- .../channels/ui/ChannelScreenHeader.tsx | 29 +- .../terminal/TerminalBootstrap.test.mjs | 210 +++++++- .../features/terminal/TerminalBootstrap.tsx | 188 ++++++- .../terminal/TerminalSubstrate.test.mjs | 415 ++++++---------- .../features/terminal/TerminalSubstrate.tsx | 463 +++++++++++------- .../terminal/terminalPanelStore.test.mjs | 33 ++ .../features/terminal/terminalPanelStore.ts | 53 ++ desktop/src/shared/styles/globals.css | 1 + .../src/shared/styles/globals/components.css | 116 ----- .../src/shared/styles/globals/terminal.css | 302 ++++++++++++ desktop/tests/e2e/terminal-wheel.spec.ts | 39 +- 15 files changed, 1256 insertions(+), 616 deletions(-) create mode 100644 desktop/src/features/terminal/terminalPanelStore.test.mjs create mode 100644 desktop/src/features/terminal/terminalPanelStore.ts create mode 100644 desktop/src/shared/styles/globals/terminal.css diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 736dad1f6..8370e8efd 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -17,12 +17,6 @@ type AppHuddleShellProps = { onShowHuddleInMainApp: (ephemeralChannelId: string) => void; onViewHuddleChannel: (ephemeralChannelId: string) => void; onVisibilityChange: (visible: boolean) => void; - /** - * Terminal substrate layer. Rendered behind the app surface (which carries - * z-10) so the ⌘J handoff can reveal it by fading the surface above. Not - * mounted in the dedicated Huddle room window. - */ - terminal?: React.ReactNode; }; export function AppHuddleShell({ @@ -37,7 +31,6 @@ export function AppHuddleShell({ onShowHuddleInMainApp, onViewHuddleChannel, onVisibilityChange, - terminal, }: AppHuddleShellProps) { return ( - {isRoom ? null : terminal}
} > {hasCommunityRail && !isHuddleRoom ? ( } > diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx index 4ab2ea1df..37be3448e 100644 --- a/desktop/src/app/AppShellChannelSurface.tsx +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -11,6 +11,7 @@ type AppShellChannelSurfaceProps = { isHuddleRoom: boolean; isHuddleRoomStarting: boolean; mainInsetRef: React.RefObject; + terminal?: React.ReactNode; }; export function AppShellChannelSurface({ @@ -18,6 +19,7 @@ export function AppShellChannelSurface({ isHuddleRoom, isHuddleRoomStarting, mainInsetRef, + terminal, }: AppShellChannelSurfaceProps) { return ( @@ -34,7 +36,7 @@ export function AppShellChannelSurface({ style={chromeCssVarDefaults as React.CSSProperties} > {isHuddleRoom && !isHuddleRoomStarting ? : null} - + {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 4976fc2ed..b7912c98b 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -23,8 +23,10 @@ export function GradientLayer() { export function ContentSurface({ children, unframed = false, + terminal, }: { children: ReactNode; + terminal?: ReactNode; /** Used by dedicated huddle windows, which should not resemble app cards. */ unframed?: boolean; }) { @@ -38,7 +40,12 @@ export function ContentSurface({ data-buzz-content-surface data-buzz-content-unframed={unframed ? true : undefined} > - {children} +
+ {children} +
+
+ {terminal} +
); } diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 4c545baf6..358a0e637 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,4 +1,4 @@ -import { LogIn } from "lucide-react"; +import { LogIn, SquareTerminal } from "lucide-react"; import type * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; @@ -17,6 +17,10 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + toggleTerminalPanel, + useTerminalPanel, +} from "@/features/terminal/terminalPanelStore"; const DM_HEADER_AVATAR_SIZE = 32; const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( @@ -74,7 +78,22 @@ export function ChannelScreenHeader({ !activeChannel.archivedAt && onJoinChannel; - const actions = activeChannel ? ( + const terminalPanel = useTerminalPanel(); + const terminalButton = activeChannel ? ( + + ) : null; + const channelActions = activeChannel ? ( showJoinButton ? ( + -
))} @@ -548,20 +645,36 @@ export function TerminalSubstrate({ onClick={() => runTabAction(onNewSession)} type="button" > - + +
- {channelName ? `#${channelName}` : "BUZZ"} - LOCAL PTY · PRIVATE - {shortcutLabel} BUZZ + +
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
{ - if (owner !== "terminal") return; // Preventing the canvas mousedown also suppresses selection. Revisit // this when the terminal gains mouse selection support. event.preventDefault(); @@ -616,7 +729,7 @@ export function TerminalSubstrate({ }} ref={textareaRef} spellCheck={false} - tabIndex={owner === "terminal" ? 0 : -1} + tabIndex={0} />
diff --git a/desktop/src/features/terminal/terminalPanelStore.test.mjs b/desktop/src/features/terminal/terminalPanelStore.test.mjs new file mode 100644 index 000000000..8a3020f0c --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { + resetTerminalPanelForTests, + setTerminalPanelMode, + setTerminalSessionChannels, + toggleTerminalPanel, + getTerminalPanelSnapshotForTests, +} from "./terminalPanelStore.ts"; + +beforeEach(resetTerminalPanelForTests); + +test("panel toggles between closed and the docked default", () => { + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "docked"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); + setTerminalPanelMode("maximized"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); +}); + +test("session channel identities are de-duplicated", () => { + setTerminalSessionChannels(["one", "one", "two"]); + // Regression guard: accepting an iterable (rather than Session objects) keeps + // this store UI-only and prevents mutable PTYs from leaking into header state. + setTerminalSessionChannels(new Set(["one", "two"])); + assert.deepEqual( + [...getTerminalPanelSnapshotForTests().sessionChannelIds], + ["one", "two"], + ); +}); diff --git a/desktop/src/features/terminal/terminalPanelStore.ts b/desktop/src/features/terminal/terminalPanelStore.ts new file mode 100644 index 000000000..3c8fa778a --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +export type TerminalPanelMode = "closed" | "docked" | "maximized"; + +type Snapshot = { + mode: TerminalPanelMode; + sessionChannelIds: ReadonlySet; +}; + +let snapshot: Snapshot = { mode: "closed", sessionChannelIds: new Set() }; +const listeners = new Set<() => void>(); + +function publish(next: Snapshot) { + snapshot = next; + for (const listener of listeners) listener(); +} + +export function setTerminalPanelMode(mode: TerminalPanelMode) { + if (snapshot.mode === mode) return; + publish({ ...snapshot, mode }); +} + +export function toggleTerminalPanel() { + setTerminalPanelMode(snapshot.mode === "closed" ? "docked" : "closed"); +} + +export function setTerminalSessionChannels(channelIds: Iterable) { + const next = new Set(channelIds); + if ( + next.size === snapshot.sessionChannelIds.size && + [...next].every((id) => snapshot.sessionChannelIds.has(id)) + ) + return; + publish({ ...snapshot, sessionChannelIds: next }); +} + +export function useTerminalPanel() { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +export function resetTerminalPanelForTests() { + snapshot = { mode: "closed", sessionChannelIds: new Set() }; +} + +export function getTerminalPanelSnapshotForTests() { + return snapshot; +} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index c45bee413..0d5a10321 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -10,6 +10,7 @@ @import "./globals/skeleton.css"; @import "./globals/spoilers.css"; @import "./globals/components.css"; +@import "./globals/terminal.css"; @import "./globals/utilities.css"; @import "./globals/media-controls.css"; @import "./globals/avatar-framing.css"; diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index fe4001a87..27f641578 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -751,119 +751,3 @@ } } } - -@layer components { - .buzz-terminal-substrate { - background: var(--buzz-terminal-background, #101014); - color: var(--buzz-terminal-foreground, #e8e8ec); - display: flex; - flex-direction: column; - font-family: "JetBrains Mono", monospace; - font-variant-ligatures: none; - inset: 0; - position: absolute; - user-select: none; - z-index: 0; - } - - .buzz-terminal-contract-bar { - align-items: stretch; - border-bottom: 1px solid hsl(var(--border)); - display: flex; - flex: 0 0 32px; - justify-content: space-between; - min-width: 0; - } - - .buzz-terminal-tabs, - .buzz-terminal-readout { - align-items: center; - display: flex; - min-width: 0; - } - - .buzz-terminal-tab, - .buzz-terminal-tab-select, - .buzz-terminal-close, - .buzz-terminal-new-tab { - align-items: center; - background: transparent; - border: 0; - color: inherit; - display: flex; - font: inherit; - gap: 8px; - height: 100%; - opacity: 0.62; - padding: 0 10px; - position: relative; - } - - .buzz-terminal-tab-active { - opacity: 1; - } - - .buzz-terminal-tab-active::after { - background: hsl(var(--buzz-selected-accent)); - bottom: 0; - content: ""; - height: 1px; - left: 0; - position: absolute; - right: 0; - } - - .buzz-terminal-designator, - .buzz-terminal-readout { - font-size: 0.5625rem; - letter-spacing: 0.12em; - text-transform: uppercase; - } - - .buzz-terminal-close { - opacity: 0; - } - - .buzz-terminal-tab:hover .buzz-terminal-close, - .buzz-terminal-close:focus { - opacity: 1; - } - - .buzz-terminal-readout { - gap: 14px; - padding: 0 12px; - white-space: nowrap; - } - - .buzz-terminal-viewport, - .buzz-terminal-viewport canvas { - height: 100%; - min-height: 0; - width: 100%; - } - - .buzz-terminal-viewport { - overflow: hidden; - position: relative; - } - - .buzz-terminal-viewport canvas { - display: block; - } - - .buzz-terminal-welcome { - inset: 0; - pointer-events: none; - position: absolute; - z-index: 1; - } - - .buzz-terminal-input { - height: 1px; - left: -10000px; - opacity: 0; - position: absolute; - top: 0; - width: 1px; - } -} diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css new file mode 100644 index 000000000..26b5f42ba --- /dev/null +++ b/desktop/src/shared/styles/globals/terminal.css @@ -0,0 +1,302 @@ +@layer components { + .buzz-terminal-substrate { + background: var(--buzz-terminal-background, #101014); + color: var(--buzz-terminal-foreground, #e8e8ec); + display: flex; + flex-direction: column; + font-family: "JetBrains Mono", monospace; + font-variant-ligatures: none; + inset: 0; + position: absolute; + user-select: none; + z-index: 0; + } + + .buzz-terminal-contract-bar { + align-items: center; + background: hsl(var(--secondary)); + border-bottom: 1px solid hsl(var(--border)); + color: hsl(var(--secondary-foreground)); + display: flex; + flex: 0 0 40px; + font-family: inherit; + font-variant-ligatures: normal; + gap: 8px; + justify-content: space-between; + min-width: 0; + padding: 4px 1.25rem; + } + + .buzz-terminal-tabs, + .buzz-terminal-readout { + align-items: center; + display: flex; + min-width: 0; + } + + .buzz-terminal-tab, + .buzz-terminal-tab-select, + .buzz-terminal-close, + .buzz-terminal-new-tab { + align-items: center; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + display: flex; + font: inherit; + font-size: 0.75rem; + font-weight: 500; + gap: 6px; + height: 30px; + padding: 0 9px; + position: relative; + } + + .buzz-terminal-tabs { + flex: 1 1 auto; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + + .buzz-terminal-tabs::-webkit-scrollbar { + display: none; + } + + .buzz-terminal-readout { + background: hsl(var(--secondary)); + flex: 0 0 auto; + gap: 4px; + padding: 0; + position: relative; + z-index: 1; + } + + .buzz-terminal-tab { + background: hsl(var(--background)); + border-radius: 4px; + flex: 0 0 auto; + padding: 0; + } + + .buzz-terminal-tab-active { + background: hsl(var(--background)); + } + + .buzz-terminal-tab:hover, + .buzz-terminal-tab:focus-within { + background: hsl(var(--foreground) / 0.06); + } + + .buzz-terminal-tab-select { + border-radius: inherit; + max-width: 12rem; + min-width: 0; + padding-left: 32px; + } + + .buzz-terminal-tab-active .buzz-terminal-tab-select { + color: hsl(var(--foreground)); + } + + .buzz-terminal-new-tab:hover { + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); + } + + .buzz-terminal-new-tab { + align-items: center; + background: hsl(var(--background)); + border-radius: 4px; + height: 30px; + justify-content: center; + padding: 7px; + width: 30px; + } + + .buzz-terminal-new-tab svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .buzz-terminal-designator { + align-items: center; + display: inline-flex; + font-size: 0.75rem; + gap: 2px; + letter-spacing: 0; + } + + .buzz-terminal-designator svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-close { + height: 16px; + left: 8px; + opacity: 0; + padding: 0; + pointer-events: none; + position: absolute; + width: 16px; + z-index: 1; + } + + .buzz-terminal-tab:hover .buzz-terminal-close, + .buzz-terminal-tab:focus-within .buzz-terminal-close { + color: hsl(var(--foreground)); + opacity: 1; + pointer-events: auto; + } + + .buzz-terminal-close svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-readout { + white-space: nowrap; + } + + .buzz-terminal-viewport, + .buzz-terminal-viewport canvas { + height: 100%; + min-height: 0; + width: 100%; + } + + .buzz-terminal-viewport { + overflow: hidden; + position: relative; + } + + .buzz-terminal-viewport canvas { + display: block; + } + + .buzz-terminal-welcome { + inset: 0; + pointer-events: none; + position: absolute; + z-index: 1; + } + + .buzz-terminal-input { + height: 1px; + left: -10000px; + opacity: 0; + position: absolute; + top: 0; + width: 1px; + } +} + +@layer components { + .buzz-terminal-dock-host:has(.buzz-terminal-substrate) { + align-items: flex-end; + display: flex; + flex: 0 0 auto; + min-height: 0; + overflow: hidden; + transition: + flex-grow 180ms ease, + flex-basis 180ms ease; + } + + .buzz-content-primary { + transition: + flex-grow 180ms ease, + flex-basis 180ms ease; + } + + .buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) { + flex: 1 1 auto; + } + + .buzz-content-primary:has( + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] + ) { + flex: 0 1 0%; + min-height: 0; + } + + .buzz-terminal-substrate { + border-top: 1px solid hsl(var(--border)); + inset: auto; + min-height: 180px; + opacity: 1; + position: relative; + transform: translateY(0); + transition: + height 180ms ease, + transform 180ms ease; + width: 100%; + z-index: 20; + } + + .buzz-terminal-substrate[data-terminal-resizing="true"] { + transition: none; + } + + .buzz-terminal-substrate[data-terminal-visible="false"] { + height: 0 !important; + min-height: 0; + pointer-events: none; + transform: translateY(16px); + } + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] { + flex: 1 1 auto; + height: 100%; + } + + .buzz-terminal-resize-handle { + cursor: ns-resize; + height: 5px; + left: 0; + position: absolute; + right: 0; + top: -3px; + z-index: 2; + } + + .buzz-terminal-window-action { + align-items: center; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); + display: inline-flex; + height: 30px; + justify-content: center; + width: 30px; + } + + .buzz-terminal-window-action:hover, + .buzz-terminal-window-action:focus-visible { + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); + } + + .buzz-terminal-window-action svg { + height: 16px; + width: 16px; + } + + @media (prefers-reduced-motion: reduce) { + .buzz-content-primary, + .buzz-terminal-dock-host:has(.buzz-terminal-substrate), + .buzz-terminal-substrate { + transition: none; + } + } +} diff --git a/desktop/tests/e2e/terminal-wheel.spec.ts b/desktop/tests/e2e/terminal-wheel.spec.ts index d90fb9da8..c6c6fc5c7 100644 --- a/desktop/tests/e2e/terminal-wheel.spec.ts +++ b/desktop/tests/e2e/terminal-wheel.spec.ts @@ -156,16 +156,17 @@ async function reveal(page: Page) { "data-terminal-owner", "terminal", ); - // data-terminal-owner flips synchronously at chord-up; the 360ms reveal fade - // is still running. Every capture below must wait for it to settle or it - // photographs a half-faded app surface and reads as a rendering defect. + // The dock opens in the normal app surface now; unlike the removed + // full-screen takeover, it must not fade that surface away. Wait for the + // dock's own height transition before interacting with its viewport. + await expect(page.locator(TERM)).toHaveAttribute( + "data-terminal-mode", + "docked", + ); + await expect(page.locator(TERM)).toBeVisible(); await expect - .poll(async () => - page - .locator(".buzz-huddle-app-surface") - .evaluate((el) => getComputedStyle(el).opacity), - ) - .toBe("0"); + .poll(async () => page.locator(TERM).evaluate((el) => el.clientHeight)) + .toBeGreaterThanOrEqual(180); } test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({ @@ -217,16 +218,16 @@ test("concealed terminal viewport does not steal Buzz focus", async ({ }) => { await reveal(page); await page.keyboard.press("Meta+j"); - await expect(page.locator(TERM)).toHaveAttribute( - "data-terminal-owner", - "buzz", - ); + await expect(page.locator(TERM)).toHaveCount(0); const input = page.getByLabel("Terminal input"); - await expect(input).not.toBeFocused(); - await page.locator(".buzz-terminal-viewport").click({ - force: true, - position: { x: 40, y: 40 }, - }); - await expect(input).not.toBeFocused(); + await expect(input).toHaveCount(0); + await page.getByTestId("chat-title").click(); + await page.keyboard.type("BUZZ_KEYSTROKE"); + const terminalInputs = await page.evaluate( + () => + (window as typeof window & { __SAMI_TERM__: { inputs: string[] } }) + .__SAMI_TERM__.inputs, + ); + expect(terminalInputs.join("")).not.toContain("BUZZ_KEYSTROKE"); }); From e5efd047050f5e2a64fe6cd9e3faed1685b03f5c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 12:33:42 -0600 Subject: [PATCH 16/30] fix(desktop): close reconnect gaps that previously required CMD+R (#4737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Opened by Brain (agent) on behalf of @wesbillman. ## Problem Users report the desktop app doesn't reliably reconnect and can wedge in states where only CMD+R (or a full restart) restores connectivity (thread `c2205e2b` in #desktop-reconnecting). Pinky's empirical light-switch matrix (real `buzz-relay`, SIGTERM/1012 + SIGKILL × 1s/45s/3min, at `f18a9cb10`) passed 4/4 — the backoff state machine recovers cleanly from ordinary relay loss. That isolates the user-stuck states to four special cases a reload resets but the auto flow never did. ## Fixes | Gap | Change | |---|---| | **G1** — recovery rode solely on the backoff timer (max 30s), throttled by WKWebView in occluded/background windows; nothing fired on network return or wake | New `useRelayResumeTriggers`: `online`, window focus, and visibility→visible call `preconnect()` when the session is `reconnecting`/`stalled`, rate-limited to one attempt per 5s (`relayResumeTriggerPolicy.ts`). Deliberately inert for the terminal `disconnected` state. | | **G2** — any AUTH `OK false` latched the session terminal forever, though the relay also rejects for transient causes (duplicate-AUTH "already authenticated" race, ±60s clock skew, fail-closed allowlist DB errors) | New `AuthOkTracker` (`relayAuthPolicy.ts`): "already authenticated" resolves as success; transient rejections retry with normal backoff; latch only on `restricted:` or after 3 consecutive rejections. | | **G3** — an `auth-required:` CLOSED (REQ racing AUTH after reconnect) permanently deleted the live subscription with no UI signal — frozen channel while state reads "connected" | Reclassified `auth-required:` as retryable in `relayClosedPolicy.ts`. Genuinely terminal classes (`restricted:`, `invalid:`, …) still delete. Can't loop: a truly unauthenticated session latches terminal at the connection level. | | **G4** — `useRelayAutoHeal` observed the 2s-debounced connection hook, so sub-2s flaps never triggered the heal even though `resetConnection` had already rejected every in-flight query | Auto-heal now observes the raw connection-state emitter. The existing 15s heal rate-limit still guards against flap storms. | Each fix is a colocated pure-policy module + unit tests, matching the existing `relayReconnectPolicy`/`relayClosedPolicy` pattern. ## Validation - Full desktop unit suite: **4151 pass, 0 fail** (at branch tip, `pnpm -C desktop test`) - `pnpm -C desktop typecheck` and `pnpm -C desktop check` clean (file-size ratchet respected — `relayClientSession.ts` net −2 lines despite the tracker wiring) - Evidence trail: `RESEARCH/DESKTOP_RECONNECT_CMDR_GAP_AUDIT.md` (audit), `RESEARCH/DESKTOP_RECONNECT_LIGHT_SWITCH_RESULTS.md` (Pinky's matrix) ## Not covered / follow-ups - Native macOS sleep-wake was not automated (would kill the harness session); G1's focus trigger is the mechanism that covers wake in practice, but a manual sleep-wake verification on a real build is worthwhile. - G3 terminal-CLOSED classes (`restricted:` etc.) still silently delete subs with no UI signal — surfacing that is a separate UX decision. - Stall-watchdog latency (60s idle + 10s check) left unchanged; G1 triggers largely mask it. --------- Signed-off-by: Wes Signed-off-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Co-authored-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Co-authored-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> --- .../src/app/useAppShellLifecycleEffects.ts | 5 + .../src/shared/api/relayAuthPolicy.test.mjs | 93 ++++++++ desktop/src/shared/api/relayAuthPolicy.ts | 65 ++++++ desktop/src/shared/api/relayClientSession.ts | 104 ++++----- .../src/shared/api/relayClosedPolicy.test.mjs | 17 +- desktop/src/shared/api/relayClosedPolicy.ts | 6 +- .../src/shared/api/relayReconnectPolicy.ts | 10 + .../api/relayResumeTriggerPolicy.test.mjs | 70 ++++++ .../shared/api/relayResumeTriggerPolicy.ts | 39 ++++ desktop/src/shared/api/useRelayAutoHeal.ts | 30 ++- .../src/shared/api/useRelayResumeTriggers.ts | 60 +++++ desktop/src/testing/e2eBridge.ts | 33 ++- desktop/tests/e2e/relay-reconnect.spec.ts | 217 +++++++++++++++++- 13 files changed, 680 insertions(+), 69 deletions(-) create mode 100644 desktop/src/shared/api/relayAuthPolicy.test.mjs create mode 100644 desktop/src/shared/api/relayAuthPolicy.ts create mode 100644 desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs create mode 100644 desktop/src/shared/api/relayResumeTriggerPolicy.ts create mode 100644 desktop/src/shared/api/useRelayResumeTriggers.ts diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 02c97bac5..2d56d24cb 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; +import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { desktopBadgeEnabled: boolean; @@ -16,6 +17,10 @@ export function useAppShellLifecycleEffects({ unreadChannelIds, unreadChannelNotificationCount, }: AppShellLifecycleEffectsOptions) { + // Event-driven reconnect: network online / focus / visibility short-circuit + // the backoff timer when the relay session is degraded (CMD+R gap G1). + useRelayResumeTriggers(); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/shared/api/relayAuthPolicy.test.mjs b/desktop/src/shared/api/relayAuthPolicy.test.mjs new file mode 100644 index 000000000..e2b709c0e --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AuthOkTracker, + MAX_CONSECUTIVE_AUTH_REJECTIONS, +} from "./relayAuthPolicy.ts"; + +test("success resolves authenticated and resets the streak", () => { + const tracker = new AuthOkTracker(); + tracker.record(false, "auth-required: verification failed"); + tracker.record(false, "auth-required: verification failed"); + assert.equal(tracker.record(true, ""), "authenticated"); + // Streak reset: the next rejection starts a fresh count. + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); + +test("restricted rejections latch terminal immediately", () => { + for (const message of [ + "restricted: not a relay member", + "restricted: you are banned", + ]) { + assert.equal(new AuthOkTracker().record(false, message), "terminal"); + } +}); + +test("the relay's actual ban string latches terminal immediately", () => { + // Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the + // ban seam. A known-permanent ban must never enter the retry loop. + assert.equal( + new AuthOkTracker().record( + false, + "blocked: you are banned from this community", + ), + "terminal", + ); +}); + +test("verification failures retry with backoff (clock skew, DB fail-closed)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("unknown rejection reasons retry rather than latch", () => { + assert.equal(new AuthOkTracker().record(false, ""), "retry"); + assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry"); +}); + +test("consecutive rejections latch terminal at the cap", () => { + const tracker = new AuthOkTracker(); + let decision = "retry"; + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + decision = tracker.record(false, "auth-required: verification failed"); + } + assert.equal(decision, "terminal"); +}); + +test("reset grants a fresh retry streak after explicit re-engagement", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + tracker.record(false, "auth-required: verification failed"); + } + tracker.reset(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated wins even past the cap (session is usable)", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) { + tracker.record(false, "auth-required: verification failed"); + } + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts new file mode 100644 index 000000000..2d76e0c3f --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.ts @@ -0,0 +1,65 @@ +/** + * Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit). + * + * Historically ANY auth `OK false` latched the session terminal — no + * reconnect until explicit user re-engagement. But the relay sends + * `OK false` for conditions that are transient from the client's side: + * + * - `auth-required: already authenticated` — a duplicate/late AUTH event on + * a connection that is in fact authenticated. The session is usable; + * treat it as authenticated. + * - `auth-required: verification failed` — covers ±60s clock-skew rejects + * and the relay's fail-closed allowlist DB lookup errors, both of which + * can clear on retry. + * + * Only `restricted:` and `blocked:` rejections (not a relay member / banned) + * are known permanent. Everything else retries with normal backoff, but + * latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive + * rejections so a genuinely broken identity (e.g. persistently wrong system + * clock) still surfaces the terminal error card instead of flapping forever. + * + * The rejection streak is preserved across environment-driven resume + * attempts (focus/online/visibility); only explicit user re-engagement — + * the reconnect card or a community switch — may reset it. + */ +export type AuthOkDecision = "authenticated" | "retry" | "terminal"; + +export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3; + +/** Tracks consecutive AUTH rejections across reconnect attempts. */ +export class AuthOkTracker { + private consecutiveRejections = 0; + + /** + * Record an AUTH `OK` and decide the session's next move. + * A success — real or "already authenticated" — resets the streak. + */ + record(success: boolean, message: string): AuthOkDecision { + const normalized = message.trim().toLowerCase(); + if ( + success || + normalized.startsWith("auth-required: already authenticated") + ) { + this.consecutiveRejections = 0; + return "authenticated"; + } + + this.consecutiveRejections++; + + if ( + normalized.startsWith("restricted:") || + normalized.startsWith("blocked:") + ) { + return "terminal"; + } + if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) { + return "terminal"; + } + return "retry"; + } + + /** Called on explicit re-engagement (disconnect / manual preconnect). */ + reset(): void { + this.consecutiveRejections = 0; + } +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 53d541ff0..fd6758f79 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm import { isServiceRestartClose, isWebSocketClose, + isWebSocketError, shouldRefuseConnect, shouldScheduleReconnect, shouldWaitForScheduledReconnect, @@ -65,6 +66,7 @@ import { STALL_IDLE_TIMEOUT_MS, } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; +import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; export class RelayClient { @@ -92,6 +94,7 @@ export class RelayClient { private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; + private authOkTracker = new AuthOkTracker(); private terminal = false; @@ -128,6 +131,7 @@ export class RelayClient { this.notifyReconnectListeners = false; this.terminal = false; this.visibleChannelId = null; + this.authOkTracker.reset(); this.connectionStateEmitter.set("idle"); if (this.wsId !== null) { @@ -295,7 +299,7 @@ export class RelayClient { parentEventId?: string | null, rootEventId?: string | null, ) { - // Bail when disconnected — not worth triggering a reconnect for ephemeral typing events. + // Disconnected: not worth triggering a reconnect for ephemeral typing. if (this.wsId === null) { return; } @@ -325,11 +329,11 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { + // 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS' + // other consumers (unread tracking, cache merges) must never see + // summary overlays. return this.subscribe( { - // 39005 rides only this window-store subscription — not - // CHANNEL_EVENT_KINDS, whose other consumers (unread tracking, - // timeline-cache merges) must never see summary overlays. kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY], "#h": [channelId], limit: 1000, @@ -340,10 +344,9 @@ export class RelayClient { } /** - * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel. - * Used by HuddleIndicator to detect active huddles without being drowned - * out by regular channel messages in the generic subscription window. - * Includes both historical (last 10) and live events. + * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel, + * so HuddleIndicator detects active huddles without being drowned out by + * regular channel messages. Includes the last 10 historical events. */ async subscribeToHuddleEvents( channelId: string, @@ -425,12 +428,26 @@ export class RelayClient { } async preconnect() { - // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. A manual - // reconnect also bypasses the current delay once; ordinary operations do - // not, so background traffic cannot continuously defeat backoff. + // Explicit re-engagement (reconnect card / community switch): clears the + // terminal latch and AUTH rejection streak, and bypasses backoff once. this.terminal = false; + this.authOkTracker.reset(); this.keepAliveRequested = true; + await this.connectBypassingBackoff(); + } + + /** + * Environment-driven resume (online/focus/visibility): bypasses a pending + * backoff timer but preserves the terminal latch and AUTH rejection streak + * — only `preconnect()` clears those, so resume events during repeated + * AUTH rejection cannot defeat the consecutive-rejection cap. + */ + async resumeReconnect() { + if (this.terminal) return; + await this.connectBypassingBackoff(); + } + + private async connectBypassingBackoff() { if (this.reconnectTimeout !== null) { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -448,7 +465,6 @@ export class RelayClient { subscribeToReconnects(listener: () => void) { this.reconnectListeners.add(listener); - return () => { this.reconnectListeners.delete(listener); }; @@ -460,8 +476,8 @@ export class RelayClient { } /** - * Subscribe to connection-state transitions. The listener is invoked - * immediately with the current state so callers don't need a separate + * Subscribe to connection-state transitions. The listener fires + * immediately with the current state, so callers need no separate * `getConnectionState()` call to seed their UI. */ subscribeToConnectionState(listener: (state: ConnectionState) => void) { @@ -470,11 +486,10 @@ export class RelayClient { private async ensureConnected() { if (shouldRefuseConnect({ terminal: this.terminal })) { - // Session is terminal (e.g. relay rejected auth). Refuse to connect - // until an explicit re-engagement (disconnect()/preconnect()) clears - // the flag. Without this, the reconnect timer's catch handler — and - // the retry wrappers in publishEvent / sendRawWithReconnectRetry — - // would race the terminal "disconnected" state back to "reconnecting". + // Terminal (e.g. relay rejected auth): refuse until disconnect() or + // preconnect() clears the latch, else the reconnect-timer catch and + // the publish/subscribe retry wrappers would race the terminal + // "disconnected" state back to "reconnecting". throw new Error("Relay session is terminal; cannot reconnect."); } @@ -494,7 +509,7 @@ export class RelayClient { // The reconnect coordinator owns outage pacing. Query, publish, and // subscription callers must wait for its scheduled attempt instead of // clearing the timer and creating an immediate reconnect storm. - return this.waitForScheduledReconnect(); + return this.reconnectWaiters.wait(); } const connectPromise = this.connect(); @@ -670,7 +685,6 @@ export class RelayClient { error, fallbackMessage, ); - try { await this.ensureConnected(); await this.sendRaw(payload); @@ -749,12 +763,7 @@ export class RelayClient { this.resetConnection(new Error("Relay connection closed.")); return; } - if ( - typeof message === "object" && - message !== null && - "type" in message && - message.type === "Error" - ) { + if (isWebSocketError(message)) { this.resetConnection(new Error("Relay connection errored.")); return; } @@ -819,8 +828,7 @@ export class RelayClient { if (type === "NOTICE" && typeof rest[0] === "string") { const notice: string = rest[0]; - // Relay back-pressure signal — activate the gate so pending operations - // back off until the window expires. + // Relay back-pressure — arm the gate until the window expires. if (notice.startsWith("rate-limited:")) { activateRateLimit(parseRateLimitHint(notice)); } @@ -892,12 +900,14 @@ export class RelayClient { const authRequest = this.authRequest; this.authRequest = null; - if (success) { + // Decision table lives in relayAuthPolicy.ts. + const decision = this.authOkTracker.record(success, message); + if (decision === "authenticated") { authRequest.resolve(); } else { const error = new Error(message || "Relay authentication rejected."); authRequest.reject(error); - this.resetConnection(error, { reconnect: false }); + this.resetConnection(error, { reconnect: decision === "retry" }); } return; @@ -919,13 +929,7 @@ export class RelayClient { } private hasLiveSubscriptions() { - for (const subscription of this.subscriptions.values()) { - if (subscription.mode === "live") { - return true; - } - } - - return false; + return [...this.subscriptions.values()].some((s) => s.mode === "live"); } private async replayLiveSubscriptions() { @@ -948,13 +952,6 @@ export class RelayClient { } } - private waitForScheduledReconnect(): Promise { - if (this.reconnectTimeout === null) { - return this.ensureConnected(); - } - return this.reconnectWaiters.wait(); - } - private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -968,9 +965,8 @@ export class RelayClient { return; } - // Apply ±25% jitter so a fleet of clients reconnecting simultaneously - // spreads their AUTH storms across a 50% window instead of all hitting - // the relay at the same instant. + // ±25% jitter spreads a fleet's AUTH storms across a 50% window instead + // of hitting the relay at the same instant. const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5); const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS); this.reconnectDelayMs = Math.min( @@ -1031,9 +1027,13 @@ export class RelayClient { if (options?.reconnect === false) { this.terminal = true; this.connectionStateEmitter.set("disconnected"); - } else if (this.connectionStateEmitter.get() !== "stalled") { - // Stall is a stronger signal than a generic drop; keep it until the - // reconnect timer transitions us back to "reconnecting" in connect(). + } else if ( + // A late retry failure racing a terminal latch must not paint + // "reconnecting" over the terminal "disconnected" state; stall is a + // stronger signal than a generic drop and is kept until reconnect. + !this.terminal && + this.connectionStateEmitter.get() !== "stalled" + ) { this.connectionStateEmitter.set("reconnecting"); } diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs index 715df77f6..2e5cad4b7 100644 --- a/desktop/src/shared/api/relayClosedPolicy.test.mjs +++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs @@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => { for (const message of [ "restricted: not a channel member", "restricted: channel access revoked", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", @@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => { } }); +test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => { + // A REQ that lands before the AUTH handshake completes gets CLOSED with + // auth-required. Deleting the subscription would silently freeze the + // channel while the connection state still reads "connected" — the sub + // must survive and be retried after backoff. + assert.equal( + classifyRelayClosed("auth-required: not authenticated"), + "retryable", + ); + assert.equal( + classifyRelayClosed("auth-required: authenticate before subscribing"), + "retryable", + ); +}); + // ── Subscription-survival semantics ────────────────────────────────────────── // These replace the removed isRetryableRelayClosed wrapper tests. // rate-limited must not delete the subscription; terminal must. @@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => { for (const message of [ "restricted: not a channel member", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts index 8a39e8a4d..9e747a2df 100644 --- a/desktop/src/shared/api/relayClosedPolicy.ts +++ b/desktop/src/shared/api/relayClosedPolicy.ts @@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass { if (normalized.startsWith("rate-limited:")) { return "rate-limited"; } + // `auth-required:` is deliberately retryable, NOT terminal: it occurs + // transiently when a REQ races the AUTH handshake after a reconnect. The + // backoff retry re-sends the REQ once the session is authenticated. A + // session that is genuinely unauthenticated latches `terminal` at the + // connection level (AUTH OK=false), so this cannot loop forever. if ( normalized.startsWith("restricted:") || - normalized.startsWith("auth-required:") || normalized.startsWith("blocked:") || normalized.startsWith("invalid:") || normalized.startsWith("pow:") || diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index 00d8e412b..780ad3516 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean { data.code === 1012 ); } + +/** Whether a WS-layer message is the plugin's `Error` frame. */ +export function isWebSocketError(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Error" + ); +} diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs new file mode 100644 index 000000000..0430beb0b --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RESUME_TRIGGER_MIN_INTERVAL_MS, + shouldTriggerResumeReconnect, +} from "./relayResumeTriggerPolicy.ts"; + +const base = Object.freeze({ + connectionState: "reconnecting", + lastAttemptAt: 0, + now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1, +}); + +test("reconnecting + interval elapsed triggers", () => { + assert.equal(shouldTriggerResumeReconnect({ ...base }), true); +}); + +test("stalled + interval elapsed triggers", () => { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }), + true, + ); +}); + +test("healthy and terminal states never trigger", () => { + for (const connectionState of [ + "idle", + "connecting", + "connected", + "disconnected", + ]) { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState }), + false, + connectionState, + ); + } +}); + +test("burst of triggers within the rate window fires once", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1, + }), + false, + ); + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS, + }), + true, + ); +}); + +test("custom interval override is honoured", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 0, + now: 10, + minIntervalMs: 5, + }), + true, + ); +}); diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.ts b/desktop/src/shared/api/relayResumeTriggerPolicy.ts new file mode 100644 index 000000000..bb2ec8048 --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.ts @@ -0,0 +1,39 @@ +/** + * Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit). + * + * The exponential-backoff timer is the only thing driving recovery after an + * outage — and WKWebView throttles JS timers in occluded/background windows, + * so at max backoff (30s) a scheduled attempt may not fire until the user + * focuses the window. These triggers short-circuit the wait the moment the + * environment signals recovery: network `online`, window focus, and + * visibility becoming visible. `preconnect()` already clears the pending + * backoff timer, so a trigger converts "wait up to 30s (or forever, if + * throttled)" into "reconnect now". + */ +import type { ConnectionState } from "@/shared/api/relayClientShared"; + +/** Min ms between trigger-driven preconnect attempts. */ +export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000; + +export function shouldTriggerResumeReconnect(inputs: { + connectionState: ConnectionState; + lastAttemptAt: number; + now: number; + minIntervalMs?: number; +}): boolean { + const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS; + + // Only degraded-but-recoverable states. `disconnected` is the terminal + // latch — explicit user re-engagement owns that path, and `idle` / + // `connecting` / `connected` need no help. + if ( + inputs.connectionState !== "reconnecting" && + inputs.connectionState !== "stalled" + ) { + return false; + } + + // Rate-limit: focus/online events arrive in bursts (e.g. wake fires all + // three); one attempt per window is enough. + return inputs.now - inputs.lastAttemptAt >= minInterval; +} diff --git a/desktop/src/shared/api/useRelayAutoHeal.ts b/desktop/src/shared/api/useRelayAutoHeal.ts index 11909228b..429dcc502 100644 --- a/desktop/src/shared/api/useRelayAutoHeal.ts +++ b/desktop/src/shared/api/useRelayAutoHeal.ts @@ -2,12 +2,12 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { ConnectionState } from "@/shared/api/relayClientShared"; -import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; +import { relayClient } from "@/shared/api/relayClient"; import { isRelayConnectionDegraded, - useRelayConnection, -} from "@/shared/api/useRelayConnection"; + type ConnectionState, +} from "@/shared/api/relayClientShared"; +import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { isRateLimited, waitForRateLimit, @@ -100,8 +100,6 @@ export class RelayAutoHealScheduler { */ export function useRelayAutoHeal(): void { const queryClient = useQueryClient(); - const connectionState = useRelayConnection(); - const prevConnectionStateRef = React.useRef(connectionState); const schedulerRef = React.useRef(null); if (schedulerRef.current === null) { @@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void { } React.useEffect(() => { + // Observe the RAW connection-state emitter, not the 2s-debounced + // useRelayConnection() hook. A sub-2s flap never surfaces through the + // debounced hook (its degraded report is cancelled by the recovery), yet + // resetConnection() already rejected every in-flight query — the heal + // must still fire or errored panes persist until a manual reconnect. + let prev: ConnectionState | null = null; + const unsubscribe = relayClient.subscribeToConnectionState((next) => { + if (prev !== null) { + schedulerRef.current?.onTransition(prev, next); + } + prev = next; + }); + return () => { + unsubscribe(); schedulerRef.current?.dispose(); }; }, []); - - React.useEffect(() => { - const prev = prevConnectionStateRef.current; - prevConnectionStateRef.current = connectionState; - schedulerRef.current?.onTransition(prev, connectionState); - }, [connectionState]); } diff --git a/desktop/src/shared/api/useRelayResumeTriggers.ts b/desktop/src/shared/api/useRelayResumeTriggers.ts new file mode 100644 index 000000000..abb2e5d49 --- /dev/null +++ b/desktop/src/shared/api/useRelayResumeTriggers.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy"; + +/** + * Event-driven reconnect triggers: network `online`, window `focus`, and + * visibility→visible each attempt an immediate `resumeReconnect()` when the + * relay session is degraded (reconnecting/stalled), rate-limited by + * `RESUME_TRIGGER_MIN_INTERVAL_MS`. + * + * Rationale (CMD+R gap audit G1): without these, recovery rides solely on + * the backoff timer, which WKWebView throttles while the window is occluded + * or the system was asleep. The moment a user focuses the window to hit + * CMD+R *is* a focus event — this fires the reconnect first. + * + * Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the + * pending backoff timer but must preserve the terminal latch and the AUTH + * rejection streak. Otherwise a focus/online event arriving during repeated + * AUTH rejection would reset the consecutive-rejection cap and the session + * could retry indefinitely instead of surfacing `disconnected`. The + * terminal state stays user-owned via the reconnect card. + */ +export function useRelayResumeTriggers(): void { + React.useEffect(() => { + let lastAttemptAt = -Infinity; + + const attempt = () => { + const now = Date.now(); + if ( + !shouldTriggerResumeReconnect({ + connectionState: relayClient.getConnectionState(), + lastAttemptAt, + now, + }) + ) { + return; + } + lastAttemptAt = now; + // resumeReconnect() clears any pending backoff timer and connects now, + // preserving terminal/AUTH-streak state. Failures re-arm the normal + // backoff loop; nothing to handle here. + void relayClient.resumeReconnect().catch(() => {}); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") attempt(); + }; + + window.addEventListener("online", attempt); + window.addEventListener("focus", attempt); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + window.removeEventListener("online", attempt); + window.removeEventListener("focus", attempt); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, []); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e430549d4..dc46d50ae 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1186,6 +1186,12 @@ declare global { }; __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: ConnectionState) => void; __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState; + /** Queue deterministic mock AUTH outcomes, consumed in order. */ + __BUZZ_E2E_QUEUE_AUTH_RESPONSES__?: ( + responses: Array<{ success: boolean; message: string }>, + ) => void; + /** Inject CLOSED into every active mock live subscription. */ + __BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__?: (reason: string) => number; __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; @@ -2942,6 +2948,7 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +const mockAuthResponses: Array<{ success: boolean; message: string }> = []; let mockWebsocketUnavailable = false; const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; @@ -9443,7 +9450,16 @@ function sendToMockSocket(args: { if (type === "AUTH") { const event = rest[0] as RelayEvent; - sendWsText(socket.handler, ["OK", event.id, true, ""]); + const response = mockAuthResponses.shift() ?? { + success: true, + message: "", + }; + sendWsText(socket.handler, [ + "OK", + event.id, + response.success, + response.message, + ]); return; } @@ -9806,6 +9822,7 @@ export function maybeInstallE2eTauriMocks() { mockClosedChannelLiveSubscription = false; mockWebsocketUnavailable = false; + mockAuthResponses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } @@ -10053,6 +10070,20 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ = () => relayClient.getConnectionState(); + window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__ = (responses) => { + mockAuthResponses.push(...responses); + }; + window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__ = (reason) => { + let closed = 0; + for (const socket of mockSockets.values()) { + for (const subId of [...socket.subscriptions.keys()]) { + sendWsText(socket.handler, ["CLOSED", subId, reason]); + socket.subscriptions.delete(subId); + closed += 1; + } + } + return closed; + }; window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => { mockReminderEvents.length = 0; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6606d5f04..ec3e87171 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -111,9 +111,32 @@ async function emitMockMessages( }, messages); } +async function queueAuthResponses( + page: import("@playwright/test").Page, + responses: Array<{ success: boolean; message: string }>, +) { + await page.evaluate((queued) => { + const queue = window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__; + if (!queue) throw new Error("E2E AUTH response seam is not installed."); + queue(queued); + }, responses); +} + +async function closeLiveSubscriptions( + page: import("@playwright/test").Page, + reason: string, +) { + const closed = await page.evaluate((message) => { + const close = window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__; + if (!close) throw new Error("E2E live CLOSED seam is not installed."); + return close(message); + }, reason); + expect(closed).toBeGreaterThan(0); +} + async function driveConnectionDegraded( page: import("@playwright/test").Page, - state: "reconnecting" | "stalled" | "disconnected", + state: "connected" | "reconnecting" | "stalled" | "disconnected", ) { await page.evaluate((s) => { const setter = ( @@ -322,6 +345,198 @@ test("profile popover does not show relay reconnect controls", async ({ await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0); }); +test("resume event short-circuits accumulated reconnect backoff", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + await expect + .poll(() => getMockWebsocketConnectAttempts(page), { timeout: 10_000 }) + .toHaveLength(3); + + await setMockWebsocketUnavailable(page, false); + const resumedAt = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { + timeout: 2_000, + }, + ) + .toBe("connected"); + expect(Date.now() - resumedAt).toBeLessThan(2_000); +}); + +test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Three consecutive rejections must latch terminal even when resume + // events interleave with the handshakes: resume attempts bypass backoff + // but must preserve the AUTH rejection streak (review finding on + // PR #4737 — preconnect()'s streak reset previously made the cap + // unreachable under focus/online bursts). + // + // Queue MORE rejections than the cap: superseded connection attempts + // (resume racing the backoff timer) consume queue entries on handshakes + // whose frames the client drops as stale, exactly like a real relay that + // rejects every attempt. A queue of exactly 3 can be silently eaten and + // a default-success AUTH would then reset the streak. + await queueAuthResponses( + page, + Array.from({ length: 12 }, () => ({ + success: false, + message: "auth-required: verification failed", + })), + ); + await disconnectMockWebsockets(page); + + // Fire resume events while the rejection sequence plays out. Repeated + // dispatch (post-rate-limit spacing is irrelevant here: each poll tick + // dispatches both events) guarantees at least one lands between + // handshakes. + await expect + .poll( + async () => { + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("focus")); + }); + return page.evaluate(() => + window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(), + ); + }, + { intervals: [500], timeout: 20_000 }, + ) + .toBe("disconnected"); + + // Terminal is user-owned: further resume events must not revive the + // session. + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("focus")); + }); + await page.waitForTimeout(1_000); + expect( + await page.evaluate(() => + window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(), + ), + ).toBe("disconnected"); +}); + +test("sub-2s degraded flap invalidates relay queries on recovery", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await page.evaluate(() => { + const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as + | { + invalidateQueries: (...args: unknown[]) => unknown; + __rawHealInvalidations?: number; + } + | undefined; + if (!queryClient) + throw new Error("E2E query client seam is not installed."); + const original = queryClient.invalidateQueries.bind(queryClient); + queryClient.__rawHealInvalidations = 0; + queryClient.invalidateQueries = (...args: unknown[]) => { + queryClient.__rawHealInvalidations = + (queryClient.__rawHealInvalidations ?? 0) + 1; + return original(...args); + }; + }); + + await driveConnectionDegraded(page, "reconnecting"); + await page.waitForTimeout(100); + await driveConnectionDegraded(page, "connected"); + + await expect + .poll(() => + page.evaluate( + () => + ( + window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + __rawHealInvalidations?: number; + } + )?.__rawHealInvalidations ?? 0, + ), + ) + .toBeGreaterThan(0); +}); + +test("transient AUTH rejection reconnects and restores live traffic", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await queueAuthResponses(page, [ + { success: false, message: "auth-required: verification failed" }, + { success: true, message: "" }, + ]); + await disconnectMockWebsockets(page); + + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { + timeout: 10_000, + }, + ) + .toBe("connected"); + + const recovered = `live after transient AUTH rejection ${Date.now()}`; + await emitMockMessages(page, [ + { content: recovered, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await expect(page.getByTestId("message-timeline")).toContainText(recovered); + + const sent = `send after transient AUTH rejection ${Date.now()}`; + await page.getByTestId("message-input").fill(sent); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(sent); +}); + +test("auth-required CLOSED restores the active live subscription", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await closeLiveSubscriptions(page, "auth-required: not authenticated"); + + const recovered = `live after auth-required CLOSED ${Date.now()}`; + await expect + .poll( + async () => { + await emitMockMessages(page, [ + { content: recovered, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + return page + .getByTestId("message-timeline") + .evaluate( + (element, content) => (element.textContent ?? "").includes(content), + recovered, + ); + }, + { intervals: [1_100], timeout: 5_000 }, + ) + .toBe(true); +}); + test("reconnect backfills more missed channel messages than the live subscription limit", async ({ page, }) => { From 7bee84da8267605ada939c4f911d90f1b0ff1a11 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 13:37:20 -0600 Subject: [PATCH 17/30] fix(mobile): stop oversized read-state retry loop (#4595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - stop retrying remote read-state publishes after the local replacement blob exceeds NIP-44's 65,535-byte plaintext limit - preserve every local read marker and leave existing relay state untouched rather than truncating remote state - keep incoming remote read-state available while suppressing further invalid publishes for the manager lifetime ## Why A repaired/reconnecting relay exposed a 1,404-context read-state on iOS. The app repeatedly serialized and attempted to encrypt that structurally oversized blob while reconnect catch-up work was running, saturating Flutter's debug UI isolate and making channel navigation take roughly ten seconds. This is intentionally fail-closed and behavior-preserving: local read behavior continues, but remote publishing pauses until the manager is recreated. No protocol or persisted-data format changes. ## Verification - `flutter test` — 1,093 passed, 1 skipped - `flutter analyze` — no issues - pre-push `mobile-test` and `branch-skew` hooks passed at `0b6423c5d4d583194f0bbe69662912133b9ae1ef` - independent review by Princess Donut: no blocking findings; compatibility-safe and correctly fail-closed Signed-off-by: Wes Co-authored-by: Carl --- .../shared/read_state/read_state_manager.dart | 16 ++++++++ .../read_state/read_state_manager_test.dart | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 65b52c7d3..f8dcb878d 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -423,6 +423,16 @@ class ReadStateManager { _maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt); _persistLocalState(); } catch (error) { + if (_isOversizedReadStateError(error)) { + _remoteUnsupported = true; + _debounceTimer?.cancel(); + _debounceTimer = null; + debugPrint( + '[ReadStateManager] remote read-state sync disabled because the ' + 'local state exceeds the NIP-44 plaintext limit.', + ); + return; + } if (_isPermanentReadStateRemoteError(error)) { _remoteUnsupported = true; _debounceTimer?.cancel(); @@ -526,6 +536,12 @@ class ReadStateManager { bool _isPlausibleCreatedAt(int createdAt) => createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds; + bool _isOversizedReadStateError(Object error) { + final msg = error.toString().toLowerCase(); + return error is ArgumentError && + msg.contains('plaintext must be 1-65535 bytes'); + } + bool _isPermanentReadStateRemoteError(Object error) { // Relay rejections come back as `Exception("")` from the // websocket OK handler. Pattern-match on the message text since we no diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index 9f3301408..f453bf563 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -107,6 +107,41 @@ void main() { }, ); + test('disables remote sync after an oversized local blob', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keychain = nostr.Keys.generate(); + final crypto = ReadStateCrypto.tryCreate( + nsec: keychain.nsec, + pubkey: keychain.public, + )!; + final relay = _FakeSignedEventRelay(); + final manager = ReadStateManager( + pubkey: keychain.public, + prefs: prefs, + crypto: crypto, + relaySession: null, + signedEventRelay: relay, + remoteEnabled: true, + onChanged: () {}, + ); + + for (var index = 0; index < 1400; index++) { + manager.markContextRead( + 'channel-${index.toString().padLeft(4, '0')}-${'x' * 48}', + index + 1, + ); + } + await manager.flush(); + + manager.markContextRead('channel-new', 2000); + await manager.flush(); + + expect(relay.submitCount, 0); + expect(manager.getEffectiveTimestamp('channel-0000-${'x' * 48}'), 1); + expect(manager.getEffectiveTimestamp('channel-new'), 2000); + }); + test('remote read-state rollback is ignored', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -171,6 +206,7 @@ NostrEvent _stubAckEvent() => const NostrEvent( class _FakeSignedEventRelay implements SignedEventRelay { final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>(); + int submitCount = 0; @override String? get pubkey => null; @@ -183,6 +219,7 @@ class _FakeSignedEventRelay implements SignedEventRelay { int? createdAt, void Function(NostrEvent event)? onSigned, }) async { + submitCount++; submitted.complete(_SubmittedEvent(kind: kind, tags: tags)); return _stubAckEvent(); } From 5179726737108a4a91076d262c30a53d4a7237e9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 4 Aug 2026 16:02:34 -0400 Subject: [PATCH 18/30] fix(local-archive): default both archive settings to enabled (#4750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Both local archive settings — "Archive my agents' observer frames" (kind 24200) and "Archive my agents' turn metrics" (kind 44200) — previously defaulted to OFF in OSS builds, controlled by build-time env vars. This had an irreversible cost: observer frames are ephemeral (not stored by the relay), so any missed events are permanently unrecoverable. This PR makes both settings default to enabled for all builds and removes the build-time flag machinery entirely. ## What changed ### Rust - `observer_archive_default_enabled()` — returns `true` unconditionally; removed `option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT")` check and `nest_is_dev()` runtime fallback. - `agent_metric_archive_default_enabled()` — returns `true` unconditionally; removed `option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT")` check and its OSS-build test. - `build.rs` — removed both `rerun-if-env-changed` declarations (`BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT`, `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT`) and the two baked-env emitting blocks. ### Build / CI - `Justfile` — removed `desktop-tauri-test-compiled-flags` recipe (the dual-compile test machinery). - `.github/workflows/ci.yml` — removed the "Desktop Tauri compiled-flag verification" CI step. ### TypeScript - `useObserverArchiveSeed.ts` — removed `observerArchiveDefaultEnabled` dep from `ObserverArchiveSeedDeps` and the `policyOn` gate in `reconcileObserverArchive`; the function now unconditionally calls `mergeSaveSubscriptionKinds`. - `useAgentMetricArchiveSeed.ts` — removed `agentMetricArchiveDefaultEnabled` dep from `AgentMetricArchiveSeedDeps` and the `defaultOn` flag-check path in `maybeSeed`; the `hasExplicitChoice` guard is preserved as the sole gate against re-seeding. - `LocalArchiveSettingsCard.tsx` — removed `policy` prop, `observerPolicy` state, and `observerArchiveDefaultEnabled` fetch from `ObserverArchiveSection`; toggle is now always enabled (just `toggling` disables it); removed the stale "Always on for internal builds" copy branch; removed the `observerPolicy !== false` guard from `handleObserverToggle`. - `tauriArchive.ts` — updated JSDoc on both default-enabled functions to reflect always-true. - `e2eBridge.ts` — changed both mock defaults from `?? false` to `?? true` so E2E tests without an explicit mock override exercise the real default behavior. ### Tests - `useObserverArchiveSeed.test.mjs` — replaced `policyOn` dep with direct merge dep; updated `test_oss_policy_off_no_merge` → `test_reconcile_always_seeds_24200`; all cancellation, identity-switch, and ordering tests adapted. - `useAgentMetricArchiveSeed.test.mjs` — removed `defaultOn` dep and `test_oss_build_does_not_seed`; updated `test_internal_build_unset_seeds_*` → `test_default_enabled_*`; `hasExplicitChoice` guard tests unchanged. ## Preservation of explicit opt-outs Users who have previously toggled the setting off are unaffected: - `useAgentMetricArchiveSeed` skips seeding when `hasExplicitChoice(pubkey)` returns true (localStorage-persisted per identity). - Observer archive reconciliation now unconditionally calls `mergeSaveSubscriptionKinds`, but a user who already deleted the subscription can turn it off via the Settings toggle, which calls `removeSaveSubscriptionKind` — this is the existing explicit opt-out path, and the toggle is now always enabled (not locked by a policy flag). ## Result - No `BUZZ_BUILD_*_ARCHIVE_DEFAULT` / `BUZZ_DESKTOP_BUILD_*_ARCHIVE_DEFAULT` references remain outside CHANGELOG/history. - Desktop node tests: 4168 pass, 0 fail. - `just desktop-tauri-check`: clean. - `just desktop-tauri-test`: all pass. --------- Signed-off-by: Will Pfleger Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- Justfile | 11 +- desktop/src-tauri/build.rs | 17 - .../src/commands/agent_metric_archive.rs | 41 +-- .../src/commands/observer_archive.rs | 60 +--- desktop/src-tauri/src/managed_agents/nest.rs | 25 -- .../src/managed_agents/nest/tests.rs | 36 -- desktop/src/app/AppShell.tsx | 4 +- .../observerArchivePreference.ts | 85 +++++ .../ui/LocalArchiveSettingsCard.tsx | 26 +- .../useAgentMetricArchiveSeed.test.mjs | 76 +--- .../useAgentMetricArchiveSeed.ts | 40 +-- .../useObserverArchiveSeed.test.mjs | 204 +++++++---- .../local-archive/useObserverArchiveSeed.ts | 34 +- desktop/src/shared/api/tauriArchive.ts | 17 +- desktop/src/testing/e2eBridge.ts | 55 +-- .../tests/e2e/observer-archive-policy.spec.ts | 326 ++++-------------- desktop/tests/helpers/bridge.ts | 22 -- 17 files changed, 373 insertions(+), 706 deletions(-) create mode 100644 desktop/src/features/local-archive/observerArchivePreference.ts diff --git a/Justfile b/Justfile index d80341eca..c3d755ffe 100644 --- a/Justfile +++ b/Justfile @@ -213,24 +213,17 @@ desktop-terminal-performance-test: cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture # Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the observer_archive focused test twice with independently supplied +# Runs the auto-connect compiled-flag test twice with independently supplied # expected values; build.rs rerun-if-env-changed triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail cd desktop/src-tauri echo "=== Clean build (no flag) → expect false ===" - env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \ - -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flags set) → expect true ===" - BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture + echo "=== Internal build (flag set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb374771..2b997af89 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,8 +13,6 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); @@ -75,21 +73,6 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } - // Presence-only flag: when set (any non-empty value), observer-feed archive - // defaults to ON for the current identity on first run. OSS builds leave - // this unset → default OFF. No JSON validation needed — the command only - // checks `.is_some()`. - if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); - } - - // Presence-only flag: when set (any non-empty value), agent-turn-metric - // archive defaults to ON for the current identity on first run. OSS builds - // leave this unset → default OFF. - if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); - } - // Presence-only release capability: internal desktop builds opt into // auto-connecting their configured default relay on first run. OSS builds // leave this unset and retain explicit community selection. diff --git a/desktop/src-tauri/src/commands/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs index 43cfc7b08..77de87087 100644 --- a/desktop/src-tauri/src/commands/agent_metric_archive.rs +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -1,35 +1,18 @@ -//! Build-time flag for agent-turn-metric archive default. +//! Agent-turn-metric archive default — always enabled. //! -//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time -//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` -//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 -//! (agent turn metrics) on first run for the current identity. -//! -//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in -//! manually via the Local Archive settings card. +//! `agent_metric_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this once at startup to decide whether to seed the +//! `owner_p` [44200] save subscription for the current identity on first run. +//! The `hasExplicitChoice` guard in the TS seed hook ensures a user who has +//! explicitly opted out remains opted out. -/// Returns `true` when an internal build has agent-turn-metric archive -/// default-on. +/// Returns `true`: agent-turn-metric archive defaults to enabled for all builds. /// -/// The frontend calls this once at startup to decide whether to seed the -/// `owner_p` [44200] save subscription. The result is stable for the lifetime -/// of the binary — it is baked at compile time. +/// The frontend uses this to decide whether to auto-seed an `owner_p` [44200] +/// save subscription on first run. Existing explicit choices (stored in +/// localStorage per identity) are preserved by the seed hook's `hasExplicitChoice` +/// guard — this default only applies to identities that have never made a choice. #[tauri::command] pub fn agent_metric_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { - // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT - // baked in), this must return false. - assert!( - !agent_metric_archive_default_enabled(), - "expected false in OSS/test build" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs index 707e86b63..d8b2832b9 100644 --- a/desktop/src-tauri/src/commands/observer_archive.rs +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -1,54 +1,18 @@ -//! Build-time flag and runtime dev-nest check for observer-feed archive policy. +//! Observer-feed archive default — always enabled. //! -//! `observer_archive_default_enabled()` returns `true` when either: -//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal -//! builds bake in the flag via `build.rs`), **or** -//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the -//! case for all dev builds launched with `just staging` or `just dev`. -//! -//! When `true`, the frontend reconciles the observer archive subscription -//! every startup — unconditionally ensuring kind 24200 exists in the DB -//! regardless of stale localStorage markers. -//! -//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` — -//! no reconciliation; the user manages the subscription via Settings. +//! `observer_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this every startup to decide whether to reconcile the +//! `owner_p` subscription for kind 24200 (observer frames). Kind 24200 events +//! are ephemeral — not stored by the relay — so local archiving is the only +//! way to retain them. -/// Returns `true` when observer-feed archive policy is enforced. +/// Returns `true`: observer-feed archive defaults to enabled for all builds. /// -/// True when the build has the internal baked flag set, or when the running -/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this -/// every startup to decide whether to reconcile the `owner_p` subscription. +/// The frontend reconciles the `owner_p` subscription every startup when this +/// returns `true`. A user who has explicitly disabled the toggle keeps it off +/// because the Settings card's explicit-opt-out path deletes the subscription +/// and the seed hook skips identities that already have an explicit choice. #[tauri::command] pub fn observer_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() - || crate::managed_agents::nest_is_dev() -} - -#[cfg(test)] -mod tests { - use super::*; - - // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock - // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so - // the compiled flag is the sole variable. No runner normalization needed. - // - // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be - // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with - // `--ignored` under both compile states; general `cargo test` skips it. - #[test] - #[ignore] - fn test_observer_archive_default_enabled_matches_expected() { - let result = observer_archive_default_enabled(); - let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect( - "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \ - the dual-compile CI step supplies it; bare `cargo test` is \ - not sufficient to validate compiled-flag behavior", - ); - let expected = expected_str == "true" || expected_str == "1"; - assert_eq!( - result, expected, - "observer_archive_default_enabled() returned {result}, \ - expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})" - ); - } + true } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index c8f008836..a57676f0a 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -106,31 +106,6 @@ pub fn nest_dir() -> Option { } } -/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`). -/// -/// Pure function — no globals — so it can be unit-tested without touching the -/// process-lifetime [`NEST_DIR`] `OnceLock`. -fn path_is_dev_nest(path: &std::path::Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n == NEST_DIR_DEV) - .unwrap_or(false) -} - -/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`). -/// -/// This is `true` for all dev builds — `just staging` and `just dev` — because -/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data -/// directory starts with `"xyz.block.buzz.app.dev"`. -/// -/// Returns `false` when: -/// - The nest is the production nest (`~/.buzz`, signed DMG). -/// - [`init_nest_dir`] has not been called yet (unit tests, home dir -/// unresolvable) — the fallback path is always the prod nest. -pub fn nest_is_dev() -> bool { - nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false) -} - /// Creates the Buzz nest at `~/.buzz` if it doesn't already exist. /// /// Delegates to [`ensure_nest_at`] with the resolved nest directory. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a4..cbef171f6 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -923,39 +923,3 @@ fn refresh_skill_overwrites_on_version_bump() { "SKILL.md must be refreshed on version bump" ); } - -#[test] -fn test_path_is_dev_nest_dev_path_returns_true() { - let path = std::path::Path::new("/Users/someone/.buzz-dev"); - assert!( - path_is_dev_nest(path), - ".buzz-dev path must be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_prod_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz"); - assert!( - !path_is_dev_nest(path), - ".buzz path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_unrelated_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz-staging"); - assert!( - !path_is_dev_nest(path), - "unrelated path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_root_returns_false() { - let path = std::path::Path::new("/"); - assert!( - !path_is_dev_nest(path), - "root path must not be identified as dev nest" - ); -} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 36e6106c1..dc71312bd 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -193,8 +193,8 @@ export function AppShell() { // guard here would drop managed-agent coverage during startup. useAgentObserverIngestion(); // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not - // deferred) and unconditionally repairs the DB subscription on internal - // builds — otherwise frames emitted before the listener opens are lost. + // deferred): seeds kind 24200 for fresh identities, no-ops for explicit + // opt-outs. Frames before the listener opens are permanently lost. const observerReconciled = useObserverArchiveReconciliation( identityQuery.data?.pubkey, ); diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 000000000..53748e7d8 --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,85 @@ +/** + * Persists whether the user has made an explicit choice about the + * observer-frame archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on community switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + * + * Storage-error contract: a single read that throws is treated the same as + * a stored "1" (treat-as-set, fail-closed). This matches the metric-archive + * path: a storage error must never cause the seeding guard to fire or allow + * a stored opt-out to be silently overridden. + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Reads the stored explicit choice for this identity in a single localStorage + * access. + * + * Returns: + * `false` — user explicitly opted out ("0" stored) + * `true` — user explicitly opted in ("1" stored) + * `"unset"` — no choice recorded yet + * + * On storage error, returns `true` (fail-closed: treat as already opted in, + * suppress auto-seeding, and never override a potentially stored opt-out). + */ +export function readExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean | "unset" { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + const raw = window.localStorage.getItem(storageKey(identityPubkey)); + if (raw === null) return "unset"; + return raw !== "0"; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitObserverArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (merge_save_subscription_kinds is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitObserverArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index fbf8c506e..f7dd331c2 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -25,8 +25,8 @@ import { SettingsOptionRow, } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; -import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; +import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; import { buildSubscriptionRequest, @@ -66,18 +66,16 @@ function kindSummary(kinds: number[]): string { type ObserverSectionProps = { enabled: boolean; - policy: boolean | undefined; toggling: boolean; onToggle: (checked: boolean) => void; }; function ObserverArchiveSection({ enabled, - policy, toggling, onToggle, }: ObserverSectionProps) { - const toggleDisabled = toggling || policy === undefined || policy === true; + const toggleDisabled = toggling; return (

@@ -93,9 +91,7 @@ function ObserverArchiveSection({ Archive my agents' observer frames

- {policy === true - ? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.` - : `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`} + {`Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}

(undefined); - - React.useEffect(() => { - observerArchiveDefaultEnabled() - .then((on) => setObserverPolicy(on)) - .catch(() => { - // Fail closed: leave as undefined so toggle stays disabled. - }); - }, []); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -465,7 +450,6 @@ export function LocalArchiveSettingsCard() { const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; - if (!checked && observerPolicy !== false) return; setObserverToggling(true); try { if (checked) { @@ -473,6 +457,7 @@ export function LocalArchiveSettingsCard() { } else { await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME); } + setExplicitObserverArchiveChoice(pubkey, checked); toast.success( checked ? "Observer feed archive enabled." @@ -489,7 +474,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, observerPolicy, reload], + [pubkey, reload], ); const handleMetricToggle = React.useCallback( @@ -539,7 +524,6 @@ export function LocalArchiveSettingsCard() { void handleObserverToggle(checked)} - policy={observerPolicy} toggling={observerToggling} /> diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs index 6a832b4bd..e19b80a7f 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs @@ -1,8 +1,8 @@ /** * Tests for useAgentMetricArchiveSeed seeding logic. * - * Mirrors the pattern in useObserverArchiveSeed.test.mjs — drives the async - * seed logic via the deps-injection interface, no React required. + * Archive defaults to enabled for all builds. The seed fires for any identity + * without an explicit prior choice. */ import assert from "node:assert/strict"; @@ -10,16 +10,11 @@ import test from "node:test"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - defaultOn = false, - hasExplicitChoice = false, - mergeShouldFail = false, -} = {}) { +function makeDeps({ hasExplicitChoice = false, mergeShouldFail = false } = {}) { const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; return { calls, - agentMetricArchiveDefaultEnabled: async () => defaultOn, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.mergeSaveSubscriptionKinds.push({ kind }); @@ -39,15 +34,6 @@ async function runSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - - if (!defaultOn) return; - try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch { @@ -59,8 +45,8 @@ async function runSeed(pubkey, deps) { // ── Tests ──────────────────────────────────────────────────────────────────── -test("test_internal_build_unset_seeds_owner_p_subscription", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -72,8 +58,8 @@ test("test_internal_build_unset_seeds_owner_p_subscription", async () => { assert.equal(call.kind, 44200); }); -test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -86,7 +72,7 @@ test("test_internal_build_unset_persists_explicit_choice_after_seed", async () = }); test("test_explicit_choice_set_does_not_reseed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + const deps = makeDeps({ hasExplicitChoice: true }); await runSeed("pubkey123", deps); assert.equal( @@ -101,25 +87,8 @@ test("test_explicit_choice_set_does_not_reseed", async () => { ); }); -test("test_oss_build_does_not_seed", async () => { - const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); - - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 0, - "should not call mergeSaveSubscriptionKinds in OSS build", - ); - assert.equal( - deps.calls.setExplicitChoice.length, - 0, - "should not persist explicit choice in OSS build", - ); -}); - test("test_merge_failure_does_not_persist_explicit_choice", async () => { const deps = makeDeps({ - defaultOn: true, hasExplicitChoice: false, mergeShouldFail: true, }); @@ -133,7 +102,7 @@ test("test_merge_failure_does_not_persist_explicit_choice", async () => { }); test("test_empty_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("", deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -141,7 +110,7 @@ test("test_empty_pubkey_does_nothing", async () => { }); test("test_undefined_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed(undefined, deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -150,18 +119,9 @@ test("test_undefined_pubkey_does_nothing", async () => { // ── Concurrent-interleave test ─────────────────────────────────────────────── // -// Verifies the scenario Paul identified: on an internal-build first run with -// both flags on and no prior owner_p row, the observer and metric seeds race. -// With the old TS-side list+merge+create pattern the interleave could be: -// -// 1. observer seed: await list() → [] -// 2. metric seed: await list() → [] (row not yet written) -// 3. observer writes [24200] -// 4. metric writes [44200] → clobbers 24200 -// -// The new pattern delegates the merge to Rust under a single SQLite tx. -// Here we model that by tracking a shared "db state" and verifying that -// running both seeds concurrently (Promise.all) leaves both kinds present. +// Verifies the scenario where both observer and metric seeds race on first +// run. With the atomic merge, running both seeds concurrently leaves both +// kinds present. test("test_concurrent_seeds_both_kinds_survive", async () => { // Shared in-memory "db" — the atomic merge impl would serialize via SQLite @@ -169,9 +129,8 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { // the final state. const db = new Set(); // kinds present after all merges - function makeConcurrentDeps(defaultOn = true) { + function makeConcurrentDeps() { return { - agentMetricArchiveDefaultEnabled: async () => defaultOn, // Simulates the atomic merge: each call simply adds its kind to the set, // regardless of what was there before (atomicity guarantee). mergeSaveSubscriptionKinds: async (kind) => { @@ -189,13 +148,6 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { async function runObserverSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - if (!defaultOn) return; try { await deps.mergeSaveSubscriptionKinds(24200); } catch { diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts index 520d0e660..2cadaa260 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts @@ -1,27 +1,23 @@ /** * First-run seeding for agent-turn-metric archive. * - * When an internal build has `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` set and - * the current identity has not yet made an explicit choice, this hook - * auto-creates an `owner_p` save subscription including kind 44200 agent turn - * metrics, scoped to the current identity's pubkey. + * Archive defaults to enabled for all builds. When the current identity has + * not yet made an explicit choice, this hook auto-creates an `owner_p` save + * subscription including kind 44200 agent turn metrics, scoped to the current + * identity's pubkey. * * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently * running observer seed (24200) cannot clobber this kind — the union happens * under a single SQLite transaction regardless of await ordering. * - * OSS builds return `false` from `agent_metric_archive_default_enabled` → - * no-op. After any explicit user action (seeding or opt-out), the localStorage - * flag prevents re-seeding on subsequent starts. + * After any explicit user action (seeding or opt-out), the localStorage flag + * prevents re-seeding on subsequent starts. */ import * as React from "react"; import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; -import { - mergeSaveSubscriptionKinds, - agentMetricArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { hasExplicitAgentMetricArchiveChoice, setExplicitAgentMetricArchiveChoice, @@ -31,14 +27,12 @@ import { * Deps interface for testing. Production callers pass nothing. */ export interface AgentMetricArchiveSeedDeps { - agentMetricArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; hasExplicitChoice: (pubkey: string) => boolean; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: AgentMetricArchiveSeedDeps = { - agentMetricArchiveDefaultEnabled, mergeSaveSubscriptionKinds, hasExplicitChoice: hasExplicitAgentMetricArchiveChoice, setExplicitChoice: setExplicitAgentMetricArchiveChoice, @@ -46,7 +40,7 @@ const defaultDeps: AgentMetricArchiveSeedDeps = { /** * Seed the agent-turn-metric archive subscription for `pubkey` once per - * identity per device on internal builds. + * identity per device. * * @param pubkey - current identity pubkey. When undefined (identity not yet * loaded), the hook waits until it becomes available. @@ -69,23 +63,7 @@ export function useAgentMetricArchiveSeed( // boundary — re-guard here so the call below is type-safe. if (!pubkey) return; - let defaultOn: boolean; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch (err) { - console.warn("[useAgentMetricArchiveSeed] flag check failed:", err); - return; - } - - if (cancelled) return; - - if (!defaultOn) { - // OSS build (flag off): don't persist a choice — leave null so seeding - // can still fire if this identity later runs an internal build. - return; - } - - // Internal build + no prior choice → auto-seed via atomic DB merge. + // Auto-seed via atomic DB merge. try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch (err) { diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index ccd249892..2cf8df00a 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,23 +10,29 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - policyOn = false, - mergeShouldFail = false, - flagShouldFail = false, -} = {}) { +function makeDeps({ mergeShouldFail = false, explicitChoice = "unset" } = {}) { const calls = { merge: [] }; + // Simulates a per-pubkey localStorage map. "unset" means no choice stored. + const choices = new Map(); + if (explicitChoice !== "unset") { + // Pre-populate a choice for any pubkey that asks (single-pubkey tests). + choices.set("__default__", explicitChoice); + } return { calls, - observerArchiveDefaultEnabled: async () => { - if (flagShouldFail) throw new Error("flag check failed"); - return policyOn; - }, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.merge.push({ kind }); }, + readExplicitChoice: (pubkey) => { + if (choices.has(pubkey)) return choices.get(pubkey); + if (choices.has("__default__")) return choices.get("__default__"); + return "unset"; + }, + setExplicitChoice: (pubkey, enabled) => { + choices.set(pubkey, enabled); + }, }; } @@ -35,50 +41,111 @@ function tick() { return new Promise((r) => setTimeout(r, 0)); } -// ── Internal policy build ──────────────────────────────────────────────────── +// ── Reconciliation always seeds 24200 ──────────────────────────────────────── -test("test_internal_policy_seeds_24200", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); +test("test_reconcile_always_seeds_24200", async () => { + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200); }); -// ── OSS build — policy-off is a pure no-op ────────────────────────────────── - -test("test_oss_policy_off_no_merge", async () => { - const deps = makeDeps({ policyOn: false }); - await reconcileObserverArchive(deps); - - assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); -}); - // ── Failure behavior ───────────────────────────────────────────────────────── test("test_merge_failure_rejects", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); - await assert.rejects(() => reconcileObserverArchive(deps), { + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { message: "merge failed", }); }); -test("test_flag_check_failure_rejects", async () => { - const deps = makeDeps({ flagShouldFail: true }); +// ── Explicit opt-out survives restart ──────────────────────────────────────── - await assert.rejects(() => reconcileObserverArchive(deps), { - message: "flag check failed", - }); - assert.equal(deps.calls.merge.length, 0); +test("test_reconcile_explicit_optout_skips_merge", async () => { + // Simulate a user who explicitly opted out (choice stored as false). + const deps = makeDeps({ explicitChoice: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when user has explicitly opted out", + ); +}); + +test("test_reconcile_storage_error_treated_as_fail_closed", async () => { + // Storage errors return `true` (not "unset"), which means reconcile + // treats them as an already-set choice and skips the merge. + // This prevents auto-seeding from silently overriding a stored opt-out + // that we couldn't read due to the error. + const deps = makeDeps(); + // Override readExplicitChoice to simulate a storage error returning `true`. + deps.readExplicitChoice = () => true; + + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when storage error returns fail-closed true", + ); +}); + +test("test_reconcile_explicit_optin_already_seeded_skips_merge", async () => { + // Simulate a user who already has an explicit opt-in recorded (already + // seeded on a prior run). The new tri-state model skips merge for any + // non-"unset" choice — re-merging is idempotent but wasteful. + const deps = makeDeps({ explicitChoice: true }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when choice is already recorded as opted-in", + ); +}); + +test("test_reconcile_no_prior_choice_seeds_and_records_choice", async () => { + const deps = makeDeps(); // no explicitChoice set + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 1, "merge must fire on first run"); + // After reconciliation the choice should now be recorded as true. + assert.equal( + deps.readExplicitChoice("pk1"), + true, + "stored choice must be true after seed", + ); +}); + +test("test_reconcile_toggle_off_then_restart_does_not_remerge", async () => { + // This is the exact failure mode Paul described: + // 1. User toggles OFF → setExplicitChoice("pk1", false) + // 2. App restarts → reconcileObserverArchive runs again + // 3. Expected: merge is NOT called (opt-out preserved) + const deps = makeDeps(); + + // Simulate the card's handleObserverToggle(false) path: explicit opt-out stored. + deps.setExplicitChoice("pk1", false); + + // Simulate app restart — reconciliation fires. + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire after explicit opt-out on app restart", + ); }); // ── Startup ordering (real ArchiveSyncManager + real reconciler) ───────────── test("test_archive_sync_blocked_until_reconciliation", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const subscribeCalls = []; @@ -90,8 +157,9 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }; const reconcilerDeps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const manager = new ArchiveSyncManager({ @@ -110,8 +178,8 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { onSubscriptionChange: () => () => {}, }); - // Start reconciliation (pending — flag check not yet resolved). - const reconciling = reconcileObserverArchive(reconcilerDeps); + // Start reconciliation (pending — merge not yet resolved). + const reconciling = reconcileObserverArchive("pk1", reconcilerDeps); // Before reconciliation resolves, manager must not have been started. await tick(); @@ -122,7 +190,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { ); // Resolve reconciliation — now start the manager (simulating the gate). - resolveFlag(true); + resolveMerge(); await reconciling; await manager.start(); @@ -139,7 +207,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }); test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { - const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const reconcilerDeps = makeDeps({ mergeShouldFail: true }); const subscribeCalls = []; const fakeRelay = { @@ -168,7 +236,7 @@ test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { // Reconciliation rejects — gate must remain closed. let rejected = false; try { - await reconcileObserverArchive(reconcilerDeps); + await reconcileObserverArchive("pk1", reconcilerDeps); } catch { rejected = true; } @@ -207,8 +275,8 @@ test("test_identity_change_resets_readiness", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; assert.equal( isReconciledFor(reconciledPubkey, "pkA"), @@ -224,8 +292,8 @@ test("test_identity_change_resets_readiness", async () => { ); // B reconciles successfully. - const depsB = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsB); + const depsB = makeDeps(); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; assert.equal( isReconciledFor(reconciledPubkey, "pkB"), @@ -243,14 +311,14 @@ test("test_identity_change_b_failure_stays_closed", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; // Identity changes to B — B's reconciliation fails. - const depsB = makeDeps({ policyOn: true, mergeShouldFail: true }); + const depsB = makeDeps({ mergeShouldFail: true }); try { - await reconcileObserverArchive(depsB); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; } catch { // B failed — reconciledPubkey stays "pkA" (stale). @@ -272,7 +340,7 @@ test("test_identity_change_b_failure_stays_closed", async () => { // re-running an effect with new deps (identity switch). test("test_startReconciliation_calls_onReady_after_success", async () => { - const deps = makeDeps({ policyOn: true }); + const deps = makeDeps(); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -283,13 +351,14 @@ test("test_startReconciliation_calls_onReady_after_success", async () => { }); test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const deps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const readyCalls = []; @@ -297,9 +366,9 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async readyCalls.push(pubkey), ); - // Unmount (or re-run effect) before the flag check resolves. + // Unmount (or re-run effect) before the merge resolves. cancel(); - resolveFlag(true); + resolveMerge(); await tick(); assert.deepEqual( @@ -310,15 +379,16 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async }); test("test_startReconciliation_identity_switch_stale_completion_suppressed", async () => { - let resolveFlagA; - const flagPromiseA = new Promise((resolve) => { - resolveFlagA = resolve; + let resolveMergeA; + const mergePromiseA = new Promise((resolve) => { + resolveMergeA = resolve; }); const depsA = { - observerArchiveDefaultEnabled: () => flagPromiseA, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromiseA, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; - const depsB = makeDeps({ policyOn: true }); + const depsB = makeDeps(); const readyCalls = []; const onReady = (pubkey) => readyCalls.push(pubkey); @@ -330,8 +400,8 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy cancelA(); startReconciliation("pkB", depsB, onReady); - // A's flag check now resolves late — its stale completion must not fire. - resolveFlagA(true); + // A's merge now resolves late — its stale completion must not fire. + resolveMergeA(); await tick(); assert.deepEqual( @@ -342,7 +412,7 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy }); test("test_startReconciliation_failure_does_not_call_onReady", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -354,8 +424,8 @@ test("test_startReconciliation_failure_does_not_call_onReady", async () => { // ── Metric seed independence ───────────────────────────────────────────────── test("test_metric_seed_remains_independently_deferrable", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200"); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 7fc87f010..6a70ed209 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -1,40 +1,46 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { - mergeSaveSubscriptionKinds, - observerArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; + readExplicitObserverArchiveChoice, + setExplicitObserverArchiveChoice, +} from "./observerArchivePreference"; export interface ObserverArchiveSeedDeps { - observerArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; + readExplicitChoice: (pubkey: string) => boolean | "unset"; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { - observerArchiveDefaultEnabled, mergeSaveSubscriptionKinds, + readExplicitChoice: readExplicitObserverArchiveChoice, + setExplicitChoice: setExplicitObserverArchiveChoice, }; /** * Reconcile observer-feed archive state for the current identity. * - * Internal builds (policy flag ON): unconditionally ensure kind 24200 exists - * in the DB subscription. - * - * OSS builds (policy flag OFF): no-op. The Settings toggle is the only - * mutation path for OSS users. + * Archive defaults to enabled for all builds. Merges kind 24200 into the + * DB subscription via an atomic DB-side merge — UNLESS the user has + * previously made an explicit opt-out choice for this identity, in which + * case we skip the merge to preserve their preference across restarts. * * Rejects on failure — callers must not open archive listeners against * unreconciled state. */ export async function reconcileObserverArchive( + pubkey: string, deps: ObserverArchiveSeedDeps = defaultDeps, ): Promise { - const policyOn = await deps.observerArchiveDefaultEnabled(); - if (!policyOn) return; - + const choice = deps.readExplicitChoice(pubkey); + // Any explicit choice (or a storage error treated as fail-closed) skips the + // merge: opted-out users stay opted out; already-seeded users stay seeded. + if (choice !== "unset") return; + // No prior choice: seed the default-on subscription and record it. await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); + deps.setExplicitChoice(pubkey, true); } /** @@ -73,7 +79,7 @@ export function startReconciliation( ): () => void { let cancelled = false; - reconcileObserverArchive(deps) + reconcileObserverArchive(pubkey, deps) .then(() => { if (!cancelled) onReady(pubkey); }) diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 0e4be6c9a..40f4a2e3a 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -88,23 +88,22 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription { // ── API wrappers ───────────────────────────────────────────────────────────── /** - * Returns `true` when observer-feed archive policy is enforced. + * Returns `true` when observer-feed archive is enabled by default. * - * Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time; - * OSS builds never set it, so this returns `false`. The frontend calls this - * every startup to decide whether to reconcile the `owner_p` subscription. + * Always returns `true` — archive defaults to enabled for all builds. + * The frontend calls this every startup to decide whether to reconcile + * the `owner_p` subscription for kind 24200 (observer frames). */ export async function observerArchiveDefaultEnabled(): Promise { return invokeTauri("observer_archive_default_enabled"); } /** - * Returns `true` when the build has agent-turn-metric archive default-on. + * Returns `true` when agent-turn-metric archive is enabled by default. * - * Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time; - * OSS builds never set it, so this returns `false`. The frontend calls this - * once at startup to decide whether to auto-seed an `owner_p` [44200] - * subscription. + * Always returns `true` — archive defaults to enabled for all builds. + * The frontend calls this once at startup to decide whether to auto-seed + * an `owner_p` [44200] subscription for new identities. */ export async function agentMetricArchiveDefaultEnabled(): Promise { return invokeTauri("agent_metric_archive_default_enabled"); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dc46d50ae..13ba5ed9c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -401,21 +401,6 @@ type E2eConfig = { // Seed rows returned by `list_save_subscriptions`. Each entry uses the same // snake_case wire shape the Rust backend returns so tests can drive the // LocalArchiveSettingsCard without a real SQLite database. - observerArchiveDefaultEnabled?: boolean; - /** - * Delay (ms) applied to `observer_archive_default_enabled` so E2E tests - * can exercise short-lived loading UI. 0/undefined = instant. Prefer the - * explicit defer/release seam for pending-state ordering assertions. - */ - observerArchiveDefaultEnabledDelayMs?: number; - /** Hold the observer policy command until the E2E release seam is called. */ - deferObserverArchiveDefaultEnabled?: boolean; - /** - * When set, `observer_archive_default_enabled` throws with this message - * instead of resolving — drives the fail-closed `.catch()` path in - * `useObserverArchiveReconciliation` / `LocalArchiveSettingsCard`. - */ - observerArchiveDefaultEnabledError?: string; agentMetricArchiveDefaultEnabled?: boolean; saveSubscriptions?: Array<{ scope_type: string; @@ -1298,10 +1283,6 @@ declare global { /** Count of `get_event` invocations for the current defer-target ID since * the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */ __BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number; - /** Release every deferred observer archive policy command. */ - __BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number; - /** Number of observer archive policy commands currently held by the seam. */ - __BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1411,7 +1392,6 @@ type DeferredGetEvent = { run: () => Promise; }; let deferredGetEventQueue: DeferredGetEvent[] = []; -let deferredObserverArchivePolicyQueue: Array<() => void> = []; let deferNextChannelsRead = false; let deferredChannelsReadResolve: (() => void) | null = null; @@ -2963,8 +2943,8 @@ let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = []; // mutated by `create_save_subscription` / `delete_save_subscription` / // `merge_save_subscription_kinds` / `remove_save_subscription_kind` exactly // as the real SQLite-backed Rust commands would (see `archive/store.rs`). -// This lets E2E specs drive the fresh-internal-repair path (start from `[]`, -// reconcile, observe a kind-24200 row appear) and OSS toggle ON/OFF, neither +// This lets E2E specs drive the default-on seeding path (start from `[]`, +// reconcile, observe a kind-24200 row appear) and toggle ON/OFF, neither // of which an immutable seed can represent. type MockSaveSubscriptionRow = { scope_type: string; @@ -9996,14 +9976,6 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0; window.__BUZZ_E2E_DEFER_GET_EVENT__ = null; deferredGetEventQueue = []; - deferredObserverArchivePolicyQueue = []; - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0; - window.__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__ = () => { - const queued = deferredObserverArchivePolicyQueue.splice(0); - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0; - for (const resolve of queued) resolve(); - return queued.length; - }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; @@ -12634,7 +12606,7 @@ export function maybeInstallE2eTauriMocks() { // install); create/merge/delete/remove mutate it with the same // union / delete-row-when-empty semantics as the real Rust commands // (see `archive/store.rs::merge_owner_p_kinds` / `remove_owner_p_kind`) - // so specs can drive fresh-internal-repair and toggle ON/OFF flows. + // so specs can drive default-on seeding and toggle ON/OFF flows. case "list_save_subscriptions": { const win = window as unknown as Record; if (!win.__BUZZ_E2E_IPC_COUNTERS__) { @@ -12692,27 +12664,8 @@ export function maybeInstallE2eTauriMocks() { case "archive_events": // Returns the ArchiveBatchResult shape the UI expects. return { persisted: 0, dropped: 0 }; - case "observer_archive_default_enabled": { - if (activeConfig?.mock?.deferObserverArchiveDefaultEnabled) { - await new Promise((resolve) => { - deferredObserverArchivePolicyQueue.push(resolve); - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = - deferredObserverArchivePolicyQueue.length; - }); - } - const delayMs = - activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs; - if (delayMs && delayMs > 0) { - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - } - const error = activeConfig?.mock?.observerArchiveDefaultEnabledError; - if (error) { - throw new Error(error); - } - return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false; - } case "agent_metric_archive_default_enabled": - return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false; + return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true; case "set_prevent_sleep_active": return null; case "plugin:window|is_fullscreen": diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts index de297df5b..29b6a6b6e 100644 --- a/desktop/tests/e2e/observer-archive-policy.spec.ts +++ b/desktop/tests/e2e/observer-archive-policy.spec.ts @@ -14,32 +14,13 @@ async function openLocalArchiveSettings(page: import("@playwright/test").Page) { } test.describe("observer archive policy — Settings toggle", () => { - test("internal policy: toggle disabled with policy-locked copy", async ({ + test("fresh identity: observer toggle is enabled and checked by default", async ({ page, }) => { + // Archive is default-on for all builds. A fresh identity (no stored opt-out) + // should show the toggle enabled and checked after reconciliation seeds the + // kind-24200 subscription. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - await expect(toggle).toBeDisabled(); - await expect( - card.getByText(/always on for internal builds/i), - ).toBeVisible(); - }); - - test("OSS policy: toggle is functional", async ({ page }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [ { scope_type: "owner_p", @@ -56,114 +37,94 @@ test.describe("observer archive policy — Settings toggle", () => { await expect(toggle).toBeChecked(); }); - test("OSS policy, no subscriptions: toggle enabled and unchecked", async ({ - page, - }) => { - // Resolved-OSS empty-subscription state: no owner_p/24200 row exists, - // so the toggle reads unchecked, and OSS policy (false) keeps it - // enabled — confirming fail-closed doesn't permanently lock OSS users - // out once the policy flag resolves. + test("toggle click OFF disables, then ON re-enables", async ({ page }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - saveSubscriptions: [], + saveSubscriptions: [ + { + scope_type: "owner_p", + scope_value: "deadbeef".repeat(8), + kinds: "[24200]", + }, + ], }); const card = await openLocalArchiveSettings(page); const toggle = card.getByTestId("local-archive-observer-toggle"); await expect(toggle).toBeVisible({ timeout: 5_000 }); - await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); + + // OFF: removes kind 24200. + await toggle.click(); await expect(toggle).not.toBeChecked(); + + // ON again: re-creates the row from empty. + await toggle.click(); + await expect(toggle).toBeChecked(); }); - test("policy pending: toggle disabled, then enabled once resolved", async ({ + test("explicit opt-out persists across reload: toggle stays OFF", async ({ page, }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - observerArchiveDefaultEnabledDelayMs: 500, - saveSubscriptions: [], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - // Fail-closed: disabled while the policy check is still in flight. - await expect(toggle).toBeDisabled(); - await expect(toggle).toBeEnabled({ timeout: 5_000 }); - await expect(toggle).not.toBeChecked(); - }); - - test("policy check fails: toggle stays disabled and issues no mutation", async ({ - page, - }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - observerArchiveDefaultEnabledError: "policy check failed", - saveSubscriptions: [], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - // Rejection leaves `observerPolicy` at its initial `undefined` — the - // fail-closed `.catch()` in LocalArchiveSettingsCard must not flip it - // to a permissive state. Give the rejection time to settle, then - // assert the disabled state holds (not just "hasn't flipped yet"). - await page.waitForTimeout(200); - await expect(toggle).toBeDisabled(); - - const commands = await page.evaluate( - () => - (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) - .__BUZZ_E2E_COMMANDS__ ?? [], + // Simulate a user who previously clicked OFF: the identity-scoped opt-out + // is recorded in localStorage ("0") and the owner_p/24200 subscription row + // is absent. Reconciliation must honour the stored choice and leave the + // toggle unchecked (user can re-enable via the toggle). + const MOCK_PUBKEY = "deadbeef".repeat(8); + await page.addInitScript( + ({ storageKey }) => { + window.localStorage.setItem(storageKey, "0"); + }, + { + storageKey: `buzz:observer-archive-default-seeded:${MOCK_PUBKEY}`, + }, ); - expect( - commands.filter( - (c) => - c === "merge_save_subscription_kinds" || - c === "remove_save_subscription_kind", - ), - ).toEqual([]); - }); - test("OSS policy: toggle click ON merges kind 24200, click OFF removes the row", async ({ - page, - }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [], }); const card = await openLocalArchiveSettings(page); const toggle = card.getByTestId("local-archive-observer-toggle"); await expect(toggle).toBeVisible({ timeout: 5_000 }); + await expect(toggle).toBeEnabled(); await expect(toggle).not.toBeChecked(); + }); - // ON: merges kind 24200 into a fresh owner_p row (the row-creation edge - // of merge_save_subscription_kinds). - await toggle.click(); + test("no subscriptions, no stored choice: defaults ON then OFF removes, ON re-creates", async ({ + page, + }) => { + // A fresh identity with no stored choice and an empty subscription table + // must be seeded to ON by reconciliation. Thereafter the toggle must + // function: OFF removes kind 24200, ON re-creates it. + await installMockBridge(page, { + saveSubscriptions: [], + }); + + const card = await openLocalArchiveSettings(page); + const toggle = card.getByTestId("local-archive-observer-toggle"); + await expect(toggle).toBeVisible({ timeout: 5_000 }); + + // Default-on: reconciliation seeds the row, toggle must be checked. await expect(toggle).toBeChecked(); - // OFF: removes kind 24200. Since it's the row's only kind, the row is - // deleted entirely (remove_save_subscription_kind's row-delete-on-empty - // edge) — re-checking observerEnabled must correctly read "no row" as - // unchecked, not stale/checked. + // OFF: removes kind 24200. await toggle.click(); await expect(toggle).not.toBeChecked(); - // ON again: re-creates the row from empty, proving the delete above was - // a real row removal and not a lingering empty-kinds row. + // ON again: re-creates the row from empty. await toggle.click(); await expect(toggle).toBeChecked(); }); }); test.describe("observer archive policy — reconciliation gate", () => { - test("internal policy: archive sync reaches subscription path after reconciliation", async ({ + test("archive sync reaches subscription path after reconciliation", async ({ page, }) => { + // The reconciliation gate (useObserverArchiveReconciliation) must resolve + // successfully for a fresh identity, allowing useArchiveSync to start the + // ArchiveSyncManager, which calls list_save_subscriptions. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, saveSubscriptions: [ { scope_type: "owner_p", @@ -175,15 +136,12 @@ test.describe("observer archive policy — reconciliation gate", () => { await page.goto("/", { waitUntil: "domcontentloaded" }); - // Wait for the channel list to appear (proves AppShell mounted fully). + // Wait for the channel list (proves AppShell mounted fully). await expect(page.getByTestId("channel-general")).toBeVisible({ timeout: 10_000, }); - // The reconciliation gate (useObserverArchiveReconciliation) must have - // resolved successfully, allowing useArchiveSync to start the - // ArchiveSyncManager, which calls list_save_subscriptions. The IPC - // counter proves the subscription path was reached. + // The IPC counter proves the subscription path was reached. await page.waitForFunction( () => { const counters = (window as Record) @@ -201,8 +159,8 @@ test.describe("observer archive policy — reconciliation gate", () => { }); expect(count).toBeGreaterThan(0); - // Bonus (Thufir pass 2, F4): the reconciliation gate must also result - // in a real `#p` + kind-24200 live REQ filter, not just an IPC call. + // The reconciliation gate must also result in a real `#p` + kind-24200 + // live REQ filter. const hasOwnerKindSubscription = await page.evaluate( (ownerPubkey) => ( @@ -221,170 +179,12 @@ test.describe("observer archive policy — reconciliation gate", () => { expect(hasOwnerKindSubscription).toBe(true); }); - test("policy pending: no subscription list call or live filter until resolved", async ({ + test("fresh install with empty subscriptions: reconciliation seeds kind 24200", async ({ page, }) => { + // A fresh install with no owner_p/24200 row must end up with one after + // startup reconciliation runs — the actual production repair path. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - deferObserverArchiveDefaultEnabled: true, - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForFunction( - () => - ( - window as Window & { - __BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number; - } - ).__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ === 1, - ); - await expect(page.getByTestId("channel-general")).toBeVisible({ - timeout: 10_000, - }); - - // While the policy check is pending, useArchiveSync must not have - // started — no list_save_subscriptions call, no owner/24200 live - // filter. This is the discriminating half pass 2 found missing: the - // prior test only proved "eventually starts", not "doesn't start - // early." - const countWhilePending = await page.evaluate( - () => - ( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0, - ); - expect(countWhilePending).toBe(0); - const hasSubscriptionWhilePending = await page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ); - expect(hasSubscriptionWhilePending).toBe(false); - - const released = await page.evaluate( - () => - ( - window as Window & { - __BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number; - } - ).__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?.() ?? 0, - ); - expect(released).toBe(1); - - // After the policy resolves, both the IPC call and the live filter - // appear. - await page.waitForFunction( - () => - (( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0) > 0, - null, - { timeout: 10_000 }, - ); - await expect - .poll( - () => - page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ), - { timeout: 5_000 }, - ) - .toBe(true); - }); - - test("policy check fails: subscription path never opens", async ({ - page, - }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - observerArchiveDefaultEnabledError: "policy check failed", - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await expect(page.getByTestId("channel-general")).toBeVisible({ - timeout: 10_000, - }); - - // Give the rejected reconciliation time to settle, then assert the - // gate stayed shut: no list_save_subscriptions call, no live filter. - await page.waitForTimeout(500); - const count = await page.evaluate( - () => - ( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0, - ); - expect(count).toBe(0); - const hasSubscription = await page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ); - expect(hasSubscription).toBe(false); - }); - - test("fresh internal install: reconciliation repairs an empty subscription list", async ({ - page, - }) => { - // The actual production repair path Will's bug report was about: a - // fresh internal install with no owner_p/24200 row yet must end up - // with one after startup reconciliation runs — not just "no-op - // because the row was already there" (the prior fixture always - // pre-seeded the row). - await installMockBridge(page, { - observerArchiveDefaultEnabled: true, saveSubscriptions: [], }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d9e2c68e9..5e90e5a7f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -308,28 +308,6 @@ type MockBridgeOptions = { websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; - /** - * Value returned by the `observer_archive_default_enabled` mock command. - * `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS - * build (toggle functional). Drives LocalArchiveSettingsCard policy state. - */ - observerArchiveDefaultEnabled?: boolean; - /** - * Delay (ms) applied to `observer_archive_default_enabled` so specs can - * exercise short-lived loading UI. Prefer the explicit defer/release seam - * when asserting behavior while the policy check is pending. - */ - observerArchiveDefaultEnabledDelayMs?: number; - /** - * Hold `observer_archive_default_enabled` until the test calls - * `__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__`. - */ - deferObserverArchiveDefaultEnabled?: boolean; - /** - * When set, `observer_archive_default_enabled` throws with this message — - * drives the fail-closed path when the policy check itself fails. - */ - observerArchiveDefaultEnabledError?: string; // NIP-IA gate inputs — drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. /** From ce3cf3cd2591f132f286fbc0a42a9e6699d0b08d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 21:07:50 +0100 Subject: [PATCH 19/30] Polish Huddle voice controls (#4694) ## Summary - add a visible Stop control for interrupting agent speech - make push-to-talk available by default while preserving manual mute controls - refine agent management, muted audio states, drawer layering, and return navigation - suppress duplicate notification sounds for Huddle messages ## Why Huddles could trap users behind long agent speech, hide useful agent controls, and leave temporary Huddle state visible after the call. The drawer also regressed when the terminal substrate began painting behind the rounded app surface. ## Validation - `just desktop-ci` - focused Huddle Playwright coverage for the drawer, speech interruption, agent picker, and leave navigation --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/huddle/commands.rs | 132 +++++ desktop/src-tauri/src/huddle/mod.rs | 30 +- desktop/src-tauri/src/huddle/pipeline.rs | 14 +- desktop/src-tauri/src/huddle/state.rs | 17 +- desktop/src-tauri/src/huddle/stt.rs | 62 ++- desktop/src-tauri/src/huddle/tts.rs | 344 ++++++------- .../src/huddle/tts_pipeline_controls.rs | 100 ++++ .../src/huddle/tts_speaker_cancellation.rs | 177 +++++++ .../src/huddle/tts_voice_selection_tests.rs | 12 + .../src/huddle/tts_voice_transition.rs | 437 ++++++++++++++++ desktop/src-tauri/src/lib.rs | 10 +- desktop/src/app/AppHuddleShell.tsx | 9 +- desktop/src/app/AppShell.tsx | 4 +- .../app/useAppShellDesktopNotifications.ts | 15 +- desktop/src/app/useHuddlePresentation.ts | 25 +- desktop/src/features/huddle/HuddleContext.tsx | 75 ++- .../features/huddle/HuddleContext.types.ts | 2 + .../huddle/components/AddAgentDialog.tsx | 197 ++++--- .../huddle/components/AgentVoiceMenu.tsx | 126 +++-- .../features/huddle/components/HuddleBar.tsx | 54 +- .../components/HuddleProfileControl.tsx | 2 - .../huddle/components/HuddleRoomHeader.tsx | 64 +-- .../huddle/components/MicControls.tsx | 66 +-- .../huddle/components/ParticipantList.tsx | 88 +++- .../src/features/huddle/lib/audioWorklet.ts | 58 +-- .../features/huddle/lib/useHuddlePttState.ts | 18 +- desktop/src/features/notifications/hooks.ts | 2 + .../features/notifications/lib/sound.test.mjs | 18 + .../src/features/notifications/lib/sound.ts | 7 + .../use-feed-desktop-notifications.ts | 7 +- .../src/shared/styles/globals/components.css | 31 +- .../src/shared/ui/chooser-dialog-content.tsx | 12 +- desktop/src/testing/e2eBridge.ts | 48 +- .../tests/e2e/huddle-transcription.spec.ts | 479 +++++++++++++++++- 34 files changed, 2196 insertions(+), 546 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/commands.rs create mode 100644 desktop/src-tauri/src/huddle/tts_pipeline_controls.rs create mode 100644 desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs create mode 100644 desktop/src/features/notifications/lib/sound.test.mjs diff --git a/desktop/src-tauri/src/huddle/commands.rs b/desktop/src-tauri/src/huddle/commands.rs new file mode 100644 index 000000000..993d8e54e --- /dev/null +++ b/desktop/src-tauri/src/huddle/commands.rs @@ -0,0 +1,132 @@ +//! Small Huddle controls that mutate an active session. + +use std::sync::{atomic::Ordering, Arc}; + +use tauri::State; +use uuid::Uuid; + +use crate::{app_state::AppState, events, relay::submit_event}; + +use super::{relay_api::validate_pubkey_hex, HuddlePhase}; + +/// Update the clickable microphone control independently from the PTT shortcut. +#[tauri::command] +pub fn set_huddle_manual_mic_unmuted( + enabled: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.manual_mic_unmuted.store(enabled, Ordering::Release); + Ok(()) +} + +/// Immediately interrupt the agent utterance that is currently speaking. +#[tauri::command] +pub fn interrupt_huddle_speech( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + let tts_pipeline = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + huddle.tts_pipeline.as_ref().map(Arc::clone) + }; + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_active_speaker(&agent_pubkey); + } + Ok(()) +} + +/// Remove an agent from the active huddle without removing its parent-channel +/// membership. Keeping the parent membership intact means it remains available +/// to rejoin this huddle from the agent picker. +#[tauri::command] +pub async fn remove_agent_from_huddle( + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_pubkey_hex(&agent_pubkey)?; + + let (ephemeral_channel_id, huddle_generation) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Err("no active huddle".to_string()); + } + + let is_huddle_agent = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if !is_huddle_agent { + return Err("agent is not in this huddle".to_string()); + } + + ( + huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?, + huddle.huddle_generation, + ) + }; + + let ephemeral_channel_uuid = + Uuid::parse_str(&ephemeral_channel_id).map_err(|error| error.to_string())?; + submit_event( + events::build_remove_member(ephemeral_channel_uuid, &agent_pubkey)?, + &state, + ) + .await?; + + let (roster_changed, tts_pipeline) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + (false, None) + } else { + let mut agent_pubkeys = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + let initial_count = agent_pubkeys.len(); + agent_pubkeys.retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + let changed = agent_pubkeys.len() != initial_count; + drop(agent_pubkeys); + + if changed { + huddle + .participants + .retain(|pubkey| !pubkey.eq_ignore_ascii_case(&agent_pubkey)); + if let Some(settings_pubkey) = huddle + .agent_voice_settings + .keys() + .find(|pubkey| pubkey.eq_ignore_ascii_case(&agent_pubkey)) + .cloned() + { + huddle.agent_voice_settings.remove(&settings_pubkey); + } + } + let tts_pipeline = changed + .then_some(huddle.tts_pipeline.as_ref()) + .flatten() + .map(Arc::clone); + (changed, tts_pipeline) + } + }; + + if let Some(tts_pipeline) = tts_pipeline { + tts_pipeline.cancel_speaker(&agent_pubkey); + } + if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 99337400c..fcf29d688 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -27,6 +27,7 @@ mod agent_tts_routing; pub mod agent_voice; pub mod agents; pub mod audio_output; +mod commands; pub mod jitter; pub mod models; pub mod pipeline; @@ -67,6 +68,9 @@ pub(super) fn drain_until_shutdown( // ── Re-exports ──────────────────────────────────────────────────────────────── +pub use commands::{ + interrupt_huddle_speech, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, +}; pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; @@ -868,11 +872,27 @@ pub async fn speak_agent_message( let sender = { let hs = state.huddle()?; + let agent_is_present = hs + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(&speaker_pubkey)); + if !agent_is_present { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={route_id}" + ); + return Ok(()); + } hs.tts_pipeline .as_ref() .map(|pipeline| pipeline.text_sender()) + .map(|sender| { + let speaker_generation = sender.speaker_generation(&speaker_pubkey); + (sender, speaker_generation) + }) }; - let Some(sender) = sender else { + let Some((sender, speaker_generation)) = sender else { eprintln!( "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}" ); @@ -880,7 +900,13 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, speaker_pubkey, voice_reference, text) + .send( + route_id, + speaker_pubkey, + speaker_generation, + voice_reference, + text, + ) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 9572ac25b..e523ee22b 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -315,6 +315,7 @@ pub(crate) async fn maybe_start_stt_pipeline( expected_generation, stt_starting, ptt_active_for_stt, + manual_mic_unmuted_for_stt, old_stt, ) = { let mut hs = state.huddle()?; @@ -338,6 +339,11 @@ pub(crate) async fn maybe_start_stt_pipeline( } else { None }; + let manual_mic_unmuted = if hs.voice_input_mode == VoiceInputMode::PushToTalk { + Some(Arc::clone(&hs.manual_mic_unmuted)) + } else { + None + }; ( Arc::clone(&hs.tts_active), Arc::clone(&hs.agent_pubkeys), @@ -345,6 +351,7 @@ pub(crate) async fn maybe_start_stt_pipeline( hs.session_generation.load(Ordering::Acquire), stt_starting, ptt, + manual_mic_unmuted, old, ) }; @@ -352,7 +359,12 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) + stt::SttPipeline::new( + model_dir, + tts_active, + ptt_active_for_stt, + manual_mic_unmuted_for_stt, + ) }) .await; let (pipeline, text_rx) = match constructed { diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 0fe3a46f5..7acf5fe63 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -15,7 +15,7 @@ use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). /// -/// PTT: mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets +/// PTT (the default): mic is gated by a global shortcut (Ctrl+Space). Pressing the key sets /// `ptt_active` and immediately cancels any playing TTS. Releasing the key /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// @@ -26,8 +26,8 @@ use super::{stt, tts}; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { - PushToTalk, #[default] + PushToTalk, VoiceActivity, } @@ -135,6 +135,10 @@ pub struct HuddleState { /// Shared with the STT pipeline for mic gating. #[serde(skip)] pub ptt_active: Arc, + /// True while the clickable microphone control is manually unmuted. + /// In PTT mode, either this flag or `ptt_active` opens the STT gate. + #[serde(skip)] + pub manual_mic_unmuted: Arc, } fn serialize_agent_pubkeys(v: &Arc>>, s: S) -> Result @@ -190,6 +194,7 @@ impl Clone for HuddleState { session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), + manual_mic_unmuted: Arc::clone(&self.manual_mic_unmuted), } } } @@ -221,6 +226,7 @@ impl Default for HuddleState { session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), + manual_mic_unmuted: Arc::new(AtomicBool::new(true)), } } } @@ -332,6 +338,13 @@ mod tests { assert!(!state.maybe_auto_enable_transcription_for_agents()); } + #[test] + fn defaults_to_push_to_talk_with_an_open_microphone() { + let state = HuddleState::default(); + assert_eq!(state.voice_input_mode, super::VoiceInputMode::PushToTalk); + assert!(state.manual_mic_unmuted.load(Ordering::Acquire)); + } + #[test] fn explicit_user_disable_is_not_undone_by_agent_presence() { let mut state = HuddleState::default(); diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 30a47f449..70a808864 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -71,9 +71,10 @@ impl SttPipeline { /// therefore never cancel TTS. Push-to-talk and remote participant speech /// remain explicit, reliable barge-in paths. /// - /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT - /// pipeline only accumulates speech while the flag is true (key held). - /// When `None`, the pipeline runs in continuous VAD mode. + /// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut + /// is enabled. The pipeline accepts speech while either input path is open; + /// manual unmute uses normal VAD flushing while a shortcut hold is grouped + /// into one utterance. /// /// Returns `Err` only if the thread cannot be spawned (OS error). /// If model files are missing, the worker logs and exits cleanly — @@ -87,6 +88,7 @@ impl SttPipeline { model_dir: PathBuf, tts_active: Arc, ptt_active: Option>, + manual_mic_unmuted: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); let (text_tx, text_rx) = tokio_mpsc::channel::(64); @@ -94,6 +96,7 @@ impl SttPipeline { let shutdown_worker = Arc::clone(&shutdown); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); + let manual_mic_unmuted_worker = manual_mic_unmuted.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) .spawn(move || { @@ -104,6 +107,7 @@ impl SttPipeline { shutdown_worker, tts_active, ptt_active_worker, + manual_mic_unmuted_worker, ) }) .map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?; @@ -203,6 +207,7 @@ fn stt_worker( shutdown: Arc, tts_active: Arc, ptt_active: Option>, + manual_mic_unmuted: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── use rubato::{Fft, FixedSync, Resampler}; @@ -275,9 +280,12 @@ fn stt_worker( // ── 5. Main loop ────────────────────────────────────────────────────────── let mut tts_was_active = false; - let mut ptt_was_active = ptt_active + let mut transmit_was_active = ptt_active .as_ref() - .is_some_and(|p| p.load(Ordering::Acquire)); + .is_some_and(|ptt| ptt.load(Ordering::Acquire)) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); loop { // Check shutdown flag before blocking. if shutdown.load(Ordering::Acquire) { @@ -292,20 +300,22 @@ fn stt_worker( } tts_was_active = tts_now; - // Track PTT transitions — flush accumulated speech when key is released. - // The worklet stops sending frames when PTT is inactive, so the normal - // silence-accumulation flush path never runs. We must flush here on the - // active→inactive edge to avoid buffering speech across PTT presses. + // Track the combined manual/PTT transmission edge. When both paths + // close, the worklet stops sending frames, so flush here rather than + // waiting for silence that will never arrive. if let Some(ref ptt) = ptt_active { - let ptt_now = ptt.load(Ordering::Acquire); - if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { + let transmit_now = ptt.load(Ordering::Acquire) + || manual_mic_unmuted + .as_ref() + .is_some_and(|manual| manual.load(Ordering::Acquire)); + if transmit_was_active && !transmit_now && in_speech && !speech_buf.is_empty() { flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; voiced_frames = 0; } - ptt_was_active = ptt_now; + transmit_was_active = transmit_now; } // Use recv_timeout so we can periodically check the shutdown flag. @@ -343,6 +353,7 @@ fn stt_worker( &tts_active, &mut tts_stopped_at, ptt_active.as_ref(), + manual_mic_unmuted.as_ref(), ); } } @@ -385,11 +396,9 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, + manual_mic_unmuted: Option<&Arc>, ) { leftover.extend_from_slice(samples); @@ -413,13 +423,11 @@ fn process_16k_samples( let prob = vad.predict_f32(&clamped); let is_speech = prob > VAD_THRESHOLD; - // PTT gating: when PTT key is not held, treat as silence. - // This causes natural flush when the key is released — silence_frames - // accumulates and the existing flush logic kicks in after - // SILENCE_FLUSH_FRAMES. The 200 ms release delay + ~300 ms silence - // flush gives a natural utterance tail. + let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire)); + // Shortcut-enabled mode accepts input from either the held shortcut or + // a manually open microphone. let is_speech = if let Some(ptt) = ptt_active { - is_speech && ptt.load(Ordering::Acquire) + is_speech && (ptt.load(Ordering::Acquire) || manually_open) } else { is_speech }; @@ -478,11 +486,9 @@ fn process_16k_samples( speech_buf.extend_from_slice(&frame); *silence_frames += 1; - // In PTT mode, don't flush on silence — accumulate the entire - // key-hold as one utterance. The PTT release edge in the main - // loop handles the flush. In VAD mode, flush after the silence - // threshold so each natural pause becomes a separate message. - if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { + // A manually open microphone behaves like normal VAD. A + // shortcut-only transmission stays grouped until key release. + if (ptt_active.is_none() || manually_open) && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 1901bb3d2..6a56f8544 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -64,6 +64,11 @@ use audio::*; #[path = "tts_activity.rs"] mod activity; use activity::*; +#[path = "tts_pipeline_controls.rs"] +mod pipeline_controls; +#[path = "tts_speaker_cancellation.rs"] +mod speaker_cancellation; +use speaker_cancellation::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -126,7 +131,15 @@ const MAX_CHUNK_CHARS: usize = 200; /// Injected as a silent buffer between each synthesized sentence chunk. const INTER_SENTENCE_SILENCE: f32 = 0.1; -type WorkerControlState = (Arc, Arc, WorkerCancelSignals); +type WorkerControlState = ( + Arc, + Arc, + WorkerCancelSignals, + SpeakerGenerations, + ActiveSpeaker, + SpeakerCancellation, + PlaybackProbe, +); // ── Public pipeline handle ──────────────────────────────────────────────────── @@ -154,6 +167,15 @@ pub struct TtsPipeline { voice: Arc>, /// Tags messages so a voice change drops only pre-change queue entries. voice_generation: Arc, + /// Per-agent generations let removal invalidate that agent's queued and + /// in-flight text without poisoning speech queued after the agent rejoins. + speaker_generations: SpeakerGenerations, + /// Speaker whose audio currently owns the shared player queue. + active_speaker: ActiveSpeaker, + /// Targeted cancellation used when an agent leaves the huddle. + speaker_cancel: SpeakerCancellation, + /// Shared player handle used to reject Stop clicks after playback drains. + playback_probe: PlaybackProbe, /// Completed after the worker drains pre-change text and installs the new style. voice_change_ack: VoiceChangeAck, /// Worker thread handle — taken on drop to join cleanly. @@ -187,6 +209,14 @@ impl TtsPipeline { let voice_worker = Arc::clone(&voice); let voice_generation = Arc::new(AtomicU64::new(1)); let worker_voice_generation = Arc::clone(&voice_generation); + let speaker_generations = Arc::new(Mutex::new(HashMap::new())); + let worker_speaker_generations = Arc::clone(&speaker_generations); + let active_speaker = Arc::new(Mutex::new(None)); + let worker_active_speaker = Arc::clone(&active_speaker); + let speaker_cancel = Arc::new(Mutex::new(None)); + let worker_speaker_cancel = Arc::clone(&speaker_cancel); + let playback_probe = PlaybackProbe::new(); + let worker_playback_probe = playback_probe.clone(); let voice_change_ack = Arc::new(Mutex::new(None)); let worker_voice_change_ack = Arc::clone(&voice_change_ack); let model_dir_worker = model_dir.clone(); @@ -207,6 +237,10 @@ impl TtsPipeline { tts_active_worker, shutdown_worker, (cancel_worker, worker_voice_cancel), + worker_speaker_generations, + worker_active_speaker, + worker_speaker_cancel, + worker_playback_probe, ), output_device, activity_app, @@ -224,79 +258,14 @@ impl TtsPipeline { voice_cancel, voice, voice_generation, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, voice_change_ack, thread: Some(handle), }) } - - /// Queue `text` for TTS synthesis and playback. - /// - /// Non-blocking. Returns `Err` if the queue is full (bounded at - /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. - pub fn speak(&self, text: String) -> Result<(), String> { - self.text_tx - .try_send(QueuedText { - generation: self.voice_generation.load(Ordering::Acquire), - route_id: 0, - speaker_pubkey: None, - voice_reference: None, - text, - }) - .map_err(|e| { - eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); - format!("TTS queue full, dropping: {e}") - }) - } - - /// Clone the bounded queue sender so callers can apply backpressure without - /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks - /// any waiting sender while the shared cancellation flag stops playback. - pub(crate) fn text_sender(&self) -> TtsTextSender { - TtsTextSender { - text_tx: self.text_tx.clone(), - generation: self.voice_generation.load(Ordering::Acquire), - } - } - - /// Select a bundled Pocket voice for subsequent speech. - /// - /// Current playback and queued text are cancelled immediately so content - /// cannot continue in the old voice. The worker keeps its warmed inference - /// engine and reloads only the reference style before the next utterance. - pub fn select_voice(&self, voice: &str) -> Option> { - let acknowledged = begin_voice_change( - &self.voice, - &self.voice_generation, - &self.voice_cancel, - &self.voice_change_ack, - voice, - ); - if acknowledged.is_some() { - eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); - } - acknowledged - } - - /// Reconcile the voice of a pipeline that has not been published yet. - /// - /// No caller can enqueue text before publication, so raising the shared - /// cancellation flag here would create a race that could discard the first - /// message queued immediately after installation. - pub(crate) fn select_voice_before_publish(&self, voice: &str) { - *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); - } - - /// Signal the worker thread to stop. - pub fn shutdown(&self) { - eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); - self.shutdown.store(true, Ordering::Release); - } - - /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). - /// Used by hot-start to detect dead pipelines and clear them for retry. - pub fn is_finished(&self) -> bool { - self.thread.as_ref().is_none_or(|h| h.is_finished()) - } } impl Drop for TtsPipeline { @@ -322,7 +291,15 @@ fn tts_worker( startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; - let (tts_active, shutdown, cancel_signals) = control_state; + let ( + tts_active, + shutdown, + cancel_signals, + speaker_generations, + active_speaker, + speaker_cancel, + playback_probe, + ) = control_state; let (cancel, voice_cancel) = cancel_signals; // ── 1. Initialise TTS engine ────────────────────────────────────────────── let model_dir_str = model_dir.to_string_lossy().to_string(); @@ -415,6 +392,7 @@ fn tts_worker( // Shared (Arc) with the barge-in monitor thread below, which needs to // silence it while this thread is blocked inside `synth_chunk`. let player = Arc::new(Player::connect_new(sink_handle.mixer())); + playback_probe.install(Arc::clone(&player)); // Prime the audio output stream with a short silent buffer. // On macOS, CoreAudio initializes the output device lazily on first use. @@ -443,103 +421,21 @@ fn tts_worker( } eprintln!("buzz-desktop: tts stage=startup status=ready"); - // ── 3b. Barge-in monitor thread ─────────────────────────────────────────── - // - // The worker loop only observes `cancel` between sentences — while it is - // blocked inside `synth_chunk` (hundreds of ms for a long sentence), - // nothing would silence the audio that is already playing. The monitor - // closes that gap: every MONITOR_TICK it checks the flag and, while set, - // silences the player and releases the mic gate. It does NOT consume the - // flag — the worker still owns that (drain queue, reset lead-in), so the - // monitor keeps re-clearing until the worker catches up, which also - // covers a sentence appended in the race window after the worker's own - // post-synthesis cancel check. - // - // `player_ops` closes the converse race (found in review): the monitor - // loads `cancel == true`, is preempted, the worker consumes the cancel - // and appends a fresh post-cancel utterance, then the monitor resumes - // from its stale branch and deletes audio that was meant to play. All - // worker player mutations (appends and cancel/shutdown clears) hold this - // lock, and the monitor re-checks `cancel` *while holding it* — so its - // clear either runs before fresh audio can be appended, or observes - // `cancel == false` and no-ops. The lock is uncontended except during an - // actual barge-in, so the hot path is unaffected. - let player_ops = Arc::new(Mutex::new(())); + let player_ops = Arc::clone(&playback_probe.player_ops); let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); - let monitor = { - let player = Arc::clone(&player); - let cancel = Arc::clone(&cancel); - let voice_cancel = Arc::clone(&voice_cancel); - let tts_active = Arc::clone(&tts_active); - let stop = Arc::clone(&monitor_stop); - let player_ops = Arc::clone(&player_ops); - let activity_frames = Arc::clone(&activity_frames); - thread::Builder::new() - .name("tts-barge-in-monitor".into()) - .spawn(move || { - let mut last_activity_pubkey: Option = None; - let mut next_activity_tick = Instant::now(); - while !stop.load(Ordering::Acquire) { - if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { - let _ops = lock_player_ops(&player_ops); - // Re-check under the lock: the worker may have - // consumed this cancel (and appended fresh audio) - // between the load above and the lock acquisition. - if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { - // clear() pauses the persistent player; play() - // un-pauses (see handle_cancel_or_shutdown). - // Idempotent — safe to repeat every tick until - // the worker consumes the flag. - player.clear(); - player.play(); - tts_active.store(false, Ordering::Release); - } - } - if let Some(ref app) = activity_app { - if tts_active.load(Ordering::Acquire) { - let now = Instant::now(); - if now >= next_activity_tick { - let frame = activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .pop_front(); - if let Some(frame) = frame { - use tauri::Emitter; - let _ = app.emit( - "huddle-tts-speaker-level", - TtsSpeakerActivityPayload { - pubkey: Some(frame.pubkey.clone()), - level: frame.level, - }, - ); - last_activity_pubkey = Some(frame.pubkey); - } - next_activity_tick = now + SPEAKER_ACTIVITY_TICK; - } - } else { - let had_activity = last_activity_pubkey.take().is_some(); - activity_frames - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clear(); - if had_activity { - use tauri::Emitter; - let _ = app.emit( - "huddle-tts-speaker-level", - TtsSpeakerActivityPayload { - pubkey: None, - level: 0.0, - }, - ); - } - next_activity_tick = Instant::now(); - } - } - thread::sleep(MONITOR_TICK); - } - }) - }; + let monitor = spawn_tts_monitor(TtsMonitorState { + player: Arc::clone(&player), + cancel: Arc::clone(&cancel), + voice_cancel: Arc::clone(&voice_cancel), + tts_active: Arc::clone(&tts_active), + stop: Arc::clone(&monitor_stop), + player_ops: Arc::clone(&player_ops), + activity_frames: Arc::clone(&activity_frames), + active_speaker: Arc::clone(&active_speaker), + speaker_cancel: Arc::clone(&speaker_cancel), + activity_app, + }); if let Err(ref e) = monitor { // Degraded but functional: barge-in still works between sentences // via the worker's own checks, just not mid-synthesis. @@ -564,7 +460,8 @@ fn tts_worker( let mut deferred_text = VecDeque::new(); let append_audio = |prepared: PreparedModelAudio, route_id: u64, - speaker_pubkey: Option<&str>| { + speaker_pubkey: Option<&str>, + speaker_generation: u64| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -582,6 +479,30 @@ fn tts_worker( ); return false; } + let speaker_is_current = speaker_pubkey.is_none_or(|pubkey| { + current_speaker_generation(&speaker_generations, pubkey) == speaker_generation + }); + if !speaker_is_current { + eprintln!( + "buzz-desktop: tts stage=synthesis status=cancelled reason=speaker_removed route_id={route_id}" + ); + return false; + } + if let Some(pubkey) = speaker_pubkey { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if player.empty() { + active.take(); + } + if active + .as_deref() + .is_some_and(|current| !current.eq_ignore_ascii_case(pubkey)) + { + return false; + } + active.get_or_insert_with(|| pubkey.to_ascii_lowercase()); + } if let Some(pubkey) = speaker_pubkey { activity_frames .lock() @@ -604,6 +525,17 @@ fn tts_worker( loop { let mut no_current_text = None; + if consume_speaker_cancel( + &speaker_cancel, + &active_speaker, + &speaker_generations, + &tts_active, + (&text_rx, &mut deferred_text, &mut no_current_text), + Some((&player, &player_ops)), + ) { + first_append = true; + continue; + } if handle_cancel_or_shutdown( (&cancel, &voice_cancel), &shutdown, @@ -647,6 +579,10 @@ fn tts_worker( // lead-in so the next utterance gets a fresh cushion. if player.empty() && !first_append { tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); eprintln!( "buzz-desktop: tts stage=player status=drained route_id={last_route_id}" ); @@ -679,6 +615,13 @@ fn tts_worker( let Some(queued_text) = queued_text else { continue; }; + if !queued_speaker_is_current(&speaker_generations, &queued_text) { + eprintln!( + "buzz-desktop: tts stage=queue status=dropped reason=speaker_removed route_id={}", + queued_text.route_id + ); + continue; + } if queued_text.generation < voice_generation.load(Ordering::Acquire) { eprintln!( "buzz-desktop: tts stage=queue status=dropped reason=voice_switch route_id={}", @@ -686,6 +629,22 @@ fn tts_worker( ); continue; } + if !player.empty() + && queued_text + .speaker_pubkey + .as_deref() + .is_some_and(|speaker| { + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| !active.eq_ignore_ascii_case(speaker)) + }) + { + deferred_text.push_front(queued_text); + thread::sleep(RECV_TIMEOUT); + continue; + } let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { selected_voice .lock() @@ -694,9 +653,32 @@ fn tts_worker( }); let raw_text = queued_text.text; let speaker_pubkey = queued_text.speaker_pubkey; + let speaker_generation = queued_text.speaker_generation; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); + // If playback already drained while we were waiting for this item, + // release stale ownership before doing any potentially slow voice or + // synthesis work. Serialize the drain decision with Stop and append so + // those paths observe one coherent utterance boundary. + { + let _ops = lock_player_ops(&player_ops); + if player.empty() && !first_append { + tts_active.store(false, Ordering::Release); + active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); + first_append = true; + } + } + + // From this point until the item finishes, an empty player can mean a + // voice-preparation or synthesis gap rather than a drained utterance. + // Stop must remain able to invalidate the in-flight speaker generation. + let _synthesis_flight = playback_probe.begin_synthesis(); + // The selected per-agent voice travels with the queue item, preserving // message order while allowing one warmed Pocket engine to alternate // between cached reference styles. @@ -714,20 +696,6 @@ fn tts_worker( continue; } - // If playback already drained while we were waiting for this item, - // the agent is silent — release the mic gate BEFORE preprocessing/ - // synthesis. Without this, an item arriving inside the recv timeout - // window would run the whole synthesis pass with `tts_active` stuck - // true and nothing playing, making STT discard human speech as - // "echo" during a silent window. (Pipelining is unaffected: when - // audio is still draining, `player.empty()` is false and the flag - // stays set across items.) - if player.empty() && !first_append { - tts_active.store(false, Ordering::Release); - eprintln!("buzz-desktop: tts stage=player status=drained route_id={last_route_id}"); - first_append = true; - } - // Preprocess text. let text = preprocess_for_tts(&raw_text); if text.is_empty() { @@ -846,7 +814,12 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -872,7 +845,12 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { + if !append_audio( + prepared, + route_id, + speaker_pubkey.as_deref(), + speaker_generation, + ) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs new file mode 100644 index 000000000..0ee472f0f --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_pipeline_controls.rs @@ -0,0 +1,100 @@ +use super::*; + +impl TtsPipeline { + /// Queue `text` for TTS synthesis and playback. + /// + /// Non-blocking. Returns `Err` if the queue is full (bounded at + /// `TEXT_QUEUE_DEPTH`) — caller may log and discard. + pub fn speak(&self, text: String) -> Result<(), String> { + self.text_tx + .try_send(QueuedText { + generation: self.voice_generation.load(Ordering::Acquire), + route_id: 0, + speaker_pubkey: None, + speaker_generation: 0, + voice_reference: None, + text, + }) + .map_err(|e| { + eprintln!("buzz-desktop: TTS queue saturated, dropping message: {e}"); + format!("TTS queue full, dropping: {e}") + }) + } + + /// Clone the bounded queue sender so callers can apply backpressure without + /// holding the huddle mutex. Disabling TTS drops the receiver and unblocks + /// any waiting sender while the shared cancellation flag stops playback. + pub(crate) fn text_sender(&self) -> TtsTextSender { + TtsTextSender { + text_tx: self.text_tx.clone(), + generation: self.voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::clone(&self.speaker_generations), + } + } + + /// Invalidate speech queued for one agent and cancel the player only when + /// that same agent currently owns it. + pub(crate) fn cancel_speaker(&self, speaker_pubkey: &str) { + request_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + speaker_pubkey, + ); + } + + /// Cancel exactly the speaker utterance currently owning playback. + /// + /// The speaker generation is advanced while ownership is locked, so a + /// stale Stop click cannot cancel a later utterance that starts after the + /// observed one drains. + pub(crate) fn cancel_active_speaker(&self, expected_speaker_pubkey: &str) -> bool { + request_active_speaker_cancel( + &self.speaker_generations, + &self.active_speaker, + &self.speaker_cancel, + &self.playback_probe, + expected_speaker_pubkey, + ) + } + + /// Select a bundled Pocket voice for subsequent speech. + /// + /// Current playback and queued text are cancelled immediately so content + /// cannot continue in the old voice. The worker keeps its warmed inference + /// engine and reloads only the reference style before the next utterance. + pub fn select_voice(&self, voice: &str) -> Option> { + let acknowledged = begin_voice_change( + &self.voice, + &self.voice_generation, + &self.voice_cancel, + &self.voice_change_ack, + voice, + ); + if acknowledged.is_some() { + eprintln!("buzz-desktop: tts stage=cancellation reason=voice_switch route_id=0"); + } + acknowledged + } + + /// Reconcile the voice of a pipeline that has not been published yet. + /// + /// No caller can enqueue text before publication, so raising the shared + /// cancellation flag here would create a race that could discard the first + /// message queued immediately after installation. + pub(crate) fn select_voice_before_publish(&self, voice: &str) { + *self.voice.lock().unwrap_or_else(|error| error.into_inner()) = voice.to_string(); + } + + /// Signal the worker thread to stop. + pub fn shutdown(&self) { + eprintln!("buzz-desktop: tts stage=cancellation reason=shutdown route_id=0"); + self.shutdown.store(true, Ordering::Release); + } + + /// Returns `true` if the worker thread has exited (init failure, crash, or normal exit). + /// Used by hot-start to detect dead pipelines and clear them for retry. + pub fn is_finished(&self) -> bool { + self.thread.as_ref().is_none_or(|h| h.is_finished()) + } +} diff --git a/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs new file mode 100644 index 000000000..4b9c2824f --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_speaker_cancellation.rs @@ -0,0 +1,177 @@ +use super::*; + +pub(super) struct TtsMonitorState { + pub(super) player: Arc, + pub(super) cancel: Arc, + pub(super) voice_cancel: Arc, + pub(super) tts_active: Arc, + pub(super) stop: Arc, + pub(super) player_ops: Arc>, + pub(super) activity_frames: Arc>>, + pub(super) active_speaker: ActiveSpeaker, + pub(super) speaker_cancel: SpeakerCancellation, + pub(super) activity_app: Option, +} + +pub(super) fn spawn_tts_monitor(state: TtsMonitorState) -> std::io::Result> { + thread::Builder::new() + .name("tts-barge-in-monitor".into()) + .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); + while !state.stop.load(Ordering::Acquire) { + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + let _ops = lock_player_ops(&state.player_ops); + if state.cancel.load(Ordering::Acquire) + || state.voice_cancel.load(Ordering::Acquire) + { + state.player.clear(); + state.player.play(); + state + .active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + state.tts_active.store(false, Ordering::Release); + } + } + silence_cancelled_speaker( + &state.speaker_cancel, + &state.active_speaker, + &state.player, + &state.player_ops, + &state.tts_active, + ); + if let Some(ref app) = state.activity_app { + if state.tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = state + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + state + .activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } + thread::sleep(MONITOR_TICK); + } + }) +} + +pub(super) fn silence_cancelled_speaker( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + player: &rodio::Player, + player_ops: &Mutex<()>, + tts_active: &AtomicBool, +) { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + else { + return; + }; + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + } +} + +fn take_cancelled_active_speaker(cancelled: &str, active_speaker: &ActiveSpeaker) -> bool { + let mut active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !active + .as_deref() + .is_some_and(|speaker| speaker.eq_ignore_ascii_case(cancelled)) + { + return false; + } + active.take(); + true +} + +pub(super) fn consume_speaker_cancel( + cancellation: &SpeakerCancellation, + active_speaker: &ActiveSpeaker, + generations: &SpeakerGenerations, + tts_active: &AtomicBool, + text_state: CancelTextState<'_>, + player: Option<(&rodio::Player, &Mutex<()>)>, +) -> bool { + let Some(cancelled) = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + else { + return false; + }; + let (text_rx, deferred_text, current_text) = text_state; + retain_current_speaker_text(generations, deferred_text, current_text, text_rx); + let mut cleared_player = false; + if let Some((player, player_ops)) = player { + let _ops = lock_player_ops(player_ops); + if take_cancelled_active_speaker(&cancelled, active_speaker) { + player.clear(); + player.play(); + tts_active.store(false, Ordering::Release); + cleared_player = true; + } + } + // The monitor may already have cleared the cancelled speaker while the + // worker was blocked. If another speaker has since claimed the player, + // preserve that speaker's activity flag and lead-in state. + cleared_player +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_targeted_cancel_does_not_release_the_next_speaker() { + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + + assert!(!take_cancelled_active_speaker("alice", &active_speaker)); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob") + ); + } +} diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 044b1acf1..bff5ab4f7 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -19,6 +19,10 @@ fn inert_pipeline(cancel: Arc) -> TtsPipeline { voice_cancel: Arc::new(AtomicBool::new(false)), voice: Arc::new(std::sync::Mutex::new("reference_sample".to_string())), voice_generation: Arc::new(AtomicU64::new(1)), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), + active_speaker: Arc::new(std::sync::Mutex::new(None)), + speaker_cancel: Arc::new(std::sync::Mutex::new(None)), + playback_probe: PlaybackProbe::new(), voice_change_ack: Arc::new(std::sync::Mutex::new(None)), thread: Some(thread), } @@ -168,6 +172,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: voice_generation.load(Ordering::Acquire), route_id: 1, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "new message".to_string(), }) @@ -181,6 +186,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: 1, route_id: 2, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "old message".to_string(), }, @@ -188,6 +194,7 @@ fn an_in_hand_post_change_message_survives_cancellation() { generation: voice_generation.load(Ordering::Acquire), route_id: 3, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "later new message".to_string(), }, @@ -246,6 +253,7 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { generation: voice_generation.load(Ordering::Acquire), route_id: 4, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "message for Eve".to_string(), }); @@ -294,6 +302,7 @@ fn barge_in_clears_deferred_voice_change_messages() { generation: 2, route_id: 5, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "deferred message".to_string(), }]); @@ -337,6 +346,7 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { generation: voice_generation.load(Ordering::Acquire), route_id: 6, speaker_pubkey: None, + speaker_generation: 0, voice_reference: None, text: "post-change message".to_string(), }); @@ -365,6 +375,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() let old_sender = TtsTextSender { text_tx, generation: voice_generation.load(Ordering::Acquire), + speaker_generations: Arc::new(std::sync::Mutex::new(HashMap::new())), }; let shutdown = AtomicBool::new(false); let active = AtomicBool::new(true); @@ -392,6 +403,7 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() .send( 7, "agent".to_string(), + 0, "reference_sample".to_string(), "late old message".to_string(), ) diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 3a6555375..a60d3506f 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, + fmt, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -19,6 +20,9 @@ pub(super) struct PendingVoiceChange { pub(super) type VoiceChangeAck = Arc>>; pub(super) type WorkerVoiceState = (Arc>, Arc, VoiceChangeAck); pub(super) type WorkerCancelSignals = (Arc, Arc); +pub(super) type SpeakerGenerations = Arc>>; +pub(super) type ActiveSpeaker = Arc>>; +pub(super) type SpeakerCancellation = Arc>>; pub(super) type CancelTextState<'a> = ( &'a mpsc::Receiver, &'a mut VecDeque, @@ -26,11 +30,73 @@ pub(super) type CancelTextState<'a> = ( ); pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); +#[derive(Clone)] +pub(super) struct PlaybackProbe { + player: Arc>>>, + pub(super) player_ops: Arc>, + synthesis_in_flight: Arc, +} + +pub(super) struct SynthesisFlightGuard { + playback_probe: PlaybackProbe, +} + +impl Drop for SynthesisFlightGuard { + fn drop(&mut self) { + self.playback_probe.set_synthesis_in_flight(false); + } +} + +impl PlaybackProbe { + pub(super) fn new() -> Self { + Self { + player: Arc::new(Mutex::new(None)), + player_ops: Arc::new(Mutex::new(())), + synthesis_in_flight: Arc::new(AtomicBool::new(false)), + } + } + + pub(super) fn install(&self, player: Arc) { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(player); + } + + pub(super) fn set_synthesis_in_flight(&self, in_flight: bool) { + let _ops = super::lock_player_ops(&self.player_ops); + self.synthesis_in_flight.store(in_flight, Ordering::Release); + } + + pub(super) fn begin_synthesis(&self) -> SynthesisFlightGuard { + self.set_synthesis_in_flight(true); + SynthesisFlightGuard { + playback_probe: self.clone(), + } + } + + fn player(&self) -> Option> { + self.player + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } +} + +impl fmt::Debug for PlaybackProbe { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackProbe") + .finish_non_exhaustive() + } +} + #[derive(Debug)] pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, pub(super) speaker_pubkey: Option, + pub(super) speaker_generation: u64, pub(super) voice_reference: Option, pub(super) text: String, } @@ -39,6 +105,7 @@ pub(super) struct QueuedText { pub(crate) struct TtsTextSender { pub(super) text_tx: SyncSender, pub(super) generation: u64, + pub(super) speaker_generations: SpeakerGenerations, } impl TtsTextSender { @@ -46,6 +113,7 @@ impl TtsTextSender { &self, route_id: u64, speaker_pubkey: String, + speaker_generation: u64, voice_reference: String, text: String, ) -> Result<(), String> { @@ -54,11 +122,156 @@ impl TtsTextSender { generation: self.generation, route_id, speaker_pubkey: Some(speaker_pubkey), + speaker_generation, voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } + + pub(crate) fn speaker_generation(&self, speaker_pubkey: &str) -> u64 { + current_speaker_generation(&self.speaker_generations, speaker_pubkey) + } +} + +pub(super) fn current_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + generations + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&speaker_pubkey.to_ascii_lowercase()) + .copied() + .unwrap_or(0) +} + +pub(super) fn advance_speaker_generation( + generations: &SpeakerGenerations, + speaker_pubkey: &str, +) -> u64 { + let mut generations = generations + .lock() + .unwrap_or_else(|error| error.into_inner()); + let generation = generations + .entry(speaker_pubkey.to_ascii_lowercase()) + .or_default(); + *generation = generation.saturating_add(1); + *generation +} + +pub(super) fn queued_speaker_is_current( + generations: &SpeakerGenerations, + queued: &QueuedText, +) -> bool { + queued + .speaker_pubkey + .as_deref() + .is_none_or(|speaker_pubkey| { + current_speaker_generation(generations, speaker_pubkey) == queued.speaker_generation + }) +} + +pub(super) fn request_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + speaker_pubkey: &str, +) { + advance_speaker_generation(generations, speaker_pubkey); + let owns_player = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_deref() + .is_some_and(|active| active.eq_ignore_ascii_case(speaker_pubkey)); + if owns_player { + cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()) + .replace(speaker_pubkey.to_ascii_lowercase()); + } +} + +pub(super) fn request_active_speaker_cancel( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_probe: &PlaybackProbe, + expected_speaker_pubkey: &str, +) -> bool { + let Some(player) = playback_probe.player() else { + return false; + }; + let _ops = super::lock_player_ops(&playback_probe.player_ops); + let playback_live = + !player.empty() || playback_probe.synthesis_in_flight.load(Ordering::Acquire); + request_active_speaker_cancel_while_locked( + generations, + active_speaker, + cancellation, + playback_live, + expected_speaker_pubkey, + ) +} + +fn request_active_speaker_cancel_while_locked( + generations: &SpeakerGenerations, + active_speaker: &ActiveSpeaker, + cancellation: &SpeakerCancellation, + playback_live: bool, + expected_speaker_pubkey: &str, +) -> bool { + if !playback_live { + return false; + } + let active = active_speaker + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(speaker_pubkey) = active.as_deref() else { + return false; + }; + if !speaker_pubkey.eq_ignore_ascii_case(expected_speaker_pubkey) { + return false; + } + + // Keep ownership locked until the generation and cancellation request are + // committed. The drain path takes the same lock, so the request is bound + // to the utterance the Stop action actually observed. + let mut cancellation = cancellation + .lock() + .unwrap_or_else(|error| error.into_inner()); + if cancellation + .as_deref() + .is_some_and(|pending| pending.eq_ignore_ascii_case(speaker_pubkey)) + { + return false; + } + advance_speaker_generation(generations, speaker_pubkey); + cancellation.replace(speaker_pubkey.to_ascii_lowercase()); + true +} + +pub(super) fn retain_current_speaker_text( + generations: &SpeakerGenerations, + deferred_text: &mut VecDeque, + current_text: &mut Option, + text_rx: &mpsc::Receiver, +) { + deferred_text.retain(|text| queued_speaker_is_current(generations, text)); + if let Some(text) = current_text.take() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_front(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } + while let Ok(text) = text_rx.try_recv() { + if queued_speaker_is_current(generations, &text) { + deferred_text.push_back(text); + } else { + log_cancelled_route(text.route_id, "speaker_removed"); + } + } } pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { @@ -258,3 +471,227 @@ pub(super) fn retain_cancelled_text( fn log_cancelled_route(route_id: u64, reason: &str) { eprintln!("buzz-desktop: tts stage=queue status=dropped reason={reason} route_id={route_id}"); } + +#[cfg(test)] +mod speaker_generation_tests { + use super::*; + + fn playback_probe(playback_live: bool) -> PlaybackProbe { + let channels = std::num::NonZero::new(1).expect("non-zero channels"); + let sample_rate = std::num::NonZero::new(24_000).expect("non-zero sample rate"); + let (mixer, _mixer_source) = rodio::mixer::mixer(channels, sample_rate); + let player = Arc::new(rodio::Player::connect_new(&mixer)); + if playback_live { + player.append(rodio::buffer::SamplesBuffer::new( + channels, + sample_rate, + vec![0.0; 24_000], + )); + } + let probe = PlaybackProbe::new(); + probe.install(player); + probe + } + + fn queued_speech(speaker_pubkey: &str, speaker_generation: u64) -> QueuedText { + QueuedText { + generation: 1, + route_id: 1, + speaker_pubkey: Some(speaker_pubkey.to_string()), + speaker_generation, + voice_reference: Some("pocket:mary".to_string()), + text: "Hello".to_string(), + } + } + + #[test] + fn removing_a_speaker_invalidates_only_that_speakers_queued_text() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let alice = queued_speech("ALICE", current_speaker_generation(&generations, "alice")); + let bob = queued_speech("bob", current_speaker_generation(&generations, "bob")); + + advance_speaker_generation(&generations, "alice"); + + assert!(!queued_speaker_is_current(&generations, &alice)); + assert!(queued_speaker_is_current(&generations, &bob)); + + let rejoined_alice = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &rejoined_alice)); + } + + #[test] + fn removing_a_silent_speaker_does_not_cancel_the_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "bob"); + + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice") + ); + } + + #[test] + fn targeted_cancellation_preserves_other_speakers_queue_entries() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let alice = queued_speech("alice", 0); + let bob = queued_speech("bob", 0); + let (_text_tx, text_rx) = mpsc::sync_channel(1); + let mut deferred = VecDeque::from([alice, bob]); + let mut current = None; + + request_speaker_cancel(&generations, &active_speaker, &cancellation, "alice"); + retain_current_speaker_text(&generations, &mut deferred, &mut current, &text_rx); + + assert_eq!(deferred.len(), 1); + assert_eq!(deferred[0].speaker_pubkey.as_deref(), Some("bob")); + } + + #[test] + fn stop_request_is_bound_to_the_observed_speaker_generation() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice") + ); + + active_speaker.lock().expect("active speaker").take(); + cancellation.lock().expect("cancellation").take(); + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + + let next_utterance = + queued_speech("alice", current_speaker_generation(&generations, "alice")); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + } + + #[test] + fn stop_request_does_not_cancel_a_different_active_speaker() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("bob".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(true), + "alice", + )); + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert_eq!(current_speaker_generation(&generations, "bob"), 0); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("bob"), + ); + } + + #[test] + fn stop_request_during_empty_synthesis_gap_cancels_in_flight_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_chunk = queued_speech("alice", 0); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(!queued_speaker_is_current(&generations, &next_chunk)); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn repeated_stop_for_same_in_flight_utterance_is_idempotent() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let probe = playback_probe(false); + let _synthesis_flight = probe.begin_synthesis(); + + assert!(request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + let speech_queued_after_first_stop = queued_speech("alice", 1); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &probe, + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 1); + assert!(queued_speaker_is_current( + &generations, + &speech_queued_after_first_stop, + )); + assert_eq!( + cancellation.lock().expect("cancellation").as_deref(), + Some("alice"), + ); + } + + #[test] + fn stop_request_after_playback_drains_preserves_queued_speech() { + let generations = Arc::new(Mutex::new(HashMap::new())); + let active_speaker = Arc::new(Mutex::new(Some("alice".to_string()))); + let cancellation = Arc::new(Mutex::new(None)); + let next_utterance = queued_speech("alice", 0); + + assert!(!request_active_speaker_cancel( + &generations, + &active_speaker, + &cancellation, + &playback_probe(false), + "alice", + )); + + assert_eq!(current_speaker_generation(&generations, "alice"), 0); + assert!(queued_speaker_is_current(&generations, &next_utterance)); + assert!(cancellation.lock().expect("cancellation").is_none()); + assert_eq!( + active_speaker.lock().expect("active speaker").as_deref(), + Some("alice"), + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d59936946..1e73b1523 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -56,9 +56,10 @@ use huddle::reconnect::reconnect_huddle_audio; use huddle::{ add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, - get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window, - push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, - speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, + get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, + open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, + set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, + start_huddle, start_stt_pipeline, HuddlePhase, }; use initial_window::*; use managed_agents::{ @@ -851,7 +852,9 @@ pub fn run() { huddle::agent_voice::set_huddle_agent_tts_enabled, huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, + interrupt_huddle_speech, add_agent_to_huddle, + remove_agent_from_huddle, huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, @@ -859,6 +862,7 @@ pub fn run() { get_huddle_agent_pubkeys, set_voice_input_mode, get_voice_input_mode, + set_huddle_manual_mic_unmuted, list_audio_output_devices, set_audio_output_device, get_audio_output_device, diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 8370e8efd..29dcd26cd 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -48,6 +48,13 @@ export function AppHuddleShell({ data-huddle-open={isDrawerOpen} data-huddle-window={isRoom} > +