diff --git a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt index 455c9b4b..a1081be8 100644 --- a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt @@ -3,12 +3,16 @@ package com.sprout.sprout_mobile import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder +import android.media.MediaExtractor +import android.media.MediaMuxer import android.os.Build import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.io.File import java.nio.ByteBuffer +import java.util.UUID class MainActivity : FlutterActivity() { private var mediaUploadChannel: MethodChannel? = null @@ -28,6 +32,9 @@ class MainActivity : FlutterActivity() { TRANSCODE_IMAGE_TO_JPEG_METHOD -> { handleTranscodeImageToJpeg(call.arguments, result) } + TRANSCODE_VIDEO_TO_MP4_METHOD -> { + handleTranscodeVideoToMp4(call.arguments, result) + } else -> result.notImplemented() } } @@ -150,6 +157,63 @@ class MainActivity : FlutterActivity() { result.success(transformedBytes) } + private fun handleTranscodeVideoToMp4( + arguments: Any?, + result: MethodChannel.Result, + ) { + val sourcePath = arguments as? String ?: run { + invalidArguments(result, "Expected source file path as String.") + return + } + + Thread { + val outputFile = File(cacheDir, "${UUID.randomUUID()}.mp4") + var muxer: MediaMuxer? = null + val extractor = MediaExtractor() + try { + extractor.setDataSource(sourcePath) + muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + val trackIndices = mutableMapOf() + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val newIndex = muxer.addTrack(format) + trackIndices[i] = newIndex + extractor.selectTrack(i) + } + + muxer.start() + val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB buffer + val bufferInfo = android.media.MediaCodec.BufferInfo() + + while (true) { + val sampleSize = extractor.readSampleData(buffer, 0) + if (sampleSize < 0) break + val muxerTrack = trackIndices[extractor.sampleTrackIndex]!! + bufferInfo.offset = 0 + bufferInfo.size = sampleSize + bufferInfo.presentationTimeUs = extractor.sampleTime + bufferInfo.flags = extractor.sampleFlags + muxer.writeSampleData(muxerTrack, buffer, bufferInfo) + extractor.advance() + } + + muxer.stop() + result.success(outputFile.absolutePath) + } catch (e: Exception) { + outputFile.delete() + result.error( + "transcode_failed", + e.message ?: "Video transcoding failed.", + null, + ) + } finally { + try { muxer?.release() } catch (_: Exception) {} + extractor.release() + } + }.start() + } + private fun invalidArguments( result: MethodChannel.Result, message: String, @@ -161,5 +225,6 @@ class MainActivity : FlutterActivity() { private const val MEDIA_UPLOAD_CHANNEL = "sprout/media_upload" 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" } } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index db858f39..e5361f29 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import AVFoundation import Flutter import UIKit @@ -103,8 +104,69 @@ import UIKit } result(FlutterStandardTypedData(bytes: jpegData)) + case "transcodeVideoToMp4": + guard let sourcePath = call.arguments as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected source file path as String.", + details: nil + ) + ) + return + } + transcodeVideoToMp4(sourcePath: sourcePath, result: result) default: result(FlutterMethodNotImplemented) } } + + private func transcodeVideoToMp4( + sourcePath: String, + result: @escaping FlutterResult + ) { + let sourceURL = URL(fileURLWithPath: sourcePath) + let asset = AVURLAsset(url: sourceURL) + + guard let exportSession = AVAssetExportSession( + asset: asset, + presetName: AVAssetExportPresetPassthrough + ) else { + result( + FlutterError( + code: "transcode_failed", + message: "Unable to create export session.", + details: nil + ) + ) + return + } + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mp4") + + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + exportSession.shouldOptimizeForNetworkUse = true + + exportSession.exportAsynchronously { + switch exportSession.status { + case .completed: + result(outputURL.path) + default: + let errorMessage = exportSession.error?.localizedDescription + ?? "Video transcoding failed with status \(exportSession.status.rawValue)." + result( + FlutterError( + code: "transcode_failed", + message: errorMessage, + details: nil + ) + ) + // Clean up partial output on failure. + try? FileManager.default.removeItem(at: outputURL) + } + } + } } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f2fdbee7..3cf70956 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,7 +1,6 @@ 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'; @@ -19,6 +18,11 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'date_formatters.dart'; +import 'day_divider.dart'; +import 'manage_channel_sheet.dart'; +import 'members_sheet.dart'; +import 'message_actions.dart'; import 'message_content.dart'; import 'send_message_provider.dart'; import 'thread_detail_page.dart'; @@ -115,7 +119,7 @@ class ChannelDetailPage extends HookConsumerWidget { context: context, isScrollControlled: true, showDragHandle: true, - builder: (_) => _MembersSheet( + builder: (_) => MembersSheet( channel: resolvedChannel, currentPubkey: currentPubkey, ), @@ -131,7 +135,7 @@ class ChannelDetailPage extends HookConsumerWidget { context: context, isScrollControlled: true, showDragHandle: true, - builder: (_) => _ManageChannelSheet(channel: resolvedChannel), + builder: (_) => ManageChannelSheet(channel: resolvedChannel), ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(); @@ -317,42 +321,51 @@ class _MessageList extends HookConsumerWidget { final entry = entries[chronIdx]; final message = entry.message; - if (message.isSystem) { - return _SystemMessageRow(message: message); - } - - // The message visually above is the one earlier in time. + // Day boundary check — applies to all messages including system. final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null; final prevMessage = prevEntry?.message; - final showAuthor = + final showDayDivider = prevMessage == null || - prevMessage.isSystem || - prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() || - (message.createdAt - prevMessage.createdAt) > 300; + !isSameDay(prevMessage.createdAt, message.createdAt); + + final showAuthor = + !message.isSystem && + (prevMessage == null || + prevMessage.isSystem || + showDayDivider || + prevMessage.pubkey.toLowerCase() != + message.pubkey.toLowerCase() || + (message.createdAt - prevMessage.createdAt) > 300); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _MessageBubble( - message: message, - showAuthor: showAuthor, - channelNames: channelNamesMap, - currentChannelId: channelId, - currentPubkey: currentPubkey, - allMessages: allMessages, - isMember: isMember, - isArchived: isArchived, - ), - if (entry.summary != null) - _ThreadSummaryRow( - summary: entry.summary!, + if (showDayDivider) + DayDivider(label: formatDayHeading(message.createdAt)), + if (message.isSystem) + _SystemMessageRow(message: message) + else ...[ + _MessageBubble( message: message, - allMessages: allMessages, - channelId: channelId, + showAuthor: showAuthor, + channelNames: channelNamesMap, + currentChannelId: channelId, currentPubkey: currentPubkey, + allMessages: allMessages, isMember: isMember, isArchived: isArchived, ), + if (entry.summary != null) + _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ], ); }, @@ -387,7 +400,7 @@ class _SystemMessageRow extends ConsumerWidget { final description = systemEvent.describe(resolveLabel); return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.half), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), child: Row( children: [ Container( @@ -408,14 +421,14 @@ class _SystemMessageRow extends ConsumerWidget { child: Text( description, style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, + color: context.colors.onSurfaceVariant, ), ), ), Text( _formatTime(message.createdAt), style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline.withValues(alpha: 0.6), + color: context.colors.outline, ), ), ], @@ -598,7 +611,7 @@ class _MessageBubble extends ConsumerWidget { return GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: () => _showMessageActions( + onLongPress: () => showMessageActions( context: context, ref: ref, message: message, @@ -611,7 +624,7 @@ class _MessageBubble extends ConsumerWidget { isArchived: isArchived, ), child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.half), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -804,228 +817,6 @@ class _ReactionRow extends StatelessWidget { } } -// --------------------------------------------------------------------------- -// Message actions (long-press sheet) -// --------------------------------------------------------------------------- - -const _quickEmojis = ['👍', '❤️', '😂', '🎉', '👀', '🙏']; - -void _showMessageActions({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool isOwnMessage, - List? allMessages, - String? currentPubkey, - bool isMember = false, - bool isArchived = false, -}) { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Quick emoji row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - for (final emoji in _quickEmojis) - GestureDetector( - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelActionsProvider) - .addReaction(message.id, emoji); - }, - child: Container( - width: 44, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Theme.of( - sheetContext, - ).colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Text(emoji, style: const TextStyle(fontSize: 20)), - ), - ), - ], - ), - const SizedBox(height: Grid.xs), - if (allMessages != null) - ListTile( - leading: const Icon(LucideIcons.messageSquareReply), - title: const Text('Reply in thread'), - onTap: () { - Navigator.of(sheetContext).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - ), - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - // Copy to clipboard - final data = ClipboardData(text: message.content); - Clipboard.setData(data); - }, - ), - if (isOwnMessage) ...[ - ListTile( - leading: const Icon(LucideIcons.pencil), - title: const Text('Edit message'), - onTap: () { - Navigator.of(sheetContext).pop(); - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }, - ), - ListTile( - leading: Icon( - LucideIcons.trash2, - color: Theme.of(sheetContext).colorScheme.error, - ), - title: Text( - 'Delete message', - style: TextStyle( - color: Theme.of(sheetContext).colorScheme.error, - ), - ), - onTap: () { - Navigator.of(sheetContext).pop(); - _confirmDelete( - context: context, - ref: ref, - messageId: message.id, - ); - }, - ), - ], - ], - ), - ), - ), - ); -} - -void _showEditSheet({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, -}) { - final controller = TextEditingController(text: message.content); - showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (sheetContext) => Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: controller, - autofocus: true, - minLines: 1, - maxLines: 5, - decoration: const InputDecoration(hintText: 'Edit message'), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: () { - final text = controller.text.trim(); - if (text.isEmpty || text == message.content) { - Navigator.of(sheetContext).pop(); - return; - } - ref - .read(channelActionsProvider) - .editMessage( - channelId: channelId, - eventId: message.id, - content: text, - ); - Navigator.of(sheetContext).pop(); - }, - child: const Text('Save'), - ), - ], - ), - ], - ), - ), - ), - ); -} - -void _confirmDelete({ - required BuildContext context, - required WidgetRef ref, - required String messageId, -}) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete message'), - content: const Text('This cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - ref.read(channelActionsProvider).deleteMessage(messageId); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - child: const Text('Delete'), - ), - ], - ), - ); -} - // --------------------------------------------------------------------------- // Channel management // --------------------------------------------------------------------------- @@ -1069,583 +860,6 @@ class _ReadOnlyNotice extends StatelessWidget { } } -class _MembersSheet extends HookConsumerWidget { - final Channel channel; - final String? currentPubkey; - - const _MembersSheet({required this.channel, required this.currentPubkey}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final membersAsync = ref.watch(channelMembersProvider(channel.id)); - final allMembers = membersAsync.asData?.value ?? const []; - final people = allMembers.where((member) => !member.isBot).toList(); - final userCache = ref.watch(userCacheProvider); - - // Determine if the current user can manage members. - final currentMember = allMembers.cast().firstWhere( - (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), - orElse: () => null, - ); - final canManage = - currentMember != null && - currentMember.isElevated && - !channel.isArchived; - - // Preload profiles for all members so avatars appear. - useEffect(() { - if (people.isNotEmpty) { - ref - .read(userCacheProvider.notifier) - .preload(people.map((m) => m.pubkey).toList()); - } - return null; - }, [people.length]); - - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Members', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - Text( - 'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (!channel.isDm) ...[const Divider(height: Grid.sm)], - SizedBox( - height: 280, - child: membersAsync.when( - data: (_) => people.isEmpty - ? Center( - child: Text( - 'No people found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - ), - ) - : ListView( - shrinkWrap: true, - children: [ - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - ), - ], - ), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text( - error.toString(), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -const _changeableRoles = ['admin', 'member', 'guest']; - -class _MemberTile extends ConsumerWidget { - final ChannelMember member; - final String? currentPubkey; - final UserProfile? profile; - final bool canManage; - final bool isSelf; - final String channelId; - - const _MemberTile({ - required this.member, - required this.currentPubkey, - required this.profile, - required this.canManage, - required this.isSelf, - required this.channelId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final label = member.labelFor(currentPubkey); - final initial = label.substring(0, 1).toUpperCase(); - final showMenu = canManage && !isSelf && !member.isOwner; - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), - title: Text(label), - subtitle: Text( - _roleLabel(member.role), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - ), - trailing: showMenu - ? IconButton( - icon: const Icon(LucideIcons.ellipsis, size: 18), - onPressed: () => _showMemberActions(context, ref), - visualDensity: VisualDensity.compact, - ) - : null, - ); - } - - String _roleLabel(String role) { - if (role.isEmpty) return 'Member'; - return '${role[0].toUpperCase()}${role.substring(1)}'; - } - - void _showMemberActions(BuildContext context, WidgetRef ref) { - final label = member.labelFor(currentPubkey); - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (_) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Text(label, style: context.textTheme.titleSmall), - ), - const SizedBox(height: Grid.xxs), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Text( - 'Change role', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.outline, - ), - ), - ), - const SizedBox(height: Grid.half), - for (final role in _changeableRoles) - ListTile( - title: Text(_roleLabel(role)), - trailing: role == member.role - ? Icon( - LucideIcons.check, - size: 16, - color: context.colors.primary, - ) - : null, - enabled: role != member.role, - onTap: role == member.role - ? null - : () async { - Navigator.of(context).pop(); - await ref - .read(channelActionsProvider) - .changeMemberRole( - channelId: channelId, - pubkey: member.pubkey, - role: role, - ); - }, - ), - const Divider(), - ListTile( - leading: Icon( - LucideIcons.userMinus, - size: 18, - color: context.colors.error, - ), - title: Text( - 'Remove from channel', - style: TextStyle(color: context.colors.error), - ), - onTap: () async { - Navigator.of(context).pop(); - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove member'), - content: Text('Remove $label from this channel?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text( - 'Remove', - style: TextStyle(color: context.colors.error), - ), - ), - ], - ), - ); - if (confirmed == true) { - await ref - .read(channelActionsProvider) - .removeMember( - channelId: channelId, - pubkey: member.pubkey, - ); - } - }, - ), - const SizedBox(height: Grid.xxs), - ], - ), - ), - ); - } -} - -class _MemberAvatar extends HookWidget { - final String? avatarUrl; - final String initial; - - const _MemberAvatar({required this.avatarUrl, required this.initial}); - - @override - Widget build(BuildContext context) { - final failed = useState(false); - - useEffect(() { - failed.value = false; - return null; - }, [avatarUrl]); - - final url = avatarUrl; - if (url == null || failed.value) { - return CircleAvatar(child: Text(initial)); - } - return CircleAvatar( - backgroundImage: NetworkImage(url), - onBackgroundImageError: (_, _) => failed.value = true, - child: null, - ); - } -} - -class _ManageChannelSheet extends HookConsumerWidget { - final Channel channel; - - const _ManageChannelSheet({required this.channel}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); - final isEditingCanvas = useState(false); - final isSavingCanvas = useState(false); - final isBusy = useState(false); - final actionError = useState(null); - final canvasController = useTextEditingController(); - - useEffect(() { - final canvas = canvasAsync.asData?.value; - if (!isEditingCanvas.value) { - canvasController.text = canvas?.content ?? ''; - } - return null; - }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); - - final canJoin = - channel.visibility == 'open' && - !channel.isArchived && - !channel.isMember && - !channel.isDm; - final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; - final canEditCanvas = channel.isMember && !channel.isArchived; - - Future joinChannel() async { - if (isBusy.value) return; - isBusy.value = true; - actionError.value = null; - try { - await ref.read(channelActionsProvider).joinChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(false); - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isBusy.value = false; - } - } - - Future leaveChannel() async { - if (isBusy.value) return; - isBusy.value = true; - actionError.value = null; - try { - await ref.read(channelActionsProvider).leaveChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(true); - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isBusy.value = false; - } - } - - Future saveCanvas() async { - if (isSavingCanvas.value) { - return; - } - isSavingCanvas.value = true; - actionError.value = null; - try { - await ref - .read(channelActionsProvider) - .setCanvas( - channelId: channel.id, - content: canvasController.text.trim(), - ); - if (context.mounted) { - isEditingCanvas.value = false; - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isSavingCanvas.value = false; - } - } - - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: ListView( - shrinkWrap: true, - children: [ - Text('Manage channel', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - Text( - 'Basic management for ${channel.name}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (actionError.value case final error?) ...[ - const SizedBox(height: Grid.xs), - Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ], - if (canJoin || canLeave) ...[ - const SizedBox(height: Grid.xs), - Wrap( - spacing: Grid.xxs, - children: [ - if (canJoin) - FilledButton.tonal( - onPressed: isBusy.value ? null : joinChannel, - child: Text(isBusy.value ? 'Joining…' : 'Join channel'), - ), - if (canLeave) - OutlinedButton( - onPressed: isBusy.value ? null : leaveChannel, - child: Text(isBusy.value ? 'Leaving…' : 'Leave channel'), - ), - ], - ), - ], - const SizedBox(height: Grid.sm), - Text('Context', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Description', - value: channel.description, - emptyLabel: 'No description set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Topic', - value: channel.topic, - emptyLabel: 'No topic set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Purpose', - value: channel.purpose, - emptyLabel: 'No purpose set', - ), - if (!channel.isDm) ...[ - const SizedBox(height: Grid.sm), - Text('Canvas', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - canvasAsync.when( - data: (canvas) { - if (isEditingCanvas.value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: canvasController, - maxLines: 8, - minLines: 6, - decoration: const InputDecoration( - hintText: 'Write your canvas content in Markdown…', - ), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isSavingCanvas.value - ? null - : () { - isEditingCanvas.value = false; - canvasController.text = - canvas.content ?? ''; - }, - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: isSavingCanvas.value - ? null - : saveCanvas, - child: Text( - isSavingCanvas.value - ? 'Saving…' - : 'Save canvas', - ), - ), - ], - ), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Text( - canvas.content?.trim().isNotEmpty == true - ? canvas.content! - : 'No canvas set for this channel.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: Grid.xxs), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonal( - onPressed: canEditCanvas - ? () => isEditingCanvas.value = true - : null, - child: Text( - canvas.content?.trim().isNotEmpty == true - ? 'Edit canvas' - : 'Create canvas', - ), - ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Text( - error.toString(), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -class _ContextCard extends StatelessWidget { - final String label; - final String? value; - final String emptyLabel; - - const _ContextCard({ - required this.label, - required this.value, - required this.emptyLabel, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Grid.half), - Text( - value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 124d8d8f..f4b4ad55 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -10,6 +10,7 @@ import '../../shared/theme/theme.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; +import 'emoji_picker.dart'; /// Rich compose bar with @mention autocomplete, emoji picker, and a markdown /// formatting toolbar. Used in both channel and thread views — the caller @@ -226,13 +227,11 @@ class ComposeBar extends HookConsumerWidget { } } - Future pickAndUploadAttachment() async { + Future pickAndUpload(Future Function() pick) async { uploadError.value = null; uploadingCount.value += 1; try { - final uploaded = await ref - .read(mediaUploadServiceProvider) - .pickAndUploadImage(); + final uploaded = await pick(); if (uploaded != null && context.mounted) { attachments.value = [...attachments.value, uploaded]; } @@ -389,11 +388,47 @@ class ComposeBar extends HookConsumerWidget { children: [ _ComposeAction( icon: LucideIcons.paperclip, - onTap: pickAndUploadAttachment, + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('Photo'), + onTap: () { + Navigator.of(sheetContext).pop(); + pickAndUpload( + ref + .read(mediaUploadServiceProvider) + .pickAndUploadImage, + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.video), + title: const Text('Video'), + onTap: () { + Navigator.of(sheetContext).pop(); + pickAndUpload( + ref + .read(mediaUploadServiceProvider) + .pickAndUploadVideo, + ); + }, + ), + ], + ), + ), + ); + }, ), _ComposeAction( icon: LucideIcons.smilePlus, - onTap: () => _showEmojiPicker( + onTap: () => showEmojiPicker( context: context, onSelect: insertEmoji, ), @@ -474,304 +509,6 @@ void _sendTypingIndicator( } } -// --------------------------------------------------------------------------- -// Emoji picker -// --------------------------------------------------------------------------- - -void _showEmojiPicker({ - required BuildContext context, - required void Function(String emoji) onSelect, -}) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - builder: (sheetContext) => _EmojiPickerSheet( - onSelect: (emoji) { - Navigator.of(sheetContext).pop(); - onSelect(emoji); - }, - ), - ); -} - -/// Emoji categories for the picker. System Unicode emoji — no packages needed. -const _emojiCategories = <({String label, IconData icon, List emoji})>[ - ( - label: 'Popular', - icon: LucideIcons.clock, - emoji: [ - '\u{1F44D}', - '\u{2764}\u{FE0F}', - '\u{1F602}', - '\u{1F389}', - '\u{1F440}', - '\u{1F64F}', - '\u{1F525}', - '\u{2705}', - ], - ), - ( - label: 'Smileys', - icon: LucideIcons.smile, - emoji: [ - '\u{1F600}', - '\u{1F603}', - '\u{1F604}', - '\u{1F601}', - '\u{1F605}', - '\u{1F602}', - '\u{1F923}', - '\u{1F607}', - '\u{1F60A}', - '\u{1F60D}', - '\u{1F618}', - '\u{1F617}', - '\u{1F61A}', - '\u{1F619}', - '\u{1F60B}', - '\u{1F61B}', - '\u{1F61D}', - '\u{1F61C}', - '\u{1F911}', - '\u{1F917}', - '\u{1F914}', - '\u{1F910}', - '\u{1F928}', - '\u{1F610}', - '\u{1F611}', - '\u{1F636}', - '\u{1F60F}', - '\u{1F612}', - '\u{1F644}', - '\u{1F62C}', - '\u{1F925}', - '\u{1F60C}', - '\u{1F614}', - '\u{1F62A}', - '\u{1F924}', - '\u{1F634}', - '\u{1F637}', - '\u{1F912}', - '\u{1F915}', - '\u{1F922}', - '\u{1F92E}', - '\u{1F927}', - '\u{1F975}', - '\u{1F976}', - '\u{1F974}', - '\u{1F635}', - '\u{1F92F}', - '\u{1F920}', - '\u{1F973}', - '\u{1F978}', - ], - ), - ( - label: 'Gestures', - icon: LucideIcons.hand, - emoji: [ - '\u{1F44D}', - '\u{1F44E}', - '\u{1F44A}', - '\u{270A}', - '\u{1F91B}', - '\u{1F91C}', - '\u{1F44F}', - '\u{1F64C}', - '\u{1F450}', - '\u{1F64F}', - '\u{1F91D}', - '\u{270C}\u{FE0F}', - '\u{1F91E}', - '\u{1F91F}', - '\u{1F918}', - '\u{1F448}', - '\u{1F449}', - '\u{1F446}', - '\u{1F447}', - '\u{261D}\u{FE0F}', - '\u{1F4AA}', - '\u{1F44B}', - '\u{1F590}\u{FE0F}', - ], - ), - ( - label: 'Objects', - icon: LucideIcons.lightbulb, - emoji: [ - '\u{2764}\u{FE0F}', - '\u{1F525}', - '\u{2B50}', - '\u{1F31F}', - '\u{1F4A5}', - '\u{1F389}', - '\u{1F38A}', - '\u{1F3C6}', - '\u{1F947}', - '\u{1F4A1}', - '\u{1F4AF}', - '\u{2705}', - '\u{274C}', - '\u{26A0}\u{FE0F}', - '\u{1F6A8}', - '\u{1F4DD}', - '\u{1F4CB}', - '\u{1F4CC}', - '\u{1F517}', - '\u{1F4E3}', - '\u{1F514}', - '\u{1F3B5}', - '\u{1F3B6}', - '\u{1F680}', - ], - ), - ( - label: 'Nature', - icon: LucideIcons.sprout, - emoji: [ - '\u{1F331}', - '\u{1F332}', - '\u{1F333}', - '\u{1F334}', - '\u{1F335}', - '\u{1F33B}', - '\u{1F33A}', - '\u{1F337}', - '\u{1F339}', - '\u{1F340}', - '\u{1F341}', - '\u{1F343}', - '\u{1F31E}', - '\u{1F308}', - '\u{2600}\u{FE0F}', - '\u{1F327}\u{FE0F}', - '\u{26A1}', - '\u{2744}\u{FE0F}', - '\u{1F30A}', - '\u{1F436}', - '\u{1F431}', - '\u{1F98A}', - '\u{1F42C}', - '\u{1F985}', - ], - ), -]; - -class _EmojiPickerSheet extends StatefulWidget { - final void Function(String emoji) onSelect; - - const _EmojiPickerSheet({required this.onSelect}); - - @override - State<_EmojiPickerSheet> createState() => _EmojiPickerSheetState(); -} - -class _EmojiPickerSheetState extends State<_EmojiPickerSheet> { - /// -1 = "All", 0..N = specific category. - int _selectedCategory = -1; - - static final _allEmoji = () { - final seen = {}; - return [ - for (final cat in _emojiCategories) - for (final e in cat.emoji) - if (seen.add(e)) e, - ]; - }(); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final emoji = _selectedCategory < 0 - ? _allEmoji - : _emojiCategories[_selectedCategory].emoji; - - return SizedBox( - height: 340, - child: Column( - children: [ - // Category icon bar. - SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox(width: Grid.twelve), - _CategoryIcon( - icon: LucideIcons.layoutGrid, - selected: _selectedCategory < 0, - onTap: () => setState(() => _selectedCategory = -1), - ), - for (var i = 0; i < _emojiCategories.length; i++) - _CategoryIcon( - icon: _emojiCategories[i].icon, - selected: _selectedCategory == i, - onTap: () => setState(() => _selectedCategory = i), - ), - ], - ), - ), - Divider(height: 1, color: colors.outlineVariant), - const SizedBox(height: Grid.xxs), - // Emoji grid. - Expanded( - child: GridView.builder( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - mainAxisSpacing: Grid.half, - crossAxisSpacing: Grid.half, - ), - itemCount: emoji.length, - itemBuilder: (context, index) { - final e = emoji[index]; - return GestureDetector( - onTap: () => widget.onSelect(e), - child: Center( - child: Text(e, style: const TextStyle(fontSize: 28)), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _CategoryIcon extends StatelessWidget { - final IconData icon; - final bool selected; - final VoidCallback onTap; - - const _CategoryIcon({ - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return SizedBox( - width: 40, - height: 40, - child: IconButton( - onPressed: onTap, - icon: Icon( - icon, - size: 18, - color: selected ? colors.primary : colors.onSurfaceVariant, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - ); - } -} - // --------------------------------------------------------------------------- // Mention suggestions // --------------------------------------------------------------------------- @@ -1048,6 +785,7 @@ class _AttachmentStrip extends StatelessWidget { } final attachment = attachments[index]; + final isVideo = attachment.type.startsWith('video/'); final previewUrl = attachment.thumb ?? attachment.url; return Container( key: ValueKey('compose-attachment:${attachment.url}'), @@ -1061,17 +799,28 @@ class _AttachmentStrip extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(Radii.md), - child: Image.network( - previewUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => ColoredBox( - color: context.colors.surface, - child: Icon( - LucideIcons.image, - color: context.colors.onSurfaceVariant, - ), - ), - ), + child: isVideo + ? ColoredBox( + color: Colors.black, + child: Center( + child: Icon( + LucideIcons.video, + color: Colors.white, + size: 24, + ), + ), + ) + : Image.network( + previewUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: context.colors.surface, + child: Icon( + LucideIcons.image, + color: context.colors.onSurfaceVariant, + ), + ), + ), ), Positioned( top: Grid.quarter, diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart new file mode 100644 index 00000000..c2681448 --- /dev/null +++ b/mobile/lib/features/channels/date_formatters.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); + +/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". +/// +/// [now] is exposed for testing; production callers should omit it. +String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { + final date = DateTime.fromMillisecondsSinceEpoch( + unixSeconds * 1000, + isUtc: true, + ).toLocal(); + now ??= DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final messageDay = DateTime(date.year, date.month, date.day); + + if (today.year == messageDay.year && + today.month == messageDay.month && + today.day == messageDay.day) { + return 'Today'; + } + final yesterday = DateTime(now.year, now.month, now.day - 1); + if (yesterday.year == messageDay.year && + yesterday.month == messageDay.month && + yesterday.day == messageDay.day) { + return 'Yesterday'; + } + return _fullDateFormat.format(date); +} + +/// Whether two unix-second timestamps fall on the same calendar day (local time). +bool isSameDay(int a, int b) { + final dtA = DateTime.fromMillisecondsSinceEpoch( + a * 1000, + isUtc: true, + ).toLocal(); + final dtB = DateTime.fromMillisecondsSinceEpoch( + b * 1000, + isUtc: true, + ).toLocal(); + return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day; +} diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart new file mode 100644 index 00000000..d9b9b1d8 --- /dev/null +++ b/mobile/lib/features/channels/day_divider.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../shared/theme/theme.dart'; + +/// A centered label between two horizontal dividers, used to separate +/// messages by calendar day ("TODAY", "YESTERDAY", full dates). +class DayDivider extends StatelessWidget { + final String label; + + const DayDivider({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Row( + children: [ + Expanded(child: Divider(color: context.colors.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + letterSpacing: 2.0, + ), + ), + ), + Expanded(child: Divider(color: context.colors.outlineVariant)), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart new file mode 100644 index 00000000..63e3f40c --- /dev/null +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// Opens the full emoji picker as a modal bottom sheet. +void showEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + builder: (sheetContext) => EmojiPickerSheet( + onSelect: (emoji) { + Navigator.of(sheetContext).pop(); + onSelect(emoji); + }, + ), + ); +} + +/// Emoji categories for the picker. System Unicode emoji — no packages needed. +const emojiCategories = <({String label, IconData icon, List emoji})>[ + ( + label: 'Popular', + icon: LucideIcons.clock, + emoji: [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', + '\u{1F525}', + '\u{2705}', + ], + ), + ( + label: 'Smileys', + icon: LucideIcons.smile, + emoji: [ + '\u{1F600}', + '\u{1F603}', + '\u{1F604}', + '\u{1F601}', + '\u{1F605}', + '\u{1F602}', + '\u{1F923}', + '\u{1F607}', + '\u{1F60A}', + '\u{1F60D}', + '\u{1F618}', + '\u{1F617}', + '\u{1F61A}', + '\u{1F619}', + '\u{1F60B}', + '\u{1F61B}', + '\u{1F61D}', + '\u{1F61C}', + '\u{1F911}', + '\u{1F917}', + '\u{1F914}', + '\u{1F910}', + '\u{1F928}', + '\u{1F610}', + '\u{1F611}', + '\u{1F636}', + '\u{1F60F}', + '\u{1F612}', + '\u{1F644}', + '\u{1F62C}', + '\u{1F925}', + '\u{1F60C}', + '\u{1F614}', + '\u{1F62A}', + '\u{1F924}', + '\u{1F634}', + '\u{1F637}', + '\u{1F912}', + '\u{1F915}', + '\u{1F922}', + '\u{1F92E}', + '\u{1F927}', + '\u{1F975}', + '\u{1F976}', + '\u{1F974}', + '\u{1F635}', + '\u{1F92F}', + '\u{1F920}', + '\u{1F973}', + '\u{1F978}', + ], + ), + ( + label: 'Gestures', + icon: LucideIcons.hand, + emoji: [ + '\u{1F44D}', + '\u{1F44E}', + '\u{1F44A}', + '\u{270A}', + '\u{1F91B}', + '\u{1F91C}', + '\u{1F44F}', + '\u{1F64C}', + '\u{1F450}', + '\u{1F64F}', + '\u{1F91D}', + '\u{270C}\u{FE0F}', + '\u{1F91E}', + '\u{1F91F}', + '\u{1F918}', + '\u{1F448}', + '\u{1F449}', + '\u{1F446}', + '\u{1F447}', + '\u{261D}\u{FE0F}', + '\u{1F4AA}', + '\u{1F44B}', + '\u{1F590}\u{FE0F}', + ], + ), + ( + label: 'Objects', + icon: LucideIcons.lightbulb, + emoji: [ + '\u{2764}\u{FE0F}', + '\u{1F525}', + '\u{2B50}', + '\u{1F31F}', + '\u{1F4A5}', + '\u{1F389}', + '\u{1F38A}', + '\u{1F3C6}', + '\u{1F947}', + '\u{1F4A1}', + '\u{1F4AF}', + '\u{2705}', + '\u{274C}', + '\u{26A0}\u{FE0F}', + '\u{1F6A8}', + '\u{1F4DD}', + '\u{1F4CB}', + '\u{1F4CC}', + '\u{1F517}', + '\u{1F4E3}', + '\u{1F514}', + '\u{1F3B5}', + '\u{1F3B6}', + '\u{1F680}', + ], + ), + ( + label: 'Nature', + icon: LucideIcons.sprout, + emoji: [ + '\u{1F331}', + '\u{1F332}', + '\u{1F333}', + '\u{1F334}', + '\u{1F335}', + '\u{1F33B}', + '\u{1F33A}', + '\u{1F337}', + '\u{1F339}', + '\u{1F340}', + '\u{1F341}', + '\u{1F343}', + '\u{1F31E}', + '\u{1F308}', + '\u{2600}\u{FE0F}', + '\u{1F327}\u{FE0F}', + '\u{26A1}', + '\u{2744}\u{FE0F}', + '\u{1F30A}', + '\u{1F436}', + '\u{1F431}', + '\u{1F98A}', + '\u{1F42C}', + '\u{1F985}', + ], + ), +]; + +class EmojiPickerSheet extends StatefulWidget { + final void Function(String emoji) onSelect; + + const EmojiPickerSheet({super.key, required this.onSelect}); + + @override + State createState() => _EmojiPickerSheetState(); +} + +class _EmojiPickerSheetState extends State { + /// -1 = "All", 0..N = specific category. + int _selectedCategory = -1; + + static final _allEmoji = () { + final seen = {}; + return [ + for (final cat in emojiCategories) + for (final e in cat.emoji) + if (seen.add(e)) e, + ]; + }(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final emoji = _selectedCategory < 0 + ? _allEmoji + : emojiCategories[_selectedCategory].emoji; + + return SizedBox( + height: 340, + child: Column( + children: [ + // Category icon bar. + SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: Grid.twelve), + CategoryIcon( + icon: LucideIcons.layoutGrid, + selected: _selectedCategory < 0, + onTap: () => setState(() => _selectedCategory = -1), + ), + for (var i = 0; i < emojiCategories.length; i++) + CategoryIcon( + icon: emojiCategories[i].icon, + selected: _selectedCategory == i, + onTap: () => setState(() => _selectedCategory = i), + ), + ], + ), + ), + Divider(height: 1, color: colors.outlineVariant), + const SizedBox(height: Grid.xxs), + // Emoji grid. + Expanded( + child: GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + mainAxisSpacing: Grid.half, + crossAxisSpacing: Grid.half, + ), + itemCount: emoji.length, + itemBuilder: (context, index) { + final e = emoji[index]; + return GestureDetector( + onTap: () => widget.onSelect(e), + child: Center( + child: Text(e, style: const TextStyle(fontSize: 28)), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class CategoryIcon extends StatelessWidget { + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const CategoryIcon({ + super.key, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SizedBox( + width: 40, + height: 40, + child: IconButton( + onPressed: onTap, + icon: Icon( + icon, + size: 18, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + } +} diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart new file mode 100644 index 00000000..19d67cab --- /dev/null +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; + +class ManageChannelSheet extends HookConsumerWidget { + final Channel channel; + + const ManageChannelSheet({super.key, required this.channel}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); + final isEditingCanvas = useState(false); + final isSavingCanvas = useState(false); + final isBusy = useState(false); + final actionError = useState(null); + final canvasController = useTextEditingController(); + + useEffect(() { + final canvas = canvasAsync.asData?.value; + if (!isEditingCanvas.value) { + canvasController.text = canvas?.content ?? ''; + } + return null; + }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); + + final canJoin = + channel.visibility == 'open' && + !channel.isArchived && + !channel.isMember && + !channel.isDm; + final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; + final canEditCanvas = channel.isMember && !channel.isArchived; + + Future joinChannel() async { + if (isBusy.value) return; + isBusy.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).joinChannel(channel.id); + if (context.mounted) { + Navigator.of(context).pop(false); + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isBusy.value = false; + } + } + + Future leaveChannel() async { + if (isBusy.value) return; + isBusy.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).leaveChannel(channel.id); + if (context.mounted) { + Navigator.of(context).pop(true); + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isBusy.value = false; + } + } + + Future saveCanvas() async { + if (isSavingCanvas.value) { + return; + } + isSavingCanvas.value = true; + actionError.value = null; + try { + await ref + .read(channelActionsProvider) + .setCanvas( + channelId: channel.id, + content: canvasController.text.trim(), + ); + if (context.mounted) { + isEditingCanvas.value = false; + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isSavingCanvas.value = false; + } + } + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: ListView( + shrinkWrap: true, + children: [ + Text('Manage channel', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + Text( + 'Basic management for ${channel.name}.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (actionError.value case final error?) ...[ + const SizedBox(height: Grid.xs), + Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + if (canJoin || canLeave) ...[ + const SizedBox(height: Grid.xs), + Wrap( + spacing: Grid.xxs, + children: [ + if (canJoin) + FilledButton.tonal( + onPressed: isBusy.value ? null : joinChannel, + child: Text( + isBusy.value ? 'Joining\u2026' : 'Join channel', + ), + ), + if (canLeave) + OutlinedButton( + onPressed: isBusy.value ? null : leaveChannel, + child: Text( + isBusy.value ? 'Leaving\u2026' : 'Leave channel', + ), + ), + ], + ), + ], + const SizedBox(height: Grid.sm), + Text('Context', style: context.textTheme.labelLarge), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Description', + value: channel.description, + emptyLabel: 'No description set', + ), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Topic', + value: channel.topic, + emptyLabel: 'No topic set', + ), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Purpose', + value: channel.purpose, + emptyLabel: 'No purpose set', + ), + if (!channel.isDm) ...[ + const SizedBox(height: Grid.sm), + Text('Canvas', style: context.textTheme.labelLarge), + const SizedBox(height: Grid.xxs), + canvasAsync.when( + data: (canvas) { + if (isEditingCanvas.value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: canvasController, + maxLines: 8, + minLines: 6, + decoration: const InputDecoration( + hintText: + 'Write your canvas content in Markdown\u2026', + ), + ), + const SizedBox(height: Grid.xxs), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isSavingCanvas.value + ? null + : () { + isEditingCanvas.value = false; + canvasController.text = + canvas.content ?? ''; + }, + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.half), + FilledButton( + onPressed: isSavingCanvas.value + ? null + : saveCanvas, + child: Text( + isSavingCanvas.value + ? 'Saving\u2026' + : 'Save canvas', + ), + ), + ], + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Text( + canvas.content?.trim().isNotEmpty == true + ? canvas.content! + : 'No canvas set for this channel.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: Grid.xxs), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonal( + onPressed: canEditCanvas + ? () => isEditingCanvas.value = true + : null, + child: Text( + canvas.content?.trim().isNotEmpty == true + ? 'Edit canvas' + : 'Create canvas', + ), + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ContextCard extends StatelessWidget { + final String label; + final String? value; + final String emptyLabel; + + const _ContextCard({ + required this.label, + required this.value, + required this.emptyLabel, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.half), + Text( + value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart new file mode 100644 index 00000000..bfc7c55c --- /dev/null +++ b/mobile/lib/features/channels/members_sheet.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.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'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; + +class MembersSheet extends HookConsumerWidget { + final Channel channel; + final String? currentPubkey; + + const MembersSheet({ + super.key, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final membersAsync = ref.watch(channelMembersProvider(channel.id)); + final allMembers = membersAsync.asData?.value ?? const []; + final people = allMembers.where((member) => !member.isBot).toList(); + final userCache = ref.watch(userCacheProvider); + + // Determine if the current user can manage members. + final currentMember = allMembers.cast().firstWhere( + (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), + orElse: () => null, + ); + final canManage = + currentMember != null && + currentMember.isElevated && + !channel.isArchived; + + // Preload profiles for all members so avatars appear. + useEffect(() { + if (people.isNotEmpty) { + ref + .read(userCacheProvider.notifier) + .preload(people.map((m) => m.pubkey).toList()); + } + return null; + }, [people.length]); + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Members', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + Text( + 'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (!channel.isDm) ...[const Divider(height: Grid.sm)], + SizedBox( + height: 280, + child: membersAsync.when( + data: (_) => people.isEmpty + ? Center( + child: Text( + 'No people found.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ) + : ListView( + shrinkWrap: true, + children: [ + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + ), + ], + ), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +const _changeableRoles = ['admin', 'member', 'guest']; + +class _MemberTile extends ConsumerWidget { + final ChannelMember member; + final String? currentPubkey; + final UserProfile? profile; + final bool canManage; + final bool isSelf; + final String channelId; + + const _MemberTile({ + required this.member, + required this.currentPubkey, + required this.profile, + required this.canManage, + required this.isSelf, + required this.channelId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = member.labelFor(currentPubkey); + final initial = label.substring(0, 1).toUpperCase(); + final showMenu = canManage && !isSelf && !member.isOwner; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), + title: Text(label), + subtitle: Text( + _roleLabel(member.role), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + trailing: showMenu + ? IconButton( + icon: const Icon(LucideIcons.ellipsis, size: 18), + onPressed: () => _showMemberActions(context, ref), + visualDensity: VisualDensity.compact, + ) + : null, + ); + } + + String _roleLabel(String role) { + if (role.isEmpty) return 'Member'; + return '${role[0].toUpperCase()}${role.substring(1)}'; + } + + void _showMemberActions(BuildContext context, WidgetRef ref) { + final label = member.labelFor(currentPubkey); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Text(label, style: context.textTheme.titleSmall), + ), + const SizedBox(height: Grid.xxs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Text( + 'Change role', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + ), + ), + ), + const SizedBox(height: Grid.half), + for (final role in _changeableRoles) + ListTile( + title: Text(_roleLabel(role)), + trailing: role == member.role + ? Icon( + LucideIcons.check, + size: 16, + color: context.colors.primary, + ) + : null, + enabled: role != member.role, + onTap: role == member.role + ? null + : () async { + Navigator.of(context).pop(); + await ref + .read(channelActionsProvider) + .changeMemberRole( + channelId: channelId, + pubkey: member.pubkey, + role: role, + ); + }, + ), + const Divider(), + ListTile( + leading: Icon( + LucideIcons.userMinus, + size: 18, + color: context.colors.error, + ), + title: Text( + 'Remove from channel', + style: TextStyle(color: context.colors.error), + ), + onTap: () async { + Navigator.of(context).pop(); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove member'), + content: Text('Remove $label from this channel?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Remove', + style: TextStyle(color: context.colors.error), + ), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(channelActionsProvider) + .removeMember( + channelId: channelId, + pubkey: member.pubkey, + ); + } + }, + ), + const SizedBox(height: Grid.xxs), + ], + ), + ), + ); + } +} + +class _MemberAvatar extends HookWidget { + final String? avatarUrl; + final String initial; + + const _MemberAvatar({required this.avatarUrl, required this.initial}); + + @override + Widget build(BuildContext context) { + final failed = useState(false); + + useEffect(() { + failed.value = false; + return null; + }, [avatarUrl]); + + final url = avatarUrl; + if (url == null || failed.value) { + return CircleAvatar(child: Text(initial)); + } + return CircleAvatar( + backgroundImage: NetworkImage(url), + onBackgroundImageError: (_, _) => failed.value = true, + child: null, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart new file mode 100644 index 00000000..9e29f4ec --- /dev/null +++ b/mobile/lib/features/channels/message_actions.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import 'channel_management_provider.dart'; +import 'emoji_picker.dart'; +import 'thread_detail_page.dart'; +import 'timeline_message.dart'; + +const quickEmojis = [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', +]; + +void showMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool isOwnMessage, + List? allMessages, + String? currentPubkey, + bool isMember = false, + bool isArchived = false, +}) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Quick emoji row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final emoji in quickEmojis) + GestureDetector( + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelActionsProvider) + .addReaction(message.id, emoji); + }, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + sheetContext, + ).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Text(emoji, style: const TextStyle(fontSize: 20)), + ), + ), + GestureDetector( + onTap: () { + Navigator.of(sheetContext).pop(); + showEmojiPicker( + context: context, + onSelect: (emoji) { + ref + .read(channelActionsProvider) + .addReaction(message.id, emoji); + }, + ); + }, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + sheetContext, + ).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.plus, + size: 20, + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + if (allMessages != null) + ListTile( + leading: const Icon(LucideIcons.messageSquareReply), + title: const Text('Reply in thread'), + onTap: () { + Navigator.of(sheetContext).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + // Copy to clipboard + final data = ClipboardData(text: message.content); + Clipboard.setData(data); + }, + ), + if (isOwnMessage) ...[ + ListTile( + leading: const Icon(LucideIcons.pencil), + title: const Text('Edit message'), + onTap: () { + Navigator.of(sheetContext).pop(); + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete message', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + messageId: message.id, + ); + }, + ), + ], + ], + ), + ), + ), + ); +} + +void _showEditSheet({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, +}) { + final controller = TextEditingController(text: message.content); + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 5, + decoration: const InputDecoration(hintText: 'Edit message'), + ), + const SizedBox(height: Grid.xxs), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.half), + FilledButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isEmpty || text == message.content) { + Navigator.of(sheetContext).pop(); + return; + } + ref + .read(channelActionsProvider) + .editMessage( + channelId: channelId, + eventId: message.id, + content: text, + ); + Navigator.of(sheetContext).pop(); + }, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ), + ); +} + +void _confirmDelete({ + required BuildContext context, + required WidgetRef ref, + required String messageId, +}) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete message'), + content: const Text('This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + ref.read(channelActionsProvider).deleteMessage(messageId); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 02eb0546..d43efb78 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -51,8 +51,27 @@ class MessageContent extends StatelessWidget { context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final imetaByUrl = parseImetaTags(tags); + // Convert angle-bracket autolinks to standard markdown links, + // but skip content inside backticks (inline code / fenced blocks). + final buffer = StringBuffer(); + final parts = content.split('`'); + for (var i = 0; i < parts.length; i++) { + if (i.isOdd) { + // Inside backticks — preserve as-is. + buffer.write('`${parts[i]}`'); + } else { + buffer.write( + parts[i].replaceAllMapped( + RegExp(r'<(https?://[^>]+)>'), + (m) => '[${m[1]}](${m[1]})', + ), + ); + } + } + final processed = buffer.toString(); + return GptMarkdown( - content, + processed, style: style, followLinkColor: false, linkBuilder: (context, linkText, url, linkStyle) => diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index d29ad9ff..a238307e 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -13,6 +14,7 @@ import 'relay_provider.dart'; const _mediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'sprout/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; +const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; @@ -31,6 +33,8 @@ final _mediaUploadPlatformChannel = MethodChannel( ); const _allowedImageMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; +const _allowedVideoMimeTypes = {'video/mp4'}; +const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB const _unsupportedAnimatedImageMimeTypes = {'image/gif'}; const _unsupportedGifUploadMessage = 'GIF uploads are not supported on mobile yet'; @@ -40,9 +44,11 @@ const _unsupportedAnimatedWebpUploadMessage = 'Animated WebP uploads are not supported on mobile yet'; typedef PickGalleryImage = Future Function(); +typedef PickGalleryVideo = Future Function(); typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); +typedef TranscodeVideoToMp4 = Future Function(String filePath); @immutable class _PreparedUploadImage { @@ -113,8 +119,10 @@ class MediaUploadService { final String? _apiToken; final String? _nsec; final PickGalleryImage _pickGalleryImage; + final PickGalleryVideo _pickGalleryVideo; final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; + final TranscodeVideoToMp4 _transcodeVideoToMp4; final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; @@ -124,17 +132,21 @@ class MediaUploadService { required String? apiToken, required String? nsec, required PickGalleryImage pickGalleryImage, + required PickGalleryVideo pickGalleryVideo, SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, + TranscodeVideoToMp4? transcodeVideoToMp4, DateTime Function()? now, http.Client? httpClient, }) : _baseUrl = baseUrl, _apiToken = apiToken, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickGalleryVideo = pickGalleryVideo, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, + _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), _ownsHttpClient = httpClient == null; @@ -152,12 +164,56 @@ class MediaUploadService { return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } + Future pickAndUploadVideo() async { + final pickedVideo = await _pickGalleryVideo(); + if (pickedVideo == null) return null; + final length = await pickedVideo.length(); + if (length > _maxVideoSizeBytes) { + throw Exception( + 'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + + // Read first 32 bytes to check if it's already an MP4 container. + final header = await _readFileHeader(pickedVideo.path, 32); + + if (_isAlreadyMp4Container(header)) { + // Already MP4 — upload directly. + final bytes = await pickedVideo.readAsBytes(); + return uploadBytes(bytes, mimeType: 'video/mp4'); + } + + // Non-MP4 container (e.g. QuickTime .mov) — remux to MP4 via platform. + String? transcodedPath; + try { + transcodedPath = await _transcodeVideoToMp4(pickedVideo.path); + final transcodedFile = File(transcodedPath); + final transcodedLength = await transcodedFile.length(); + if (transcodedLength > _maxVideoSizeBytes) { + throw Exception( + 'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + final bytes = await transcodedFile.readAsBytes(); + return uploadBytes(bytes, mimeType: 'video/mp4'); + } finally { + if (transcodedPath != null) { + try { + await File(transcodedPath).delete(); + } catch (_) { + // Best-effort temp file cleanup. + } + } + } + } + Future uploadBytes( Uint8List bytes, { required String mimeType, }) async { _validateUpload(bytes, mimeType); - if (!_allowedImageMimeTypes.contains(mimeType)) { + if (!_allowedImageMimeTypes.contains(mimeType) && + !_allowedVideoMimeTypes.contains(mimeType)) { throw Exception('unsupported file type: $mimeType'); } @@ -495,6 +551,50 @@ 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. +/// Known MP4 ftyp major brands. If the file's major brand (bytes 8–11) +/// matches one of these, it's already an MP4-compatible container. +const _mp4FtypBrands = {'isom', 'mp41', 'mp42', 'M4V ', 'avc1', 'iso5'}; + +/// Checks whether [bytes] (at least 12 bytes of file header) represent +/// an MP4-family container by inspecting the ftyp box major brand. +/// +/// Exposed for testing as [isAlreadyMp4Container]. +@visibleForTesting +bool isAlreadyMp4Container(Uint8List bytes) => _isAlreadyMp4Container(bytes); + +bool _isAlreadyMp4Container(Uint8List bytes) { + if (bytes.length < 12) return false; + if (!_matchesAscii(bytes, 4, 'ftyp')) return false; + final brand = ascii.decode(bytes.sublist(8, 12), allowInvalid: true); + return _mp4FtypBrands.contains(brand); +} + +/// Reads the first [count] bytes of a file without loading it entirely. +Future _readFileHeader(String path, int count) async { + final file = File(path); + final raf = await file.open(mode: FileMode.read); + try { + final bytes = await raf.read(count); + return bytes; + } finally { + await raf.close(); + } +} + +Future _transcodePickedVideoToMp4(String filePath) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + _transcodeVideoToMp4Method, + filePath, + ); + if (result == null || result.isEmpty) { + throw Exception('Failed to convert video to MP4.'); + } + return result; +} + String? _extractServerAuthority(String baseUrl) { final uri = Uri.parse(baseUrl); if (uri.host.isEmpty) return null; @@ -547,6 +647,7 @@ final mediaUploadServiceProvider = Provider((ref) { source: ImageSource.gallery, requestFullMetadata: false, ), + pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), ); ref.onDispose(service.dispose); return service; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f3bb6164..6fc0e735 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -568,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 830af7c4..e6948dca 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: pointycastle: ^3.7.3 url_launcher: ^6.3.2 gpt_markdown: ^1.1.6 + intl: ^0.20.2 image_picker: ^1.1.2 video_player: ^2.10.1 diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index cc8ec74a..ca328dc9 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -174,6 +175,7 @@ void main() { 200, ); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -196,7 +198,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); @@ -237,6 +240,7 @@ void main() { 200, ); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -254,7 +258,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( @@ -293,6 +298,7 @@ void main() { httpClient: http_testing.MockClient((request) async { return http.Response('bad upload', 401); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -310,7 +316,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); @@ -323,6 +330,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: 'sprout_test_token', nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); @@ -340,7 +348,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect( @@ -358,6 +367,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: 'sprout_test_token', nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); @@ -375,7 +385,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect( @@ -383,5 +394,88 @@ void main() { 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, ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + // 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', + apiToken: 'sprout_test_token', + 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, + ); + + String? sentContent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + }, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pumpAndSettle(); + 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)); + } + + // Video attachment should show a video icon (not a broken image). + expect(find.byIcon(LucideIcons.video), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); + } finally { + await tempDir.delete(recursive: true); + } + }); }); } diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart new file mode 100644 index 00000000..8ab57141 --- /dev/null +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/date_formatters.dart'; + +/// Helper: build a unix-second timestamp from a local DateTime. +int _ts(DateTime local) => local.millisecondsSinceEpoch ~/ 1000; + +void main() { + group('formatDayHeading', () { + // Fix "now" so tests are deterministic. + final now = DateTime(2026, 4, 23, 14, 30); // Apr 23 2026, 2:30 PM local + + test('same day returns "Today"', () { + final morning = _ts(DateTime(2026, 4, 23, 8, 0)); + expect(formatDayHeading(morning, now: now), 'Today'); + }); + + test('yesterday returns "Yesterday"', () { + final yesterday = _ts(DateTime(2026, 4, 22, 18, 0)); + expect(formatDayHeading(yesterday, now: now), 'Yesterday'); + }); + + test('older date returns full formatted date', () { + final older = _ts(DateTime(2026, 3, 31, 12, 0)); + expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026'); + }); + + test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () { + final lateTonight = _ts(DateTime(2026, 4, 23, 23, 59)); + final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1)); + + expect(formatDayHeading(lateTonight, now: now), 'Today'); + // Tomorrow relative to our fixed "now" is not today or yesterday. + expect( + formatDayHeading(earlyTomorrow, now: now), + 'Friday, April 24, 2026', + ); + }); + + test('cross-month boundary: April 1 → March 31 is yesterday', () { + final april1 = DateTime(2026, 4, 1, 10, 0); + final march31 = _ts(DateTime(2026, 3, 31, 20, 0)); + expect(formatDayHeading(march31, now: april1), 'Yesterday'); + }); + }); + + group('isSameDay', () { + test('same calendar day returns true', () { + final a = _ts(DateTime(2026, 4, 23, 1, 0)); + final b = _ts(DateTime(2026, 4, 23, 23, 59)); + expect(isSameDay(a, b), isTrue); + }); + + test('different days returns false', () { + final a = _ts(DateTime(2026, 4, 23, 23, 59)); + final b = _ts(DateTime(2026, 4, 24, 0, 1)); + expect(isSameDay(a, b), isFalse); + }); + }); +} diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 1228e9b9..44948e12 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -236,7 +236,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('Hello forum')); + await tester.tap(find.byType(ForumPostCard)); expect(tapped, isTrue); }); @@ -284,7 +284,7 @@ void main() { await tester.pumpWidget(_buildPostCard(post: _makePost())); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Copy text'), findsOneWidget); @@ -301,7 +301,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Delete post'), findsOneWidget); @@ -319,7 +319,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Delete post'), findsNothing); }); @@ -336,7 +336,7 @@ void main() { await tester.pumpAndSettle(); // Long press → action sheet. - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); // Tap Delete post. diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 951bb785..18c52951 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -260,6 +261,7 @@ void main() { apiToken: 'sprout_test_token', nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), @@ -312,6 +314,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: null, + pickGalleryVideo: () async => null, pickGalleryImage: () async => null, ); @@ -344,6 +347,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -396,6 +400,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_heicBytes, name: 'photo.heic'), transcodeImageToJpeg: (bytes) async { @@ -446,6 +451,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_jpegBytes, name: 'photo.jpg'), sanitizeImageBytes: (bytes, mimeType) async { @@ -497,6 +503,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_heicBytes, name: 'photo.heic'), transcodeImageToJpeg: (bytes) async { @@ -547,6 +554,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_jpegBytes, name: 'photo.jpg'), sanitizeImageBytes: (bytes, mimeType) async { @@ -599,6 +607,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'photo.png'), sanitizeImageBytes: (bytes, mimeType) async { @@ -627,6 +636,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); @@ -654,6 +664,7 @@ void main() { httpClient: http_testing.MockClient( (request) async => http.Response('{}', 200), ), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); @@ -695,6 +706,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'), ); @@ -719,6 +731,7 @@ void main() { httpClient: http_testing.MockClient( (request) async => http.Response('{}', 200), ), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_animatedWebpBytes, name: 'animated.webp'), ); @@ -743,6 +756,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData( Uint8List.fromList(utf8.encode('not an image')), name: 'note.txt', @@ -761,4 +775,220 @@ void main() { ); }); }); + + group('_isAlreadyMp4Container', () { + // ftyp box: [size(4 bytes)][ftyp(4 bytes)][major_brand(4 bytes)]... + Uint8List buildFtypHeader(String brand) { + final bytes = Uint8List(32); + // Box size = 32 + bytes[0] = 0; + bytes[1] = 0; + bytes[2] = 0; + bytes[3] = 32; + // 'ftyp' + bytes[4] = 0x66; // f + bytes[5] = 0x74; // t + bytes[6] = 0x79; // y + bytes[7] = 0x70; // p + // Major brand + final brandBytes = ascii.encode(brand); + for (var i = 0; i < 4 && i < brandBytes.length; i++) { + bytes[8 + i] = brandBytes[i]; + } + return bytes; + } + + test('returns true for isom brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('isom')), isTrue); + }); + + test('returns true for mp41 brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('mp41')), isTrue); + }); + + test('returns true for mp42 brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('mp42')), isTrue); + }); + + test('returns false for QuickTime qt brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('qt ')), isFalse); + }); + + test('returns false for too-short header', () { + expect(isAlreadyMp4Container(Uint8List(8)), isFalse); + }); + + test('returns false when no ftyp box', () { + final bytes = Uint8List(32); + bytes[4] = 0x6D; // m + bytes[5] = 0x6F; // o + bytes[6] = 0x6F; // o + bytes[7] = 0x76; // v + expect(isAlreadyMp4Container(bytes), isFalse); + }); + }); + + group('pickAndUploadVideo', () { + // Helper: build ftyp header bytes for a given brand. + Uint8List buildFtypHeader(String brand) { + final bytes = Uint8List(32); + bytes[3] = 32; + bytes[4] = 0x66; + bytes[5] = 0x74; + bytes[6] = 0x79; + bytes[7] = 0x70; + final brandBytes = ascii.encode(brand); + for (var i = 0; i < 4 && i < brandBytes.length; i++) { + bytes[8 + i] = brandBytes[i]; + } + return bytes; + } + + // Helper: write bytes to a temp file, return its XFile. + Future<(XFile, File)> writeTempVideo(Uint8List bytes, String name) async { + final dir = await Directory.systemTemp.createTemp('video_test_'); + final file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes); + return (XFile(file.path), file); + } + + test('uploads MP4 container directly without transcoding', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + var transcodeCalled = false; + final client = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 32, + 'type': 'video/mp4', + 'uploaded': 1, + }), + 200, + ); + }); + + final mp4Bytes = buildFtypHeader('isom'); + final (xfile, tempFile) = await writeTempVideo(mp4Bytes, 'clip.mp4'); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + transcodeCalled = true; + return path; + }, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadVideo(); + expect(descriptor, isNotNull); + expect(descriptor!.type, 'video/mp4'); + expect(transcodeCalled, isFalse); + } finally { + await tempFile.parent.delete(recursive: true); + } + }); + + test('transcodes non-MP4 container before uploading', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + var transcodeCalled = false; + final client = http_testing.MockClient((request) async { + expect(request.headers['Content-Type'], 'video/mp4'); + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 32, + 'type': 'video/mp4', + 'uploaded': 1, + }), + 200, + ); + }); + + // QuickTime container ('qt ' brand) — needs transcoding. + final movBytes = buildFtypHeader('qt '); + final (xfile, tempFile) = await writeTempVideo(movBytes, 'clip.mov'); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + transcodeCalled = true; + // Mock transcoding: write an "MP4" file + final outDir = await Directory.systemTemp.createTemp('transcode_'); + final outFile = File('${outDir.path}/out.mp4'); + await outFile.writeAsBytes(buildFtypHeader('isom')); + return outFile.path; + }, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadVideo(); + expect(descriptor, isNotNull); + expect(descriptor!.type, 'video/mp4'); + expect(transcodeCalled, isTrue); + } finally { + await tempFile.parent.delete(recursive: true); + } + }); + + test('returns null when video picker is cancelled', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + final result = await service.pickAndUploadVideo(); + expect(result, isNull); + }); + + test('rejects videos over 100MB', () async { + // Create a temp file with 101MB of zeros. + final dir = await Directory.systemTemp.createTemp('video_size_test_'); + final file = File('${dir.path}/huge.mp4'); + final raf = await file.open(mode: FileMode.write); + await raf.truncate(101 * 1024 * 1024); + await raf.close(); + + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: null, + pickGalleryVideo: () async => XFile(file.path), + pickGalleryImage: () async => null, + ); + + await expectLater( + () => service.pickAndUploadVideo(), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('too large'), + ), + ), + ); + } finally { + await dir.delete(recursive: true); + } + }); + }); }