feat(mobile): day dividers, video upload, emoji picker & UX polish (#396)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-24 09:32:36 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4cc8c1df71
commit 30adbad810
18 changed files with 2006 additions and 1158 deletions
@@ -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<Int, Int>()
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"
}
}
+62
View File
@@ -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)
}
}
}
}
@@ -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<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
}) {
showModalBottomSheet<void>(
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<void>(
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<void>(
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<void>(
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 <ChannelMember>[];
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<ChannelMember?>().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<void>(
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<bool>(
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<String?>(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<void> 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<void> 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<void> 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
// ---------------------------------------------------------------------------
+64 -315
View File
@@ -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<void> pickAndUploadAttachment() async {
Future<void> pickAndUpload(Future<BlobDescriptor?> 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<void>(
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<void>(
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<String> 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 = <String>{};
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,
@@ -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;
}
@@ -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)),
],
),
);
}
}
@@ -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<void>(
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<String> 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<EmojiPickerSheet> createState() => _EmojiPickerSheetState();
}
class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
/// -1 = "All", 0..N = specific category.
int _selectedCategory = -1;
static final _allEmoji = () {
final seen = <String>{};
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,
),
);
}
}
@@ -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<String?>(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<void> 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<void> 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<void> 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,
),
),
],
),
);
}
}
@@ -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 <ChannelMember>[];
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<ChannelMember?>().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<void>(
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<bool>(
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,
);
}
}
@@ -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<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
}) {
showModalBottomSheet<void>(
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<void>(
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<void>(
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<void>(
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'),
),
],
),
);
}
@@ -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) =>
+102 -1
View File
@@ -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<XFile?> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
typedef SanitizeImageBytes =
Future<Uint8List> Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> 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<BlobDescriptor?> 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<BlobDescriptor> 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 811)
/// 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<Uint8List> _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<String> _transcodePickedVideoToMp4(String filePath) async {
final result = await _mediaUploadPlatformChannel.invokeMethod<String>(
_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<MediaUploadService>((ref) {
source: ImageSource.gallery,
requestFullMetadata: false,
),
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
);
ref.onDispose(service.dispose);
return service;
+8
View File
@@ -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:
+1
View File
@@ -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
@@ -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 <List<String>>[],
}) 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);
}
});
});
}
@@ -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);
});
});
}
@@ -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.
@@ -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<Exception>().having(
(e) => e.toString(),
'message',
contains('too large'),
),
),
);
} finally {
await dir.delete(recursive: true);
}
});
});
}