mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 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<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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user