From 58be676c9f972743b5ca0732696a3fa6e9e028e6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 11:45:54 +0100 Subject: [PATCH 01/20] Polish mobile inbox and media flows Signed-off-by: kenny lopez --- crates/buzz-media/src/validation.rs | 27 +- desktop/src-tauri/src/commands/media.rs | 10 +- .../features/messages/lib/useMediaUpload.ts | 2 +- .../ui/ComposerUploadProgressPill.tsx | 103 ++++ .../features/messages/ui/MessageComposer.tsx | 6 +- desktop/src/shared/api/tauri.ts | 8 +- .../xyz/block/buzz/mobile/MainActivity.kt | 54 ++ mobile/ios/Runner/AppDelegate.swift | 224 ++++++++- .../lib/features/activity/activity_page.dart | 1 + .../activity/activity_page/inbox_row.dart | 383 +++++++++++---- .../activity/inbox_unread_provider.dart | 29 ++ .../channel_detail_page/message_list.dart | 13 +- .../lib/features/channels/channels_page.dart | 14 +- .../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 - mobile/lib/features/channels/compose_bar.dart | 461 +++++++++--------- .../channels/compose_bar/attachments.dart | 225 ++++----- .../features/channels/compose_bar/layout.dart | 9 +- .../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 | 319 ++++++++++++ .../features/channels/message_content.dart | 86 +--- .../message_content/video_preview.dart | 276 +++++++++++ .../lib/features/channels/message_media.dart | 6 +- mobile/lib/features/home/home_page.dart | 121 ++++- mobile/lib/shared/relay/media_upload.dart | 169 ++++++- .../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 | 149 +++++- .../features/channels/channels_page_test.dart | 26 +- .../features/channels/compose_bar_test.dart | 262 +++++----- .../channels/message_content_test.dart | 131 ++++- .../features/channels/message_media_test.dart | 4 +- mobile/test/features/home/home_page_test.dart | 177 ++++++- .../test/shared/relay/media_upload_test.dart | 121 +++++ 40 files changed, 3160 insertions(+), 1060 deletions(-) create mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx create mode 100644 mobile/lib/features/activity/inbox_unread_provider.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 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 f1387fc9d6..450f8f353e 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/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b340238..b3921be02d 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -573,6 +573,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,8 +640,7 @@ 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).await?; if let Some(poster) = poster_bytes { match do_upload(poster, "image/jpeg", state, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), @@ -675,6 +675,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 +695,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,7 +737,7 @@ 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)) } diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b627633be2..440f5f4a2e 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -374,7 +374,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) { diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx new file mode 100644 index 0000000000..3714de7602 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -0,0 +1,103 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import type { + MediaUploadController, + UploadingAttachmentPreview, +} from "@/features/messages/lib/useMediaUpload"; +import { cn } from "@/shared/lib/cn"; + +function aggregateUploadProgress( + previews: UploadingAttachmentPreview[], +): number { + if (previews.length === 0) return 0; + return Math.min( + ...previews.map((preview) => + Math.min(100, Math.max(0, Math.round(preview.progress ?? 0))), + ), + ); +} + +export function ComposerUploadProgressPill({ + media, +}: { + media: Pick< + MediaUploadController, + "cancelUpload" | "isUploading" | "uploadingPreviews" + >; +}) { + const reducedMotion = useReducedMotion(); + const previews = media.uploadingPreviews; + const percentage = aggregateUploadProgress(previews); + const handleCancel = () => { + for (const preview of previews) media.cancelUpload(preview.id); + }; + + return ( + + {media.isUploading ? ( + +
+ +
+ + Uploading + + + {percentage}% + + +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..f044bbe308 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -50,6 +50,7 @@ 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"; @@ -837,9 +838,7 @@ function MessageComposerImpl({ ], ); - const handleCaptureSelection = React.useCallback(() => { - // No-op for Tiptap — selection is managed by ProseMirror. - }, []); + const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -897,6 +896,7 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> +
{ - 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/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 8718ffd8e2..d9b456b270 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 f6fe3aaf48..58b1cf689a 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,60 @@ 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() + let timeRange = CMTimeRange(start: .zero, duration: asset.duration) + 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 { + try destinationVideo.insertTimeRange(timeRange, of: sourceVideo, at: .zero) + destinationVideo.preferredTransform = sourceVideo.preferredTransform + + if + let sourceAudio = asset.tracks(withMediaType: .audio).first, + let destinationAudio = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + { + try destinationAudio.insertTimeRange(timeRange, of: sourceAudio, at: .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 +354,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 +396,138 @@ import UserNotifications } } } + + 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/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 5b4cebfa97..6dad29dbe4 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'; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 9dd8b6ddfa..bb1eb7138e 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; @@ -58,6 +66,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 +113,221 @@ 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, - ), - ), - ], - ], + final reducedMotion = MediaQuery.of(context).disableAnimations; + 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, + ), ), - 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, + ), + AnimatedSlide( + offset: Offset(-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) + .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 < -900 || + revealAmount.value >= actionExtent * commitThreshold; + if (shouldCommit) { + toggleReadState(); + } else { + revealAmount.value = + velocity < -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, ), - ), - 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: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _RowAvatar( + pubkey: item.item.pubkey, + profile: profile, ), - child: Text( - '#${label.channelLabel}', - style: activityContextTextStyle.copyWith( - color: mutedColor, + 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, + ), + ), + ], ), - 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 +364,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/inbox_unread_provider.dart b/mobile/lib/features/activity/inbox_unread_provider.dart new file mode 100644 index 0000000000..a35ba134a2 --- /dev/null +++ b/mobile/lib/features/activity/inbox_unread_provider.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../channels/read_state/read_state_provider.dart'; +import 'activity_provider.dart'; +import 'inbox_local_state_provider.dart'; +import 'inbox_read_state.dart'; + +/// Number of Inbox conversations that still need attention. +/// +/// The floating tab bar uses this same read-state projection as Inbox rows, so +/// its indicator appears for every unread Inbox source and clears as soon as +/// the corresponding row is read. +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; +}); diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index ac73a5076e..499c4efb31 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -32,6 +32,7 @@ class _MessageList extends HookConsumerWidget { final itemPositionsListener = useMemoized(ItemPositionsListener.create); final isLoadingOlder = useState(false); final isAtLatest = useState(true); + final hasPendingLatest = useState(false); final hasUserScrolled = useState(false); final followsLatest = useRef( initialMessageId == null && initialThreadRootId == null, @@ -57,6 +58,7 @@ class _MessageList extends HookConsumerWidget { if (!itemScrollController.isAttached || isAutoScrolling.value) return; followsLatest.value = true; hasUserScrolled.value = false; + hasPendingLatest.value = false; isAutoScrolling.value = true; try { await itemScrollController.scrollTo( @@ -101,6 +103,7 @@ class _MessageList extends HookConsumerWidget { final nextIsAtLatest = latestIsAtBoundary(); if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; + if (hasPendingLatest.value) hasPendingLatest.value = false; } else if (followsLatest.value && !hasUserScrolled.value) { // The viewport can shrink when the composer or keyboard opens. // Preserve auto-follow until the user scrolls the timeline. @@ -181,8 +184,11 @@ class _MessageList extends HookConsumerWidget { previousLatestEntryId.value = latestEntryId; if (previous == null || latestEntryId == null || - previous == latestEntryId || - !isAtLatest.value) { + previous == latestEntryId) { + return null; + } + if (!isAtLatest.value) { + hasPendingLatest.value = true; return null; } WidgetsBinding.instance.addPostFrameCallback((_) { @@ -244,6 +250,7 @@ class _MessageList extends HookConsumerWidget { hasUserScrolled.value = false; followsLatest.value = true; if (!isAtLatest.value) isAtLatest.value = true; + if (hasPendingLatest.value) hasPendingLatest.value = false; }); } return false; @@ -354,7 +361,7 @@ class _MessageList extends HookConsumerWidget { ), ), ), - if (!isAtLatest.value) + if (hasPendingLatest.value) Positioned( left: 0, right: 0, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9d..0fc9438c50 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'; @@ -89,9 +90,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({ @@ -100,19 +100,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; } @@ -138,13 +136,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 164d17171a..e7e480e11f 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 9f0b1dd4a7..b48951e5ad 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -147,11 +147,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. @@ -208,7 +203,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => starredExpanded.value = !starredExpanded.value, channels: starredStreamChannels, unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, emptyLabel: '', @@ -226,7 +220,6 @@ class _SliverChannelsList extends HookConsumerWidget { ) .toList(), unreadChannelIds: unreadChannelIds, - unreadChannelCounts: unreadChannelCounts, mutedChannelIds: mutedChannelIds, currentPubkey: currentPubkey, expanded: sectionExpanded(section.id), @@ -312,7 +305,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', @@ -326,7 +318,6 @@ class _SliverChannelsList extends HookConsumerWidget { onToggle: () => dmsExpanded.value = !dmsExpanded.value, channels: dmChannels, 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 551e3f8dd6..4a494c1b91 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 3653932089..f575144876 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 febe16e996..d9a6d68d5b 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; @@ -25,7 +24,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, @@ -66,7 +64,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, @@ -328,7 +325,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; @@ -342,7 +338,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, @@ -385,7 +380,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/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index fe0d5cf5d4..9f46a8dcc5 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:io'; import 'dart:math' as math; -import 'dart:ui' show FlutterView; import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; @@ -20,10 +20,8 @@ import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; -import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; -import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; @@ -45,15 +43,13 @@ part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; +part 'compose_bar/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'; 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 @@ -74,7 +70,6 @@ class ComposeBar extends HookConsumerWidget { /// Optional thread IDs for thread-scoped typing indicators. final String? threadHeadId; final String? rootId; - const ComposeBar({ super.key, required this.channelId, @@ -84,13 +79,10 @@ class ComposeBar extends HookConsumerWidget { 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. // @@ -131,13 +123,7 @@ class ComposeBar extends HookConsumerWidget { 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, @@ -149,13 +135,13 @@ class ComposeBar extends HookConsumerWidget { ); final isSending = useState(false); final showFormatting = useState(false); - final attachments = useState>([]); + final attachments = useState>([]); final uploadError = useState(null); final uploadingCount = useState(0); + final uploadProgress = useState(0.0); + final uploadGeneration = useRef(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( @@ -166,47 +152,9 @@ class ComposeBar extends HookConsumerWidget { 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) { @@ -215,8 +163,8 @@ class ComposeBar extends HookConsumerWidget { composerExpansionController.animateWith( SpringSimulation( SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 220), - bounce: 0.08, + duration: const Duration(milliseconds: 280), + bounce: 0.16, ), composerExpansionController.value, target, @@ -227,7 +175,6 @@ class ComposeBar extends HookConsumerWidget { } return null; }, [isComposerExpanded.value, reducedMotion]); - useEffect(() { if (defaultTargetPlatform != TargetPlatform.iOS) return null; @@ -467,16 +414,14 @@ class ComposeBar extends HookConsumerWidget { focusNode.requestFocus(); } - void removeAttachment(String url) { - attachments.value = _withoutAttachment(attachments.value, url); + void removeAttachment(int id) { + attachments.value = _withoutAttachment(attachments.value, id); } // Send the message. Future send() async { final text = controller.text.trim(); - if ((text.isEmpty && !hasAttachments) || - isSending.value || - hasPendingUploads) { + if ((text.isEmpty && !hasAttachments) || isSending.value) { return; } @@ -550,11 +495,7 @@ class ComposeBar extends HookConsumerWidget { } } - final payload = _ComposeDraftPayload.fromDraft( - text: text, - attachments: attachments.value, - customEmoji: customEmoji, - ); + final queuedAttachments = attachments.value; isSending.value = true; try { @@ -572,47 +513,88 @@ class ComposeBar extends HookConsumerWidget { .read(channelActionsProvider) .addMembers(channelId: channelId, pubkeys: inviteHumanPubkeys); } - await onSend( - payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], - ); - if (context.mounted) { - clearComposer(); + if (queuedAttachments.isEmpty) { + final payload = _ComposeDraftPayload.fromDraft( + text: text, + attachments: const [], + customEmoji: customEmoji, + ); + await onSend( + payload.content, + mentionPubkeys, + mediaTags: [...payload.mediaTags, ...referenceMentionTags], + ); + if (context.mounted) clearComposer(); + return; } + + clearComposer(); + uploadingCount.value += 1; + uploadProgress.value = 0; + isSending.value = false; + final queueGeneration = uploadGeneration.value; + unawaited(() async { + try { + final uploaded = []; + for (var index = 0; index < queuedAttachments.length; index++) { + final attachment = queuedAttachments[index]; + final descriptor = await _uploadPendingAttachment( + ref.read(mediaUploadServiceProvider), + attachment, + onProgress: (progress) { + if (context.mounted) { + uploadProgress.value = + (index + progress) / queuedAttachments.length; + } + }, + ); + 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 onSend( + payload.content, + mentionPubkeys, + mediaTags: [...payload.mediaTags, ...referenceMentionTags], + ); + } catch (error) { + if (context.mounted) uploadError.value = _formatUploadError(error); + } finally { + if (context.mounted && queueGeneration == uploadGeneration.value) { + uploadingCount.value = math.max(0, uploadingCount.value - 1); + } + } + }()); } finally { - if (context.mounted) isSending.value = false; + if (context.mounted && isSending.value) isSending.value = false; } } - Future pickAndUpload(Future Function() pick) async { + void queueAttachment(XFile file, _PendingAttachmentKind kind) { 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; - } - } + attachments.value = [ + ...attachments.value, + _PendingAttachment(file: file, kind: kind), + ]; } - Future pickThenUpload({ + Future pickThenQueue({ required Future Function() pick, - required Future Function(XFile file) upload, + required _PendingAttachmentKind kind, }) async { uploadError.value = null; try { final picked = await pick(); if (picked == null || !context.mounted) return; - await pickAndUpload(() => upload(picked)); + queueAttachment(picked, kind); } catch (error) { if (context.mounted) { uploadError.value = _formatUploadError(error); @@ -620,62 +602,14 @@ class ComposeBar extends HookConsumerWidget { } } - Future uploadImages(List images) async { + void queueImages(List images) { 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, - ); - } - } + attachments.value = [ + ...attachments.value, + for (final image in images) + _PendingAttachment(file: image, kind: _PendingAttachmentKind.image), + ]; } Widget buildContextMenu( @@ -684,9 +618,20 @@ class ComposeBar extends HookConsumerWidget { ) { void pasteImage() { ContextMenuController.removeAny(); - pickAndUpload( - ref.read(mediaUploadServiceProvider).readAndUploadClipboardImage, - ); + 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 && @@ -725,10 +670,9 @@ class ComposeBar extends HookConsumerWidget { return; } - pickAndUpload( - () => ref - .read(mediaUploadServiceProvider) - .uploadImage(XFile.fromData(bytes)), + queueAttachment( + XFile.fromData(bytes, name: 'Pasted image'), + _PendingAttachmentKind.image, ); } @@ -802,28 +746,27 @@ class ComposeBar extends HookConsumerWidget { iosAttachmentPopover .present( sourceContext: triggerContext, - onCapture: (image) => pickAndUpload( - () => ref.read(mediaUploadServiceProvider).uploadImage(image), - ), - onChoosePhotos: uploadImages, + onCapture: (image) async => + queueAttachment(image, _PendingAttachmentKind.image), + onChoosePhotos: (photos) async => queueImages(photos), onAllPhotos: () => chooseAttachment(() async { final photos = await ref .read(mediaUploadServiceProvider) .pickGalleryImages(); - await uploadImages(photos); + queueImages(photos); }, errorMessage: 'Unable to open your photo library.'), onVideo: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); - return pickThenUpload( + return pickThenQueue( pick: service.pickGalleryVideo, - upload: service.uploadVideo, + kind: _PendingAttachmentKind.video, ); }), onFiles: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); - return pickThenUpload( + return pickThenQueue( pick: service.pickAttachmentFile, - upload: service.uploadFile, + kind: _PendingAttachmentKind.file, ); }), ) @@ -907,91 +850,145 @@ class ComposeBar extends HookConsumerWidget { }, onVideo: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); - return pickThenUpload( + return pickThenQueue( pick: service.pickGalleryVideo, - upload: service.uploadVideo, + kind: _PendingAttachmentKind.video, ); }), onFiles: () => chooseAttachment(() { final service = ref.read(mediaUploadServiceProvider); - return pickThenUpload( + return pickThenQueue( pick: service.pickAttachmentFile, - upload: service.uploadFile, + kind: _PendingAttachmentKind.file, ); }), onCapture: (image) async { attachmentSurface.value = _AttachmentSurface.closed; - await pickAndUpload( - () => ref.read(mediaUploadServiceProvider).uploadImage(image), - ); + queueAttachment(image, _PendingAttachmentKind.image); }, onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages, onChoosePhotos: (photos) async { attachmentSurface.value = _AttachmentSurface.closed; - await uploadImages(photos); + 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; - 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, - ), + return Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _UploadProgressMotion( + visible: uploadingCount.value > 0, + progress: uploadProgress.value, + reducedMotion: reducedMotion, + onCancel: () { + uploadGeneration.value += 1; + uploadingCount.value = 0; + uploadProgress.value = 0; + }, + ), + OverlayPortal.overlayChildLayoutBuilder( + controller: suggestionOverlayController, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return ValueListenableBuilder<_AttachmentSurface>( + valueListenable: attachmentSurface, + builder: (context, surface, _) { + final surfaceDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + final expandedSurfaceCoversComposer = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final overlayAnchorY = + composerOrigin.dy + + (expandedSurfaceCoversComposer + ? layoutInfo.childSize.height + Grid.twelve + : 0); + return AnimatedPositioned( + duration: surfaceDuration, + curve: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? const Cubic(0.34, 1.25, 0.64, 1) + : const Cubic(0.22, 1, 0.36, 1), + left: composerOrigin.dx, + bottom: layoutInfo.overlaySize.height - overlayAnchorY, + width: layoutInfo.childSize.width, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: surface == _AttachmentSurface.closed + ? _SuggestionPanelMotion( + duration: surfaceDuration, + alignment: Alignment.bottomLeft, + child: buildOverlayPanel(surface), + ) + : buildOverlayPanel(surface), + ), + ), + ); + }, + ); + }, + 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; + showEmojiPicker(context: context, onSelect: insertEmoji); + }, + onOpenFormatting: () { + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = true; + }, + hasPendingUploads: false, + isSending: isSending.value, + ), + ), + ], ), ); } diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index eee4d222ac..41d10e88ac 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -177,7 +177,7 @@ class _AttachmentSurfacePanel extends HookWidget { final height = menuLayout.height + ((expandedHeight - menuLayout.height) * sizeProgress); - final baseColor = appPopoverColor(context); + final baseColor = context.colors.surfaceContainerHighest; final expandedColor = visibleExpandedSurface == _AttachmentSurface.camera ? Colors.black @@ -189,51 +189,57 @@ class _AttachmentSurfacePanel extends HookWidget { child: SizedBox( width: width, height: height, - child: Material( - key: const ValueKey('attachment-surface-popover'), - type: MaterialType.card, - color: Color.lerp(baseColor, expandedColor, sizeProgress), - surfaceTintColor: Colors.transparent, - elevation: appPopoverElevation, - shadowColor: appPopoverShadowColor(context), - shape: appPopoverShape(context), - clipBehavior: Clip.antiAlias, - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - Positioned( - left: 0, - top: 0, - width: _attachmentMenuWidth, - height: menuLayout.height, - child: IgnorePointer( - ignoring: surface != _AttachmentSurface.menu, - child: Opacity( - opacity: menuOpacity, - child: _AttachmentMenu( - layout: menuLayout, - onCamera: onCamera, - onPhotos: onPhotos, - onVideo: onVideo, - onFiles: onFiles, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color.lerp(baseColor, expandedColor, sizeProgress), + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.dialog), + child: Material( + type: MaterialType.transparency, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: 0, + top: 0, + width: _attachmentMenuWidth, + height: menuLayout.height, + child: IgnorePointer( + ignoring: surface != _AttachmentSurface.menu, + child: Opacity( + opacity: menuOpacity, + child: _AttachmentMenu( + layout: menuLayout, + onCamera: onCamera, + onPhotos: onPhotos, + onVideo: onVideo, + onFiles: onFiles, + ), + ), ), ), - ), - ), - Positioned( - left: 0, - top: 0, - width: expandedWidth, - height: expandedHeight, - child: IgnorePointer( - ignoring: !isExpanded, - child: Opacity( - opacity: expandedOpacity, - child: expandedContent, + Positioned( + left: 0, + top: 0, + width: expandedWidth, + height: expandedHeight, + child: IgnorePointer( + ignoring: !isExpanded, + child: Opacity( + opacity: expandedOpacity, + child: expandedContent, + ), + ), ), - ), + ], ), - ], + ), ), ), ), @@ -266,6 +272,38 @@ class _ComposeDraftPayload { } } +enum _PendingAttachmentKind { image, video, file } + +@immutable +class _PendingAttachment { + static var _nextId = 0; + + final int id; + final XFile file; + final _PendingAttachmentKind kind; + + _PendingAttachment({required this.file, required this.kind}) : id = _nextId++; +} + +Future _uploadPendingAttachment( + MediaUploadService service, + _PendingAttachment attachment, { + ValueChanged? onProgress, +}) => switch (attachment.kind) { + _PendingAttachmentKind.image => service.uploadImage( + attachment.file, + onProgress: onProgress, + ), + _PendingAttachmentKind.video => service.uploadVideo( + attachment.file, + onProgress: onProgress, + ), + _PendingAttachmentKind.file => service.uploadFile( + attachment.file, + onProgress: onProgress, + ), +}; + class _AttachmentTrigger extends StatelessWidget { final _AttachmentSurface surface; final bool formattingOpen; @@ -302,7 +340,7 @@ class _AttachmentTrigger extends StatelessWidget { _AttachmentSurface.camera || _AttachmentSurface.photos => 'Back to attachment options', }, - onPressed: () => _runComposerAction(() => onTap(context)), + onPressed: () => onTap(context), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, icon: AnimatedRotation( @@ -411,7 +449,7 @@ class _AttachmentMenuItem extends StatelessWidget { child: Tooltip( message: label, child: InkWell( - onTap: () => _runComposerAction(onTap), + onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( @@ -451,26 +489,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; - - const _AttachmentStrip({ - required this.attachments, - required this.uploadingCount, - required this.onRemove, - }); + final List<_PendingAttachment> attachments; + final ValueChanged onRemove; + + const _AttachmentStrip({required this.attachments, required this.onRemove}); @override Widget build(BuildContext context) { @@ -481,70 +514,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 +530,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 +541,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, @@ -591,7 +567,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, @@ -611,8 +589,7 @@ class _AttachmentStrip extends StatelessWidget { width: 24, height: 24, child: IconButton( - onPressed: () => - _runComposerAction(() => onRemove(attachment.url)), + onPressed: () => onRemove(attachment.id), tooltip: 'Remove attachment', visualDensity: VisualDensity.compact, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index e0adb9621c..029a4368e2 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/upload_progress_pill.dart b/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart new file mode 100644 index 0000000000..1f8cae3ee7 --- /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 HookWidget { + 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) { + 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 f7e1af9211..b5eb308c63 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 0000000000..e0c8cd72e8 --- /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 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); + 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 0000000000..0fccbcacd1 --- /dev/null +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -0,0 +1,319 @@ +part of '../media_viewer_page.dart'; + +// 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; + final VoidCallback? onReply; + + const MediaVideoViewerPage({ + super.key, + required this.videoUrl, + this.posterUrl, + this.onReply, + }); + + @override + State createState() => _MediaVideoViewerPageState(); +} + +class _MediaVideoViewerPageState extends State + with SingleTickerProviderStateMixin { + static const _dismissThreshold = 100.0; + static const _dismissVelocity = 700.0; + static const _backgroundFadeDivisor = 300.0; + + VideoPlayerController? _controller; + File? _videoFile; + late final Future _initializeFuture; + late final AnimationController _snapBackController; + String? _error; + double _dragOffset = 0; + bool _isDragging = false; + + @override + void initState() { + super.initState(); + _snapBackController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _initializeFuture = _initializeVideo(); + } + + /// Download through Buzz's authenticated media client before handing the + /// local file to AVPlayer. AVPlayer does not consistently preserve custom + /// authorization headers for its follow-up range requests, which makes + /// protected relay video fail after the first request. + Future _initializeVideo() async { + try { + final container = ProviderScope.containerOf(context, listen: false); + final client = container.read(mediaHttpClientProvider); + final auth = container.read(mediaGetAuthServiceProvider); + final uri = Uri.parse(widget.videoUrl); + final request = http.Request('GET', uri) + ..headers.addAll(auth.headersFor(widget.videoUrl)); + final response = await client.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.drain(); + throw HttpException( + 'Video download failed (${response.statusCode})', + uri: uri, + ); + } + + final directory = await getTemporaryDirectory(); + final file = File( + '${directory.path}${Platform.pathSeparator}' + 'buzz-video-${DateTime.now().microsecondsSinceEpoch}' + '${_videoFileExtension(uri)}', + ); + _videoFile = file; + await response.stream.pipe(file.openWrite()); + if (!mounted) { + await _deleteVideoFile(); + return; + } + + final controller = VideoPlayerController.file(file); + _controller = controller; + await controller.initialize(); + if (mounted) setState(() {}); + } catch (error) { + if (mounted) { + setState(() { + _error = error.toString(); + }); + } + } + } + + @override + void dispose() { + _snapBackController.dispose(); + final controller = _controller; + if (controller != null) unawaited(controller.dispose()); + unawaited(_deleteVideoFile()); + super.dispose(); + } + + void _onVerticalDragStart(DragStartDetails details) { + _snapBackController.stop(); + setState(() => _isDragging = true); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + if (!_isDragging) return; + setState(() { + _dragOffset = (_dragOffset + details.delta.dy).clamp( + 0.0, + MediaQuery.sizeOf(context).height, + ); + }); + } + + void _onVerticalDragEnd(DragEndDetails details) { + _isDragging = false; + final velocity = details.primaryVelocity ?? 0; + if (_dragOffset > _dismissThreshold || velocity > _dismissVelocity) { + _controller?.pause(); + Navigator.of(context).maybePop(); + return; + } + _animateSnapBack(); + } + + void _animateSnapBack() { + _isDragging = false; + if (MediaQuery.disableAnimationsOf(context)) { + setState(() => _dragOffset = 0); + return; + } + + final tween = Tween(begin: _dragOffset, end: 0); + void listener() { + if (mounted) { + setState(() => _dragOffset = 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 _deleteVideoFile() async { + final file = _videoFile; + _videoFile = 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. + } + } + + @override + Widget build(BuildContext context) { + final viewportHeight = MediaQuery.sizeOf(context).height; + final dragProgress = (_dragOffset / viewportHeight).clamp(0.0, 1.0); + final videoScale = 1 - (dragProgress * 0.1); + final chromeOpacity = (1 - (_dragOffset / 160)).clamp(0.0, 1.0); + return Scaffold( + key: const ValueKey('message-media-video-viewer'), + backgroundColor: Colors.black.withValues( + alpha: (1 - (_dragOffset / _backgroundFadeDivisor)).clamp(0.3, 1.0), + ), + body: Stack( + children: [ + Positioned.fill( + child: Transform.translate( + offset: Offset(0, _dragOffset), + child: Transform.scale( + scale: videoScale, + child: GestureDetector( + key: const ValueKey('message-media-video-viewer-gesture'), + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _animateSnapBack, + 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, + ); + } + + final controller = _controller; + if (controller == null || + !controller.value.isInitialized) { + return _VideoLoadingPoster( + posterUrl: widget.posterUrl, + ); + } + + return AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ); + }, + ), + ), + ), + ), + ), + ), + ), + 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, + onReply: widget.onReply, + ), + ), + ), + ), + ], + ), + ); + } +} + +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_content.dart b/mobile/lib/features/channels/message_content.dart index 465585d966..d03d7f8c6f 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -3,14 +3,17 @@ 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'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; +import 'package:http/http.dart' as http; 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 +28,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 +310,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 +457,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 0000000000..ca9c320246 --- /dev/null +++ b/mobile/lib/features/channels/message_content/video_preview.dart @@ -0,0 +1,276 @@ +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); + final client = ref.watch(mediaHttpClientProvider); + return (url) => _loadVideoPreviewFrame( + url, + headers: auth.headersFor(url), + client: client, + ); +}); + +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, + required http.Client client, +}) async { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) return null; + + VideoPlayerController? controller; + File? downloadedFile; + + Future disposeResources() async { + final currentController = controller; + controller = null; + if (currentController != null) await currentController.dispose(); + final currentFile = downloadedFile; + downloadedFile = null; + if (currentFile != null) { + try { + if (await currentFile.exists()) await currentFile.delete(); + } on FileSystemException { + // Temporary preview cleanup should never break the message timeline. + } + } + } + + 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 (_) { + // Some protected media servers or AVPlayer range requests do not retain + // custom headers. Fall back to the same authenticated local-file path as + // the full-screen viewer so posterless historical events still get a frame. + await disposeResources(); + try { + final request = http.Request('GET', uri)..headers.addAll(headers); + final response = await client.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.drain(); + return null; + } + final directory = await getTemporaryDirectory(); + downloadedFile = File( + '${directory.path}${Platform.pathSeparator}' + 'buzz-video-preview-${DateTime.now().microsecondsSinceEpoch}' + '${_previewVideoFileExtension(uri)}', + ); + await response.stream.pipe(downloadedFile!.openWrite()); + if (!await initialize(VideoPlayerController.file(downloadedFile!))) { + await disposeResources(); + return null; + } + } catch (_) { + 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(); + }, + ); +} + +String _previewVideoFileExtension(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'; +} diff --git a/mobile/lib/features/channels/message_media.dart b/mobile/lib/features/channels/message_media.dart index f46a6614e2..40d0e31773 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/home/home_page.dart b/mobile/lib/features/home/home_page.dart index a40e317469..cb461d0045 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -8,8 +8,10 @@ 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 '../activity/inbox_unread_provider.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; @@ -29,6 +31,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 +59,19 @@ 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 hasUnreadInbox = ref.watch(unreadInboxItemCountProvider) > 0; final systemBottomInset = MediaQuery.paddingOf(context).bottom; final navigationBarWidth = _floatingTabBarWidth( MediaQuery.sizeOf(context).width, @@ -80,7 +101,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 +136,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 +202,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 +310,7 @@ class _FloatingTabBar extends StatelessWidget { child: _FloatingTabDestination( destination: destinations[i], selected: i == safeSelectedIndex, + showUnreadBadge: i == 1 && hasUnreadInbox, onTap: () => onDestinationSelected(i), ), ), @@ -292,27 +332,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 +377,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/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58c93979d7..02ba6afdb3 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,5 +1,6 @@ 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 +21,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 +75,7 @@ typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); typedef TranscodeVideoToMp4 = Future Function(String filePath); +typedef GenerateVideoPoster = Future Function(String filePath); typedef ReadClipboardImage = Future Function(); class MediaPolicyUploadException implements Exception { @@ -146,6 +149,20 @@ class BlobDescriptor { filename: value, ); + 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 +198,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 +214,7 @@ class MediaUploadService { SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, + GenerateVideoPoster? generateVideoPoster, ReadClipboardImage? readClipboardImage, DateTime Function()? now, http.Client? httpClient, @@ -214,6 +233,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 +254,15 @@ class MediaUploadService { /// Opens the system picker with multi-selection enabled. Future> pickGalleryImages() => _pickGalleryImages(); - Future uploadImage(XFile image) async { + Future uploadImage( + XFile image, { + ValueChanged? onProgress, + }) async { final preparedImage = await _prepareUploadImage(image); return _uploadPreparedBytes( preparedImage.bytes, mimeType: preparedImage.mimeType, + onProgress: onProgress, ); } @@ -250,18 +274,26 @@ 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, + }) async { final length = await pickedVideo.length(); if (length > _maxVideoSizeBytes) { throw Exception( @@ -282,7 +314,59 @@ class MediaUploadService { ); } final bytes = await transcodedFile.readAsBytes(); - return uploadBytes(bytes, mimeType: 'video/mp4'); + final video = await uploadBytes( + bytes, + mimeType: 'video/mp4', + onProgress: onProgress == null + ? null + : (progress) => onProgress(progress * 0.9), + ); + + // 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 { + 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)), + ); + 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 +394,10 @@ class MediaUploadService { } /// Uploads [pickedFile] as a size-limited generic attachment. - Future uploadFile(XFile pickedFile) async { + Future uploadFile( + XFile pickedFile, { + ValueChanged? onProgress, + }) async { final length = await pickedFile.length(); if (length == 0) { throw Exception('File is empty.'); @@ -325,6 +412,7 @@ class MediaUploadService { bytes, mimeType: 'application/octet-stream', allowGenericFile: true, + onProgress: onProgress, ); return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); } @@ -338,6 +426,7 @@ class MediaUploadService { Future uploadBytes( Uint8List bytes, { required String mimeType, + ValueChanged? onProgress, }) async { if (mimeType == 'image/gif' || (mimeType == 'image/png' && _isAnimatedPng(bytes)) || @@ -348,13 +437,18 @@ class MediaUploadService { throw Exception('failed to sanitize image for upload'); } } - return _uploadPreparedBytes(bytes, mimeType: mimeType); + return _uploadPreparedBytes( + bytes, + mimeType: mimeType, + onProgress: onProgress, + ); } Future _uploadPreparedBytes( Uint8List bytes, { required String mimeType, bool allowGenericFile = false, + ValueChanged? onProgress, }) async { if (!allowGenericFile && !_allowedImageMimeTypes.contains(mimeType) && @@ -363,25 +457,22 @@ class MediaUploadService { } final sha256 = _sha256Hex(bytes); - var request = _buildUploadRequest( + var response = await _sendUploadRequest( bytes: bytes, mimeType: mimeType, sha256: sha256, path: _mediaUploadPath, + onProgress: onProgress, ); - - 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, ); - streamed = await _http.send(request); - response = await http.Response.fromStream(streamed); } if (response.statusCode < 200 || response.statusCode >= 300) { if (_allowedImageMimeTypes.contains(mimeType) && @@ -399,18 +490,27 @@ 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, + }) async { + final request = http.StreamedRequest( + 'PUT', + Uri.parse(_baseUrl).resolve(path), + ); + 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; + return http.Response.fromStream(response); } Map _buildUploadHeaders({ @@ -547,6 +647,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 +847,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 0000000000..5e4e5971ac --- /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 6a0bd2729d..8e8a8b1ce1 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: DefaultTextStyle.merge( - style: effectiveTitleStyle, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, + 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!, + ), ), - ), - ) - 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 fc0e2fe506..9772fd0246 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 aecb3303fc..75dad316f4 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.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,10 +603,155 @@ 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', ( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 56032a5dbf..24aba9ecfd 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1276,7 +1276,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', @@ -1320,15 +1320,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 = [ @@ -1387,9 +1393,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 { @@ -1431,7 +1438,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 7dd2c2b608..e78b8d828e 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1392,7 +1392,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 +1444,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,62 +1795,130 @@ 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 {}, - ), - ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sent = true; + }, + ), + ); - await _openSystemPhotoPicker(tester); - await tester.pump(); + await _openSystemPhotoPicker(tester); + await tester.pump(); - expect( - find.byKey(const ValueKey('compose-upload-progress')), - findsOneWidget, - ); - expect(find.bySemanticsLabel('Uploading attachment…'), findsOneWidget); - - uploadResponse.complete( - http.Response( - jsonEncode({ - 'url': 'https://relay.example/media/test.png', - 'sha256': - '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - 'size': 16, - 'type': 'image/png', - 'uploaded': 1, - }), - 200, - ), - ); - await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); - 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('renders markdown formatting without visible delimiters', ( tester, @@ -2034,21 +2091,14 @@ void main() { await tester.pumpAndSettle(); expect(galleryPickerCalled, isFalse); - expect(uploadedBytes, _pngBytes); - expect(uploadedMimeType, 'image/png'); - expect( - find.byKey( - const ValueKey( - 'compose-attachment:https://relay.example/media/pasted.png', - ), - ), - findsOneWidget, - ); + expect(uploadedBytes, isNull); 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 +2167,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 +2289,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 +2458,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,6 +2514,9 @@ void main() { await _openSystemPhotoPicker(tester); await tester.pumpAndSettle(); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); }); @@ -2516,6 +2557,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 +2609,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,14 +2897,7 @@ 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 diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 9d8adb69bc..0198ee77f5 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 { @@ -1134,6 +1186,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 +1264,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 b5ddf0ee7d..8e61a71dd1 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/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index cf688ce726..49569a788d 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; @@ -8,13 +9,25 @@ 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)], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + unreadInboxItemCountProvider.overrideWith((_) => unreadInboxCount), + ], child: MaterialApp( theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: child!, + ), home: const HomePage(settingsPageBuilder: _buildSettingsPage), ), ); @@ -95,6 +108,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 dcd04359b2..1d3303b13f 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1340,6 +1340,127 @@ void main() { } }); + 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 75c2c9f5580ac7d14820d8fc8544af505bc5a5cb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 12:14:53 +0100 Subject: [PATCH 02/20] Refine desktop upload progress pill Signed-off-by: kenny lopez --- .../features/messages/ui/ComposerUploadProgressPill.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx index 3714de7602..a8f4167c9b 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -79,13 +79,14 @@ export function ComposerUploadProgressPill({
Uploading - - - {percentage}% + + {" · "} + {percentage}% + + + Remove attachment + + ) : null} + + ); + })} {isUploading && uploadPlaceholders.map((preview) => ( - Math.min(100, Math.max(0, Math.round(preview.progress ?? 0))), - ), - ); -} - export function ComposerUploadProgressPill({ - media, + isUploading, + onCancel, + percentage, }: { - media: Pick< - MediaUploadController, - "cancelUpload" | "isUploading" | "uploadingPreviews" - >; + isUploading: boolean; + onCancel: () => void; + percentage: number; }) { const reducedMotion = useReducedMotion(); - const previews = media.uploadingPreviews; - const percentage = aggregateUploadProgress(previews); - const handleCancel = () => { - for (const preview of previews) media.cancelUpload(preview.id); - }; return ( - {media.isUploading ? ( + {isUploading ? ( Cancel diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index f044bbe308..e454c62be8 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -10,20 +10,20 @@ 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, + 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 { @@ -56,6 +56,7 @@ 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"; @@ -84,6 +85,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, + showBackgroundUploadProgress = false, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -143,11 +145,12 @@ function MessageComposerImpl({ 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(); + // New files stay local so the composer can render previews immediately; + // submitting hands them to the app-wide background upload queue. + const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; + const backgroundUpload = useBackgroundMediaUpload(); // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on // key change, and persist the outgoing draft in the cleanup. The StrictMode @@ -309,6 +312,8 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -513,77 +518,52 @@ function MessageComposerImpl({ // Edit mode if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; - const currentPendingImeta = media.pendingImetaRef.current; // 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, + customEmoji, + originalContent: editTargetRef.current.body, + ownerPubkey: ownerPubkeyRef.current, + 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); + }, + 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; @@ -604,6 +584,7 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, + queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -623,8 +604,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, @@ -826,15 +811,19 @@ function MessageComposerImpl({ const sendDisabled = React.useMemo( () => disabled || - media.isUploading || + (editTarget !== null && media.isUploading) || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0), [ disabled, + editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + media.queuedAttachments.length, ], ); @@ -896,7 +885,13 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> - + {showBackgroundUploadProgress ? ( + + ) : null} ) : null} - {(media.pendingImeta.length > 0 || media.isUploading) && ( + {(media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading) && (
diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts new file mode 100644 index 0000000000..6762745461 --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -0,0 +1,100 @@ +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +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; + pendingImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + spoileredAttachmentUrls: Set; +}; + +type SubmitMessageEditOptions = EditDraft & { + clearComposer: () => void; + customEmoji: ReadonlyArray; + extractMentionPubkeys: (content: string) => string[]; + originalContent: string; + ownerPubkey: string | null; + restoreComposer: (draft: EditDraft) => void; + save: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; + setUploadError: (message: string) => void; +}; + +/** Clear an edited message immediately, then upload and save captured state. */ +export async function submitMessageEdit({ + clearComposer, + content, + customEmoji, + extractMentionPubkeys, + originalContent, + ownerPubkey, + pendingImeta, + queuedAttachments, + restoreComposer, + save, + setUploadError, + spoileredAttachmentUrls, +}: SubmitMessageEditOptions): Promise { + const draft: EditDraft = { + content, + pendingImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeys(originalContent), + extractMentionPubkeys(content), + ownerPubkey ?? "", + ); + clearComposer(); + + const finishEdit = async (uploaded: ImetaMedia[]) => { + // An explicit empty media tag set tells edit receivers to wipe attachments. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + content, + [...draft.pendingImeta, ...uploaded], + draft.spoileredAttachmentUrls, + ); + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ) ?? []; + await save(finalContent, outgoingTags, addedMentionPubkeys); + }; + + if (draft.queuedAttachments.length > 0) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded) => { + try { + await finishEdit(uploaded); + } catch { + restoreComposer(draft); + } + }, + onError: (error) => { + restoreComposer(draft); + setUploadError(String(error)); + }, + }); + return; + } + + try { + await finishEdit([]); + } catch { + restoreComposer(draft); + } +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..345b87fc12 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -13,6 +13,10 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import { + enqueueBackgroundMediaUpload, + type QueuedMediaAttachment, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -36,7 +40,7 @@ type PendingNonMemberMentionSend = { parentEventId: string | null; threadHeadId: string | null; } | null; - finalContent: string; + trimmed: string; mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; @@ -44,6 +48,7 @@ type PendingNonMemberMentionSend = { readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; savedSpoileredAttachmentUrls: Set; sentDraftKey: string | null | undefined; audienceGeneration: number; @@ -60,6 +65,7 @@ type SendMessageWithMentionFlowInput = { threadHeadId: string | null; } | null; pendingImeta: ImetaMedia[]; + queuedAttachments?: QueuedMediaAttachment[]; sentDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; @@ -98,6 +104,8 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -161,6 +169,8 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, + restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -398,6 +408,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -415,6 +426,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -514,11 +526,37 @@ export function useMentionSendFlow({ ); } - try { - await onSendRef.current( - draft.finalContent, + const send = onSendRef.current; + const restoreComposerAfterFailure = () => { + if ( + !isMountedRef.current || + draft.capturedChannelId !== channelIdRef.current + ) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + const finishSend = async (uploaded: ImetaMedia[]) => { + const { content: finalContent, mediaTags } = buildOutgoingMessage( + draft.trimmed, + [...draft.savedImeta, ...uploaded], + draft.savedSpoileredAttachmentUrls, + ); + const finalOutgoingTags = mergeOutgoingTags( + mediaTags, + outgoingTags ?? [], + ); + await send( + finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); @@ -542,17 +580,30 @@ export function useMentionSendFlow({ [...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 (draft.queuedAttachments.length > 0) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded) => { + try { + await finishSend(uploaded); + } catch { + restoreComposerAfterFailure(); + } + }, + onError: (error) => { + restoreComposerAfterFailure(); + toast.error( + `Upload failed: ${getErrorMessage(error, "Unknown error")}`, + ); + }, + }); + } else { + try { + await finishSend([]); + } catch { + restoreComposerAfterFailure(); } } } finally { @@ -576,6 +627,7 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + restoreQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -642,6 +694,7 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, + queuedAttachments = [], sentDraftKey, spoileredAttachmentUrls = new Set(), trimmed, @@ -703,15 +756,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) => @@ -734,7 +779,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - finalContent, + trimmed, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -745,6 +790,7 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, audienceGeneration, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 037274176e..0a836126c7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -355,6 +355,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; diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 403d26c136..dd7a700c4d 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -11,6 +12,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 +26,25 @@ 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", + }); +} + 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 +77,64 @@ 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("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("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 345e1ee4d7..9e1626b756 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -361,6 +361,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 279a8842838f8dd4930dfe3661fe589f7b7b547c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 14:01:30 +0100 Subject: [PATCH 04/20] Move desktop uploads to a separate PR Signed-off-by: kenny lopez --- desktop/src-tauri/src/commands/media.rs | 10 +- .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../features/communities/useCommunityInit.ts | 2 - .../lib/backgroundMediaUploadStore.ts | 175 ------------------ .../features/messages/lib/useMediaUpload.ts | 172 +---------------- .../messages/ui/ComposerAttachments.tsx | 68 +------ .../ui/ComposerUploadProgressPill.tsx | 85 --------- .../features/messages/ui/MessageComposer.tsx | 141 +++++++------- .../messages/ui/MessageComposer.types.ts | 2 - .../features/messages/ui/NewMessageScreen.tsx | 1 - .../features/messages/ui/submitMessageEdit.ts | 100 ---------- .../messages/ui/useMentionSendFlow.ts | 98 +++------- desktop/src/shared/api/tauri.ts | 8 +- desktop/src/testing/e2eBridge.ts | 2 - desktop/tests/e2e/file-attachment.spec.ts | 76 +------- desktop/tests/helpers/bridge.ts | 2 - 16 files changed, 113 insertions(+), 832 deletions(-) delete mode 100644 desktop/src/features/messages/lib/backgroundMediaUploadStore.ts delete mode 100644 desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx delete mode 100644 desktop/src/features/messages/ui/submitMessageEdit.ts diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index b3921be02d..ed3b340238 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -573,7 +573,6 @@ 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. @@ -640,7 +639,8 @@ 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, progress).await?; + let mut descriptor = do_upload(body, &mime, state, None).await?; + if let Some(poster) = poster_bytes { match do_upload(poster, "image/jpeg", state, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), @@ -675,7 +675,6 @@ 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; @@ -695,8 +694,7 @@ 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 progress = progress_id.clone().map(|id| (app.clone(), id)); - let descriptor = process_picked_path(path, &state, false, progress).await?; + let descriptor = process_picked_path(path, &state, false).await?; descriptors.push(descriptor); } @@ -737,7 +735,7 @@ 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, None).await?; + let descriptor = process_picked_path(path, &state, true).await?; Ok(Some(descriptor)) } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9489d95209..770c45e445 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -174,7 +174,7 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, ); - const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const mainComposerMedia = useMediaUpload(); const isNonMemberView = activeChannel !== null && !activeChannel.isMember && @@ -792,7 +792,6 @@ export const ChannelPane = React.memo(function ChannelPane({ : "Select a channel" } showTopBorder={false} - showBackgroundUploadProgress /> {/* The activity accessory is anchored in the dock's reserved bottom rail, so fading it cannot change the observed diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..afa69f913f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -19,7 +19,6 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; -import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -68,7 +67,6 @@ function resetCommunityState({ resetMediaCaches(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); - resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts deleted file mode 100644 index 9add1223a3..0000000000 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as React from "react"; - -import { type BlobDescriptor, uploadMediaBytes } from "@/shared/api/tauri"; - -export type QueuedMediaAttachment = { - file: File; - id: number; - previewUrl?: string; -}; - -type BackgroundUploadTask = { - canceled: boolean; - fileProgress: number[]; - id: number; -}; - -type BackgroundUploadSnapshot = { - isUploading: boolean; - percentage: number; -}; - -type EnqueueBackgroundUploadOptions = { - attachments: QueuedMediaAttachment[]; - onComplete: (descriptors: BlobDescriptor[]) => Promise; - onError: (error: unknown) => void; -}; - -const tasks = new Map(); -const listeners = new Set<() => void>(); -let nextTaskId = 0; -let snapshot: BackgroundUploadSnapshot = { - isUploading: false, - percentage: 0, -}; -let stopProgressListener: (() => void) | null = null; -let progressListenerPromise: Promise | null = null; - -function progressId(taskId: number, fileIndex: number): string { - return `background-media-upload-${taskId}-${fileIndex}`; -} - -function rebuildSnapshot(): void { - const allProgress = [...tasks.values()].flatMap((task) => task.fileProgress); - snapshot = { - isUploading: allProgress.length > 0, - percentage: - allProgress.length === 0 - ? 0 - : Math.round( - allProgress.reduce((total, progress) => total + progress, 0) / - allProgress.length, - ), - }; - for (const listener of listeners) listener(); -} - -async function ensureProgressListener(): Promise { - if (stopProgressListener || progressListenerPromise) return; - progressListenerPromise = (async () => { - try { - const { listen } = await import("@tauri-apps/api/event"); - const dispose = 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.fileProgress[fileIndex] = Math.min( - 100, - Math.max( - 0, - Math.round((event.payload.sent / event.payload.total) * 100), - ), - ); - rebuildSnapshot(); - }); - if (tasks.size === 0) dispose(); - else stopProgressListener = dispose; - } catch { - // Browser and E2E runtimes do not emit native byte-level progress. - } finally { - progressListenerPromise = null; - } - })(); - await progressListenerPromise; -} - -function finishTask(taskId: number): void { - tasks.delete(taskId); - rebuildSnapshot(); - if (tasks.size === 0 && stopProgressListener) { - stopProgressListener(); - stopProgressListener = null; - } -} - -export function enqueueBackgroundMediaUpload({ - attachments, - onComplete, - onError, -}: EnqueueBackgroundUploadOptions): void { - if (attachments.length === 0) { - void onComplete([]).catch(onError); - return; - } - - const taskId = nextTaskId; - nextTaskId += 1; - const task: BackgroundUploadTask = { - canceled: false, - fileProgress: attachments.map(() => 0), - id: taskId, - }; - tasks.set(taskId, task); - rebuildSnapshot(); - void ensureProgressListener(); - - void (async () => { - try { - const descriptors: BlobDescriptor[] = []; - for (let index = 0; index < attachments.length; index += 1) { - if (task.canceled) return; - const attachment = attachments[index]; - const buffer = await attachment.file.arrayBuffer(); - if (task.canceled) return; - const descriptor = await uploadMediaBytes( - [...new Uint8Array(buffer)], - attachment.file.name, - progressId(taskId, index), - ); - if (task.canceled) return; - task.fileProgress[index] = 100; - rebuildSnapshot(); - descriptors.push(descriptor); - } - - if (!task.canceled) await onComplete(descriptors); - } catch (error) { - if (!task.canceled) onError(error); - } finally { - finishTask(taskId); - } - })(); -} - -export function cancelBackgroundMediaUploads(): void { - for (const task of tasks.values()) task.canceled = true; - tasks.clear(); - rebuildSnapshot(); - stopProgressListener?.(); - stopProgressListener = null; -} - -export const resetBackgroundMediaUploads = cancelBackgroundMediaUploads; - -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/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index f14d153d4d..b627633be2 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,7 +5,6 @@ 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. @@ -134,22 +133,7 @@ async function captureVideoPosterFrame( } } -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); +export function useMediaUpload() { const [uploadState, setUploadState] = React.useState({ status: "idle", }); @@ -160,11 +144,6 @@ 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; @@ -269,83 +248,6 @@ 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 }; - }); - - 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)); - }, - [clearQueuedAttachments, queueFiles], - ); - - React.useEffect( - () => () => { - for (const attachment of queuedAttachmentsRef.current) { - if (attachment.previewUrl?.startsWith("blob:")) { - URL.revokeObjectURL(attachment.previewUrl); - } - } - }, - [], - ); const isUploadCanceled = React.useCallback( (previewId?: number) => @@ -465,19 +367,6 @@ 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 @@ -485,7 +374,7 @@ export function useMediaUpload({ const previewId = reserveUploadingPreview(); setUploadingCount((c) => c + 1); try { - const descriptors = await pickAndUploadMedia(uploadProgressId(previewId)); + const descriptors = await pickAndUploadMedia(); if (isUploadCanceled(previewId)) return; finishUpload(previewId); for (const descriptor of descriptors) { @@ -496,14 +385,7 @@ export function useMediaUpload({ if (isUploadCanceled(previewId)) return; onUploadError(err, previewId); } - }, [ - queueUntilSend, - finishUpload, - isUploadCanceled, - onUploadError, - queueFiles, - reserveUploadingPreview, - ]); + }, [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -517,11 +399,6 @@ 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); @@ -548,11 +425,9 @@ export function useMediaUpload({ }, [ reserveSlots, - queueUntilSend, fillSlot, isUploadCanceled, onUploadError, - queueFiles, reserveUploadingPreview, ], ); @@ -622,11 +497,6 @@ export function useMediaUpload({ event.preventDefault(); - if (queueUntilSend) { - queueFiles(mediaFiles); - return; - } - setUploadingCount((c) => c + mediaFiles.length); const baseIndex = reserveSlots(mediaFiles.length); @@ -652,11 +522,9 @@ export function useMediaUpload({ }, [ reserveSlots, - queueUntilSend, fillSlot, isUploadCanceled, onUploadError, - queueFiles, reserveUploadingPreview, ], ); @@ -664,10 +532,6 @@ 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 { @@ -683,14 +547,7 @@ export function useMediaUpload({ onUploadError(err, previewId); } }, - [ - queueUntilSend, - isUploadCanceled, - onUploaded, - onUploadError, - queueFiles, - reserveUploadingPreview, - ], + [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], ); /** @@ -785,21 +642,10 @@ export function useMediaUpload({ ); const isUploading = uploadingCount > 0; - const queuedPreviews = React.useMemo( - () => - queuedAttachments.map((attachment) => ({ - filename: attachment.file.name, - id: attachment.id, - posterUrl: attachment.previewUrl, - type: attachment.file.type, - })), - [queuedAttachments], - ); return React.useMemo( () => ({ cancelUpload, - clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -811,12 +657,7 @@ export function useMediaUpload({ originalUrlByUrl, pendingImeta, pendingImetaRef, - queuedAttachments, - queuedAttachmentsRef, - queuedPreviews, removeAttachment, - removeQueuedAttachment, - restoreQueuedAttachments, revertAttachment, setPendingImeta, setUploadState, @@ -828,7 +669,6 @@ export function useMediaUpload({ }), [ cancelUpload, - clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -839,11 +679,7 @@ export function useMediaUpload({ isUploading, originalUrlByUrl, pendingImeta, - queuedAttachments, - queuedPreviews, removeAttachment, - removeQueuedAttachment, - restoreQueuedAttachments, revertAttachment, setPendingImeta, uploadEditedAttachment, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index c27a0a2943..bee38e321c 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -61,10 +61,6 @@ type ComposerAttachmentsProps = { attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; - /** Remove a local attachment that has not started uploading yet. */ - onRemoveQueued?: (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`. */ @@ -515,8 +511,6 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, - onRemoveQueued, - queuedPreviews = [], onEditSave, onRemove, onRevert, @@ -524,8 +518,7 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { - if (attachments.length === 0 && queuedPreviews.length === 0 && !isUploading) - return null; + if (attachments.length === 0 && !isUploading) return null; const uploadPlaceholders: UploadingAttachmentPreview[] = uploadingPreviews.length > 0 @@ -616,65 +609,6 @@ 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.filename ?? "Attachment"} - -
- )} - {onRemoveQueued ? ( - - - - - Remove attachment - - ) : null} -
- ); - })} {isUploading && uploadPlaceholders.map((preview) => ( void; - percentage: number; -}) { - const reducedMotion = useReducedMotion(); - - return ( - - {isUploading ? ( - -
- -
- - Uploading - - {" · "} - {percentage}% - - - -
-
-
- ) : null} -
- ); -} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index e454c62be8..69e4ec67b5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -10,20 +10,20 @@ 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, - 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,13 +50,11 @@ 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"; @@ -85,7 +83,6 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, - showBackgroundUploadProgress = false, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -145,12 +142,11 @@ function MessageComposerImpl({ typingRootEventId, ); - // New files stay local so the composer can render previews immediately; - // submitting hands them to the app-wide background upload queue. - const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); + // 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 media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; - const backgroundUpload = useBackgroundMediaUpload(); // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on // key change, and persist the outgoing draft in the cleanup. The StrictMode @@ -312,8 +308,6 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, - clearQueuedAttachments: media.clearQueuedAttachments, - restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -518,52 +512,77 @@ function MessageComposerImpl({ // Edit mode if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; + const currentPendingImeta = media.pendingImetaRef.current; // 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). - await submitMessageEdit({ - content: trimmed, - customEmoji, - originalContent: editTargetRef.current.body, - ownerPubkey: ownerPubkeyRef.current, - pendingImeta: media.pendingImetaRef.current, - queuedAttachments: media.queuedAttachmentsRef.current, + + // 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, spoileredAttachmentUrls, - 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); - }, - setUploadError: (message) => - media.setUploadState({ status: "error", message }), - }); + ); + + // 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); + } return; } // Normal send const currentPendingImeta = media.pendingImetaRef.current; - const currentQueuedAttachments = media.queuedAttachmentsRef.current; - const hasMedia = - currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0; + const hasMedia = currentPendingImeta.length > 0; if ( (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || + isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; @@ -584,7 +603,6 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, - queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -604,12 +622,8 @@ 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, @@ -811,23 +825,21 @@ function MessageComposerImpl({ const sendDisabled = React.useMemo( () => disabled || - (editTarget !== null && media.isUploading) || + media.isUploading || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && - media.pendingImeta.length === 0 && - media.queuedAttachments.length === 0), + (isContentEmpty && media.pendingImeta.length === 0), [ disabled, - editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, - media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => {}, []); + const handleCaptureSelection = React.useCallback(() => { + // No-op for Tiptap — selection is managed by ProseMirror. + }, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -885,13 +897,6 @@ function MessageComposerImpl({ onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} /> - {showBackgroundUploadProgress ? ( - - ) : null} ) : null} - {(media.pendingImeta.length > 0 || - media.queuedAttachments.length > 0 || - media.isUploading) && ( + {(media.pendingImeta.length > 0 || media.isUploading) && (
diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts deleted file mode 100644 index 6762745461..0000000000 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; -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; - pendingImeta: ImetaMedia[]; - queuedAttachments: QueuedMediaAttachment[]; - spoileredAttachmentUrls: Set; -}; - -type SubmitMessageEditOptions = EditDraft & { - clearComposer: () => void; - customEmoji: ReadonlyArray; - extractMentionPubkeys: (content: string) => string[]; - originalContent: string; - ownerPubkey: string | null; - restoreComposer: (draft: EditDraft) => void; - save: ( - content: string, - mediaTags?: string[][], - mentionPubkeys?: string[], - ) => Promise; - setUploadError: (message: string) => void; -}; - -/** Clear an edited message immediately, then upload and save captured state. */ -export async function submitMessageEdit({ - clearComposer, - content, - customEmoji, - extractMentionPubkeys, - originalContent, - ownerPubkey, - pendingImeta, - queuedAttachments, - restoreComposer, - save, - setUploadError, - spoileredAttachmentUrls, -}: SubmitMessageEditOptions): Promise { - const draft: EditDraft = { - content, - pendingImeta: [...pendingImeta], - queuedAttachments: [...queuedAttachments], - spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), - }; - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeys(originalContent), - extractMentionPubkeys(content), - ownerPubkey ?? "", - ); - clearComposer(); - - const finishEdit = async (uploaded: ImetaMedia[]) => { - // An explicit empty media tag set tells edit receivers to wipe attachments. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - content, - [...draft.pendingImeta, ...uploaded], - draft.spoileredAttachmentUrls, - ); - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - await save(finalContent, outgoingTags, addedMentionPubkeys); - }; - - if (draft.queuedAttachments.length > 0) { - enqueueBackgroundMediaUpload({ - attachments: draft.queuedAttachments, - onComplete: async (uploaded) => { - try { - await finishEdit(uploaded); - } catch { - restoreComposer(draft); - } - }, - onError: (error) => { - restoreComposer(draft); - setUploadError(String(error)); - }, - }); - return; - } - - try { - await finishEdit([]); - } catch { - restoreComposer(draft); - } -} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 345b87fc12..6d4e007cd4 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -13,10 +13,6 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; -import { - enqueueBackgroundMediaUpload, - type QueuedMediaAttachment, -} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -40,7 +36,7 @@ type PendingNonMemberMentionSend = { parentEventId: string | null; threadHeadId: string | null; } | null; - trimmed: string; + finalContent: string; mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; @@ -48,7 +44,6 @@ type PendingNonMemberMentionSend = { readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; - queuedAttachments: QueuedMediaAttachment[]; savedSpoileredAttachmentUrls: Set; sentDraftKey: string | null | undefined; audienceGeneration: number; @@ -65,7 +60,6 @@ type SendMessageWithMentionFlowInput = { threadHeadId: string | null; } | null; pendingImeta: ImetaMedia[]; - queuedAttachments?: QueuedMediaAttachment[]; sentDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; @@ -104,8 +98,6 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -169,8 +161,6 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, - clearQueuedAttachments, - restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -408,7 +398,6 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); - clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -426,7 +415,6 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, - clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -526,37 +514,11 @@ export function useMentionSendFlow({ ); } - const send = onSendRef.current; - const restoreComposerAfterFailure = () => { - if ( - !isMountedRef.current || - draft.capturedChannelId !== channelIdRef.current - ) { - return; - } - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - restoreQueuedAttachments(draft.queuedAttachments); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); - }; - const finishSend = async (uploaded: ImetaMedia[]) => { - const { content: finalContent, mediaTags } = buildOutgoingMessage( - draft.trimmed, - [...draft.savedImeta, ...uploaded], - draft.savedSpoileredAttachmentUrls, - ); - const finalOutgoingTags = mergeOutgoingTags( - mediaTags, - outgoingTags ?? [], - ); - await send( - finalContent, + try { + await onSendRef.current( + draft.finalContent, mentionPubkeys, - finalOutgoingTags, + outgoingTags, sendChannelId, draft.capturedThreadContext, ); @@ -580,30 +542,17 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], ); } - }; - - if (draft.queuedAttachments.length > 0) { - enqueueBackgroundMediaUpload({ - attachments: draft.queuedAttachments, - onComplete: async (uploaded) => { - try { - await finishSend(uploaded); - } catch { - restoreComposerAfterFailure(); - } - }, - onError: (error) => { - restoreComposerAfterFailure(); - toast.error( - `Upload failed: ${getErrorMessage(error, "Unknown error")}`, - ); - }, - }); - } else { - try { - await finishSend([]); - } catch { - restoreComposerAfterFailure(); + } 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), + ); } } } finally { @@ -627,7 +576,6 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, - restoreQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -694,7 +642,6 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, - queuedAttachments = [], sentDraftKey, spoileredAttachmentUrls = new Set(), trimmed, @@ -756,7 +703,15 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); + const { content: finalContent, mediaTags } = buildOutgoingMessage( + trimmed, + pendingImeta, + spoileredAttachmentUrls, + ); + const outgoingTags = mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => @@ -779,7 +734,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - trimmed, + finalContent, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -790,7 +745,6 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], - queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, audienceGeneration, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 17265ae309..69e2e455ec 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( - progressId?: string, -): Promise { - return invokeTauri("pick_and_upload_media", { progressId }); + +export async function pickAndUploadMedia(): Promise { + return invokeTauri("pick_and_upload_media", {}); } + export async function uploadMediaBytes( data: number[], filename?: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0a836126c7..037274176e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -355,8 +355,6 @@ 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; diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index dd7a700c4d..403d26c136 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from "@playwright/test"; -import type { Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -12,7 +11,6 @@ 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`, @@ -26,25 +24,13 @@ 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", - }); -} - 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"); - // The paperclip queues the local file without starting its upload. - await chooseQuarterlyReport(page); + // Paperclip → mocked pick_and_upload_media returns the PDF descriptor. + await page.getByRole("button", { name: "Attach image" }).click(); // The composer shows a chip with the original filename. await expect(page.getByTestId("message-composer")).toContainText( @@ -77,64 +63,6 @@ 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("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("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 9e1626b756..345e1ee4d7 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -361,8 +361,6 @@ 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 2a617d77e55b33103620f8baca6e3db87c1d6c26 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 14:29:45 +0100 Subject: [PATCH 05/20] Fix mobile media review feedback Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 4 +- .../features/activity/inbox_read_state.dart | 2 +- .../activity/inbox_unread_provider.dart | 2 +- .../channels/channel_detail_page.dart | 6 +- .../channel_mutes/channel_mutes_manager.dart | 2 +- .../channel_sections_manager.dart | 2 +- .../channel_stars/channel_stars_manager.dart | 2 +- .../lib/features/channels/channels_page.dart | 8 +- .../features/channels/channels_provider.dart | 2 +- mobile/lib/features/channels/compose_bar.dart | 944 +---------------- .../channels/compose_bar/attachments.dart | 4 + .../compose_bar/compose_bar_widget.dart | 975 ++++++++++++++++++ .../media_viewer_page/video_viewer.dart | 48 +- .../features/channels/message_actions.dart | 6 +- .../features/channels/message_content.dart | 1 - .../message_content/video_preview.dart | 58 +- .../features/channels/thread_detail_page.dart | 4 +- .../thread_follows_provider.dart | 2 +- .../unread_badge/observed_unread_event.dart | 2 +- .../unread_badge/unread_badge_provider.dart | 4 +- .../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 | 50 +- .../features/activity/activity_page_test.dart | 2 +- .../channels/channel_detail_page_test.dart | 2 +- .../features/channels/channels_page_test.dart | 2 +- .../features/channels/compose_bar_test.dart | 3 + .../channels/message_actions_test.dart | 2 +- .../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 +- .../unread_badge_provider_test.dart | 2 +- 39 files changed, 1124 insertions(+), 1045 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/compose_bar_widget.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%) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 6dad29dbe4..dfa2bc59f7 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -22,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'; diff --git a/mobile/lib/features/activity/inbox_read_state.dart b/mobile/lib/features/activity/inbox_read_state.dart index 130562072c..8626b574f5 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/activity/inbox_unread_provider.dart b/mobile/lib/features/activity/inbox_unread_provider.dart index a35ba134a2..9de7f13ec6 100644 --- a/mobile/lib/features/activity/inbox_unread_provider.dart +++ b/mobile/lib/features/activity/inbox_unread_provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../channels/read_state/read_state_provider.dart'; +import '../../shared/read_state/read_state_provider.dart'; import 'activity_provider.dart'; import 'inbox_local_state_provider.dart'; import 'inbox_read_state.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f1efb1557f..1325d0949d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -42,9 +42,9 @@ import 'manage_channel_sheet.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_provider.dart'; -import 'read_state/read_state_time.dart'; +import '../../shared/read_state/deferred_read_state_update.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'; 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 44bc6ba8ea..eb768dd8fa 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 1d103755ba..445ce6a28f 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_stars/channel_stars_manager.dart b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart index 664c13a09d..c4f4da862f 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 0fc9438c50..f1f9561454 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -39,10 +39,10 @@ import 'channel_sections/channel_sections_provider.dart'; import 'channel_sections/channel_sections_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'; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c0525a0b29..5d7b7df866 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 9f46a8dcc5..40639d5cf0 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -50,946 +50,4 @@ part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.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); - 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(); - final isComposerExpanded = 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 uploadProgress = useState(0.0); - final uploadGeneration = useRef(0); - 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(); - 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: 280), - bounce: 0.16, - ), - 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(int id) { - attachments.value = _withoutAttachment(attachments.value, id); - } - - // Send the message. - Future send() async { - final text = controller.text.trim(); - if ((text.isEmpty && !hasAttachments) || isSending.value) { - 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 queuedAttachments = attachments.value; - - 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); - } - if (queuedAttachments.isEmpty) { - final payload = _ComposeDraftPayload.fromDraft( - text: text, - attachments: const [], - customEmoji: customEmoji, - ); - await onSend( - payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], - ); - if (context.mounted) clearComposer(); - return; - } - - clearComposer(); - uploadingCount.value += 1; - uploadProgress.value = 0; - isSending.value = false; - final queueGeneration = uploadGeneration.value; - unawaited(() async { - try { - final uploaded = []; - for (var index = 0; index < queuedAttachments.length; index++) { - final attachment = queuedAttachments[index]; - final descriptor = await _uploadPendingAttachment( - ref.read(mediaUploadServiceProvider), - attachment, - onProgress: (progress) { - if (context.mounted) { - uploadProgress.value = - (index + progress) / queuedAttachments.length; - } - }, - ); - 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 onSend( - payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], - ); - } catch (error) { - if (context.mounted) uploadError.value = _formatUploadError(error); - } finally { - 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) { - uploadError.value = null; - attachments.value = [ - ...attachments.value, - _PendingAttachment(file: file, kind: kind), - ]; - } - - 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) { - if (images.isEmpty) return; - uploadError.value = null; - attachments.value = [ - ...attachments.value, - for (final image in images) - _PendingAttachment(file: image, kind: _PendingAttachmentKind.image), - ]; - } - - 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) async => - queueAttachment(image, _PendingAttachmentKind.image), - onChoosePhotos: (photos) async => queueImages(photos), - 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; - queueAttachment(image, _PendingAttachmentKind.image); - }, - onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages, - onChoosePhotos: (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. - return Padding( - padding: EdgeInsets.only( - left: Grid.twelve, - right: Grid.twelve, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _UploadProgressMotion( - visible: uploadingCount.value > 0, - progress: uploadProgress.value, - reducedMotion: reducedMotion, - onCancel: () { - uploadGeneration.value += 1; - uploadingCount.value = 0; - uploadProgress.value = 0; - }, - ), - OverlayPortal.overlayChildLayoutBuilder( - controller: suggestionOverlayController, - overlayChildBuilder: (context, layoutInfo) { - final composerOrigin = MatrixUtils.transformPoint( - layoutInfo.childPaintTransform, - Offset.zero, - ); - return ValueListenableBuilder<_AttachmentSurface>( - valueListenable: attachmentSurface, - builder: (context, surface, _) { - final surfaceDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? 320 - : 250, - ); - final expandedSurfaceCoversComposer = - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos; - final overlayAnchorY = - composerOrigin.dy + - (expandedSurfaceCoversComposer - ? layoutInfo.childSize.height + Grid.twelve - : 0); - return AnimatedPositioned( - duration: surfaceDuration, - curve: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? const Cubic(0.34, 1.25, 0.64, 1) - : const Cubic(0.22, 1, 0.36, 1), - left: composerOrigin.dx, - bottom: layoutInfo.overlaySize.height - overlayAnchorY, - width: layoutInfo.childSize.width, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: surface == _AttachmentSurface.closed - ? _SuggestionPanelMotion( - duration: surfaceDuration, - alignment: Alignment.bottomLeft, - child: buildOverlayPanel(surface), - ) - : buildOverlayPanel(surface), - ), - ), - ); - }, - ); - }, - 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; - showEmojiPicker(context: context, onSelect: insertEmoji); - }, - onOpenFormatting: () { - attachmentSurface.value = _AttachmentSurface.closed; - showFormatting.value = true; - }, - hasPendingUploads: false, - 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 41d10e88ac..5c2caf2f68 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -289,18 +289,22 @@ 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, ), }; 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 0000000000..6e898bd7c7 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -0,0 +1,975 @@ +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); + 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 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() { + 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]); + final focusNode = useFocusNode(); + final isComposerExpanded = 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 uploadProgress = useState(0.0); + final uploadGeneration = useRef(0); + final activeUploadCancellation = useRef(null); + 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(); + 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: 280), + bounce: 0.16, + ), + 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) { + draftRevision.value += 1; + attachments.value = _withoutAttachment(attachments.value, id); + } + + // Send the message. + Future send() async { + final text = controller.text.trim(); + if ((text.isEmpty && !hasAttachments) || isSending.value) { + 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 queuedAttachments = List<_PendingAttachment>.of(attachments.value); + + 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); + } + if (queuedAttachments.isEmpty) { + final payload = _ComposeDraftPayload.fromDraft( + text: text, + attachments: const [], + customEmoji: customEmoji, + ); + await onSend( + payload.content, + mentionPubkeys, + mediaTags: [...payload.mediaTags, ...referenceMentionTags], + ); + if (context.mounted) clearComposer(); + 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(); + activeUploadCancellation.value = cancellation; + unawaited(() async { + try { + final uploaded = []; + for (var index = 0; index < queuedAttachments.length; index++) { + final attachment = queuedAttachments[index]; + final descriptor = await _uploadPendingAttachment( + ref.read(mediaUploadServiceProvider), + 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 onSend( + 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; + mentionMap.value + ..clear() + ..addAll(draftMentions); + focusNode.requestFocus(); + } + } finally { + 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) { + draftRevision.value += 1; + uploadError.value = null; + attachments.value = [ + ...attachments.value, + _PendingAttachment(file: file, kind: kind), + ]; + } + + 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) { + 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), + ]; + } + + 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) async => + queueAttachment(image, _PendingAttachmentKind.image), + onChoosePhotos: (photos) async => queueImages(photos), + 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; + queueAttachment(image, _PendingAttachmentKind.image); + }, + onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages, + onChoosePhotos: (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. + return Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _UploadProgressMotion( + visible: uploadingCount.value > 0, + progress: uploadProgress.value, + reducedMotion: reducedMotion, + onCancel: () { + activeUploadCancellation.value?.cancel(); + uploadGeneration.value += 1; + uploadingCount.value = 0; + uploadProgress.value = 0; + }, + ), + OverlayPortal.overlayChildLayoutBuilder( + controller: suggestionOverlayController, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return ValueListenableBuilder<_AttachmentSurface>( + valueListenable: attachmentSurface, + builder: (context, surface, _) { + final surfaceDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + final expandedSurfaceCoversComposer = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final overlayAnchorY = + composerOrigin.dy + + (expandedSurfaceCoversComposer + ? layoutInfo.childSize.height + Grid.twelve + : 0); + return AnimatedPositioned( + duration: surfaceDuration, + curve: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? const Cubic(0.34, 1.25, 0.64, 1) + : const Cubic(0.22, 1, 0.36, 1), + left: composerOrigin.dx, + bottom: layoutInfo.overlaySize.height - overlayAnchorY, + width: layoutInfo.childSize.width, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: surface == _AttachmentSurface.closed + ? _SuggestionPanelMotion( + duration: surfaceDuration, + alignment: Alignment.bottomLeft, + child: buildOverlayPanel(surface), + ) + : buildOverlayPanel(surface), + ), + ), + ); + }, + ); + }, + 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; + showEmojiPicker(context: context, onSelect: insertEmoji); + }, + onOpenFormatting: () { + attachmentSurface.value = _AttachmentSurface.closed; + showFormatting.value = true; + }, + hasPendingUploads: false, + isSending: isSending.value, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 0fccbcacd1..5e983a8386 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -47,11 +47,40 @@ class _MediaVideoViewerPageState extends State /// authorization headers for its follow-up range requests, which makes /// protected relay video fail after the first request. Future _initializeVideo() async { + final container = ProviderScope.containerOf(context, listen: false); + final auth = container.read(mediaGetAuthServiceProvider); + final uri = Uri.parse(widget.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(widget.videoUrl), + ); + await streamingController.initialize(); + await streamingController.play(); + if (!mounted) { + await streamingController.dispose(); + return; + } + _controller = streamingController; + setState(() {}); + 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 container = ProviderScope.containerOf(context, listen: false); final client = container.read(mediaHttpClientProvider); - final auth = container.read(mediaGetAuthServiceProvider); - final uri = Uri.parse(widget.videoUrl); final request = http.Request('GET', uri) ..headers.addAll(auth.headersFor(widget.videoUrl)); final response = await client.send(request); @@ -79,6 +108,7 @@ class _MediaVideoViewerPageState extends State final controller = VideoPlayerController.file(file); _controller = controller; await controller.initialize(); + await controller.play(); if (mounted) setState(() {}); } catch (error) { if (mounted) { @@ -171,6 +201,16 @@ class _MediaVideoViewerPageState extends State } } + Future _replyInThread() async { + final callback = widget.onReply; + if (callback == null) return; + final route = ModalRoute.of(context); + _controller?.pause(); + await Navigator.of(context).maybePop(); + await route?.completed; + callback(); + } + @override Widget build(BuildContext context) { final viewportHeight = MediaQuery.sizeOf(context).height; @@ -252,7 +292,7 @@ class _MediaVideoViewerPageState extends State child: SafeArea( child: _VideoViewerBottomControls( controller: _controller, - onReply: widget.onReply, + onReply: () => unawaited(_replyInThread()), ), ), ), diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index df04800022..b2fbc266db 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 d03d7f8c6f..8f4281aeb0 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -8,7 +8,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; -import 'package:http/http.dart' as http; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; diff --git a/mobile/lib/features/channels/message_content/video_preview.dart b/mobile/lib/features/channels/message_content/video_preview.dart index ca9c320246..61d92cb2be 100644 --- a/mobile/lib/features/channels/message_content/video_preview.dart +++ b/mobile/lib/features/channels/message_content/video_preview.dart @@ -30,12 +30,7 @@ final videoPreviewFrameLoaderProvider = Provider(( ref, ) { final auth = ref.watch(mediaGetAuthServiceProvider); - final client = ref.watch(mediaHttpClientProvider); - return (url) => _loadVideoPreviewFrame( - url, - headers: auth.headersFor(url), - client: client, - ); + return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url)); }); class _MessageVideoPreview extends HookConsumerWidget { @@ -170,27 +165,15 @@ class _MessageVideoPreview extends HookConsumerWidget { Future _loadVideoPreviewFrame( String url, { required Map headers, - required http.Client client, }) async { final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme) return null; VideoPlayerController? controller; - File? downloadedFile; - Future disposeResources() async { final currentController = controller; controller = null; if (currentController != null) await currentController.dispose(); - final currentFile = downloadedFile; - downloadedFile = null; - if (currentFile != null) { - try { - if (await currentFile.exists()) await currentFile.delete(); - } on FileSystemException { - // Temporary preview cleanup should never break the message timeline. - } - } } Future initialize(VideoPlayerController candidate) async { @@ -221,32 +204,11 @@ Future _loadVideoPreviewFrame( await disposeResources(); return null; } catch (_) { - // Some protected media servers or AVPlayer range requests do not retain - // custom headers. Fall back to the same authenticated local-file path as - // the full-screen viewer so posterless historical events still get a frame. + // 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(); - try { - final request = http.Request('GET', uri)..headers.addAll(headers); - final response = await client.send(request); - if (response.statusCode < 200 || response.statusCode >= 300) { - await response.stream.drain(); - return null; - } - final directory = await getTemporaryDirectory(); - downloadedFile = File( - '${directory.path}${Platform.pathSeparator}' - 'buzz-video-preview-${DateTime.now().microsecondsSinceEpoch}' - '${_previewVideoFileExtension(uri)}', - ); - await response.stream.pipe(downloadedFile!.openWrite()); - if (!await initialize(VideoPlayerController.file(downloadedFile!))) { - await disposeResources(); - return null; - } - } catch (_) { - await disposeResources(); - return null; - } + return null; } final initializedController = controller; @@ -264,13 +226,3 @@ Future _loadVideoPreviewFrame( }, ); } - -String _previewVideoFileExtension(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'; -} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 53be4488b2..d24c2fd222 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'; 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 b3b09cf7e7..13210ca8e9 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 f25957ad8e..d6a402b4cd 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 c967aa105d..09e33ba95f 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/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 fe99b4861d..b21175853e 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 fe51a94731..65b52c7d38 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 b82f1af5fd..22375db959 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 02ba6afdb3..60fec02311 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -85,6 +86,23 @@ 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(); + + bool get isCancelled => _cancelled.isCompleted; + Future get whenCancelled => _cancelled.future; + + void cancel() { + if (!_cancelled.isCompleted) _cancelled.complete(); + } +} + +class UploadCancelledException implements Exception { + const UploadCancelledException(); +} + @immutable class _PreparedUploadImage { final Uint8List bytes; @@ -257,12 +275,15 @@ class MediaUploadService { 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, ); } @@ -293,7 +314,9 @@ class MediaUploadService { Future uploadVideo( XFile pickedVideo, { ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { + _throwIfCancelled(cancellationToken); final length = await pickedVideo.length(); if (length > _maxVideoSizeBytes) { throw Exception( @@ -314,12 +337,14 @@ class MediaUploadService { ); } final bytes = await transcodedFile.readAsBytes(); + _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 @@ -351,6 +376,7 @@ class MediaUploadService { // 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, @@ -360,6 +386,7 @@ class MediaUploadService { onProgress: onProgress == null ? null : (progress) => onProgress(0.9 + (progress * 0.1)), + cancellationToken: cancellationToken, ); return video.withImage(poster.url); } catch (error) { @@ -397,7 +424,9 @@ class MediaUploadService { Future uploadFile( XFile pickedFile, { ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { + _throwIfCancelled(cancellationToken); final length = await pickedFile.length(); if (length == 0) { throw Exception('File is empty.'); @@ -408,11 +437,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)); } @@ -427,7 +458,9 @@ class MediaUploadService { 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))) { @@ -441,6 +474,7 @@ class MediaUploadService { bytes, mimeType: mimeType, onProgress: onProgress, + cancellationToken: cancellationToken, ); } @@ -449,7 +483,9 @@ class MediaUploadService { required String mimeType, bool allowGenericFile = false, ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { + _throwIfCancelled(cancellationToken); if (!allowGenericFile && !_allowedImageMimeTypes.contains(mimeType) && !_allowedVideoMimeTypes.contains(mimeType)) { @@ -463,6 +499,7 @@ class MediaUploadService { sha256: sha256, path: _mediaUploadPath, onProgress: onProgress, + cancellationToken: cancellationToken, ); if (response.statusCode == HttpStatus.notFound || response.statusCode == HttpStatus.methodNotAllowed) { @@ -472,6 +509,7 @@ class MediaUploadService { sha256: sha256, path: _legacyMediaUploadPath, onProgress: onProgress, + cancellationToken: cancellationToken, ); } if (response.statusCode < 200 || response.statusCode >= 300) { @@ -496,10 +534,13 @@ class MediaUploadService { required String sha256, required String path, ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, }) async { - final request = http.StreamedRequest( + _throwIfCancelled(cancellationToken); + final request = http.AbortableStreamedRequest( 'PUT', Uri.parse(_baseUrl).resolve(path), + abortTrigger: cancellationToken?.whenCancelled, ); request.contentLength = bytes.length; request.headers.addAll( @@ -510,9 +551,16 @@ class MediaUploadService { .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({ required String mimeType, required String sha256, diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 75dad316f4..7e5aa0f4dd 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'; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 51c06d0283..3ee6cea4af 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/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 24aba9ecfd..0fc50f5a14 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'; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index e78b8d828e..c19cac2565 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2515,10 +2515,13 @@ 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 [ diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 03d7951b05..c95df3446d 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/read_state/message_read_state_test.dart b/mobile/test/features/channels/read_state/message_read_state_test.dart index 97d1317ac7..0415546a76 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 d8aa65739e..57aa10d4ca 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 953d2ff10d..9f33014089 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 40cd063625..f01aa2e81e 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 f6875aafe4..f56e970142 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/unread_badge/unread_badge_provider_test.dart b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart index 987bd96b0a..6c385bfd44 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'; From b2e1b493acb208fb74d925f82c1ad1db1e696b79 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 15:12:00 +0100 Subject: [PATCH 06/20] Address mobile review feedback Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 1 + .../activity/activity_page/inbox_row.dart | 20 +- .../channels/channel_detail_page.dart | 5 +- .../compose_bar/compose_bar_widget.dart | 3 +- .../media_viewer_page/video_viewer.dart | 397 +++++++++--------- .../features/channels/thread_detail_page.dart | 5 +- mobile/lib/features/home/home_page.dart | 2 +- .../read_state}/inbox_unread_provider.dart | 13 +- mobile/test/features/home/home_page_test.dart | 2 +- 9 files changed, 223 insertions(+), 225 deletions(-) rename mobile/lib/{features/activity => shared/read_state}/inbox_unread_provider.dart (63%) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index dfa2bc59f7..82a64e3751 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -289,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 bb1eb7138e..398bd16cea 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -55,6 +55,7 @@ class _InboxRow extends HookConsumerWidget { final VoidCallback onMarkUnread; const _InboxRow({ + super.key, required this.item, required this.channel, required this.currentPubkey, @@ -114,6 +115,9 @@ class _InboxRow extends HookConsumerWidget { : mutedColor; 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(); @@ -152,7 +156,10 @@ class _InboxRow extends HookConsumerWidget { ), ), AnimatedSlide( - offset: Offset(-revealAmount.value / constraints.maxWidth, 0), + offset: Offset( + -swipeDirection * revealAmount.value / constraints.maxWidth, + 0, + ), duration: reducedMotion || isDragging.value ? Duration.zero : const Duration(milliseconds: 160), @@ -166,9 +173,10 @@ class _InboxRow extends HookConsumerWidget { onHorizontalDragUpdate: (details) { isDragging.value = true; final previous = revealAmount.value; - final next = (previous - details.delta.dx) - .clamp(0, actionExtent) - .toDouble(); + final next = + (previous - (details.delta.dx * swipeDirection)) + .clamp(0, actionExtent) + .toDouble(); if (!labelHapticFired.value && previous < _inboxSwipeLabelRevealWidth && next >= _inboxSwipeLabelRevealWidth) { @@ -181,13 +189,13 @@ class _InboxRow extends HookConsumerWidget { isDragging.value = false; final velocity = details.primaryVelocity ?? 0; final shouldCommit = - velocity < -900 || + (velocity * swipeDirection) < -900 || revealAmount.value >= actionExtent * commitThreshold; if (shouldCommit) { toggleReadState(); } else { revealAmount.value = - velocity < -200 || + (velocity * swipeDirection) < -200 || revealAmount.value >= restingRevealWidth / 2 ? restingRevealWidth : 0; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 1325d0949d..9fdd5131a2 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -128,6 +128,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)); @@ -443,9 +444,7 @@ class ChannelDetailPage extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( + }) => sendMessage.call( channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 6e898bd7c7..6c9796fc85 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -495,6 +495,7 @@ class ComposeBar extends HookConsumerWidget { final queueGeneration = uploadGeneration.value; final cancellation = UploadCancellationToken(); activeUploadCancellation.value = cancellation; + final delivery = onSend; unawaited(() async { try { final uploaded = []; @@ -523,7 +524,7 @@ class ComposeBar extends HookConsumerWidget { customEmoji: customEmoji, ); if (queueGeneration != uploadGeneration.value) return; - await onSend( + await delivery( payload.content, mentionPubkeys, mediaTags: [...payload.mediaTags, ...referenceMentionTags], diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 5e983a8386..dfde295699 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -1,12 +1,14 @@ part of '../media_viewer_page.dart'; -// StatefulWidget retained: owns a VideoPlayerController with async init and -// disposal — kept imperative deliberately (allowed exception). -class MediaVideoViewerPage extends StatefulWidget { +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, @@ -15,250 +17,239 @@ class MediaVideoViewerPage extends StatefulWidget { }); @override - State createState() => _MediaVideoViewerPageState(); -} - -class _MediaVideoViewerPageState extends State - with SingleTickerProviderStateMixin { - static const _dismissThreshold = 100.0; - static const _dismissVelocity = 700.0; - static const _backgroundFadeDivisor = 300.0; - - VideoPlayerController? _controller; - File? _videoFile; - late final Future _initializeFuture; - late final AnimationController _snapBackController; - String? _error; - double _dragOffset = 0; - bool _isDragging = false; - - @override - void initState() { - super.initState(); - _snapBackController = AnimationController( - vsync: this, + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final videoFile = 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), ); - _initializeFuture = _initializeVideo(); - } - - /// Download through Buzz's authenticated media client before handing the - /// local file to AVPlayer. AVPlayer does not consistently preserve custom - /// authorization headers for its follow-up range requests, which makes - /// protected relay video fail after the first request. - Future _initializeVideo() async { - final container = ProviderScope.containerOf(context, listen: false); - final auth = container.read(mediaGetAuthServiceProvider); - final uri = Uri.parse(widget.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; + Future deleteVideoFile() async { + final file = videoFile.value; + videoFile.value = null; + if (file == null) return; try { - streamingController = VideoPlayerController.networkUrl( - uri, - httpHeaders: auth.headersFor(widget.videoUrl), - ); - await streamingController.initialize(); - await streamingController.play(); - if (!mounted) { - await streamingController.dispose(); - return; - } - _controller = streamingController; - setState(() {}); - return; - } catch (_) { - if (streamingController != null) { - await streamingController.dispose(); - } - // Fall through to the authenticated local-file path only when the - // streaming controller cannot initialize. + if (await file.exists()) await file.delete(); + } on FileSystemException { + // Temporary storage cleanup should not make closing the viewer fail. } } - try { - final client = container.read(mediaHttpClientProvider); - final request = http.Request('GET', uri) - ..headers.addAll(auth.headersFor(widget.videoUrl)); - final response = await client.send(request); - if (response.statusCode < 200 || response.statusCode >= 300) { - await response.stream.drain(); - throw HttpException( - 'Video download failed (${response.statusCode})', - uri: uri, - ); - } - - final directory = await getTemporaryDirectory(); - final file = File( - '${directory.path}${Platform.pathSeparator}' - 'buzz-video-${DateTime.now().microsecondsSinceEpoch}' - '${_videoFileExtension(uri)}', - ); - _videoFile = file; - await response.stream.pipe(file.openWrite()); - if (!mounted) { - await _deleteVideoFile(); - return; - } - - final controller = VideoPlayerController.file(file); - _controller = controller; - await controller.initialize(); - await controller.play(); - if (mounted) setState(() {}); - } catch (error) { - if (mounted) { - setState(() { - _error = error.toString(); - }); - } - } - } + useEffect(() { + var disposed = false; + Future initializeVideo() async { + final auth = ref.read(mediaGetAuthServiceProvider); + final uri = Uri.parse(videoUrl); - @override - void dispose() { - _snapBackController.dispose(); - final controller = _controller; - if (controller != null) unawaited(controller.dispose()); - unawaited(_deleteVideoFile()); - super.dispose(); - } + // 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. + } + } - void _onVerticalDragStart(DragStartDetails details) { - _snapBackController.stop(); - setState(() => _isDragging = true); - } + try { + final client = ref.read(mediaHttpClientProvider); + final request = http.Request('GET', uri) + ..headers.addAll(auth.headersFor(videoUrl)); + final response = await client.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.drain(); + throw HttpException( + 'Video download failed (${response.statusCode})', + uri: uri, + ); + } - void _onVerticalDragUpdate(DragUpdateDetails details) { - if (!_isDragging) return; - setState(() { - _dragOffset = (_dragOffset + details.delta.dy).clamp( - 0.0, - MediaQuery.sizeOf(context).height, - ); - }); - } + final directory = await getTemporaryDirectory(); + 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(); + downloadSubscription.value = response.stream.listen( + 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(); + }, + cancelOnError: true, + ); + await completed.future; + downloadSubscription.value = null; + downloadSink.value = null; + if (disposed) { + await deleteVideoFile(); + return; + } - void _onVerticalDragEnd(DragEndDetails details) { - _isDragging = false; - final velocity = details.primaryVelocity ?? 0; - if (_dragOffset > _dismissThreshold || velocity > _dismissVelocity) { - _controller?.pause(); - Navigator.of(context).maybePop(); - return; - } - _animateSnapBack(); - } + 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(); + } + } - void _animateSnapBack() { - _isDragging = false; - if (MediaQuery.disableAnimationsOf(context)) { - setState(() => _dragOffset = 0); - return; - } + initializeFuture.value = initializeVideo(); + return () { + disposed = true; + 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]); - final tween = Tween(begin: _dragOffset, end: 0); - void listener() { - if (mounted) { - setState(() => _dragOffset = tween.evaluate(_snapBackController)); + void animateSnapBack() { + isDragging.value = false; + if (MediaQuery.disableAnimationsOf(context)) { + dragOffset.value = 0; + return; } - } - - _snapBackController - ..stop() - ..reset() - ..addListener(listener); - _snapBackController - .animateWith( - SpringSimulation( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 260), - bounce: 0.14, + 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, ), - 0, - 1, - 0, - snapToEnd: true, - ), - ) - .whenCompleteOrCancel(() { - _snapBackController.removeListener(listener); - }); - } - - Future _deleteVideoFile() async { - final file = _videoFile; - _videoFile = 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. + ) + .whenCompleteOrCancel( + () => snapBackController.removeListener(listener), + ); } - } - Future _replyInThread() async { - final callback = widget.onReply; - if (callback == null) return; - final route = ModalRoute.of(context); - _controller?.pause(); - await Navigator.of(context).maybePop(); - await route?.completed; - callback(); - } + 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(); + } - @override - Widget build(BuildContext context) { final viewportHeight = MediaQuery.sizeOf(context).height; - final dragProgress = (_dragOffset / viewportHeight).clamp(0.0, 1.0); + final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0); final videoScale = 1 - (dragProgress * 0.1); - final chromeOpacity = (1 - (_dragOffset / 160)).clamp(0.0, 1.0); + 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 / _backgroundFadeDivisor)).clamp(0.3, 1.0), + alpha: (1 - (dragOffset.value / _backgroundFadeDivisor)).clamp( + 0.3, + 1.0, + ), ), body: Stack( children: [ Positioned.fill( child: Transform.translate( - offset: Offset(0, _dragOffset), + offset: Offset(0, dragOffset.value), child: Transform.scale( scale: videoScale, child: GestureDetector( key: const ValueKey('message-media-video-viewer-gesture'), behavior: HitTestBehavior.opaque, - onVerticalDragStart: _onVerticalDragStart, - onVerticalDragUpdate: _onVerticalDragUpdate, - onVerticalDragEnd: _onVerticalDragEnd, - onVerticalDragCancel: _animateSnapBack, + 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, + future: initializeFuture.value, builder: (context, snapshot) { - if (_error != null || snapshot.hasError) { + if (error.value != null || snapshot.hasError) { return const _MediaLoadFailure( message: 'Failed to load video', icon: LucideIcons.videoOff, ); } - final controller = _controller; - if (controller == null || - !controller.value.isInitialized) { - return _VideoLoadingPoster( - posterUrl: widget.posterUrl, - ); + final videoController = controller.value; + if (videoController == null || + !videoController.value.isInitialized) { + return _VideoLoadingPoster(posterUrl: posterUrl); } return AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), + aspectRatio: videoController.value.aspectRatio, + child: VideoPlayer(videoController), ); }, ), @@ -291,8 +282,8 @@ class _MediaVideoViewerPageState extends State opacity: chromeOpacity, child: SafeArea( child: _VideoViewerBottomControls( - controller: _controller, - onReply: () => unawaited(_replyInThread()), + controller: controller.value, + onReply: () => unawaited(replyInThread()), ), ), ), diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d24c2fd222..90ea88afad 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.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,9 +534,7 @@ class ThreadDetailPage extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( + }) => sendMessage.call( channelId: channelId, content: content, mentionPubkeys: mentionPubkeys, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index cb461d0045..8a9b952f2c 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -7,11 +7,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/read_state/inbox_unread_provider.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 '../activity/inbox_unread_provider.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; diff --git a/mobile/lib/features/activity/inbox_unread_provider.dart b/mobile/lib/shared/read_state/inbox_unread_provider.dart similarity index 63% rename from mobile/lib/features/activity/inbox_unread_provider.dart rename to mobile/lib/shared/read_state/inbox_unread_provider.dart index 9de7f13ec6..7c23359846 100644 --- a/mobile/lib/features/activity/inbox_unread_provider.dart +++ b/mobile/lib/shared/read_state/inbox_unread_provider.dart @@ -1,15 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/read_state/read_state_provider.dart'; -import 'activity_provider.dart'; -import 'inbox_local_state_provider.dart'; -import 'inbox_read_state.dart'; +import '../../features/activity/activity_provider.dart'; +import '../../features/activity/inbox_local_state_provider.dart'; +import '../../features/activity/inbox_read_state.dart'; +import 'read_state_provider.dart'; /// Number of Inbox conversations that still need attention. /// -/// The floating tab bar uses this same read-state projection as Inbox rows, so -/// its indicator appears for every unread Inbox source and clears as soon as -/// the corresponding row is read. +/// This shared projection lets navigation surfaces observe Inbox state without +/// reaching into the Activity presentation feature. final unreadInboxItemCountProvider = Provider((ref) { final readState = ref.watch(readStateProvider); if (!readState.isReady) return 0; diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 49569a788d..597da9d7fd 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -1,5 +1,5 @@ import 'package:buzz/features/home/home_page.dart'; -import 'package:buzz/features/activity/inbox_unread_provider.dart'; +import 'package:buzz/shared/read_state/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; From 9b251f53accad4b540e4b7fc5fa7e5f41417105f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 16:12:14 +0100 Subject: [PATCH 07/20] Rebase mobile inbox and media work Signed-off-by: kenny lopez --- mobile/ios/Runner/AppDelegate.swift | 5 +- .../channels/channel_detail_page.dart | 10 +- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 9 +- .../features/channels/compose_bar/dock.dart | 144 ------------------ .../channels/compose_bar/helpers.dart | 35 ----- .../media_viewer_page/video_viewer.dart | 5 + .../features/channels/thread_detail_page.dart | 14 +- 8 files changed, 27 insertions(+), 196 deletions(-) delete mode 100644 mobile/lib/features/channels/compose_bar/dock.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 58b1cf689a..6f79614182 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -286,7 +286,6 @@ import UserNotifications // is cleared. A fresh composition copies only one video and one audio // track, so those private channels cannot reach the relay. let composition = AVMutableComposition() - let timeRange = CMTimeRange(start: .zero, duration: asset.duration) guard let sourceVideo = asset.tracks(withMediaType: .video).first, let destinationVideo = composition.addMutableTrack( @@ -305,7 +304,7 @@ import UserNotifications } do { - try destinationVideo.insertTimeRange(timeRange, of: sourceVideo, at: .zero) + try destinationVideo.insertTimeRange(sourceVideo.timeRange, of: sourceVideo, at: .zero) destinationVideo.preferredTransform = sourceVideo.preferredTransform if @@ -315,7 +314,7 @@ import UserNotifications preferredTrackID: kCMPersistentTrackID_Invalid ) { - try destinationAudio.insertTimeRange(timeRange, of: sourceAudio, at: .zero) + try destinationAudio.insertTimeRange(sourceAudio.timeRange, of: sourceAudio, at: .zero) } } catch { result( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 9fdd5131a2..a9dd058217 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -445,11 +445,11 @@ class ChannelDetailPage extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) => sendMessage.call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - mediaTags: mediaTags, - ), + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ), ), ], ), diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 40639d5cf0..5f76b98541 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -20,6 +20,7 @@ import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../profile/user_cache_provider.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 6c9796fc85..3edd15e836 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -31,6 +31,7 @@ class ComposeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useMemoized(_MarkdownEditingController.new); + useListenable(controller); useEffect(() => controller.dispose, [controller]); // Restore and persist unsent text as a local draft so the Activity // inbox Drafts filter reflects real composer state. @@ -375,7 +376,9 @@ class ComposeBar extends HookConsumerWidget { // Send the message. Future send() async { final text = controller.text.trim(); - if ((text.isEmpty && !hasAttachments) || isSending.value) { + if ((text.isEmpty && !hasAttachments) || + isSending.value || + uploadingCount.value > 0) { return; } @@ -494,6 +497,7 @@ class ComposeBar extends HookConsumerWidget { isSending.value = false; final queueGeneration = uploadGeneration.value; final cancellation = UploadCancellationToken(); + final uploadService = ref.read(mediaUploadServiceProvider); activeUploadCancellation.value = cancellation; final delivery = onSend; unawaited(() async { @@ -502,7 +506,7 @@ class ComposeBar extends HookConsumerWidget { for (var index = 0; index < queuedAttachments.length; index++) { final attachment = queuedAttachments[index]; final descriptor = await _uploadPendingAttachment( - ref.read(mediaUploadServiceProvider), + uploadService, attachment, onProgress: (progress) { if (context.mounted) { @@ -966,6 +970,7 @@ class ComposeBar extends HookConsumerWidget { showFormatting.value = true; }, hasPendingUploads: false, + canSend: controller.text.trim().isNotEmpty || hasAttachments, isSending: isSending.value, ), ), diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart deleted file mode 100644 index f19fe19e46..0000000000 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ /dev/null @@ -1,144 +0,0 @@ -part of '../compose_bar.dart'; - -class _ComposerDockFrame extends StatelessWidget { - final double widthFactor; - final Widget child; - - const _ComposerDockFrame({required this.widthFactor, required this.child}); - - @override - Widget build(BuildContext context) { - final backdropHeight = mobileTabFooterBackdropHeight(context); - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - key: const ValueKey('composer-footer-gradient'), - left: 0, - right: 0, - bottom: 0, - height: backdropHeight, - child: IgnorePointer( - child: MobileTabFooterBackdrop(height: backdropHeight), - ), - ), - Padding( - padding: EdgeInsets.only( - left: Grid.twelve, - right: Grid.twelve, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: Align( - alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - key: const ValueKey('composer-width-transition'), - widthFactor: widthFactor, - child: child, - ), - ), - ), - ], - ); - } -} - -class _ComposerOverlayPortal extends StatelessWidget { - final OverlayPortalController controller; - final ValueListenable<_AttachmentSurface> attachmentSurface; - final bool reducedMotion; - final Widget Function(_AttachmentSurface surface) buildOverlayPanel; - final VoidCallback onDismissAttachmentSurface; - final Widget child; - - const _ComposerOverlayPortal({ - required this.controller, - required this.attachmentSurface, - required this.reducedMotion, - required this.buildOverlayPanel, - required this.onDismissAttachmentSurface, - required this.child, - }); - - @override - Widget build(BuildContext context) { - return OverlayPortal.overlayChildLayoutBuilder( - controller: controller, - overlayChildBuilder: (context, layoutInfo) { - final composerOrigin = MatrixUtils.transformPoint( - layoutInfo.childPaintTransform, - Offset.zero, - ); - return ValueListenableBuilder<_AttachmentSurface>( - valueListenable: attachmentSurface, - builder: (context, surface, _) { - final surfaceDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? 320 - : 250, - ); - final expandedSurfaceCoversComposer = - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos; - final surfaceLeft = expandedSurfaceCoversComposer - ? Grid.twelve - : composerOrigin.dx; - final surfaceWidth = expandedSurfaceCoversComposer - ? layoutInfo.overlaySize.width - (Grid.twelve * 2) - : layoutInfo.childSize.width; - final overlayAnchorY = - composerOrigin.dy + - (expandedSurfaceCoversComposer - ? layoutInfo.childSize.height + Grid.twelve - : 0); - return Stack( - children: [ - if (surface != _AttachmentSurface.closed) - Positioned( - left: 0, - top: 0, - right: 0, - height: composerOrigin.dy, - child: ExcludeSemantics( - child: GestureDetector( - key: const ValueKey('attachment-dismiss-barrier'), - behavior: HitTestBehavior.opaque, - onTap: onDismissAttachmentSurface, - ), - ), - ), - AnimatedPositioned( - duration: surfaceDuration, - curve: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? const Cubic(0.34, 1.25, 0.64, 1) - : const Cubic(0.22, 1, 0.36, 1), - left: surfaceLeft, - bottom: layoutInfo.overlaySize.height - overlayAnchorY, - width: surfaceWidth, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: surface == _AttachmentSurface.closed - ? _SuggestionPanelMotion( - duration: surfaceDuration, - alignment: Alignment.bottomLeft, - child: buildOverlayPanel(surface), - ) - : buildOverlayPanel(surface), - ), - ), - ), - ], - ); - }, - ); - }, - child: child, - ); - } -} diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index c09815538a..9e8f3e78e8 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -2,46 +2,11 @@ part of '../compose_bar.dart'; const _typingThrottleMs = 3000; -class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { - final FlutterView view; - final VoidCallback onKeyboardHidden; - bool _wasVisible; - - _ComposerKeyboardMetricsObserver({ - required this.view, - required this.onKeyboardHidden, - }) : _wasVisible = view.viewInsets.bottom > 0; - - @override - void didChangeMetrics() { - final isVisible = view.viewInsets.bottom > 0; - if (_wasVisible && !isVisible) onKeyboardHidden(); - _wasVisible = isVisible; - } -} - void _runComposerAction(VoidCallback action) { unawaited(HapticFeedback.selectionClick()); action(); } -void _showComposerEmojiPicker( - BuildContext context, - ValueChanged onSelect, - VoidCallback onDismiss, -) { - showEmojiPicker( - context: context, - onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)), - onDismiss: onDismiss, - ); -} - -void _dismissComposerKeyboard(FocusNode focusNode) { - focusNode.unfocus(); - unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); -} - const _pastedImageMimeTypes = [ 'image/jpeg', 'image/jpg', diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index dfde295699..b72c30b371 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -80,6 +80,10 @@ class MediaVideoViewerPage extends HookConsumerWidget { final request = http.Request('GET', uri) ..headers.addAll(auth.headersFor(videoUrl)); final response = await client.send(request); + if (disposed) { + await response.stream.drain(); + return; + } if (response.statusCode < 200 || response.statusCode >= 300) { await response.stream.drain(); throw HttpException( @@ -89,6 +93,7 @@ class MediaVideoViewerPage extends HookConsumerWidget { } final directory = await getTemporaryDirectory(); + if (disposed) return; final file = File( '${directory.path}${Platform.pathSeparator}' 'buzz-video-${DateTime.now().microsecondsSinceEpoch}' diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 90ea88afad..fff7f5834b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -535,13 +535,13 @@ class ThreadDetailPage extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) => sendMessage.call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - mediaTags: mediaTags, - ), + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), ), ], ), From fb216e54c0998b7a68be312197f3fa9db68c8db6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 17:27:26 +0100 Subject: [PATCH 08/20] Resolve mobile rebase regressions and review feedback Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 13 +- mobile/lib/features/channels/compose_bar.dart | 3 + .../channels/compose_bar/attachments.dart | 97 ++++++------ .../compose_bar/compose_bar_widget.dart | 118 +++++++------- .../features/channels/compose_bar/dock.dart | 144 ++++++++++++++++++ .../channels/compose_bar/helpers.dart | 35 +++++ .../media_viewer_page/video_viewer.dart | 23 ++- .../lib/features/forum/forum_posts_view.dart | 5 +- mobile/lib/features/forum/forum_provider.dart | 22 +-- .../lib/features/forum/forum_thread_page.dart | 4 +- 10 files changed, 325 insertions(+), 139 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/dock.dart diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 499c4efb31..ac73a5076e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -32,7 +32,6 @@ class _MessageList extends HookConsumerWidget { final itemPositionsListener = useMemoized(ItemPositionsListener.create); final isLoadingOlder = useState(false); final isAtLatest = useState(true); - final hasPendingLatest = useState(false); final hasUserScrolled = useState(false); final followsLatest = useRef( initialMessageId == null && initialThreadRootId == null, @@ -58,7 +57,6 @@ class _MessageList extends HookConsumerWidget { if (!itemScrollController.isAttached || isAutoScrolling.value) return; followsLatest.value = true; hasUserScrolled.value = false; - hasPendingLatest.value = false; isAutoScrolling.value = true; try { await itemScrollController.scrollTo( @@ -103,7 +101,6 @@ class _MessageList extends HookConsumerWidget { final nextIsAtLatest = latestIsAtBoundary(); if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; - if (hasPendingLatest.value) hasPendingLatest.value = false; } else if (followsLatest.value && !hasUserScrolled.value) { // The viewport can shrink when the composer or keyboard opens. // Preserve auto-follow until the user scrolls the timeline. @@ -184,11 +181,8 @@ class _MessageList extends HookConsumerWidget { previousLatestEntryId.value = latestEntryId; if (previous == null || latestEntryId == null || - previous == latestEntryId) { - return null; - } - if (!isAtLatest.value) { - hasPendingLatest.value = true; + previous == latestEntryId || + !isAtLatest.value) { return null; } WidgetsBinding.instance.addPostFrameCallback((_) { @@ -250,7 +244,6 @@ class _MessageList extends HookConsumerWidget { hasUserScrolled.value = false; followsLatest.value = true; if (!isAtLatest.value) isAtLatest.value = true; - if (hasPendingLatest.value) hasPendingLatest.value = false; }); } return false; @@ -361,7 +354,7 @@ class _MessageList extends HookConsumerWidget { ), ), ), - if (hasPendingLatest.value) + if (!isAtLatest.value) Positioned( left: 0, right: 0, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 5f76b98541..d742464f11 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'dart:math' as math; +import 'dart:ui' show FlutterView; import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; @@ -23,6 +24,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; @@ -51,4 +53,5 @@ part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; +part 'compose_bar/dock.dart'; 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 5c2caf2f68..2b1223326d 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -177,7 +177,7 @@ class _AttachmentSurfacePanel extends HookWidget { final height = menuLayout.height + ((expandedHeight - menuLayout.height) * sizeProgress); - final baseColor = context.colors.surfaceContainerHighest; + final baseColor = appPopoverColor(context); final expandedColor = visibleExpandedSurface == _AttachmentSurface.camera ? Colors.black @@ -189,57 +189,51 @@ class _AttachmentSurfacePanel extends HookWidget { child: SizedBox( width: width, height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: Color.lerp(baseColor, expandedColor, sizeProgress), - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.dialog), - child: Material( - type: MaterialType.transparency, - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - Positioned( - left: 0, - top: 0, - width: _attachmentMenuWidth, - height: menuLayout.height, - child: IgnorePointer( - ignoring: surface != _AttachmentSurface.menu, - child: Opacity( - opacity: menuOpacity, - child: _AttachmentMenu( - layout: menuLayout, - onCamera: onCamera, - onPhotos: onPhotos, - onVideo: onVideo, - onFiles: onFiles, - ), - ), + child: Material( + key: const ValueKey('attachment-surface-popover'), + type: MaterialType.card, + color: Color.lerp(baseColor, expandedColor, sizeProgress), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), + clipBehavior: Clip.antiAlias, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: 0, + top: 0, + width: _attachmentMenuWidth, + height: menuLayout.height, + child: IgnorePointer( + ignoring: surface != _AttachmentSurface.menu, + child: Opacity( + opacity: menuOpacity, + child: _AttachmentMenu( + layout: menuLayout, + onCamera: onCamera, + onPhotos: onPhotos, + onVideo: onVideo, + onFiles: onFiles, ), ), - Positioned( - left: 0, - top: 0, - width: expandedWidth, - height: expandedHeight, - child: IgnorePointer( - ignoring: !isExpanded, - child: Opacity( - opacity: expandedOpacity, - child: expandedContent, - ), - ), + ), + ), + Positioned( + left: 0, + top: 0, + width: expandedWidth, + height: expandedHeight, + child: IgnorePointer( + ignoring: !isExpanded, + child: Opacity( + opacity: expandedOpacity, + child: expandedContent, ), - ], + ), ), - ), + ], ), ), ), @@ -344,7 +338,7 @@ class _AttachmentTrigger extends StatelessWidget { _AttachmentSurface.camera || _AttachmentSurface.photos => 'Back to attachment options', }, - onPressed: () => onTap(context), + onPressed: () => _runComposerAction(() => onTap(context)), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, icon: AnimatedRotation( @@ -453,7 +447,7 @@ class _AttachmentMenuItem extends StatelessWidget { child: Tooltip( message: label, child: InkWell( - onTap: onTap, + onTap: () => _runComposerAction(onTap), child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( @@ -593,7 +587,8 @@ class _AttachmentStrip extends StatelessWidget { width: 24, height: 24, child: IconButton( - onPressed: () => onRemove(attachment.id), + onPressed: () => + _runComposerAction(() => onRemove(attachment.id)), tooltip: 'Remove attachment', visualDensity: VisualDensity.compact, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 3edd15e836..00870b8d7e 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -75,7 +75,13 @@ class ComposeBar extends HookConsumerWidget { 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, @@ -105,6 +111,36 @@ class ComposeBar extends HookConsumerWidget { 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'); @@ -116,8 +152,8 @@ class ComposeBar extends HookConsumerWidget { composerExpansionController.animateWith( SpringSimulation( SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 280), - bounce: 0.16, + duration: const Duration(milliseconds: 220), + bounce: 0.08, ), composerExpansionController.value, target, @@ -860,17 +896,15 @@ class ComposeBar extends HookConsumerWidget { // Suggestions and attachments live in the overlay so showing them cannot // reflow the composer. Both stay anchored just above the capsule. - return Padding( - padding: EdgeInsets.only( - left: Grid.twelve, - right: Grid.twelve, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), + final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; + final hasPendingUploads = uploadingCount.value > 0; + return _ComposerDockFrame( + widthFactor: composerWidthFactor, child: Column( mainAxisSize: MainAxisSize.min, children: [ _UploadProgressMotion( - visible: uploadingCount.value > 0, + visible: hasPendingUploads, progress: uploadProgress.value, reducedMotion: reducedMotion, onCancel: () { @@ -880,58 +914,13 @@ class ComposeBar extends HookConsumerWidget { uploadProgress.value = 0; }, ), - OverlayPortal.overlayChildLayoutBuilder( + _ComposerOverlayPortal( controller: suggestionOverlayController, - overlayChildBuilder: (context, layoutInfo) { - final composerOrigin = MatrixUtils.transformPoint( - layoutInfo.childPaintTransform, - Offset.zero, - ); - return ValueListenableBuilder<_AttachmentSurface>( - valueListenable: attachmentSurface, - builder: (context, surface, _) { - final surfaceDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? 320 - : 250, - ); - final expandedSurfaceCoversComposer = - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos; - final overlayAnchorY = - composerOrigin.dy + - (expandedSurfaceCoversComposer - ? layoutInfo.childSize.height + Grid.twelve - : 0); - return AnimatedPositioned( - duration: surfaceDuration, - curve: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? const Cubic(0.34, 1.25, 0.64, 1) - : const Cubic(0.22, 1, 0.36, 1), - left: composerOrigin.dx, - bottom: layoutInfo.overlaySize.height - overlayAnchorY, - width: layoutInfo.childSize.width, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: surface == _AttachmentSurface.closed - ? _SuggestionPanelMotion( - duration: surfaceDuration, - alignment: Alignment.bottomLeft, - child: buildOverlayPanel(surface), - ) - : buildOverlayPanel(surface), - ), - ), - ); - }, - ); + attachmentSurface: attachmentSurface, + reducedMotion: reducedMotion, + buildOverlayPanel: buildOverlayPanel, + onDismissAttachmentSurface: () { + attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( attachments: attachments.value, @@ -963,13 +952,18 @@ class ComposeBar extends HookConsumerWidget { }, onEmoji: () { attachmentSurface.value = _AttachmentSurface.closed; - showEmojiPicker(context: context, onSelect: insertEmoji); + isEmojiPickerOpen.value = true; + _showComposerEmojiPicker(context, insertEmoji, () { + if (!context.mounted) return; + isEmojiPickerOpen.value = false; + focusNode.requestFocus(); + }); }, onOpenFormatting: () { attachmentSurface.value = _AttachmentSurface.closed; showFormatting.value = true; }, - hasPendingUploads: false, + hasPendingUploads: hasPendingUploads, canSend: controller.text.trim().isNotEmpty || hasAttachments, isSending: isSending.value, ), diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart new file mode 100644 index 0000000000..f19fe19e46 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -0,0 +1,144 @@ +part of '../compose_bar.dart'; + +class _ComposerDockFrame extends StatelessWidget { + final double widthFactor; + final Widget child; + + const _ComposerDockFrame({required this.widthFactor, required this.child}); + + @override + Widget build(BuildContext context) { + final backdropHeight = mobileTabFooterBackdropHeight(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: const ValueKey('composer-footer-gradient'), + left: 0, + right: 0, + bottom: 0, + height: backdropHeight, + child: IgnorePointer( + child: MobileTabFooterBackdrop(height: backdropHeight), + ), + ), + Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + key: const ValueKey('composer-width-transition'), + widthFactor: widthFactor, + child: child, + ), + ), + ), + ], + ); + } +} + +class _ComposerOverlayPortal extends StatelessWidget { + final OverlayPortalController controller; + final ValueListenable<_AttachmentSurface> attachmentSurface; + final bool reducedMotion; + final Widget Function(_AttachmentSurface surface) buildOverlayPanel; + final VoidCallback onDismissAttachmentSurface; + final Widget child; + + const _ComposerOverlayPortal({ + required this.controller, + required this.attachmentSurface, + required this.reducedMotion, + required this.buildOverlayPanel, + required this.onDismissAttachmentSurface, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return OverlayPortal.overlayChildLayoutBuilder( + controller: controller, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return ValueListenableBuilder<_AttachmentSurface>( + valueListenable: attachmentSurface, + builder: (context, surface, _) { + final surfaceDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + final expandedSurfaceCoversComposer = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final surfaceLeft = expandedSurfaceCoversComposer + ? Grid.twelve + : composerOrigin.dx; + final surfaceWidth = expandedSurfaceCoversComposer + ? layoutInfo.overlaySize.width - (Grid.twelve * 2) + : layoutInfo.childSize.width; + final overlayAnchorY = + composerOrigin.dy + + (expandedSurfaceCoversComposer + ? layoutInfo.childSize.height + Grid.twelve + : 0); + return Stack( + children: [ + if (surface != _AttachmentSurface.closed) + Positioned( + left: 0, + top: 0, + right: 0, + height: composerOrigin.dy, + child: ExcludeSemantics( + child: GestureDetector( + key: const ValueKey('attachment-dismiss-barrier'), + behavior: HitTestBehavior.opaque, + onTap: onDismissAttachmentSurface, + ), + ), + ), + AnimatedPositioned( + duration: surfaceDuration, + curve: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? const Cubic(0.34, 1.25, 0.64, 1) + : const Cubic(0.22, 1, 0.36, 1), + left: surfaceLeft, + bottom: layoutInfo.overlaySize.height - overlayAnchorY, + width: surfaceWidth, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: surface == _AttachmentSurface.closed + ? _SuggestionPanelMotion( + duration: surfaceDuration, + alignment: Alignment.bottomLeft, + child: buildOverlayPanel(surface), + ) + : buildOverlayPanel(surface), + ), + ), + ), + ], + ); + }, + ); + }, + child: child, + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 9e8f3e78e8..c09815538a 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -2,11 +2,46 @@ part of '../compose_bar.dart'; const _typingThrottleMs = 3000; +class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { + final FlutterView view; + final VoidCallback onKeyboardHidden; + bool _wasVisible; + + _ComposerKeyboardMetricsObserver({ + required this.view, + required this.onKeyboardHidden, + }) : _wasVisible = view.viewInsets.bottom > 0; + + @override + void didChangeMetrics() { + final isVisible = view.viewInsets.bottom > 0; + if (_wasVisible && !isVisible) onKeyboardHidden(); + _wasVisible = isVisible; + } +} + void _runComposerAction(VoidCallback action) { unawaited(HapticFeedback.selectionClick()); action(); } +void _showComposerEmojiPicker( + BuildContext context, + ValueChanged onSelect, + VoidCallback onDismiss, +) { + showEmojiPicker( + context: context, + onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)), + onDismiss: onDismiss, + ); +} + +void _dismissComposerKeyboard(FocusNode focusNode) { + focusNode.unfocus(); + unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); +} + const _pastedImageMimeTypes = [ 'image/jpeg', 'image/jpg', diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index b72c30b371..632cb076c1 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -20,6 +20,7 @@ class MediaVideoViewerPage extends HookConsumerWidget { 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); @@ -77,9 +78,21 @@ class MediaVideoViewerPage extends HookConsumerWidget { try { final client = ref.read(mediaHttpClientProvider); - final request = http.Request('GET', uri) - ..headers.addAll(auth.headersFor(videoUrl)); - final response = await client.send(request); + 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 response.stream.drain(); return; @@ -142,6 +155,10 @@ class MediaVideoViewerPage extends HookConsumerWidget { 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; diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index e3eed1f96c..0c9a0ed590 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -33,6 +33,9 @@ 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); // Periodic refresh (every 15s, matching desktop). useEffect(() { @@ -147,7 +150,7 @@ class ForumPostsView extends HookConsumerWidget { mediaTags = const >[], }) async { await createForumPost( - ref, + providerContainer, 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 710e16aa34..045e28c215 100644 --- a/mobile/lib/features/forum/forum_provider.dart +++ b/mobile/lib/features/forum/forum_provider.dart @@ -57,14 +57,14 @@ final forumThreadProvider = /// Creates a new forum post (kind:45001). Future createForumPost( - WidgetRef ref, { + ProviderContainer container, { 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 config = container.read(relayConfigProvider); + final session = container.read(relaySessionProvider.notifier); final relay = SignedEventRelay(session: session, nsec: config.nsec); final selfPubkey = relay.pubkey?.toLowerCase(); @@ -81,23 +81,23 @@ Future createForumPost( ['h', channelId], for (final pk in normalizedMentions) ['p', pk], ...mediaTags, - ...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)), + ...buildCustomEmojiTags(content, container.read(customEmojiListProvider)), ], ); - ref.invalidate(forumPostsProvider(channelId)); + container.invalidate(forumPostsProvider(channelId)); } /// Creates a reply to a forum post (kind:45003). Future createForumReply( - WidgetRef ref, { + ProviderContainer container, { 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 config = container.read(relayConfigProvider); + final session = container.read(relaySessionProvider.notifier); final relay = SignedEventRelay(session: session, nsec: config.nsec); final selfPubkey = relay.pubkey?.toLowerCase(); @@ -115,11 +115,11 @@ Future createForumReply( ['e', parentEventId, '', 'reply'], for (final pk in normalizedMentions) ['p', pk], ...mediaTags, - ...buildCustomEmojiTags(content, ref.read(customEmojiListProvider)), + ...buildCustomEmojiTags(content, container.read(customEmojiListProvider)), ], ); - ref.invalidate(forumPostsProvider(channelId)); - ref.invalidate( + container.invalidate(forumPostsProvider(channelId)); + container.invalidate( forumThreadProvider((channelId: channelId, eventId: parentEventId)), ); } diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 68d2562f68..611727403e 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -207,6 +207,8 @@ 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 post = thread.post; final replies = thread.replies; @@ -302,7 +304,7 @@ class _ThreadContent extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) => createForumReply( - ref, + providerContainer, channelId: channelId, parentEventId: post.eventId, content: content, From e2b079bb10e6faf92cf09d7937724cdc9b281ebc Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 17:54:16 +0100 Subject: [PATCH 09/20] Address follow-up mobile review feedback Signed-off-by: kenny lopez --- .../media_viewer_page/video_viewer.dart | 35 ++-- .../lib/features/forum/forum_posts_view.dart | 4 +- mobile/lib/features/forum/forum_provider.dart | 171 +++++++++++------- .../lib/features/forum/forum_thread_page.dart | 4 +- .../channels/message_content_test.dart | 9 + .../features/forum/forum_widgets_test.dart | 24 +++ 6 files changed, 170 insertions(+), 77 deletions(-) diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 632cb076c1..dc4a82b506 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -94,7 +94,7 @@ class MediaVideoViewerPage extends HookConsumerWidget { } } if (disposed) { - await response.stream.drain(); + await _cancelVideoResponse(response); return; } if (response.statusCode < 200 || response.statusCode >= 300) { @@ -105,8 +105,16 @@ class MediaVideoViewerPage extends HookConsumerWidget { ); } + final responseSubscription = response.stream.listen(null)..pause(); + downloadSubscription.value = responseSubscription; final directory = await getTemporaryDirectory(); - if (disposed) return; + 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}' @@ -116,20 +124,19 @@ class MediaVideoViewerPage extends HookConsumerWidget { final sink = file.openWrite(); downloadSink.value = sink; final completed = Completer(); - downloadSubscription.value = response.stream.listen( - sink.add, - onError: (Object error, StackTrace stackTrace) async { + responseSubscription + ..onData(sink.add) + ..onError((Object error, StackTrace stackTrace) async { await sink.close(); if (!completed.isCompleted) { completed.completeError(error, stackTrace); } - }, - onDone: () async { + }) + ..onDone(() async { await sink.close(); if (!completed.isCompleted) completed.complete(); - }, - cancelOnError: true, - ); + }) + ..resume(); await completed.future; downloadSubscription.value = null; downloadSink.value = null; @@ -305,7 +312,9 @@ class MediaVideoViewerPage extends HookConsumerWidget { child: SafeArea( child: _VideoViewerBottomControls( controller: controller.value, - onReply: () => unawaited(replyInThread()), + onReply: onReply == null + ? null + : () => unawaited(replyInThread()), ), ), ), @@ -316,6 +325,10 @@ class MediaVideoViewerPage extends HookConsumerWidget { } } +Future _cancelVideoResponse(http.StreamedResponse response) { + return response.stream.listen((_) {}).cancel(); +} + String _videoFileExtension(Uri uri) { final path = uri.path; final extensionStart = path.lastIndexOf('.'); diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 0c9a0ed590..f11d0444b4 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -36,6 +36,7 @@ class ForumPostsView extends HookConsumerWidget { // 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(() { @@ -149,8 +150,7 @@ class ForumPostsView extends HookConsumerWidget { mentionPubkeys, { mediaTags = const >[], }) async { - await createForumPost( - providerContainer, + 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 045e28c215..59cd72d077 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( - ProviderContainer container, { - required String channelId, - required String content, - List mentionPubkeys = const [], - List> mediaTags = const [], -}) async { - final config = container.read(relayConfigProvider); - final session = container.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, container.read(customEmojiListProvider)), - ], - ); - container.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( - ProviderContainer container, { - required String channelId, - required String parentEventId, - required String content, - List mentionPubkeys = const [], - List> mediaTags = const [], -}) async { - final config = container.read(relayConfigProvider); - final session = container.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, container.read(customEmojiListProvider)), - ], - ); - container.invalidate(forumPostsProvider(channelId)); - container.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 611727403e..f7859f16fc 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -209,6 +209,7 @@ class _ThreadContent extends HookConsumerWidget { 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; @@ -303,8 +304,7 @@ class _ThreadContent extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => createForumReply( - providerContainer, + }) => forumDelivery.createReply( channelId: channelId, parentEventId: post.eventId, content: content, diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 0198ee77f5..7c84a74715 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1166,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( diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 7f83a33c8d..f834bda644 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( From 13a66134d67ba801f49cf70fbe7d385cf2edd31c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 18:36:19 +0100 Subject: [PATCH 10/20] fix(mobile): preserve deferred attachment delivery Signed-off-by: kenny lopez --- .../channels/camera_capture_cleanup.dart | 50 +++++++++++++++ .../channels/compose_bar/attachments.dart | 64 ++++++++++++++++++- .../compose_bar/compose_bar_widget.dart | 43 ++++++++++--- .../compose_bar/ios_photo_picker.dart | 4 +- .../compose_bar/photo_gallery_picker.dart | 10 ++- .../channels/send_message_provider.dart | 20 +++++- .../channels/camera_capture_cleanup_test.dart | 17 +++++ .../channels/send_message_provider_test.dart | 25 ++++++++ 8 files changed, 219 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/channels/camera_capture_cleanup.dart b/mobile/lib/features/channels/camera_capture_cleanup.dart index 469a150e13..479a5c04d8 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/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 2b1223326d..9dcbd6e829 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 || @@ -275,8 +278,67 @@ class _PendingAttachment { final int id; final XFile file; final _PendingAttachmentKind kind; + final bool deleteAfterUse; - _PendingAttachment({required this.file, required this.kind}) : id = _nextId++; + _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( diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 00870b8d7e..1a2c48688b 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -94,6 +94,7 @@ class ComposeBar extends HookConsumerWidget { 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); @@ -405,8 +406,7 @@ class ComposeBar extends HookConsumerWidget { } void removeAttachment(int id) { - draftRevision.value += 1; - attachments.value = _withoutAttachment(attachments.value, id); + _removePendingAttachment(attachments, draftRevision, id); } // Send the message. @@ -537,6 +537,7 @@ class ComposeBar extends HookConsumerWidget { activeUploadCancellation.value = cancellation; final delivery = onSend; unawaited(() async { + var retainedForRetry = false; try { final uploaded = []; for (var index = 0; index < queuedAttachments.length; index++) { @@ -577,12 +578,16 @@ class ComposeBar extends HookConsumerWidget { 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; } @@ -596,12 +601,20 @@ class ComposeBar extends HookConsumerWidget { } } - void queueAttachment(XFile file, _PendingAttachmentKind kind) { + void queueAttachment( + XFile file, + _PendingAttachmentKind kind, { + bool deleteAfterUse = false, + }) { draftRevision.value += 1; uploadError.value = null; attachments.value = [ ...attachments.value, - _PendingAttachment(file: file, kind: kind), + _PendingAttachment( + file: file, + kind: kind, + deleteAfterUse: deleteAfterUse, + ), ]; } @@ -621,17 +634,24 @@ class ComposeBar extends HookConsumerWidget { } } - void queueImages(List images) { + 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), + _PendingAttachment( + file: image, + kind: _PendingAttachmentKind.image, + deleteAfterUse: deleteAfterUse, + ), ]; } + Future retainAndQueueImages(List images) => + _retainAndQueueImages(context, images, queueImages); + Widget buildContextMenu( BuildContext context, EditableTextState editableTextState, @@ -766,9 +786,8 @@ class ComposeBar extends HookConsumerWidget { iosAttachmentPopover .present( sourceContext: triggerContext, - onCapture: (image) async => - queueAttachment(image, _PendingAttachmentKind.image), - onChoosePhotos: (photos) async => queueImages(photos), + onCapture: (image) => retainAndQueueImages([image]), + onChoosePhotos: retainAndQueueImages, onAllPhotos: () => chooseAttachment(() async { final photos = await ref .read(mediaUploadServiceProvider) @@ -884,10 +903,14 @@ class ComposeBar extends HookConsumerWidget { }), onCapture: (image) async { attachmentSurface.value = _AttachmentSurface.closed; - queueAttachment(image, _PendingAttachmentKind.image); + 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); }, 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 ade2bec94e..0548d73fd2 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/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 5985874065..d40fd93a22 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/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4bf..730546807f 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/test/features/channels/camera_capture_cleanup_test.dart b/mobile/test/features/channels/camera_capture_cleanup_test.dart index 17d96b0d5b..0716af4d9d 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/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index f91ce87b2b..273336f283 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'; From 24014adec4aa2cb4b1c9421c8c6a9904fbccb8d9 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 19:10:49 +0100 Subject: [PATCH 11/20] fix(mobile): address final review feedback Signed-off-by: kenny lopez --- mobile/ios/Runner/AppDelegate.swift | 35 ++++++++++- mobile/ios/RunnerTests/RunnerTests.swift | 43 +++++++++++++ mobile/lib/app.dart | 35 ++++++++++- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 44 ++++++-------- .../channels/compose_bar/draft_lifecycle.dart | 60 +++++++++++++++++++ mobile/lib/features/home/home_page.dart | 14 +++-- .../read_state/inbox_unread_provider.dart | 28 --------- .../features/channels/compose_bar_test.dart | 17 +++++- mobile/test/features/home/home_page_test.dart | 11 ++-- 10 files changed, 215 insertions(+), 73 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/draft_lifecycle.dart delete mode 100644 mobile/lib/shared/read_state/inbox_unread_provider.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 6f79614182..53fa6b996f 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -304,17 +304,30 @@ import UserNotifications } do { - try destinationVideo.insertTimeRange(sourceVideo.timeRange, of: sourceVideo, at: .zero) + 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 = asset.tracks(withMediaType: .audio).first, + let sourceAudio, let destinationAudio = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid ) { - try destinationAudio.insertTimeRange(sourceAudio.timeRange, of: sourceAudio, at: .zero) + try destinationAudio.insertTimeRange( + sourceAudio.timeRange, + of: sourceAudio, + at: insertionTimes.audio ?? .zero + ) } } catch { result( @@ -396,6 +409,22 @@ 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 diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 8374ca77b6..e1c2ce00f6 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 0538215109..b1dad2a5c9 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/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index d742464f11..35083610d7 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -43,6 +43,7 @@ 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'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 1a2c48688b..6d41a0a692 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -47,33 +47,6 @@ class ComposeBar extends HookConsumerWidget { 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() { - 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]); final focusNode = useFocusNode(); useEffect( () => @@ -100,6 +73,23 @@ class ComposeBar extends HookConsumerWidget { 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); 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 0000000000..88d1c8edac --- /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/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 8a9b952f2c..27587947f3 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -4,10 +4,8 @@ import 'dart:ui'; 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'; -import '../../shared/read_state/inbox_unread_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/directional_transition_scope.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; @@ -15,10 +13,15 @@ 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}); +class HomePage extends HookWidget { + 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; @@ -57,7 +60,7 @@ class HomePage extends HookConsumerWidget { ]; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final tabIndex = useState(0); final tabContentTransitionDirection = useRef(1.0); final tabContentTransitionController = useAnimationController( @@ -71,7 +74,6 @@ class HomePage extends HookConsumerWidget { final tabContentTransitionProgress = reducedMotion ? 1.0 : _tabContentTransitionCurve.transform(tabContentTransitionValue); - final hasUnreadInbox = ref.watch(unreadInboxItemCountProvider) > 0; final systemBottomInset = MediaQuery.paddingOf(context).bottom; final navigationBarWidth = _floatingTabBarWidth( MediaQuery.sizeOf(context).width, diff --git a/mobile/lib/shared/read_state/inbox_unread_provider.dart b/mobile/lib/shared/read_state/inbox_unread_provider.dart deleted file mode 100644 index 7c23359846..0000000000 --- a/mobile/lib/shared/read_state/inbox_unread_provider.dart +++ /dev/null @@ -1,28 +0,0 @@ -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 'read_state_provider.dart'; - -/// Number of Inbox conversations that still need attention. -/// -/// This shared projection lets navigation surfaces observe Inbox state without -/// reaching into the Activity presentation feature. -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; -}); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index c19cac2565..ef8d41548f 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -548,7 +548,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 +582,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 +596,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'); diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 597da9d7fd..f22c258564 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -1,5 +1,4 @@ import 'package:buzz/features/home/home_page.dart'; -import 'package:buzz/shared/read_state/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; @@ -16,10 +15,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); return ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(prefs), - unreadInboxItemCountProvider.overrideWith((_) => unreadInboxCount), - ], + overrides: [savedPrefsProvider.overrideWithValue(prefs)], child: MaterialApp( theme: AppTheme.light(), builder: (context, child) => MediaQuery( @@ -28,7 +24,10 @@ void main() { ).copyWith(disableAnimations: disableAnimations), child: child!, ), - home: const HomePage(settingsPageBuilder: _buildSettingsPage), + home: HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: unreadInboxCount > 0, + ), ), ); } From d9993c92390c4deeec4df2ca13ccaa4906c23bc5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 20:39:23 +0100 Subject: [PATCH 12/20] fix(mobile): stop cancelled video preprocessing Signed-off-by: kenny lopez --- mobile/lib/shared/relay/media_upload.dart | 1 + .../test/shared/relay/media_upload_test.dart | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 60fec02311..2281ea29eb 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -329,6 +329,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) { diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 1d3303b13f..815f41c53a 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,63 @@ 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 { From a360c47429ccd85d83756076718a522eb8b368aa Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 20:59:53 +0100 Subject: [PATCH 13/20] fix(mobile): address final review follow-ups Signed-off-by: kenny lopez --- .../channels/compose_bar/attachments.dart | 30 +++++++++++++++++++ mobile/lib/features/home/home_page.dart | 5 ++-- mobile/lib/shared/relay/media_upload.dart | 3 ++ .../features/channels/compose_bar_test.dart | 6 ++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 9dcbd6e829..e95873f379 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -614,6 +614,8 @@ class _AttachmentStrip extends StatelessWidget { ), ), ) + : attachment.kind == _PendingAttachmentKind.image + ? _MemoryAttachmentImage(file: attachment.file) : ColoredBox( color: context.colors.surface, child: Padding( @@ -678,3 +680,31 @@ class _AttachmentStrip extends StatelessWidget { ); } } + +class _MemoryAttachmentImage extends HookWidget { + final XFile file; + + const _MemoryAttachmentImage({required this.file}); + + @override + Widget build(BuildContext context) { + 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/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 27587947f3..79ccf31999 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -4,6 +4,7 @@ import 'dart:ui'; 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'; import '../../shared/theme/theme.dart'; @@ -13,7 +14,7 @@ import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; -class HomePage extends HookWidget { +class HomePage extends HookConsumerWidget { const HomePage({ required this.settingsPageBuilder, required this.hasUnreadInbox, @@ -60,7 +61,7 @@ class HomePage extends HookWidget { ]; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); final tabContentTransitionDirection = useRef(1.0); final tabContentTransitionController = useAnimationController( diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 2281ea29eb..00ac350392 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -76,6 +76,8 @@ 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(); @@ -99,6 +101,7 @@ class UploadCancellationToken { } } +/// Indicates that a user cancelled a media upload before it completed. class UploadCancelledException implements Exception { const UploadCancelledException(); } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index ef8d41548f..6b2063f68e 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2108,6 +2108,12 @@ void main() { expect(galleryPickerCalled, isFalse); expect(uploadedBytes, isNull); expect(find.byTooltip('Remove attachment'), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => widget is Image && widget.image is MemoryImage, + ), + findsOneWidget, + ); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); From a7d7c229e6ec8735bbf88bd14dfc8a9ed09c3322 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 21:29:17 +0100 Subject: [PATCH 14/20] fix(mobile): keep unread activity live Signed-off-by: kenny lopez --- .../features/activity/activity_provider.dart | 115 +++++++++++++++++- .../compose_bar/compose_bar_widget.dart | 30 +++-- mobile/lib/shared/relay/media_upload.dart | 1 + .../activity/activity_provider_test.dart | 93 +++++++++++++- .../features/channels/compose_bar_test.dart | 80 ++++++++++++ 5 files changed, 301 insertions(+), 18 deletions(-) diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 87f00bf2bb..afa489a4c4 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,121 @@ 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; + 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), () async { + if (generation != _subscriptionGeneration) return; + final next = await AsyncValue.guard(_fetch); + if (generation == _subscriptionGeneration) state = next; + }); + } + + void _clearLiveSubscriptions() { + _liveRefreshTimer?.cancel(); + _liveRefreshTimer = null; + _unsubscribeAddressed?.call(); + _unsubscribeAddressed = null; + _unsubscribeDms?.call(); + _unsubscribeDms = null; } /// Stable identity for the joined DM channel set: null while channels are diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 6d41a0a692..8de2b996b4 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -479,24 +479,28 @@ class ComposeBar extends HookConsumerWidget { } final queuedAttachments = List<_PendingAttachment>.of(attachments.value); + final channelActions = ref.read(channelActionsProvider); - isSending.value = true; - try { + Future addMentionedNonMembers() async { if (nonMemberAgentPubkeys.isNotEmpty) { - await ref - .read(channelActionsProvider) - .addMembers( - channelId: channelId, - pubkeys: nonMemberAgentPubkeys, - role: 'bot', - ); + await channelActions.addMembers( + channelId: channelId, + pubkeys: nonMemberAgentPubkeys, + role: 'bot', + ); } if (inviteHumanPubkeys.isNotEmpty) { - await ref - .read(channelActionsProvider) - .addMembers(channelId: channelId, pubkeys: inviteHumanPubkeys); + await channelActions.addMembers( + channelId: channelId, + pubkeys: inviteHumanPubkeys, + ); } + } + + isSending.value = true; + try { if (queuedAttachments.isEmpty) { + await addMentionedNonMembers(); final payload = _ComposeDraftPayload.fromDraft( text: text, attachments: const [], @@ -555,6 +559,8 @@ class ComposeBar extends HookConsumerWidget { customEmoji: customEmoji, ); if (queueGeneration != uploadGeneration.value) return; + await addMentionedNonMembers(); + if (queueGeneration != uploadGeneration.value) return; await delivery( payload.content, mentionPubkeys, diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 00ac350392..b39f7fb444 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -170,6 +170,7 @@ 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, diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 0d927df860..1197702eef 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -7,10 +7,12 @@ 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 = []; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -22,7 +24,42 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { }) async { final h = filter.tags['#h']; if (h != null) dmQueries.add(h); - return const []; + 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); + } + } + } + + 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; } } @@ -105,4 +142,54 @@ 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'); + }, + ); +} + +class _FixedChannelsNotifier extends ChannelsNotifier { + final List channels; + + _FixedChannelsNotifier(this.channels); + + @override + Future> build() async => channels; } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 6b2063f68e..5ab17877f2 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1935,6 +1935,86 @@ void main() { }, ); + 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 { From 68bfc0dd7f5ae8409f12db2ddb58c7b0550b4618 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 21:50:44 +0100 Subject: [PATCH 15/20] fix(mobile): serialize inbox refreshes Signed-off-by: kenny lopez --- .../features/activity/activity_provider.dart | 43 ++++++- mobile/lib/shared/relay/media_upload.dart | 4 + .../activity/activity_provider_test.dart | 115 ++++++++++++++++++ 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index afa489a4c4..74038388bd 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -39,6 +39,8 @@ class ActivityNotifier extends AsyncNotifier { void Function()? _unsubscribeAddressed; void Function()? _unsubscribeDms; Timer? _liveRefreshTimer; + int? _liveRefreshGeneration; + bool _liveRefreshQueued = false; int _subscriptionGeneration = 0; @override @@ -120,16 +122,47 @@ class ActivityNotifier extends AsyncNotifier { void _scheduleLiveRefresh(int generation) { if (generation != _subscriptionGeneration) return; _liveRefreshTimer?.cancel(); - _liveRefreshTimer = Timer(const Duration(milliseconds: 50), () async { - if (generation != _subscriptionGeneration) return; - final next = await AsyncValue.guard(_fetch); - if (generation == _subscriptionGeneration) state = next; - }); + _liveRefreshTimer = Timer( + const Duration(milliseconds: 50), + () => unawaited(_runLiveRefresh(generation)), + ); + } + + Future _runLiveRefresh(int generation) async { + if (generation != _subscriptionGeneration) return; + if (_liveRefreshGeneration == generation) { + _liveRefreshQueued = true; + return; + } + + _liveRefreshGeneration = generation; + try { + do { + _liveRefreshQueued = false; + try { + final next = await _fetch(); + if (generation != _subscriptionGeneration) return; + state = AsyncData(next); + } catch (error) { + if (generation != _subscriptionGeneration) return; + debugPrint( + '[ActivityNotifier] live refresh failed; retaining feed: $error', + ); + } + } while (_liveRefreshQueued && generation == _subscriptionGeneration); + } finally { + if (_liveRefreshGeneration == generation) { + _liveRefreshGeneration = null; + _liveRefreshQueued = false; + } + } } void _clearLiveSubscriptions() { _liveRefreshTimer?.cancel(); _liveRefreshTimer = null; + _liveRefreshGeneration = null; + _liveRefreshQueued = false; _unsubscribeAddressed?.call(); _unsubscribeAddressed = null; _unsubscribeDms?.call(); diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index b39f7fb444..3161a70362 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -93,9 +93,13 @@ class MediaPolicyUploadException implements Exception { 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(); } diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 1197702eef..af41b63217 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -13,6 +13,11 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { 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); @@ -24,6 +29,25 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { }) async { final h = filter.tags['#h']; if (h != null) dmQueries.add(h); + 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(); } @@ -47,6 +71,8 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { } } + 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) { @@ -92,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(); @@ -183,6 +230,74 @@ void main() { 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('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 { From 026f3663accb585e93052950eaacdbc3c092d9e5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 22:13:58 +0100 Subject: [PATCH 16/20] fix(mobile): guard asynchronous inbox actions Signed-off-by: kenny lopez --- .../features/activity/activity_provider.dart | 46 +++++++++++-------- .../channels/channel_management_provider.dart | 21 ++++++++- .../channels/compose_bar/attachments.dart | 4 +- .../compose_bar/upload_progress_pill.dart | 4 +- .../media_viewer_page/video_controls.dart | 4 +- .../activity/activity_provider_test.dart | 37 +++++++++++++++ .../features/channels/compose_bar_test.dart | 45 ++++++++++++++++++ 7 files changed, 136 insertions(+), 25 deletions(-) diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 74038388bd..3581cbd551 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -39,8 +39,9 @@ class ActivityNotifier extends AsyncNotifier { void Function()? _unsubscribeAddressed; void Function()? _unsubscribeDms; Timer? _liveRefreshTimer; - int? _liveRefreshGeneration; - bool _liveRefreshQueued = false; + Future? _refreshInFlight; + int? _refreshGeneration; + bool _refreshQueued = false; int _subscriptionGeneration = 0; @override @@ -124,21 +125,28 @@ class ActivityNotifier extends AsyncNotifier { _liveRefreshTimer?.cancel(); _liveRefreshTimer = Timer( const Duration(milliseconds: 50), - () => unawaited(_runLiveRefresh(generation)), + () => unawaited(_queueRefresh(generation)), ); } - Future _runLiveRefresh(int generation) async { - if (generation != _subscriptionGeneration) return; - if (_liveRefreshGeneration == generation) { - _liveRefreshQueued = true; - return; + Future _queueRefresh(int generation) { + if (generation != _subscriptionGeneration) return Future.value(); + _refreshQueued = true; + final inFlight = _refreshInFlight; + if (_refreshGeneration == generation && inFlight != null) { + return inFlight; } - _liveRefreshGeneration = generation; + _refreshGeneration = generation; + final future = _drainRefreshQueue(generation); + _refreshInFlight = future; + return future; + } + + Future _drainRefreshQueue(int generation) async { try { do { - _liveRefreshQueued = false; + _refreshQueued = false; try { final next = await _fetch(); if (generation != _subscriptionGeneration) return; @@ -146,14 +154,15 @@ class ActivityNotifier extends AsyncNotifier { } catch (error) { if (generation != _subscriptionGeneration) return; debugPrint( - '[ActivityNotifier] live refresh failed; retaining feed: $error', + '[ActivityNotifier] inbox refresh failed; retaining feed: $error', ); } - } while (_liveRefreshQueued && generation == _subscriptionGeneration); + } while (_refreshQueued && generation == _subscriptionGeneration); } finally { - if (_liveRefreshGeneration == generation) { - _liveRefreshGeneration = null; - _liveRefreshQueued = false; + if (_refreshGeneration == generation) { + _refreshInFlight = null; + _refreshGeneration = null; + _refreshQueued = false; } } } @@ -161,8 +170,9 @@ class ActivityNotifier extends AsyncNotifier { void _clearLiveSubscriptions() { _liveRefreshTimer?.cancel(); _liveRefreshTimer = null; - _liveRefreshGeneration = null; - _liveRefreshQueued = false; + _refreshInFlight = null; + _refreshGeneration = null; + _refreshQueued = false; _unsubscribeAddressed?.call(); _unsubscribeAddressed = null; _unsubscribeDms?.call(); @@ -304,7 +314,7 @@ class ActivityNotifier extends AsyncNotifier { } Future refresh() async { - state = await AsyncValue.guard(_fetch); + await _queueRefresh(_subscriptionGeneration); } } diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index b990194d15..5d0055a9c9 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -484,16 +484,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, @@ -547,7 +550,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: '', @@ -558,10 +563,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, @@ -752,5 +766,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/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index e95873f379..84bf5b5903 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -681,13 +681,13 @@ class _AttachmentStrip extends StatelessWidget { } } -class _MemoryAttachmentImage extends HookWidget { +class _MemoryAttachmentImage extends HookConsumerWidget { final XFile file; const _MemoryAttachmentImage({required this.file}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final bytes = useFuture(useMemoized(() => file.readAsBytes(), [file])); final data = bytes.data; diff --git a/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart b/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart index 1f8cae3ee7..23f381b82b 100644 --- a/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart +++ b/mobile/lib/features/channels/compose_bar/upload_progress_pill.dart @@ -58,7 +58,7 @@ class _UploadProgressMotion extends StatelessWidget { /// A lightweight post-send upload status that stays above the composer, so the /// composer is immediately available for the next message. -class _UploadProgressPill extends HookWidget { +class _UploadProgressPill extends HookConsumerWidget { final double progress; final bool reducedMotion; final VoidCallback onCancel; @@ -70,7 +70,7 @@ class _UploadProgressPill extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final cancelHovered = useState(false); final clampedProgress = progress.clamp(0.0, 1.0).toDouble(); final percentage = (clampedProgress * 100).round(); diff --git a/mobile/lib/features/channels/media_viewer_page/video_controls.dart b/mobile/lib/features/channels/media_viewer_page/video_controls.dart index e0c8cd72e8..996d94ee42 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_controls.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_controls.dart @@ -40,13 +40,13 @@ class _VideoViewerBottomControls extends StatelessWidget { } } -class _VideoTransportBar extends HookWidget { +class _VideoTransportBar extends HookConsumerWidget { final VideoPlayerController controller; const _VideoTransportBar({required this.controller}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { useListenable(controller); final value = controller.value; final durationMs = value.duration.inMilliseconds; diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index af41b63217..351e08b4e1 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -271,6 +271,43 @@ void main() { }, ); + 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)); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 5ab17877f2..c89622388d 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1935,6 +1935,51 @@ void main() { }, ); + 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 { From 5b38a0cc696901b295043907796ca39e7e804e2b Mon Sep 17 00:00:00 2001 From: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 19:20:20 -0700 Subject: [PATCH 17/20] fix(mobile): report a send cancelled by a community switch The community guard added to `ChannelActions.addMembers` throws a `StateError` once the active relay changes. On the text-only send path that throw escaped into a fire-and-forget future: `send()` is invoked as `unawaited(send())` and the app installs no zone guard, so the user tapped send, the message was never delivered, and nothing anywhere said so. Catch it where the deferred upload path already catches its own errors, and report it on the messenger rather than the composer's inline error line. The identity change that causes the failure also resets the composer's error state on the next frame, so the inline surface cannot carry this message. The unsent text was already retained in the originating community's draft store and is now pinned by a test. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .../compose_bar/compose_bar_widget.dart | 41 +++++-- .../features/channels/compose_bar_test.dart | 113 ++++++++++++++++-- 2 files changed, 134 insertions(+), 20 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 8de2b996b4..d2ee76f54f 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -407,6 +407,10 @@ class ComposeBar extends HookConsumerWidget { uploadingCount.value > 0) { return; } + // Resolved before any await: a community switch mid-send rebuilds the + // composer and resets its own error state, so the messenger is the only + // surface that survives to report the failure. + final messenger = ScaffoldMessenger.maybeOf(context); // Extract pubkeys for mentions present in the final text. final selectedMentions = [ @@ -500,18 +504,31 @@ class ComposeBar extends HookConsumerWidget { isSending.value = true; try { if (queuedAttachments.isEmpty) { - 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(); + 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 { + // The active community changed mid-send, so this message can no + // longer be delivered where it was composed. Report it: the send + // path is fire-and-forget, so an escaping error would be silent. + // The composer's own error line cannot carry this, because the + // identity change resets that state on the next frame. + messenger?.showSnackBar( + const SnackBar( + content: Text('Message not sent: the community changed'), + ), + ); + } return; } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index c89622388d..b7698d708f 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -323,14 +323,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 +349,7 @@ class _RecordingRelaySocket extends RelaySocket { events.add(event); final id = event['id'] as String; super.debugHandleOkForTest(['OK', id, true, '']); + onEventAcknowledged?.call(event); } } @@ -1935,6 +1944,94 @@ void main() { }, ); + 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: _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; + }, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + 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.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 { From 2cae9545b3f465e9f1dcc7b255f9bff7b548c79c Mon Sep 17 00:00:00 2001 From: npub1w85l93z2dyetvaev42kvmgv3r5qsgc7rutrvgpqshqefj4sydqqskwstfm <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 19:52:36 -0700 Subject: [PATCH 18/20] fix(mobile): point new read_state importers at the moved library The merge of main left two files importing `features/channels/read_state/`, a directory this branch moved to `shared/read_state/`. Both files are new on main, so nothing on this side conflicted with them and the merge was textually clean while the tree did not compile: `flutter analyze` reported nine errors across the two. These rewrites were made while resolving the merge but were left unstaged, so the merge commit did not carry them. Committed separately rather than amended, to keep the pushed history append-only. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- mobile/lib/features/channels/channel_actions_sheet.dart | 4 ++-- .../features/channels/channel_sort/channel_sort_manager.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 843ee992e6..42e13be542 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_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 629e1bc322..a434fec17b 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'; From ff09b7d8ebccd61ba666836ef5f45c9d44c5548b Mon Sep 17 00:00:00 2001 From: npub1w85l93z2dyetvaev42kvmgv3r5qsgc7rutrvgpqshqefj4sydqqskwstfm <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 20:24:38 -0700 Subject: [PATCH 19/20] refactor(mobile): move two compose-bar send helpers into the part file The community-guard fix added 17 lines to `compose_bar_widget.dart`, taking it to 1012 script-counted lines and tripping the Mobile job's file size ratchet, whose allowance for a file new-to-main is 1000. The local run missed it because `check-file-sizes-core.mjs` resolves its base as `HEAD^1` under `GITHUB_ACTIONS=true` and as `merge-base origin/main HEAD` otherwise; CI checks out the PR merge ref, so its `HEAD^1` is main and it sees the growth that a local CI-shaped run cannot. Extracts the snackbar body and the mentioned-non-member add into top-level functions in `compose_bar/helpers.dart`, which is already a part of the same library, and leaves the call sites as a call and a tear-off. Behavior is unchanged: the widget file is back to 992 lines and the ratchet passes against the real merge base. Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .../compose_bar/compose_bar_widget.dart | 37 +++++-------------- .../channels/compose_bar/helpers.dart | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index d2ee76f54f..3cac205abc 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -407,9 +407,8 @@ class ComposeBar extends HookConsumerWidget { uploadingCount.value > 0) { return; } - // Resolved before any await: a community switch mid-send rebuilds the - // composer and resets its own error state, so the messenger is the only - // surface that survives to report the failure. + // Resolved before any await: see + // `_reportSendCancelledByCommunitySwitch`. final messenger = ScaffoldMessenger.maybeOf(context); // Extract pubkeys for mentions present in the final text. @@ -485,21 +484,12 @@ class ComposeBar extends HookConsumerWidget { final queuedAttachments = List<_PendingAttachment>.of(attachments.value); final channelActions = ref.read(channelActionsProvider); - Future addMentionedNonMembers() async { - if (nonMemberAgentPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: nonMemberAgentPubkeys, - role: 'bot', - ); - } - if (inviteHumanPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: inviteHumanPubkeys, - ); - } - } + Future addMentionedNonMembers() => _addMentionedNonMembers( + channelActions, + channelId: channelId, + agentPubkeys: nonMemberAgentPubkeys, + humanPubkeys: inviteHumanPubkeys, + ); isSending.value = true; try { @@ -518,16 +508,7 @@ class ComposeBar extends HookConsumerWidget { ); if (context.mounted) clearComposer(); } on StateError { - // The active community changed mid-send, so this message can no - // longer be delivered where it was composed. Report it: the send - // path is fire-and-forget, so an escaping error would be silent. - // The composer's own error line cannot carry this, because the - // identity change resets that state on the next frame. - messenger?.showSnackBar( - const SnackBar( - content: Text('Message not sent: the community changed'), - ), - ); + _reportSendCancelledByCommunitySwitch(messenger); } return; } diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index c09815538a..c630accdff 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, + ); + } +} From 2096c67a146b44d0f63cdffad7805ff0e48e1232 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 07:46:37 +0100 Subject: [PATCH 20/20] test(mobile): enable video chooser upload coverage Signed-off-by: kenny lopez --- .../features/channels/compose_bar_test.dart | 142 +++++++++--------- 1 file changed, 70 insertions(+), 72 deletions(-) diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index b7698d708f..11bf306825 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; @@ -3146,85 +3180,49 @@ void main() { 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; - - // 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); - - 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, - ); + final pickedVideo = XFile.fromData( + Uint8List.fromList([0, 1, 2, 3]), + mimeType: 'video/mp4', + name: 'clip.mp4', + ); + final uploadService = _FakeVideoUploadService(pickedVideo); - String? sentContent; - await tester.pumpWidget( - _buildComposeBar( - uploadService: uploadService, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) async { - sentContent = content; - }, - ), - ); + String? sentContent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + }, + ), + ); - 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)); - } + await _openAttachmentMenu(tester); + await tester.tap(find.text('Video')); + await tester.pumpAndSettle(); - // Video attachment should show a video icon (not a broken image). - expect(find.byIcon(LucideIcons.video), findsOneWidget); + expect(find.byIcon(LucideIcons.video), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.arrowUp)); - await tester.pump(); - await tester.pumpAndSettle(); + final sendButton = find + .ancestor( + of: find.byIcon(LucideIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(); + expect(sendButton, findsOneWidget); + await tester.tap(sendButton); + 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)'); }); });