diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index d0cad40..75b24c4 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -236,5 +236,6 @@ }, "levelingProgress": "Leveling Progress", "levelingProgressExperience": "{} EXP", - "levelingProgressLevel": "Level {}" + "levelingProgressLevel": "Level {}", + "fileUploadingProgress": "Uploading file #{}: {}%" } diff --git a/lib/database/message.dart b/lib/database/message.dart index 7e10946..f5c9d77 100644 --- a/lib/database/message.dart +++ b/lib/database/message.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:island/models/chat.dart'; +import 'package:island/models/file.dart'; class ChatMessages extends Table { TextColumn get id => text()(); @@ -23,6 +24,7 @@ class LocalChatMessage { final DateTime createdAt; MessageStatus status; final String? nonce; + List? localAttachments; LocalChatMessage({ required this.id, @@ -30,8 +32,9 @@ class LocalChatMessage { required this.senderId, required this.data, required this.createdAt, + required this.nonce, required this.status, - this.nonce, + this.localAttachments, }); SnChatMessage toRemoteMessage() { diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 75aa6b4..9d75299 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -14,6 +14,7 @@ class MessageRepository { final AppDatabase _database; final Map pendingMessages = {}; + final Map> fileUploadProgress = {}; MessageRepository(this.room, this.identity, this._apiClient, this._database); @@ -181,6 +182,7 @@ class MessageRepository { SnChatMessage? forwardingTo, SnChatMessage? editingTo, Function(LocalChatMessage)? onPending, + Function(String, Map)? onProgress, }) async { // Generate a unique nonce for this message final nonce = const Uuid().v4(); @@ -204,6 +206,7 @@ class MessageRepository { // Store in memory and database pendingMessages[localMessage.id] = localMessage; + fileUploadProgress[localMessage.id] = {}; await _database.saveMessage(_database.messageToCompanion(localMessage)); onPending?.call(localMessage); @@ -225,6 +228,13 @@ class MessageRepository { UniversalFileType.audio => 'audio/unknown', UniversalFileType.file => 'application/octet-stream', }, + onProgress: (progress, _) { + fileUploadProgress[localMessage.id]?[idx] = progress; + onProgress?.call( + localMessage.id, + fileUploadProgress[localMessage.id] ?? {}, + ); + }, ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 3da5a94..571bd1c 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -23,6 +24,10 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:super_context_menu/super_context_menu.dart'; import 'package:uuid/uuid.dart'; import 'chat.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'room.g.dart'; final messageRepositoryProvider = FutureProvider.family((ref, roomId) async { @@ -33,29 +38,22 @@ final messageRepositoryProvider = return MessageRepository(room!, identity!, apiClient, database); }); -// Provider for messages with pagination -final messagesProvider = StateNotifierProvider.family< - MessagesNotifier, - AsyncValue>, - String ->((ref, roomId) => MessagesNotifier(ref, roomId)); - -class MessagesNotifier - extends StateNotifier>> { - final Ref _ref; - final String _roomId; +@riverpod +class MessagesNotifier extends _$MessagesNotifier { + late final String _roomId; int _currentPage = 0; static const int _pageSize = 20; bool _hasMore = true; - MessagesNotifier(this._ref, this._roomId) - : super(const AsyncValue.loading()) { - loadInitial(); + @override + FutureOr> build(String roomId) async { + _roomId = roomId; + return await loadInitial(); } - Future loadInitial() async { + Future> loadInitial() async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); final synced = await repository.syncMessages(); @@ -64,11 +62,11 @@ class MessagesNotifier take: _pageSize, synced: synced, ); - state = AsyncValue.data(messages); _currentPage = 0; _hasMore = messages.length == _pageSize; - } catch (e, stack) { - state = AsyncValue.error(e, stack); + return messages; + } catch (_) { + rethrow; } } @@ -78,7 +76,7 @@ class MessagesNotifier try { final currentMessages = state.value ?? []; _currentPage++; - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); final newMessages = await repository.listMessages( @@ -100,61 +98,49 @@ class MessagesNotifier Future sendMessage( String content, List attachments, { - SnChatMessage? replyingTo, - SnChatMessage? forwardingTo, SnChatMessage? editingTo, + SnChatMessage? forwardingTo, + SnChatMessage? replyingTo, + Function(String, Map)? onProgress, }) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); - - final nonce = const Uuid().v4(); - - final baseUrl = _ref.read(serverUrlProvider); + final baseUrl = ref.read(serverUrlProvider); final atk = await getFreshAtk( - _ref.watch(tokenPairProvider), + ref.watch(tokenPairProvider), baseUrl, onRefreshed: (atk, rtk) { - setTokenPair(_ref.watch(sharedPreferencesProvider), atk, rtk); - _ref.invalidate(tokenPairProvider); + setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); + ref.invalidate(tokenPairProvider); }, ); - if (atk == null) throw Exception("Unauthorized"); + if (atk == null) throw ArgumentError('Access token is null'); - LocalChatMessage? pendingMessage; - final messageTask = repository.sendMessage( + final currentMessages = state.value ?? []; + await repository.sendMessage( atk, baseUrl, _roomId, content, - nonce, + const Uuid().v4(), attachments: attachments, - replyingTo: replyingTo, - forwardingTo: forwardingTo, editingTo: editingTo, + forwardingTo: forwardingTo, + replyingTo: replyingTo, onPending: (pending) { - pendingMessage = pending; - final currentMessages = state.value ?? []; state = AsyncValue.data([pending, ...currentMessages]); }, + onProgress: onProgress, ); - final message = await messageTask; - - final updatedMessages = state.value ?? []; - if (pendingMessage != null) { - final index = updatedMessages.indexWhere( - (m) => m.id == pendingMessage!.id, - ); - if (index >= 0) { - final newList = [...updatedMessages]; - newList[index] = message; - state = AsyncValue.data(newList); - } - } else { - state = AsyncValue.data([message, ...updatedMessages]); - } + // Refresh messages + final messages = await repository.listMessages( + offset: 0, + take: _pageSize, + ); + state = AsyncValue.data(messages); } catch (err) { showErrorAlert(err); } @@ -162,7 +148,7 @@ class MessagesNotifier Future retryMessage(String pendingMessageId) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); final updatedMessage = await repository.retryMessage(pendingMessageId); @@ -182,7 +168,7 @@ class MessagesNotifier Future receiveMessage(SnChatMessage remoteMessage) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); @@ -217,7 +203,7 @@ class MessagesNotifier Future receiveMessageUpdate(SnChatMessage remoteMessage) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); @@ -246,7 +232,7 @@ class MessagesNotifier Future receiveMessageDeletion(String messageId) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); @@ -265,41 +251,9 @@ class MessagesNotifier } } - Future updateMessage( - String messageId, - String content, { - List? attachments, - Map? meta, - }) async { - try { - final repository = await _ref.read( - messageRepositoryProvider(_roomId).future, - ); - - final updatedMessage = await repository.updateMessage( - messageId, - content, - attachments: attachments, - meta: meta, - ); - - // Update the message in the list - final currentMessages = state.value ?? []; - final index = currentMessages.indexWhere((m) => m.id == messageId); - - if (index >= 0) { - final newList = [...currentMessages]; - newList[index] = updatedMessage; - state = AsyncValue.data(newList); - } - } catch (err) { - showErrorAlert(err); - } - } - Future deleteMessage(String messageId) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); @@ -320,7 +274,7 @@ class MessagesNotifier Future fetchMessageById(String messageId) async { try { - final repository = await _ref.read( + final repository = await ref.read( messageRepositoryProvider(_roomId).future, ); return await repository.getMessageById(messageId); @@ -340,8 +294,8 @@ class ChatRoomScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final chatRoom = ref.watch(chatroomProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id)); - final messages = ref.watch(messagesProvider(id)); - final messagesNotifier = ref.read(messagesProvider(id).notifier); + final messages = ref.watch(messagesNotifierProvider(id)); + final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); final ws = ref.watch(websocketProvider); final messageController = useTextEditingController(); @@ -350,6 +304,8 @@ class ChatRoomScreen extends HookConsumerWidget { final messageReplyingTo = useState(null); final messageForwardingTo = useState(null); final messageEditingTo = useState(null); + final attachments = useState>([]); + final attachmentProgress = useState>>({}); // Add scroll listener for pagination useEffect(() { @@ -385,8 +341,6 @@ class ChatRoomScreen extends HookConsumerWidget { return () => subscription.cancel(); }, [ws, chatRoom]); - final attachments = useState>([]); - Future pickPhotoMedia() async { final result = await ref .watch(imagePickerProvider) @@ -420,6 +374,12 @@ class ChatRoomScreen extends HookConsumerWidget { editingTo: messageEditingTo.value, forwardingTo: messageForwardingTo.value, replyingTo: messageReplyingTo.value, + onProgress: (messageId, progress) { + attachmentProgress.value = { + ...attachmentProgress.value, + messageId: progress, + }; + }, ); messageController.clear(); messageEditingTo.value = null; @@ -542,12 +502,15 @@ class ChatRoomScreen extends HookConsumerWidget { message.toRemoteMessage(); } }, + progress: + attachmentProgress.value[message.id], ), loading: () => _MessageBubble( message: message, isCurrentUser: false, onAction: null, + progress: null, ), error: (_, __) => const SizedBox.shrink(), ); @@ -804,11 +767,13 @@ class _MessageBubble extends HookConsumerWidget { final LocalChatMessage message; final bool isCurrentUser; final Function(String action)? onAction; + final Map? progress; const _MessageBubble({ required this.message, required this.isCurrentUser, required this.onAction, + required this.progress, }); @override @@ -914,9 +879,58 @@ class _MessageBubble extends HookConsumerWidget { style: TextStyle(color: textColor), ), if (message.toRemoteMessage().attachments.isNotEmpty) - CloudFileList( - files: message.toRemoteMessage().attachments, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CloudFileList( + files: message.toRemoteMessage().attachments, + maxWidth: MediaQuery.of(context).size.width * 0.8, + ), + ], ).padding(top: 4), + if (progress != null && progress!.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + if ((message + .toRemoteMessage() + .content + ?.isNotEmpty ?? + false)) + const Gap(0), + for (var entry in progress!.entries) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'fileUploadingProgress'.tr( + args: [ + (entry.key + 1).toString(), + entry.value.toStringAsFixed(1), + ], + ), + style: TextStyle( + fontSize: 12, + color: textColor.withOpacity(0.8), + ), + ), + const Gap(4), + LinearProgressIndicator( + value: entry.value / 100, + backgroundColor: + Theme.of( + context, + ).colorScheme.surfaceVariant, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const Gap(0), + ], + ), const Gap(4), Row( spacing: 4, @@ -978,7 +992,7 @@ class _MessageBubble extends HookConsumerWidget { (context, ref, _) => GestureDetector( onTap: () { ref - .read(messagesProvider(message.roomId).notifier) + .read(messagesNotifierProvider(message.roomId).notifier) .retryMessage(message.id); }, child: const Icon( @@ -1006,7 +1020,7 @@ class _MessageQuoteWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messagesNotifier = ref.watch( - messagesProvider(message.roomId).notifier, + messagesNotifierProvider(message.roomId).notifier, ); return FutureBuilder( diff --git a/lib/screens/chat/room.g.dart b/lib/screens/chat/room.g.dart new file mode 100644 index 0000000..eb8927b --- /dev/null +++ b/lib/screens/chat/room.g.dart @@ -0,0 +1,179 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'room.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$messagesNotifierHash() => r'71a9fc1c6d024f6203f06225384c19335b9b6f2c'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$MessagesNotifier + extends BuildlessAutoDisposeAsyncNotifier> { + late final String roomId; + + FutureOr> build(String roomId); +} + +/// See also [MessagesNotifier]. +@ProviderFor(MessagesNotifier) +const messagesNotifierProvider = MessagesNotifierFamily(); + +/// See also [MessagesNotifier]. +class MessagesNotifierFamily + extends Family>> { + /// See also [MessagesNotifier]. + const MessagesNotifierFamily(); + + /// See also [MessagesNotifier]. + MessagesNotifierProvider call(String roomId) { + return MessagesNotifierProvider(roomId); + } + + @override + MessagesNotifierProvider getProviderOverride( + covariant MessagesNotifierProvider provider, + ) { + return call(provider.roomId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'messagesNotifierProvider'; +} + +/// See also [MessagesNotifier]. +class MessagesNotifierProvider + extends + AutoDisposeAsyncNotifierProviderImpl< + MessagesNotifier, + List + > { + /// See also [MessagesNotifier]. + MessagesNotifierProvider(String roomId) + : this._internal( + () => MessagesNotifier()..roomId = roomId, + from: messagesNotifierProvider, + name: r'messagesNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$messagesNotifierHash, + dependencies: MessagesNotifierFamily._dependencies, + allTransitiveDependencies: + MessagesNotifierFamily._allTransitiveDependencies, + roomId: roomId, + ); + + MessagesNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.roomId, + }) : super.internal(); + + final String roomId; + + @override + FutureOr> runNotifierBuild( + covariant MessagesNotifier notifier, + ) { + return notifier.build(roomId); + } + + @override + Override overrideWith(MessagesNotifier Function() create) { + return ProviderOverride( + origin: this, + override: MessagesNotifierProvider._internal( + () => create()..roomId = roomId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + roomId: roomId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement< + MessagesNotifier, + List + > + createElement() { + return _MessagesNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MessagesNotifierProvider && other.roomId == roomId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, roomId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MessagesNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `roomId` of this provider. + String get roomId; +} + +class _MessagesNotifierProviderElement + extends + AutoDisposeAsyncNotifierProviderElement< + MessagesNotifier, + List + > + with MessagesNotifierRef { + _MessagesNotifierProviderElement(super.provider); + + @override + String get roomId => (origin as MessagesNotifierProvider).roomId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index d07830b..1ef0d1a 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:island/models/file.dart'; @@ -7,7 +9,13 @@ import 'package:styled_widget/styled_widget.dart'; class CloudFileList extends StatelessWidget { final List files; final double maxHeight; - const CloudFileList({super.key, required this.files, this.maxHeight = 360}); + final double maxWidth; + const CloudFileList({ + super.key, + required this.files, + this.maxHeight = 360, + this.maxWidth = double.infinity, + }); double calculateAspectRatio() { double total = 0; @@ -44,14 +52,14 @@ class CloudFileList extends StatelessWidget { if (allImages) { return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: maxHeight, - minWidth: double.infinity, - ), + constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: CarouselView( - itemExtent: MediaQuery.of(context).size.width * 0.85, + itemExtent: math.min( + MediaQuery.of(context).size.width * 0.85, + maxWidth * 0.85, + ), itemSnapping: true, shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(16)), @@ -63,10 +71,7 @@ class CloudFileList extends StatelessWidget { } return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: maxHeight, - minWidth: double.infinity, - ), + constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: ListView.separated(