From 38f8103265db438eae524a61e4db4d9335018530 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 23 Sep 2025 16:56:02 +0800 Subject: [PATCH] :sparkles: Search and jump to message --- lib/pods/messages_notifier.dart | 852 ++++++++++ .../messages_notifier.g.dart} | 4 +- lib/pods/room_providers.dart | 34 + lib/screens/chat/public_room_preview.dart | 221 +++ lib/screens/chat/room.dart | 1419 +---------------- lib/screens/chat/room_detail.dart | 11 +- lib/screens/chat/search_messages.dart | 24 +- lib/widgets/chat/chat_input.dart | 327 ++++ lib/widgets/chat/message_content.dart | 169 ++ lib/widgets/chat/message_indicators.dart | 69 + lib/widgets/chat/message_item.dart | 301 +--- lib/widgets/chat/message_list_tile.dart | 87 + lib/widgets/chat/message_sender_info.dart | 124 ++ lib/widgets/chat/public_room_preview.dart | 223 +++ 14 files changed, 2202 insertions(+), 1663 deletions(-) create mode 100644 lib/pods/messages_notifier.dart rename lib/{screens/chat/room.g.dart => pods/messages_notifier.g.dart} (97%) create mode 100644 lib/pods/room_providers.dart create mode 100644 lib/screens/chat/public_room_preview.dart create mode 100644 lib/widgets/chat/chat_input.dart create mode 100644 lib/widgets/chat/message_content.dart create mode 100644 lib/widgets/chat/message_indicators.dart create mode 100644 lib/widgets/chat/message_list_tile.dart create mode 100644 lib/widgets/chat/message_sender_info.dart create mode 100644 lib/widgets/chat/public_room_preview.dart diff --git a/lib/pods/messages_notifier.dart b/lib/pods/messages_notifier.dart new file mode 100644 index 00000000..81e335d6 --- /dev/null +++ b/lib/pods/messages_notifier.dart @@ -0,0 +1,852 @@ +import "dart:async"; +import "dart:developer" as developer; +import "package:dio/dio.dart"; +import "package:drift/drift.dart" show Variable; +import "package:easy_localization/easy_localization.dart"; +import "package:flutter/material.dart"; +import "package:island/database/drift_db.dart"; +import "package:island/database/message.dart"; +import "package:island/models/chat.dart"; +import "package:island/models/file.dart"; +import "package:island/pods/config.dart"; +import "package:island/pods/database.dart"; +import "package:island/pods/network.dart"; +import "package:island/services/file.dart"; +import "package:island/widgets/alert.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; +import "package:uuid/uuid.dart"; +import "package:island/screens/chat/chat.dart"; +import "package:island/pods/room_providers.dart"; + +part 'messages_notifier.g.dart'; + +@riverpod +class MessagesNotifier extends _$MessagesNotifier { + late final Dio _apiClient; + late final AppDatabase _database; + late final SnChatRoom _room; + late final SnChatMember _identity; + + final Map _pendingMessages = {}; + final Map> _fileUploadProgress = {}; + int? _totalCount; + String? _searchQuery; + bool? _withLinks; + bool? _withAttachments; + + late final String _roomId; + static const int _pageSize = 20; + bool _hasMore = true; + bool _isSyncing = false; + bool _isJumping = false; + + @override + FutureOr> build(String roomId) async { + _roomId = roomId; + _apiClient = ref.watch(apiClientProvider); + _database = ref.watch(databaseProvider); + final room = await ref.watch(chatroomProvider(roomId).future); + final identity = await ref.watch(chatroomIdentityProvider(roomId).future); + + if (room == null) { + throw Exception('Room not found'); + } + _room = room; + + // Allow building even if identity is null for public rooms + if (identity != null) { + _identity = identity; + } + + developer.log( + 'MessagesNotifier built for room $roomId', + name: 'MessagesNotifier', + ); + + // Only setup sync and lifecycle listeners if user is a member + if (identity != null) { + ref.listen(appLifecycleStateProvider, (_, next) { + if (next.hasValue && next.value == AppLifecycleState.resumed) { + developer.log( + 'App resumed, syncing messages', + name: 'MessagesNotifier', + ); + syncMessages(); + } + }); + } + + loadInitial(); + return []; + } + + List _sortMessages(List messages) { + messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return messages; + } + + Future> _getCachedMessages({ + int offset = 0, + int take = 20, + }) async { + developer.log( + 'Getting cached messages from offset $offset, take $take', + name: 'MessagesNotifier', + ); + final List dbMessages; + if (_searchQuery != null && _searchQuery!.isNotEmpty) { + dbMessages = await _database.searchMessages( + _roomId, + _searchQuery ?? '', + withAttachments: _withAttachments, + ); + } else { + final chatMessagesFromDb = await _database.getMessagesForRoom( + _roomId, + offset: offset, + limit: take, + ); + dbMessages = + chatMessagesFromDb.map(_database.companionToMessage).toList(); + } + + List filteredMessages = dbMessages; + + if (_withLinks == true) { + filteredMessages = + filteredMessages.where((msg) => _hasLink(msg)).toList(); + } + + final dbLocalMessages = filteredMessages; + + // Always ensure unique messages to prevent duplicate keys + final uniqueMessages = []; + final seenIds = {}; + for (final message in dbLocalMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + + if (offset == 0) { + final pendingForRoom = + _pendingMessages.values + .where((msg) => msg.roomId == _roomId) + .toList(); + + final allMessages = [...pendingForRoom, ...uniqueMessages]; + _sortMessages(allMessages); // Use the helper function + + final finalUniqueMessages = []; + final finalSeenIds = {}; + for (final message in allMessages) { + if (finalSeenIds.add(message.id)) { + finalUniqueMessages.add(message); + } + } + return finalUniqueMessages; + } + + return uniqueMessages; + } + + Future> _fetchAndCacheMessages({ + int offset = 0, + int take = 20, + }) async { + developer.log( + 'Fetching messages from API, offset $offset, take $take', + name: 'MessagesNotifier', + ); + if (_totalCount == null) { + final response = await _apiClient.get( + '/sphere/chat/$_roomId/messages', + queryParameters: {'offset': 0, 'take': 1}, + ); + _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); + } + + if (offset >= _totalCount!) { + return []; + } + + final response = await _apiClient.get( + '/sphere/chat/$_roomId/messages', + queryParameters: {'offset': offset, 'take': take}, + ); + + final List data = response.data; + _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); + + final messages = + data.map((json) { + final remoteMessage = SnChatMessage.fromJson(json); + return LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + }).toList(); + + for (final message in messages) { + await _database.saveMessage(_database.messageToCompanion(message)); + if (message.nonce != null) { + _pendingMessages.removeWhere( + (_, pendingMsg) => pendingMsg.nonce == message.nonce, + ); + } + } + + return messages; + } + + Future syncMessages() async { + if (_isSyncing) { + developer.log( + 'Sync already in progress, skipping.', + name: 'MessagesNotifier', + ); + return; + } + _isSyncing = true; + + developer.log('Starting message sync', name: 'MessagesNotifier'); + Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); + try { + final dbMessages = await _database.getMessagesForRoom( + _room.id, + offset: 0, + limit: 1, + ); + final lastMessage = + dbMessages.isEmpty + ? null + : _database.companionToMessage(dbMessages.first); + + if (lastMessage == null) { + developer.log( + 'No local messages, fetching from network', + name: 'MessagesNotifier', + ); + final newMessages = await _fetchAndCacheMessages( + offset: 0, + take: _pageSize, + ); + state = AsyncValue.data(newMessages); + return; + } + + final resp = await _apiClient.post( + '/sphere/chat/${_room.id}/sync', + data: { + 'last_sync_timestamp': + lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, + }, + ); + + final response = MessageSyncResponse.fromJson(resp.data); + developer.log( + 'Sync response: ${response.messages.length} changes', + name: 'MessagesNotifier', + ); + for (final message in response.messages) { + switch (message.type) { + case "messages.update": + case "messages.update.links": + await receiveMessageUpdate(message); + break; + case "messages.delete": + await receiveMessageDeletion(message.id.toString()); + break; + } + // Still need receive the message to show the history actions + await receiveMessage(message); + } + } catch (err, stackTrace) { + developer.log( + 'Error syncing messages', + name: 'MessagesNotifier', + error: err, + stackTrace: stackTrace, + ); + showErrorAlert(err); + } finally { + developer.log('Finished message sync', name: 'MessagesNotifier'); + Future.microtask( + () => ref.read(isSyncingProvider.notifier).state = false, + ); + _isSyncing = false; + } + } + + Future> listMessages({ + int offset = 0, + int take = 20, + bool synced = false, + }) async { + try { + if (offset == 0 && + !synced && + (_searchQuery == null || _searchQuery!.isEmpty)) { + _fetchAndCacheMessages(offset: 0, take: take).catchError((_) { + return []; + }); + } + + final localMessages = await _getCachedMessages( + offset: offset, + take: take, + ); + + if (localMessages.isNotEmpty) { + return localMessages; + } + + if (_searchQuery == null || _searchQuery!.isEmpty) { + return await _fetchAndCacheMessages(offset: offset, take: take); + } else { + return []; // If searching, and no local messages, don't fetch from network + } + } catch (e) { + final localMessages = await _getCachedMessages( + offset: offset, + take: take, + ); + + if (localMessages.isNotEmpty) { + return localMessages; + } + rethrow; + } + } + + Future loadInitial() async { + developer.log('Loading initial messages', name: 'MessagesNotifier'); + if (_searchQuery == null || _searchQuery!.isEmpty) { + syncMessages(); + } + + final messages = await _getCachedMessages(offset: 0, take: _pageSize); + + _hasMore = messages.length == _pageSize; + + state = AsyncValue.data(messages); + } + + Future loadMore() async { + if (!_hasMore || state is AsyncLoading) return; + developer.log('Loading more messages', name: 'MessagesNotifier'); + + try { + final currentMessages = state.value ?? []; + final offset = currentMessages.length; + + final newMessages = await listMessages(offset: offset, take: _pageSize); + + if (newMessages.isEmpty || newMessages.length < _pageSize) { + _hasMore = false; + } + + state = AsyncValue.data( + _sortMessages([...currentMessages, ...newMessages]), + ); + } catch (err, stackTrace) { + developer.log( + 'Error loading more messages', + name: 'MessagesNotifier', + error: err, + stackTrace: stackTrace, + ); + showErrorAlert(err); + } + } + + Future sendMessage( + String content, + List attachments, { + SnChatMessage? editingTo, + SnChatMessage? forwardingTo, + SnChatMessage? replyingTo, + Function(String, Map)? onProgress, + }) async { + final nonce = const Uuid().v4(); + developer.log( + 'Sending message with nonce $nonce', + name: 'MessagesNotifier', + ); + final baseUrl = ref.read(serverUrlProvider); + final token = await getToken(ref.watch(tokenProvider)); + if (token == null) throw ArgumentError('Access token is null'); + + final mockMessage = SnChatMessage( + id: 'pending_$nonce', + chatRoomId: _roomId, + senderId: _identity.id, + content: content, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + nonce: nonce, + sender: _identity, + ); + + final localMessage = LocalChatMessage.fromRemoteMessage( + mockMessage, + MessageStatus.pending, + ); + + _pendingMessages[localMessage.id] = localMessage; + _fileUploadProgress[localMessage.id] = {}; + await _database.saveMessage(_database.messageToCompanion(localMessage)); + + final currentMessages = state.value ?? []; + state = AsyncValue.data([localMessage, ...currentMessages]); + + try { + var cloudAttachments = List.empty(growable: true); + for (var idx = 0; idx < attachments.length; idx++) { + final cloudFile = + await putFileToCloud( + fileData: attachments[idx], + atk: token, + baseUrl: baseUrl, + filename: attachments[idx].data.name ?? 'Post media', + mimetype: + attachments[idx].data.mimeType ?? + switch (attachments[idx].type) { + UniversalFileType.image => 'image/unknown', + UniversalFileType.video => 'video/unknown', + 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...'); + } + cloudAttachments.add(cloudFile); + } + + final response = await _apiClient.request( + editingTo == null + ? '/sphere/chat/$_roomId/messages' + : '/sphere/chat/$_roomId/messages/${editingTo.id}', + data: { + 'content': content, + 'attachments_id': cloudAttachments.map((e) => e.id).toList(), + 'replied_message_id': replyingTo?.id, + 'forwarded_message_id': forwardingTo?.id, + 'meta': {}, + 'nonce': nonce, + }, + options: Options(method: editingTo == null ? 'POST' : 'PATCH'), + ); + + final remoteMessage = SnChatMessage.fromJson(response.data); + final updatedMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + _pendingMessages.remove(localMessage.id); + await _database.deleteMessage(localMessage.id); + await _database.saveMessage(_database.messageToCompanion(updatedMessage)); + + final currentMessages = state.value ?? []; + if (editingTo != null) { + final newMessages = + currentMessages + .where((m) => m.id != localMessage.id) // remove pending message + .map( + (m) => m.id == editingTo.id ? updatedMessage : m, + ) // update original message + .toList(); + state = AsyncValue.data(newMessages); + } else { + final newMessages = + currentMessages.map((m) { + if (m.id == localMessage.id) { + return updatedMessage; + } + return m; + }).toList(); + state = AsyncValue.data(newMessages); + } + developer.log( + 'Message with nonce $nonce sent successfully', + name: 'MessagesNotifier', + ); + } catch (e, stackTrace) { + developer.log( + 'Failed to send message with nonce $nonce', + name: 'MessagesNotifier', + error: e, + stackTrace: stackTrace, + ); + localMessage.status = MessageStatus.failed; + _pendingMessages[localMessage.id] = localMessage; + await _database.updateMessageStatus( + localMessage.id, + MessageStatus.failed, + ); + final newMessages = + (state.value ?? []).map((m) { + if (m.id == localMessage.id) { + return m..status = MessageStatus.failed; + } + return m; + }).toList(); + state = AsyncValue.data(newMessages); + showErrorAlert(e); + } + } + + Future retryMessage(String pendingMessageId) async { + developer.log( + 'Retrying message $pendingMessageId', + name: 'MessagesNotifier', + ); + final message = await fetchMessageById(pendingMessageId); + if (message == null) { + throw Exception('Message not found'); + } + + message.status = MessageStatus.pending; + _pendingMessages[pendingMessageId] = message; + await _database.updateMessageStatus( + pendingMessageId, + MessageStatus.pending, + ); + + try { + var remoteMessage = message.toRemoteMessage(); + final response = await _apiClient.post( + '/sphere/chat/${message.roomId}/messages', + data: { + 'content': remoteMessage.content, + 'attachments_id': remoteMessage.attachments, + 'meta': remoteMessage.meta, + 'nonce': message.nonce, + }, + ); + + remoteMessage = SnChatMessage.fromJson(response.data); + final updatedMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + _pendingMessages.remove(pendingMessageId); + await _database.deleteMessage(pendingMessageId); + await _database.saveMessage(_database.messageToCompanion(updatedMessage)); + + final newMessages = + (state.value ?? []).map((m) { + if (m.id == pendingMessageId) { + return updatedMessage; + } + return m; + }).toList(); + state = AsyncValue.data(newMessages); + } catch (e, stackTrace) { + developer.log( + 'Failed to retry message $pendingMessageId', + name: 'MessagesNotifier', + error: e, + stackTrace: stackTrace, + ); + message.status = MessageStatus.failed; + _pendingMessages[pendingMessageId] = message; + await _database.updateMessageStatus( + pendingMessageId, + MessageStatus.failed, + ); + final newMessages = + (state.value ?? []).map((m) { + if (m.id == pendingMessageId) { + return m..status = MessageStatus.failed; + } + return m; + }).toList(); + state = AsyncValue.data(_sortMessages(newMessages)); + showErrorAlert(e); + } + } + + Future receiveMessage(SnChatMessage remoteMessage) async { + if (remoteMessage.chatRoomId != _roomId) return; + developer.log( + 'Received new message ${remoteMessage.id}', + name: 'MessagesNotifier', + ); + + final localMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + if (remoteMessage.nonce != null) { + _pendingMessages.removeWhere( + (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, + ); + } + + await _database.saveMessage(_database.messageToCompanion(localMessage)); + + final currentMessages = state.value ?? []; + final existingIndex = currentMessages.indexWhere( + (m) => + m.id == localMessage.id || + (localMessage.nonce != null && m.nonce == localMessage.nonce), + ); + + if (existingIndex >= 0) { + final newList = [...currentMessages]; + newList[existingIndex] = localMessage; + state = AsyncValue.data(_sortMessages(newList)); + } else { + state = AsyncValue.data( + _sortMessages([localMessage, ...currentMessages]), + ); + } + } + + Future receiveMessageUpdate(SnChatMessage remoteMessage) async { + if (remoteMessage.chatRoomId != _roomId) return; + developer.log( + 'Received message update ${remoteMessage.id}', + name: 'MessagesNotifier', + ); + + final updatedMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + await _database.updateMessage(_database.messageToCompanion(updatedMessage)); + + final currentMessages = state.value ?? []; + final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id); + + if (index >= 0) { + final newList = [...currentMessages]; + newList[index] = updatedMessage; + state = AsyncValue.data(_sortMessages(newList)); + } + } + + Future receiveMessageDeletion(String messageId) async { + developer.log( + 'Received message deletion $messageId', + name: 'MessagesNotifier', + ); + _pendingMessages.remove(messageId); + + final currentMessages = state.value ?? []; + final messageIndex = currentMessages.indexWhere((m) => m.id == messageId); + + LocalChatMessage? messageToUpdate; + if (messageIndex != -1) { + messageToUpdate = currentMessages[messageIndex]; + } else { + messageToUpdate = await fetchMessageById(messageId); + } + + if (messageToUpdate == null) return; + + final remote = messageToUpdate.toRemoteMessage(); + final updatedRemote = remote.copyWith( + content: 'This message was deleted', + deletedAt: DateTime.now(), + attachments: [], + ); + + final deletedMessage = LocalChatMessage.fromRemoteMessage( + updatedRemote, + messageToUpdate.status, + ); + + await _database.saveMessage(_database.messageToCompanion(deletedMessage)); + + if (messageIndex != -1) { + final newList = [...currentMessages]; + newList[messageIndex] = deletedMessage; + state = AsyncValue.data(newList); + } + } + + Future deleteMessage(String messageId) async { + developer.log('Deleting message $messageId', name: 'MessagesNotifier'); + try { + await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); + await receiveMessageDeletion(messageId); + } catch (err, stackTrace) { + developer.log( + 'Error deleting message $messageId', + name: 'MessagesNotifier', + error: err, + stackTrace: stackTrace, + ); + showErrorAlert(err); + } + } + + void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { + _searchQuery = query.trim(); + _withLinks = withLinks; + _withAttachments = withAttachments; + loadInitial(); + } + + void clearSearch() { + _searchQuery = null; + _withLinks = null; + _withAttachments = null; + loadInitial(); + } + + Future fetchMessageById(String messageId) async { + developer.log( + 'Fetching message by id $messageId', + name: 'MessagesNotifier', + ); + try { + final localMessage = + await (_database.select(_database.chatMessages) + ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); + if (localMessage != null) { + return _database.companionToMessage(localMessage); + } + + final response = await _apiClient.get( + '/sphere/chat/$_roomId/messages/$messageId', + ); + final remoteMessage = SnChatMessage.fromJson(response.data); + final message = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + await _database.saveMessage(_database.messageToCompanion(message)); + return message; + } catch (e) { + if (e is DioException) return null; + rethrow; + } + } + + Future jumpToMessage(String messageId) async { + developer.log( + 'Starting jump to message $messageId', + name: 'MessagesNotifier', + ); + if (_isJumping) { + developer.log( + 'Jump already in progress, skipping', + name: 'MessagesNotifier', + ); + return -1; + } + _isJumping = true; + + try { + developer.log('Fetching message $messageId', name: 'MessagesNotifier'); + final message = await fetchMessageById(messageId); + if (message == null) { + developer.log('Message $messageId not found', name: 'MessagesNotifier'); + showSnackBar('messageNotFound'.tr()); + return -1; + } + + // Check if message is already in current state to avoid duplicate loading + final currentMessages = state.value ?? []; + final existingIndex = currentMessages.indexWhere( + (m) => m.id == messageId, + ); + if (existingIndex >= 0) { + developer.log( + 'Message $messageId already in current state at index $existingIndex, jumping directly', + name: 'MessagesNotifier', + ); + return existingIndex; + } + + developer.log( + 'Message $messageId not in current state, loading messages around it', + name: 'MessagesNotifier', + ); + + // Count messages newer than this one + final query = _database.customSelect( + 'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?', + variables: [ + Variable.withString(_roomId), + Variable.withDateTime(message.createdAt), + ], + readsFrom: {_database.chatMessages}, + ); + final result = await query.getSingle(); + final newerCount = result.read('count'); + + // Load messages around this position + final offset = + (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); + developer.log( + 'Loading messages with offset $offset, take $_pageSize', + name: 'MessagesNotifier', + ); + final loadedMessages = await _getCachedMessages( + offset: offset, + take: _pageSize, + ); + + // Check if loaded messages are already in current state + final currentIds = currentMessages.map((m) => m.id).toSet(); + final newMessages = + loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); + developer.log( + 'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', + name: 'MessagesNotifier', + ); + + if (newMessages.isNotEmpty) { + // Merge with current messages + final allMessages = [...currentMessages, ...newMessages]; + final uniqueMessages = []; + final seenIds = {}; + for (final message in allMessages) { + if (seenIds.add(message.id)) { + uniqueMessages.add(message); + } + } + _sortMessages(uniqueMessages); + state = AsyncValue.data(uniqueMessages); + developer.log( + 'Updated state with ${uniqueMessages.length} total messages', + name: 'MessagesNotifier', + ); + } + + final finalIndex = (state.value ?? []).indexWhere( + (m) => m.id == messageId, + ); + developer.log( + 'Final index for message $messageId is $finalIndex', + name: 'MessagesNotifier', + ); + return finalIndex; + } finally { + _isJumping = false; + } + } + + bool _hasLink(LocalChatMessage message) { + final content = message.toRemoteMessage().content; + if (content == null) return false; + final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*'); + return urlRegex.hasMatch(content); + } +} diff --git a/lib/screens/chat/room.g.dart b/lib/pods/messages_notifier.g.dart similarity index 97% rename from lib/screens/chat/room.g.dart rename to lib/pods/messages_notifier.g.dart index c6d4556f..063c5d74 100644 --- a/lib/screens/chat/room.g.dart +++ b/lib/pods/messages_notifier.g.dart @@ -1,12 +1,12 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'room.dart'; +part of 'messages_notifier.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$messagesNotifierHash() => r'5787fcac9f6c77062aaf854daf2365464f771c2f'; +String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/pods/room_providers.dart b/lib/pods/room_providers.dart new file mode 100644 index 00000000..e061de36 --- /dev/null +++ b/lib/pods/room_providers.dart @@ -0,0 +1,34 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; + +final isSyncingProvider = StateProvider.autoDispose((ref) => false); + +final flashingMessagesProvider = StateProvider>((ref) => {}); + +final appLifecycleStateProvider = StreamProvider((ref) { + final controller = StreamController(); + + final observer = _AppLifecycleObserver((state) { + if (controller.isClosed) return; + controller.add(state); + }); + WidgetsBinding.instance.addObserver(observer); + + ref.onDispose(() { + WidgetsBinding.instance.removeObserver(observer); + controller.close(); + }); + + return controller.stream; +}); + +class _AppLifecycleObserver extends WidgetsBindingObserver { + final ValueChanged onChange; + _AppLifecycleObserver(this.onChange); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + onChange(state); + } +} diff --git a/lib/screens/chat/public_room_preview.dart b/lib/screens/chat/public_room_preview.dart new file mode 100644 index 00000000..6c99dae9 --- /dev/null +++ b/lib/screens/chat/public_room_preview.dart @@ -0,0 +1,221 @@ +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:gap/gap.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:island/database/message.dart"; +import "package:island/screens/chat/chat.dart"; +import "package:island/widgets/content/cloud_files.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; +import "package:easy_localization/easy_localization.dart"; +import "package:go_router/go_router.dart"; +import "package:material_symbols_icons/symbols.dart"; +import "package:styled_widget/styled_widget.dart"; +import "package:island/models/chat.dart"; +import "package:island/widgets/alert.dart"; +import "package:island/widgets/app_scaffold.dart"; +import "package:island/widgets/chat/message_item.dart"; +import "package:island/widgets/response.dart"; +import "package:island/pods/network.dart"; +import "package:island/services/responsive.dart"; +import "package:island/pods/messages_notifier.dart"; + +class PublicRoomPreview extends HookConsumerWidget { + final String id; + final SnChatRoom room; + + const PublicRoomPreview({super.key, required this.id, required this.room}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messages = ref.watch(messagesNotifierProvider(id)); + final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); + final scrollController = useScrollController(); + + final listController = useMemoized(() => ListController(), []); + + var isLoading = false; + + // Add scroll listener for pagination + useEffect(() { + void onScroll() { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + if (isLoading) return; + isLoading = true; + messagesNotifier.loadMore().then((_) => isLoading = false); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController]); + + Widget chatMessageListWidget(List messageList) => + SuperListView.builder( + listController: listController, + padding: EdgeInsets.symmetric(vertical: 16), + controller: scrollController, + reverse: true, // Show newest messages at the bottom + itemCount: messageList.length, + findChildIndexCallback: (key) { + final valueKey = key as ValueKey; + final messageId = valueKey.value as String; + return messageList.indexWhere((m) => m.id == messageId); + }, + extentEstimation: (_, _) => 40, + itemBuilder: (context, index) { + final message = messageList[index]; + final nextMessage = + index < messageList.length - 1 ? messageList[index + 1] : null; + final isLastInGroup = + nextMessage == null || + nextMessage.senderId != message.senderId || + nextMessage.createdAt + .difference(message.createdAt) + .inMinutes + .abs() > + 3; + + return MessageItem( + message: message, + isCurrentUser: false, // User is not a member, so not current user + onAction: null, // No actions allowed in preview mode + onJump: (_) {}, // No jump functionality in preview + progress: null, + showAvatar: isLastInGroup, + ); + }, + ); + + final compactHeader = isWideScreen(context); + + Widget comfortHeaderWidget() => Column( + spacing: 4, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 26, + width: 26, + child: + (room.type == 1 && room.picture?.id == null) + ? SplitAvatarWidget( + filesId: + room.members! + .map((e) => e.account.profile.picture?.id) + .toList(), + ) + : room.picture?.id != null + ? ProfilePictureWidget( + fileId: room.picture?.id, + fallbackIcon: Symbols.chat, + ) + : CircleAvatar( + child: Text( + room.name![0].toUpperCase(), + style: const TextStyle(fontSize: 12), + ), + ), + ), + Text( + (room.type == 1 && room.name == null) + ? room.members!.map((e) => e.account.nick).join(', ') + : room.name!, + ).fontSize(15), + ], + ); + + Widget compactHeaderWidget() => Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 26, + width: 26, + child: + (room.type == 1 && room.picture?.id == null) + ? SplitAvatarWidget( + filesId: + room.members! + .map((e) => e.account.profile.picture?.id) + .toList(), + ) + : room.picture?.id != null + ? ProfilePictureWidget( + fileId: room.picture?.id, + fallbackIcon: Symbols.chat, + ) + : CircleAvatar( + child: Text( + room.name![0].toUpperCase(), + style: const TextStyle(fontSize: 12), + ), + ), + ), + Text( + (room.type == 1 && room.name == null) + ? room.members!.map((e) => e.account.nick).join(', ') + : room.name!, + ).fontSize(19), + ], + ); + + return AppScaffold( + appBar: AppBar( + leading: !compactHeader ? const Center(child: PageBackButton()) : null, + automaticallyImplyLeading: false, + toolbarHeight: compactHeader ? null : 64, + title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + context.pushNamed('chatDetail', pathParameters: {'id': id}); + }, + ), + const Gap(8), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: messages.when( + data: + (messageList) => + messageList.isEmpty + ? Center(child: Text('No messages yet'.tr())) + : chatMessageListWidget(messageList), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => messagesNotifier.loadInitial(), + ), + ), + ), + // Join button at the bottom for public rooms + Container( + padding: const EdgeInsets.all(16), + child: FilledButton.tonalIcon( + onPressed: () async { + try { + showLoadingModal(context); + final apiClient = ref.read(apiClientProvider); + await apiClient.post('/sphere/chat/${room.id}/members/me'); + ref.invalidate(chatroomIdentityProvider(id)); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, + label: Text('chatJoin').tr(), + icon: const Icon(Icons.add), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 40aedb56..7f4d5266 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,49 +1,33 @@ import "dart:async"; import "dart:convert"; -import "dart:developer" as developer; -import "dart:io"; -import "package:dio/dio.dart"; -import "package:drift/drift.dart" show Variable; import "package:easy_localization/easy_localization.dart"; import "package:file_picker/file_picker.dart"; -import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:go_router/go_router.dart"; -import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:gap/gap.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:image_picker/image_picker.dart"; -import "package:island/database/drift_db.dart"; import "package:island/database/message.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; -import "package:island/pods/config.dart"; -import "package:island/pods/database.dart"; +import "package:island/pods/messages_notifier.dart"; import "package:island/pods/network.dart"; import "package:island/pods/websocket.dart"; -import "package:island/services/file.dart"; +import "package:island/screens/chat/chat.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/alert.dart"; import "package:island/widgets/app_scaffold.dart"; import "package:island/widgets/chat/call_overlay.dart"; import "package:island/widgets/chat/message_item.dart"; -import "package:island/widgets/content/attachment_preview.dart"; import "package:island/widgets/content/cloud_files.dart"; import "package:island/widgets/response.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; -import "package:pasteboard/pasteboard.dart"; import "package:styled_widget/styled_widget.dart"; import "package:super_sliver_list/super_sliver_list.dart"; - -import "package:uuid/uuid.dart"; import "package:material_symbols_icons/symbols.dart"; -import "package:riverpod_annotation/riverpod_annotation.dart"; -import "chat.dart"; import "package:island/widgets/chat/call_button.dart"; -import "package:island/widgets/stickers/picker.dart"; - -part 'room.g.dart'; +import "package:island/widgets/chat/chat_input.dart"; +import "package:island/widgets/chat/public_room_preview.dart"; final isSyncingProvider = StateProvider.autoDispose((ref) => false); @@ -76,1038 +60,6 @@ class _AppLifecycleObserver extends WidgetsBindingObserver { } } -class _PublicRoomPreview extends HookConsumerWidget { - final String id; - final SnChatRoom room; - - const _PublicRoomPreview({required this.id, required this.room}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final messages = ref.watch(messagesNotifierProvider(id)); - final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); - final scrollController = useScrollController(); - - final listController = useMemoized(() => ListController(), []); - - var isLoading = false; - - // Add scroll listener for pagination - useEffect(() { - void onScroll() { - if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 200) { - if (isLoading) return; - isLoading = true; - messagesNotifier.loadMore().then((_) => isLoading = false); - } - } - - scrollController.addListener(onScroll); - return () => scrollController.removeListener(onScroll); - }, [scrollController]); - - Widget chatMessageListWidget(List messageList) => - SuperListView.builder( - listController: listController, - padding: EdgeInsets.symmetric(vertical: 16), - controller: scrollController, - reverse: true, // Show newest messages at the bottom - itemCount: messageList.length, - findChildIndexCallback: (key) { - final valueKey = key as ValueKey; - final messageId = valueKey.value as String; - return messageList.indexWhere((m) => m.id == messageId); - }, - extentEstimation: (_, _) => 40, - itemBuilder: (context, index) { - final message = messageList[index]; - final nextMessage = - index < messageList.length - 1 ? messageList[index + 1] : null; - final isLastInGroup = - nextMessage == null || - nextMessage.senderId != message.senderId || - nextMessage.createdAt - .difference(message.createdAt) - .inMinutes - .abs() > - 3; - - return MessageItem( - message: message, - isCurrentUser: false, // User is not a member, so not current user - onAction: null, // No actions allowed in preview mode - onJump: (_) {}, // No jump functionality in preview - progress: null, - showAvatar: isLastInGroup, - ); - }, - ); - - final compactHeader = isWideScreen(context); - - Widget comfortHeaderWidget() => Column( - spacing: 4, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - height: 26, - width: 26, - child: - (room.type == 1 && room.picture?.id == null) - ? SplitAvatarWidget( - filesId: - room.members! - .map((e) => e.account.profile.picture?.id) - .toList(), - ) - : room.picture?.id != null - ? ProfilePictureWidget( - fileId: room.picture?.id, - fallbackIcon: Symbols.chat, - ) - : CircleAvatar( - child: Text( - room.name![0].toUpperCase(), - style: const TextStyle(fontSize: 12), - ), - ), - ), - Text( - (room.type == 1 && room.name == null) - ? room.members!.map((e) => e.account.nick).join(', ') - : room.name!, - ).fontSize(15), - ], - ); - - Widget compactHeaderWidget() => Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - height: 26, - width: 26, - child: - (room.type == 1 && room.picture?.id == null) - ? SplitAvatarWidget( - filesId: - room.members! - .map((e) => e.account.profile.picture?.id) - .toList(), - ) - : room.picture?.id != null - ? ProfilePictureWidget( - fileId: room.picture?.id, - fallbackIcon: Symbols.chat, - ) - : CircleAvatar( - child: Text( - room.name![0].toUpperCase(), - style: const TextStyle(fontSize: 12), - ), - ), - ), - Text( - (room.type == 1 && room.name == null) - ? room.members!.map((e) => e.account.nick).join(', ') - : room.name!, - ).fontSize(19), - ], - ); - - return AppScaffold( - appBar: AppBar( - leading: !compactHeader ? const Center(child: PageBackButton()) : null, - automaticallyImplyLeading: false, - toolbarHeight: compactHeader ? null : 64, - title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(), - actions: [ - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - context.pushNamed('chatDetail', pathParameters: {'id': id}); - }, - ), - const Gap(8), - ], - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: messages.when( - data: - (messageList) => - messageList.isEmpty - ? Center(child: Text('No messages yet'.tr())) - : chatMessageListWidget(messageList), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: () => messagesNotifier.loadInitial(), - ), - ), - ), - // Join button at the bottom for public rooms - Container( - padding: const EdgeInsets.all(16), - child: FilledButton.tonalIcon( - onPressed: () async { - try { - showLoadingModal(context); - final apiClient = ref.read(apiClientProvider); - await apiClient.post('/sphere/chat/${room.id}/members/me'); - ref.invalidate(chatroomIdentityProvider(id)); - } catch (err) { - showErrorAlert(err); - } finally { - if (context.mounted) hideLoadingModal(context); - } - }, - label: Text('chatJoin').tr(), - icon: const Icon(Icons.add), - ), - ), - ], - ), - ); - } -} - -@riverpod -class MessagesNotifier extends _$MessagesNotifier { - late final Dio _apiClient; - late final AppDatabase _database; - late final SnChatRoom _room; - late final SnChatMember _identity; - - final Map _pendingMessages = {}; - final Map> _fileUploadProgress = {}; - int? _totalCount; - String? _searchQuery; - bool? _withLinks; - bool? _withAttachments; - - late final String _roomId; - static const int _pageSize = 20; - bool _hasMore = true; - bool _isSyncing = false; - bool _isJumping = false; - - @override - FutureOr> build(String roomId) async { - _roomId = roomId; - _apiClient = ref.watch(apiClientProvider); - _database = ref.watch(databaseProvider); - final room = await ref.watch(chatroomProvider(roomId).future); - final identity = await ref.watch(chatroomIdentityProvider(roomId).future); - - if (room == null) { - throw Exception('Room not found'); - } - _room = room; - - // Allow building even if identity is null for public rooms - if (identity != null) { - _identity = identity; - } - - developer.log( - 'MessagesNotifier built for room $roomId', - name: 'MessagesNotifier', - ); - - // Only setup sync and lifecycle listeners if user is a member - if (identity != null) { - ref.listen(appLifecycleStateProvider, (_, next) { - if (next.hasValue && next.value == AppLifecycleState.resumed) { - developer.log( - 'App resumed, syncing messages', - name: 'MessagesNotifier', - ); - syncMessages(); - } - }); - } - - loadInitial(); - return []; - } - - List _sortMessages(List messages) { - messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return messages; - } - - Future> _getCachedMessages({ - int offset = 0, - int take = 20, - }) async { - developer.log( - 'Getting cached messages from offset $offset, take $take', - name: 'MessagesNotifier', - ); - final List dbMessages; - if (_searchQuery != null && _searchQuery!.isNotEmpty) { - dbMessages = await _database.searchMessages( - _roomId, - _searchQuery ?? '', - withAttachments: _withAttachments, - ); - } else { - final chatMessagesFromDb = await _database.getMessagesForRoom( - _roomId, - offset: offset, - limit: take, - ); - dbMessages = - chatMessagesFromDb.map(_database.companionToMessage).toList(); - } - - List filteredMessages = dbMessages; - - if (_withLinks == true) { - filteredMessages = - filteredMessages.where((msg) => _hasLink(msg)).toList(); - } - - final dbLocalMessages = filteredMessages; - - // Always ensure unique messages to prevent duplicate keys - final uniqueMessages = []; - final seenIds = {}; - for (final message in dbLocalMessages) { - if (seenIds.add(message.id)) { - uniqueMessages.add(message); - } - } - - if (offset == 0) { - final pendingForRoom = - _pendingMessages.values - .where((msg) => msg.roomId == _roomId) - .toList(); - - final allMessages = [...pendingForRoom, ...uniqueMessages]; - _sortMessages(allMessages); // Use the helper function - - final finalUniqueMessages = []; - final finalSeenIds = {}; - for (final message in allMessages) { - if (finalSeenIds.add(message.id)) { - finalUniqueMessages.add(message); - } - } - return finalUniqueMessages; - } - - return uniqueMessages; - } - - Future> _fetchAndCacheMessages({ - int offset = 0, - int take = 20, - }) async { - developer.log( - 'Fetching messages from API, offset $offset, take $take', - name: 'MessagesNotifier', - ); - if (_totalCount == null) { - final response = await _apiClient.get( - '/sphere/chat/$_roomId/messages', - queryParameters: {'offset': 0, 'take': 1}, - ); - _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); - } - - if (offset >= _totalCount!) { - return []; - } - - final response = await _apiClient.get( - '/sphere/chat/$_roomId/messages', - queryParameters: {'offset': offset, 'take': take}, - ); - - final List data = response.data; - _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); - - final messages = - data.map((json) { - final remoteMessage = SnChatMessage.fromJson(json); - return LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - }).toList(); - - for (final message in messages) { - await _database.saveMessage(_database.messageToCompanion(message)); - if (message.nonce != null) { - _pendingMessages.removeWhere( - (_, pendingMsg) => pendingMsg.nonce == message.nonce, - ); - } - } - - return messages; - } - - Future syncMessages() async { - if (_isSyncing) { - developer.log( - 'Sync already in progress, skipping.', - name: 'MessagesNotifier', - ); - return; - } - _isSyncing = true; - - developer.log('Starting message sync', name: 'MessagesNotifier'); - Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); - try { - final dbMessages = await _database.getMessagesForRoom( - _room.id, - offset: 0, - limit: 1, - ); - final lastMessage = - dbMessages.isEmpty - ? null - : _database.companionToMessage(dbMessages.first); - - if (lastMessage == null) { - developer.log( - 'No local messages, fetching from network', - name: 'MessagesNotifier', - ); - final newMessages = await _fetchAndCacheMessages( - offset: 0, - take: _pageSize, - ); - state = AsyncValue.data(newMessages); - return; - } - - final resp = await _apiClient.post( - '/sphere/chat/${_room.id}/sync', - data: { - 'last_sync_timestamp': - lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, - }, - ); - - final response = MessageSyncResponse.fromJson(resp.data); - developer.log( - 'Sync response: ${response.messages.length} changes', - name: 'MessagesNotifier', - ); - for (final message in response.messages) { - switch (message.type) { - case "messages.update": - case "messages.update.links": - await receiveMessageUpdate(message); - break; - case "messages.delete": - await receiveMessageDeletion(message.id.toString()); - break; - } - // Still need receive the message to show the history actions - await receiveMessage(message); - } - } catch (err, stackTrace) { - developer.log( - 'Error syncing messages', - name: 'MessagesNotifier', - error: err, - stackTrace: stackTrace, - ); - showErrorAlert(err); - } finally { - developer.log('Finished message sync', name: 'MessagesNotifier'); - Future.microtask( - () => ref.read(isSyncingProvider.notifier).state = false, - ); - _isSyncing = false; - } - } - - Future> listMessages({ - int offset = 0, - int take = 20, - bool synced = false, - }) async { - try { - if (offset == 0 && - !synced && - (_searchQuery == null || _searchQuery!.isEmpty)) { - _fetchAndCacheMessages(offset: 0, take: take).catchError((_) { - return []; - }); - } - - final localMessages = await _getCachedMessages( - offset: offset, - take: take, - ); - - if (localMessages.isNotEmpty) { - return localMessages; - } - - if (_searchQuery == null || _searchQuery!.isEmpty) { - return await _fetchAndCacheMessages(offset: offset, take: take); - } else { - return []; // If searching, and no local messages, don't fetch from network - } - } catch (e) { - final localMessages = await _getCachedMessages( - offset: offset, - take: take, - ); - - if (localMessages.isNotEmpty) { - return localMessages; - } - rethrow; - } - } - - Future loadInitial() async { - developer.log('Loading initial messages', name: 'MessagesNotifier'); - if (_searchQuery == null || _searchQuery!.isEmpty) { - syncMessages(); - } - - final messages = await _getCachedMessages(offset: 0, take: _pageSize); - - _hasMore = messages.length == _pageSize; - - state = AsyncValue.data(messages); - } - - Future loadMore() async { - if (!_hasMore || state is AsyncLoading) return; - developer.log('Loading more messages', name: 'MessagesNotifier'); - - try { - final currentMessages = state.value ?? []; - final offset = currentMessages.length; - - final newMessages = await listMessages(offset: offset, take: _pageSize); - - if (newMessages.isEmpty || newMessages.length < _pageSize) { - _hasMore = false; - } - - state = AsyncValue.data( - _sortMessages([...currentMessages, ...newMessages]), - ); - } catch (err, stackTrace) { - developer.log( - 'Error loading more messages', - name: 'MessagesNotifier', - error: err, - stackTrace: stackTrace, - ); - showErrorAlert(err); - } - } - - Future sendMessage( - String content, - List attachments, { - SnChatMessage? editingTo, - SnChatMessage? forwardingTo, - SnChatMessage? replyingTo, - Function(String, Map)? onProgress, - }) async { - final nonce = const Uuid().v4(); - developer.log( - 'Sending message with nonce $nonce', - name: 'MessagesNotifier', - ); - final baseUrl = ref.read(serverUrlProvider); - final token = await getToken(ref.watch(tokenProvider)); - if (token == null) throw ArgumentError('Access token is null'); - - final mockMessage = SnChatMessage( - id: 'pending_$nonce', - chatRoomId: _roomId, - senderId: _identity.id, - content: content, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - nonce: nonce, - sender: _identity, - ); - - final localMessage = LocalChatMessage.fromRemoteMessage( - mockMessage, - MessageStatus.pending, - ); - - _pendingMessages[localMessage.id] = localMessage; - _fileUploadProgress[localMessage.id] = {}; - await _database.saveMessage(_database.messageToCompanion(localMessage)); - - final currentMessages = state.value ?? []; - state = AsyncValue.data([localMessage, ...currentMessages]); - - try { - var cloudAttachments = List.empty(growable: true); - for (var idx = 0; idx < attachments.length; idx++) { - final cloudFile = - await putFileToCloud( - fileData: attachments[idx], - atk: token, - baseUrl: baseUrl, - filename: attachments[idx].data.name ?? 'Post media', - mimetype: - attachments[idx].data.mimeType ?? - switch (attachments[idx].type) { - UniversalFileType.image => 'image/unknown', - UniversalFileType.video => 'video/unknown', - 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...'); - } - cloudAttachments.add(cloudFile); - } - - final response = await _apiClient.request( - editingTo == null - ? '/sphere/chat/$_roomId/messages' - : '/sphere/chat/$_roomId/messages/${editingTo.id}', - data: { - 'content': content, - 'attachments_id': cloudAttachments.map((e) => e.id).toList(), - 'replied_message_id': replyingTo?.id, - 'forwarded_message_id': forwardingTo?.id, - 'meta': {}, - 'nonce': nonce, - }, - options: Options(method: editingTo == null ? 'POST' : 'PATCH'), - ); - - final remoteMessage = SnChatMessage.fromJson(response.data); - final updatedMessage = LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - - _pendingMessages.remove(localMessage.id); - await _database.deleteMessage(localMessage.id); - await _database.saveMessage(_database.messageToCompanion(updatedMessage)); - - final currentMessages = state.value ?? []; - if (editingTo != null) { - final newMessages = - currentMessages - .where((m) => m.id != localMessage.id) // remove pending message - .map( - (m) => m.id == editingTo.id ? updatedMessage : m, - ) // update original message - .toList(); - state = AsyncValue.data(newMessages); - } else { - final newMessages = - currentMessages.map((m) { - if (m.id == localMessage.id) { - return updatedMessage; - } - return m; - }).toList(); - state = AsyncValue.data(newMessages); - } - developer.log( - 'Message with nonce $nonce sent successfully', - name: 'MessagesNotifier', - ); - } catch (e, stackTrace) { - developer.log( - 'Failed to send message with nonce $nonce', - name: 'MessagesNotifier', - error: e, - stackTrace: stackTrace, - ); - localMessage.status = MessageStatus.failed; - _pendingMessages[localMessage.id] = localMessage; - await _database.updateMessageStatus( - localMessage.id, - MessageStatus.failed, - ); - final newMessages = - (state.value ?? []).map((m) { - if (m.id == localMessage.id) { - return m..status = MessageStatus.failed; - } - return m; - }).toList(); - state = AsyncValue.data(newMessages); - showErrorAlert(e); - } - } - - Future retryMessage(String pendingMessageId) async { - developer.log( - 'Retrying message $pendingMessageId', - name: 'MessagesNotifier', - ); - final message = await fetchMessageById(pendingMessageId); - if (message == null) { - throw Exception('Message not found'); - } - - message.status = MessageStatus.pending; - _pendingMessages[pendingMessageId] = message; - await _database.updateMessageStatus( - pendingMessageId, - MessageStatus.pending, - ); - - try { - var remoteMessage = message.toRemoteMessage(); - final response = await _apiClient.post( - '/sphere/chat/${message.roomId}/messages', - data: { - 'content': remoteMessage.content, - 'attachments_id': remoteMessage.attachments, - 'meta': remoteMessage.meta, - 'nonce': message.nonce, - }, - ); - - remoteMessage = SnChatMessage.fromJson(response.data); - final updatedMessage = LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - - _pendingMessages.remove(pendingMessageId); - await _database.deleteMessage(pendingMessageId); - await _database.saveMessage(_database.messageToCompanion(updatedMessage)); - - final newMessages = - (state.value ?? []).map((m) { - if (m.id == pendingMessageId) { - return updatedMessage; - } - return m; - }).toList(); - state = AsyncValue.data(newMessages); - } catch (e, stackTrace) { - developer.log( - 'Failed to retry message $pendingMessageId', - name: 'MessagesNotifier', - error: e, - stackTrace: stackTrace, - ); - message.status = MessageStatus.failed; - _pendingMessages[pendingMessageId] = message; - await _database.updateMessageStatus( - pendingMessageId, - MessageStatus.failed, - ); - final newMessages = - (state.value ?? []).map((m) { - if (m.id == pendingMessageId) { - return m..status = MessageStatus.failed; - } - return m; - }).toList(); - state = AsyncValue.data(_sortMessages(newMessages)); - showErrorAlert(e); - } - } - - Future receiveMessage(SnChatMessage remoteMessage) async { - if (remoteMessage.chatRoomId != _roomId) return; - developer.log( - 'Received new message ${remoteMessage.id}', - name: 'MessagesNotifier', - ); - - final localMessage = LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - - if (remoteMessage.nonce != null) { - _pendingMessages.removeWhere( - (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, - ); - } - - await _database.saveMessage(_database.messageToCompanion(localMessage)); - - final currentMessages = state.value ?? []; - final existingIndex = currentMessages.indexWhere( - (m) => - m.id == localMessage.id || - (localMessage.nonce != null && m.nonce == localMessage.nonce), - ); - - if (existingIndex >= 0) { - final newList = [...currentMessages]; - newList[existingIndex] = localMessage; - state = AsyncValue.data(_sortMessages(newList)); - } else { - state = AsyncValue.data( - _sortMessages([localMessage, ...currentMessages]), - ); - } - } - - Future receiveMessageUpdate(SnChatMessage remoteMessage) async { - if (remoteMessage.chatRoomId != _roomId) return; - developer.log( - 'Received message update ${remoteMessage.id}', - name: 'MessagesNotifier', - ); - - final updatedMessage = LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - await _database.updateMessage(_database.messageToCompanion(updatedMessage)); - - final currentMessages = state.value ?? []; - final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id); - - if (index >= 0) { - final newList = [...currentMessages]; - newList[index] = updatedMessage; - state = AsyncValue.data(_sortMessages(newList)); - } - } - - Future receiveMessageDeletion(String messageId) async { - developer.log( - 'Received message deletion $messageId', - name: 'MessagesNotifier', - ); - _pendingMessages.remove(messageId); - - final currentMessages = state.value ?? []; - final messageIndex = currentMessages.indexWhere((m) => m.id == messageId); - - LocalChatMessage? messageToUpdate; - if (messageIndex != -1) { - messageToUpdate = currentMessages[messageIndex]; - } else { - messageToUpdate = await fetchMessageById(messageId); - } - - if (messageToUpdate == null) return; - - final remote = messageToUpdate.toRemoteMessage(); - final updatedRemote = remote.copyWith( - content: 'This message was deleted', - deletedAt: DateTime.now(), - attachments: [], - ); - - final deletedMessage = LocalChatMessage.fromRemoteMessage( - updatedRemote, - messageToUpdate.status, - ); - - await _database.saveMessage(_database.messageToCompanion(deletedMessage)); - - if (messageIndex != -1) { - final newList = [...currentMessages]; - newList[messageIndex] = deletedMessage; - state = AsyncValue.data(newList); - } - } - - Future deleteMessage(String messageId) async { - developer.log('Deleting message $messageId', name: 'MessagesNotifier'); - try { - await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); - await receiveMessageDeletion(messageId); - } catch (err, stackTrace) { - developer.log( - 'Error deleting message $messageId', - name: 'MessagesNotifier', - error: err, - stackTrace: stackTrace, - ); - showErrorAlert(err); - } - } - - void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { - _searchQuery = query.trim(); - _withLinks = withLinks; - _withAttachments = withAttachments; - loadInitial(); - } - - void clearSearch() { - _searchQuery = null; - _withLinks = null; - _withAttachments = null; - loadInitial(); - } - - Future fetchMessageById(String messageId) async { - developer.log( - 'Fetching message by id $messageId', - name: 'MessagesNotifier', - ); - try { - final localMessage = - await (_database.select(_database.chatMessages) - ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); - if (localMessage != null) { - return _database.companionToMessage(localMessage); - } - - final response = await _apiClient.get( - '/sphere/chat/$_roomId/messages/$messageId', - ); - final remoteMessage = SnChatMessage.fromJson(response.data); - final message = LocalChatMessage.fromRemoteMessage( - remoteMessage, - MessageStatus.sent, - ); - - await _database.saveMessage(_database.messageToCompanion(message)); - return message; - } catch (e) { - if (e is DioException) return null; - rethrow; - } - } - - Future jumpToMessage(String messageId) async { - developer.log( - 'Starting jump to message $messageId', - name: 'MessagesNotifier', - ); - if (_isJumping) { - developer.log( - 'Jump already in progress, skipping', - name: 'MessagesNotifier', - ); - return -1; - } - _isJumping = true; - - try { - developer.log('Fetching message $messageId', name: 'MessagesNotifier'); - final message = await fetchMessageById(messageId); - if (message == null) { - developer.log('Message $messageId not found', name: 'MessagesNotifier'); - showSnackBar('messageNotFound'.tr()); - return -1; - } - - // Check if message is already in current state to avoid duplicate loading - final currentMessages = state.value ?? []; - final existingIndex = currentMessages.indexWhere( - (m) => m.id == messageId, - ); - if (existingIndex >= 0) { - developer.log( - 'Message $messageId already in current state at index $existingIndex, jumping directly', - name: 'MessagesNotifier', - ); - return existingIndex; - } - - developer.log( - 'Message $messageId not in current state, loading messages around it', - name: 'MessagesNotifier', - ); - - // Count messages newer than this one - final query = _database.customSelect( - 'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?', - variables: [ - Variable.withString(_roomId), - Variable.withDateTime(message.createdAt), - ], - readsFrom: {_database.chatMessages}, - ); - final result = await query.getSingle(); - final newerCount = result.read('count'); - - // Load messages around this position - final offset = - (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); - developer.log( - 'Loading messages with offset $offset, take $_pageSize', - name: 'MessagesNotifier', - ); - final loadedMessages = await _getCachedMessages( - offset: offset, - take: _pageSize, - ); - - // Check if loaded messages are already in current state - final currentIds = currentMessages.map((m) => m.id).toSet(); - final newMessages = - loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); - developer.log( - 'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', - name: 'MessagesNotifier', - ); - - if (newMessages.isNotEmpty) { - // Merge with current messages - final allMessages = [...currentMessages, ...newMessages]; - final uniqueMessages = []; - final seenIds = {}; - for (final message in allMessages) { - if (seenIds.add(message.id)) { - uniqueMessages.add(message); - } - } - _sortMessages(uniqueMessages); - state = AsyncValue.data(uniqueMessages); - developer.log( - 'Updated state with ${uniqueMessages.length} total messages', - name: 'MessagesNotifier', - ); - } - - final finalIndex = (state.value ?? []).indexWhere( - (m) => m.id == messageId, - ); - developer.log( - 'Final index for message $messageId is $finalIndex', - name: 'MessagesNotifier', - ); - return finalIndex; - } finally { - _isJumping = false; - } - } - - bool _hasLink(LocalChatMessage message) { - final content = message.toRemoteMessage().content; - if (content == null) return false; - final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*'); - return urlRegex.hasMatch(content); - } -} - class ChatRoomScreen extends HookConsumerWidget { final String id; const ChatRoomScreen({super.key, required this.id}); @@ -1129,7 +81,7 @@ class ChatRoomScreen extends HookConsumerWidget { data: (room) { if (room!.isPublic) { // Show public room preview with messages but no input - return _PublicRoomPreview(id: id, room: room); + return PublicRoomPreview(id: id, room: room); } else { // Show regular "not joined" screen for private rooms return AppScaffold( @@ -1273,6 +225,8 @@ class ChatRoomScreen extends HookConsumerWidget { var isLoading = false; + final listController = useMemoized(() => ListController(), []); + // Add scroll listener for pagination useEffect(() { void onScroll() { @@ -1437,8 +391,6 @@ class ChatRoomScreen extends HookConsumerWidget { final compactHeader = isWideScreen(context); - final listController = useMemoized(() => ListController(), []); - Widget comfortHeaderWidget(SnChatRoom? room) => Column( spacing: 4, mainAxisAlignment: MainAxisAlignment.center, @@ -1647,8 +599,52 @@ class ChatRoomScreen extends HookConsumerWidget { AudioCallButton(roomId: id), IconButton( icon: const Icon(Icons.more_vert), - onPressed: () { - context.pushNamed('chatDetail', pathParameters: {'id': id}); + onPressed: () async { + final result = await context.pushNamed( + 'chatDetail', + pathParameters: {'id': id}, + ); + if (result is String && messages.valueOrNull != null) { + // Jump to the message that was selected in search + final messageList = messages.valueOrNull!; + final messageIndex = messageList.indexWhere( + (m) => m.id == result, + ); + if (messageIndex == -1) { + messagesNotifier.jumpToMessage(result).then((index) { + if (index != -1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + listController.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: + (estimatedDistance) => + Duration(milliseconds: 250), + curve: (estimatedDistance) => Curves.easeInOut, + ); + }); + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.union({result})); + } + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + listController.animateToItem( + index: messageIndex, + scrollController: scrollController, + alignment: 0.5, + duration: + (estimatedDistance) => Duration(milliseconds: 250), + curve: (estimatedDistance) => Curves.easeInOut, + ); + }); + ref + .read(flashingMessagesProvider.notifier) + .update((set) => set.union({result})); + } + } }, ), const Gap(8), @@ -1759,7 +755,7 @@ class ChatRoomScreen extends HookConsumerWidget { key: ValueKey('typing-indicator-none'), ), ), - _ChatInput( + ChatInput( messageController: messageController, chatRoom: room!, onSend: sendMessage, @@ -1829,310 +825,3 @@ class ChatRoomScreen extends HookConsumerWidget { ); } } - -class _ChatInput extends HookConsumerWidget { - final TextEditingController messageController; - final SnChatRoom chatRoom; - final VoidCallback onSend; - final VoidCallback onClear; - final Function(bool isPhoto) onPickFile; - final SnChatMessage? messageReplyingTo; - final SnChatMessage? messageForwardingTo; - final SnChatMessage? messageEditingTo; - final List attachments; - final Function(int) onUploadAttachment; - final Function(int) onDeleteAttachment; - final Function(int, int) onMoveAttachment; - final Function(List) onAttachmentsChanged; - - const _ChatInput({ - required this.messageController, - required this.chatRoom, - required this.onSend, - required this.onClear, - required this.onPickFile, - required this.messageReplyingTo, - required this.messageForwardingTo, - required this.messageEditingTo, - required this.attachments, - required this.onUploadAttachment, - required this.onDeleteAttachment, - required this.onMoveAttachment, - required this.onAttachmentsChanged, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final inputFocusNode = useFocusNode(); - - final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend; - - final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); - - void send() { - onSend.call(); - WidgetsBinding.instance.addPostFrameCallback((_) { - inputFocusNode.requestFocus(); - }); - } - - Future handlePaste() async { - final clipboard = await Pasteboard.image; - if (clipboard == null) return; - - onAttachmentsChanged([ - ...attachments, - UniversalFile( - data: XFile.fromData(clipboard, mimeType: "image/jpeg"), - type: UniversalFileType.image, - ), - ]); - } - - void handleKeyPress( - BuildContext context, - WidgetRef ref, - RawKeyEvent event, - ) { - if (event is! RawKeyDownEvent) return; - - final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; - final isModifierPressed = event.isMetaPressed || event.isControlPressed; - - if (isPaste && isModifierPressed) { - handlePaste(); - return; - } - - final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; - final isEnter = event.logicalKey == LogicalKeyboardKey.enter; - - if (isEnter) { - if (enterToSend && !isModifierPressed) { - send(); - } else if (!enterToSend && isModifierPressed) { - send(); - } - } - } - - return Material( - elevation: 8, - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ - if (attachments.isNotEmpty) - SizedBox( - height: 280, - child: ListView.separated( - padding: EdgeInsets.symmetric(horizontal: 12), - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - itemBuilder: (context, idx) { - return SizedBox( - height: 280, - width: 280, - child: AttachmentPreview( - item: attachments[idx], - onRequestUpload: () => onUploadAttachment(idx), - onDelete: () => onDeleteAttachment(idx), - onUpdate: (value) { - attachments[idx] = value; - onAttachmentsChanged(attachments); - }, - onMove: (delta) => onMoveAttachment(idx, delta), - ), - ); - }, - separatorBuilder: (_, _) => const Gap(8), - ), - ).padding(top: 12), - if (messageReplyingTo != null || - messageForwardingTo != null || - messageEditingTo != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(8), - ), - margin: const EdgeInsets.only(left: 8, right: 8, top: 8), - child: Row( - children: [ - Icon( - messageReplyingTo != null - ? Symbols.reply - : messageForwardingTo != null - ? Symbols.forward - : Symbols.edit, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(8), - Expanded( - child: Text( - messageReplyingTo != null - ? 'Replying to ${messageReplyingTo?.sender.account.nick}' - : messageForwardingTo != null - ? 'Forwarding message' - : 'Editing message', - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: onClear, - padding: EdgeInsets.zero, - style: ButtonStyle( - minimumSize: WidgetStatePropertyAll(Size(28, 28)), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: Row( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'stickers'.tr(), - icon: const Icon(Symbols.add_reaction), - onPressed: () { - final size = MediaQuery.of(context).size; - showStickerPickerPopover( - context, - Offset( - 20, - size.height - - 480 - - MediaQuery.of(context).padding.bottom, - ), - onPick: (placeholder) { - // Insert placeholder at current cursor position - final text = messageController.text; - final selection = messageController.selection; - final start = - selection.start >= 0 - ? selection.start - : text.length; - final end = - selection.end >= 0 - ? selection.end - : text.length; - final newText = text.replaceRange( - start, - end, - placeholder, - ); - messageController.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: start + placeholder.length, - ), - ); - }, - ); - }, - ), - PopupMenuButton( - icon: const Icon(Symbols.photo_library), - itemBuilder: - (context) => [ - PopupMenuItem( - onTap: () => onPickFile(true), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.photo), - Text('addPhoto').tr(), - ], - ), - ), - PopupMenuItem( - onTap: () => onPickFile(false), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.video_call), - Text('addVideo').tr(), - ], - ), - ), - ], - ), - ], - ), - Expanded( - child: RawKeyboardListener( - focusNode: FocusNode(), - onKey: (event) => handleKeyPress(context, ref, event), - child: TextField( - focusNode: inputFocusNode, - controller: messageController, - onSubmitted: - (enterToSend && isMobile) - ? (_) { - send(); - } - : null, - keyboardType: - (enterToSend && isMobile) - ? TextInputType.text - : TextInputType.multiline, - textInputAction: TextInputAction.send, - inputFormatters: [ - if (enterToSend && !isMobile) - TextInputFormatter.withFunction((oldValue, newValue) { - if (newValue.text.endsWith('\n')) { - return oldValue; - } - return newValue; - }), - ], - decoration: InputDecoration( - hintText: - (chatRoom.type == 1 && chatRoom.name == null) - ? 'chatDirectMessageHint'.tr( - args: [ - chatRoom.members! - .map((e) => e.account.nick) - .join(', '), - ], - ) - : 'chatMessageHint'.tr(args: [chatRoom.name!]), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - counterText: - messageController.text.length > 1024 - ? '${messageController.text.length}/4096' - : null, - ), - maxLines: 3, - minLines: 1, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - ), - ), - IconButton( - icon: const Icon(Icons.send), - color: Theme.of(context).colorScheme.primary, - onPressed: send, - ), - ], - ).padding(bottom: MediaQuery.of(context).padding.bottom), - ), - ], - ), - ); - } -} diff --git a/lib/screens/chat/room_detail.dart b/lib/screens/chat/room_detail.dart index cac061ce..eb561bd7 100644 --- a/lib/screens/chat/room_detail.dart +++ b/lib/screens/chat/room_detail.dart @@ -21,6 +21,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:island/pods/database.dart'; +import 'package:island/screens/chat/search_messages.dart'; part 'room_detail.freezed.dart'; part 'room_detail.g.dart'; @@ -401,11 +402,17 @@ class ChatDetailScreen extends HookConsumerWidget { ), ), ), - onTap: () { - context.pushNamed( + onTap: () async { + final result = await context.pushNamed( 'searchMessages', pathParameters: {'id': id}, ); + if (result is SearchMessagesResult) { + // Navigate back to room screen with message to jump to + if (context.mounted) { + context.pop(result.messageId); + } + } }, ), ], diff --git a/lib/screens/chat/search_messages.dart b/lib/screens/chat/search_messages.dart index 9e4b51dd..555aa286 100644 --- a/lib/screens/chat/search_messages.dart +++ b/lib/screens/chat/search_messages.dart @@ -1,13 +1,20 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/screens/chat/room.dart'; +import 'package:island/pods/messages_notifier.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:island/widgets/chat/message_item.dart'; +import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; +// Class to represent the result when popping from search messages +class SearchMessagesResult { + final String messageId; + const SearchMessagesResult(this.messageId); +} + class SearchMessagesScreen extends HookConsumerWidget { final String roomId; @@ -116,15 +123,12 @@ class SearchMessagesScreen extends HookConsumerWidget { itemCount: messageList.length, itemBuilder: (context, index) { final message = messageList[index]; - // Simplified MessageItem for search results, no grouping logic - return MessageItem( + return MessageListTile( message: message, - isCurrentUser: - false, // Or determine based on actual user - onAction: null, - onJump: (_) {}, - progress: null, - showAvatar: true, + onJump: (messageId) { + // Return the search result and pop back to room detail + context.pop(SearchMessagesResult(messageId)); + }, ); }, ), diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart new file mode 100644 index 00000000..6e61277d --- /dev/null +++ b/lib/widgets/chat/chat_input.dart @@ -0,0 +1,327 @@ +import "dart:async"; +import "dart:io"; +import "package:easy_localization/easy_localization.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:gap/gap.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:image_picker/image_picker.dart"; +import "package:island/models/chat.dart"; +import "package:island/models/file.dart"; +import "package:island/pods/config.dart"; +import "package:island/widgets/content/attachment_preview.dart"; +import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:pasteboard/pasteboard.dart"; +import "package:styled_widget/styled_widget.dart"; +import "package:material_symbols_icons/symbols.dart"; +import "package:island/widgets/stickers/picker.dart"; + +class ChatInput extends HookConsumerWidget { + final TextEditingController messageController; + final SnChatRoom chatRoom; + final VoidCallback onSend; + final VoidCallback onClear; + final Function(bool isPhoto) onPickFile; + final SnChatMessage? messageReplyingTo; + final SnChatMessage? messageForwardingTo; + final SnChatMessage? messageEditingTo; + final List attachments; + final Function(int) onUploadAttachment; + final Function(int) onDeleteAttachment; + final Function(int, int) onMoveAttachment; + final Function(List) onAttachmentsChanged; + + const ChatInput({ + super.key, + required this.messageController, + required this.chatRoom, + required this.onSend, + required this.onClear, + required this.onPickFile, + required this.messageReplyingTo, + required this.messageForwardingTo, + required this.messageEditingTo, + required this.attachments, + required this.onUploadAttachment, + required this.onDeleteAttachment, + required this.onMoveAttachment, + required this.onAttachmentsChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final inputFocusNode = useFocusNode(); + + final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend; + + final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + void send() { + onSend.call(); + WidgetsBinding.instance.addPostFrameCallback((_) { + inputFocusNode.requestFocus(); + }); + } + + Future handlePaste() async { + final clipboard = await Pasteboard.image; + if (clipboard == null) return; + + onAttachmentsChanged([ + ...attachments, + UniversalFile( + data: XFile.fromData(clipboard, mimeType: "image/jpeg"), + type: UniversalFileType.image, + ), + ]); + } + + void handleKeyPress( + BuildContext context, + WidgetRef ref, + RawKeyEvent event, + ) { + if (event is! RawKeyDownEvent) return; + + final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; + final isModifierPressed = event.isMetaPressed || event.isControlPressed; + + if (isPaste && isModifierPressed) { + handlePaste(); + return; + } + + final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; + final isEnter = event.logicalKey == LogicalKeyboardKey.enter; + + if (isEnter) { + if (enterToSend && !isModifierPressed) { + send(); + } else if (!enterToSend && isModifierPressed) { + send(); + } + } + } + + return Material( + elevation: 8, + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + if (attachments.isNotEmpty) + SizedBox( + height: 280, + child: ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 12), + scrollDirection: Axis.horizontal, + itemCount: attachments.length, + itemBuilder: (context, idx) { + return SizedBox( + height: 280, + width: 280, + child: AttachmentPreview( + item: attachments[idx], + onRequestUpload: () => onUploadAttachment(idx), + onDelete: () => onDeleteAttachment(idx), + onUpdate: (value) { + attachments[idx] = value; + onAttachmentsChanged(attachments); + }, + onMove: (delta) => onMoveAttachment(idx, delta), + ), + ); + }, + separatorBuilder: (_, _) => const Gap(8), + ), + ).padding(top: 12), + if (messageReplyingTo != null || + messageForwardingTo != null || + messageEditingTo != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(8), + ), + margin: const EdgeInsets.only(left: 8, right: 8, top: 8), + child: Row( + children: [ + Icon( + messageReplyingTo != null + ? Symbols.reply + : messageForwardingTo != null + ? Symbols.forward + : Symbols.edit, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const Gap(8), + Expanded( + child: Text( + messageReplyingTo != null + ? 'Replying to ${messageReplyingTo?.sender.account.nick}' + : messageForwardingTo != null + ? 'Forwarding message' + : 'Editing message', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: onClear, + padding: EdgeInsets.zero, + style: ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size(28, 28)), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Row( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'stickers'.tr(), + icon: const Icon(Symbols.add_reaction), + onPressed: () { + final size = MediaQuery.of(context).size; + showStickerPickerPopover( + context, + Offset( + 20, + size.height - + 480 - + MediaQuery.of(context).padding.bottom, + ), + onPick: (placeholder) { + // Insert placeholder at current cursor position + final text = messageController.text; + final selection = messageController.selection; + final start = + selection.start >= 0 + ? selection.start + : text.length; + final end = + selection.end >= 0 + ? selection.end + : text.length; + final newText = text.replaceRange( + start, + end, + placeholder, + ); + messageController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: start + placeholder.length, + ), + ); + }, + ); + }, + ), + PopupMenuButton( + icon: const Icon(Symbols.photo_library), + itemBuilder: + (context) => [ + PopupMenuItem( + onTap: () => onPickFile(true), + child: Row( + spacing: 12, + children: [ + const Icon(Symbols.photo), + Text('addPhoto').tr(), + ], + ), + ), + PopupMenuItem( + onTap: () => onPickFile(false), + child: Row( + spacing: 12, + children: [ + const Icon(Symbols.video_call), + Text('addVideo').tr(), + ], + ), + ), + ], + ), + ], + ), + Expanded( + child: RawKeyboardListener( + focusNode: FocusNode(), + onKey: (event) => handleKeyPress(context, ref, event), + child: TextField( + focusNode: inputFocusNode, + controller: messageController, + onSubmitted: + (enterToSend && isMobile) + ? (_) { + send(); + } + : null, + keyboardType: + (enterToSend && isMobile) + ? TextInputType.text + : TextInputType.multiline, + textInputAction: TextInputAction.send, + inputFormatters: [ + if (enterToSend && !isMobile) + TextInputFormatter.withFunction((oldValue, newValue) { + if (newValue.text.endsWith('\n')) { + return oldValue; + } + return newValue; + }), + ], + decoration: InputDecoration( + hintText: + (chatRoom.type == 1 && chatRoom.name == null) + ? 'chatDirectMessageHint'.tr( + args: [ + chatRoom.members! + .map((e) => e.account.nick) + .join(', '), + ], + ) + : 'chatMessageHint'.tr(args: [chatRoom.name!]), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + counterText: + messageController.text.length > 1024 + ? '${messageController.text.length}/4096' + : null, + ), + maxLines: 3, + minLines: 1, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + ), + ), + IconButton( + icon: const Icon(Icons.send), + color: Theme.of(context).colorScheme.primary, + onPressed: send, + ), + ], + ).padding(bottom: MediaQuery.of(context).padding.bottom), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/message_content.dart b/lib/widgets/chat/message_content.dart new file mode 100644 index 00000000..1d07fa58 --- /dev/null +++ b/lib/widgets/chat/message_content.dart @@ -0,0 +1,169 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/pods/call.dart'; +import 'package:island/widgets/content/markdown.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:pretty_diff_text/pretty_diff_text.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class MessageContent extends StatelessWidget { + final SnChatMessage item; + final String? translatedText; + + const MessageContent({super.key, required this.item, this.translatedText}); + + @override + Widget build(BuildContext context) { + switch (item.type) { + case 'call.start': + case 'call.ended': + return _MessageContentCall( + isEnded: item.type == 'call.ended', + duration: item.meta['duration']?.toDouble(), + ); + case 'messages.update': + case 'messages.update.links': + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Symbols.edit, + size: 14, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + const Gap(4), + if (item.meta['previous_content'] is String) + PrettyDiffText( + oldText: item.meta['previous_content'], + newText: item.content ?? 'Edited a message', + defaultTextStyle: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + addedTextStyle: TextStyle( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryFixedDim.withOpacity(0.4), + ), + deletedTextStyle: TextStyle( + decoration: TextDecoration.lineThrough, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.7), + ), + ) + else + Text( + item.content ?? 'Edited a message', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + ), + ], + ); + case 'messages.delete': + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Symbols.delete, + size: 14, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + const Gap(4), + Text( + item.content ?? 'Deleted a message', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + fontStyle: FontStyle.italic, + ), + ), + ], + ); + case 'text': + default: + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownTextContent( + content: item.content ?? '*${item.type} has no content*', + isSelectable: true, + linesMargin: EdgeInsets.zero, + ), + if (translatedText?.isNotEmpty ?? false) + ...([ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.min( + 280, + MediaQuery.of(context).size.width * 0.4, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('translated').tr().fontSize(11).opacity(0.75), + const Gap(8), + Flexible(child: Divider()), + ], + ).padding(vertical: 4), + ), + MarkdownTextContent( + content: translatedText!, + isSelectable: true, + linesMargin: EdgeInsets.zero, + ), + ]), + ], + ); + } + } + + static bool hasContent(SnChatMessage item) { + return item.type != 'text' || (item.content?.isNotEmpty ?? false); + } +} + +class _MessageContentCall extends StatelessWidget { + final bool isEnded; + final double? duration; + + const _MessageContentCall({required this.isEnded, this.duration}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isEnded ? Symbols.call_end : Symbols.phone_in_talk, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + Gap(4), + Text( + isEnded + ? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}' + : 'Call started', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ], + ); + } +} diff --git a/lib/widgets/chat/message_indicators.dart b/lib/widgets/chat/message_indicators.dart new file mode 100644 index 00000000..5813b410 --- /dev/null +++ b/lib/widgets/chat/message_indicators.dart @@ -0,0 +1,69 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/database/message.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class MessageIndicators extends StatelessWidget { + final DateTime? editedAt; + final MessageStatus? status; + final bool isCurrentUser; + final Color textColor; + + const MessageIndicators({ + super.key, + this.editedAt, + this.status, + required this.isCurrentUser, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 4, + mainAxisSize: MainAxisSize.min, + children: [ + if (editedAt != null) + Text( + 'edited'.tr().toLowerCase(), + style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)), + ), + if (isCurrentUser && status != null) + _buildStatusIcon( + context, + status!, + textColor.withOpacity(0.7), + ).padding(bottom: 3), + ], + ); + } + + Widget _buildStatusIcon( + BuildContext context, + MessageStatus status, + Color textColor, + ) { + switch (status) { + case MessageStatus.pending: + return Icon(Icons.access_time, size: 12, color: textColor); + case MessageStatus.sent: + return Icon(Icons.check, size: 12, color: textColor); + case MessageStatus.failed: + return Consumer( + builder: + (context, ref, _) => GestureDetector( + onTap: () { + // This would need to be passed in or accessed differently + // For now, just show the error icon + }, + child: const Icon( + Icons.error_outline, + size: 12, + color: Colors.red, + ), + ), + ); + } + } +} diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 309db1a5..5f7ea180 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -10,22 +10,19 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; -import 'package:island/models/chat.dart'; import 'package:island/models/embed.dart'; -import 'package:island/pods/call.dart'; +import 'package:island/pods/messages_notifier.dart'; import 'package:island/pods/translate.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/utils/mapping.dart'; -import 'package:island/widgets/account/account_name.dart'; -import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/chat/message_content.dart'; +import 'package:island/widgets/chat/message_indicators.dart'; +import 'package:island/widgets/chat/message_sender_info.dart'; import 'package:island/widgets/content/alert.native.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; -import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/embed/link.dart'; -import 'package:island/widgets/content/markdown.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:pretty_diff_text/pretty_diff_text.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_context_menu/super_context_menu.dart'; @@ -228,62 +225,10 @@ class MessageItem extends HookConsumerWidget { children: [ if (showAvatar) ...[ const Gap(8), - Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - AccountPfcGestureDetector( - uname: sender.account.name, - child: ProfilePictureWidget( - fileId: sender.account.profile.picture?.id, - radius: 16, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 2, - children: [ - Text( - DateTime.now().difference(message.createdAt).inDays > - 365 - ? DateFormat( - 'yyyy/MM/dd HH:mm', - ).format(message.createdAt.toLocal()) - : DateTime.now() - .difference(message.createdAt) - .inDays > - 0 - ? DateFormat( - 'MM/dd HH:mm', - ).format(message.createdAt.toLocal()) - : DateFormat( - 'HH:mm', - ).format(message.createdAt.toLocal()), - style: TextStyle(fontSize: 10, color: textColor), - ), - Row( - mainAxisSize: MainAxisSize.min, - spacing: 5, - children: [ - AccountName( - account: sender.account, - style: Theme.of(context).textTheme.bodySmall, - ), - Badge( - label: - Text( - sender.role >= 100 - ? 'permissionOwner' - : sender.role >= 50 - ? 'permissionModerator' - : 'permissionMember', - ).tr(), - ), - ], - ), - ], - ), - ], + MessageSenderInfo( + sender: sender, + createdAt: message.createdAt, + textColor: textColor, ), const Gap(4), ], @@ -319,8 +264,8 @@ class MessageItem extends HookConsumerWidget { textColor: textColor, isReply: false, ).padding(vertical: 4), - if (_MessageItemContent.hasContent(remoteMessage)) - _MessageItemContent( + if (MessageContent.hasContent(remoteMessage)) + MessageContent( item: remoteMessage, translatedText: translatedText.value, ), @@ -406,12 +351,11 @@ class MessageItem extends HookConsumerWidget { ), ), ), - _buildMessageIndicators( - context, - textColor, - remoteMessage, - message, - isCurrentUser, + MessageIndicators( + editedAt: remoteMessage.editedAt, + status: message.status, + isCurrentUser: isCurrentUser, + textColor: textColor, ), ], ), @@ -421,61 +365,6 @@ class MessageItem extends HookConsumerWidget { ), ); } - - Widget _buildMessageIndicators( - BuildContext context, - Color textColor, - SnChatMessage remoteMessage, - LocalChatMessage message, - bool isCurrentUser, - ) { - return Row( - spacing: 4, - mainAxisSize: MainAxisSize.min, - children: [ - if (remoteMessage.editedAt != null) - Text( - 'edited'.tr().toLowerCase(), - style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)), - ), - if (isCurrentUser) - _buildStatusIcon( - context, - message.status, - textColor.withOpacity(0.7), - ).padding(bottom: 3), - ], - ); - } - - Widget _buildStatusIcon( - BuildContext context, - MessageStatus status, - Color textColor, - ) { - switch (status) { - case MessageStatus.pending: - return Icon(Icons.access_time, size: 12, color: textColor); - case MessageStatus.sent: - return Icon(Icons.check, size: 12, color: textColor); - case MessageStatus.failed: - return Consumer( - builder: - (context, ref, _) => GestureDetector( - onTap: () { - ref - .read(messagesNotifierProvider(message.roomId).notifier) - .retryMessage(message.id); - }, - child: const Icon( - Icons.error_outline, - size: 12, - color: Colors.red, - ), - ), - ); - } - } } class MessageQuoteWidget extends HookConsumerWidget { @@ -552,8 +441,8 @@ class MessageQuoteWidget extends HookConsumerWidget { ).textColor(textColor).bold(), ], ).padding(right: 8), - if (_MessageItemContent.hasContent(remoteMessage)) - _MessageItemContent(item: remoteMessage), + if (MessageContent.hasContent(remoteMessage)) + MessageContent(item: remoteMessage), if (remoteMessage.attachments.isNotEmpty) Row( mainAxisSize: MainAxisSize.min, @@ -580,159 +469,3 @@ class MessageQuoteWidget extends HookConsumerWidget { ); } } - -class _MessageItemContent extends StatelessWidget { - final SnChatMessage item; - final String? translatedText; - const _MessageItemContent({required this.item, this.translatedText}); - - @override - Widget build(BuildContext context) { - switch (item.type) { - case 'call.start': - case 'call.ended': - return _MessageContentCall( - isEnded: item.type == 'call.ended', - duration: item.meta['duration']?.toDouble(), - ); - case 'messages.update': - case 'messages.update.links': - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Symbols.edit, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.6), - ), - const Gap(4), - if (item.meta['previous_content'] is String) - PrettyDiffText( - oldText: item.meta['previous_content'], - newText: item.content ?? 'Edited a message', - defaultTextStyle: Theme.of( - context, - ).textTheme.bodySmall!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - addedTextStyle: TextStyle( - backgroundColor: Theme.of( - context, - ).colorScheme.primaryFixedDim.withOpacity(0.4), - ), - deletedTextStyle: TextStyle( - decoration: TextDecoration.lineThrough, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.7), - ), - ) - else - Text( - item.content ?? 'Edited a message', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.6), - ), - ), - ], - ); - case 'messages.delete': - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Symbols.delete, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.6), - ), - const Gap(4), - Text( - item.content ?? 'Deleted a message', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withOpacity(0.6), - fontStyle: FontStyle.italic, - ), - ), - ], - ); - case 'text': - default: - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MarkdownTextContent( - content: item.content ?? '*${item.type} has no content*', - isSelectable: true, - linesMargin: EdgeInsets.zero, - ), - if (translatedText?.isNotEmpty ?? false) - ...([ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: math.min( - 280, - MediaQuery.of(context).size.width * 0.4, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('translated').tr().fontSize(11).opacity(0.75), - const Gap(8), - Flexible(child: Divider()), - ], - ).padding(vertical: 4), - ), - MarkdownTextContent( - content: translatedText!, - isSelectable: true, - linesMargin: EdgeInsets.zero, - ), - ]), - ], - ); - } - } - - static bool hasContent(SnChatMessage item) { - return item.type != 'text' || (item.content?.isNotEmpty ?? false); - } -} - -class _MessageContentCall extends StatelessWidget { - final bool isEnded; - final double? duration; - const _MessageContentCall({required this.isEnded, this.duration}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isEnded ? Symbols.call_end : Symbols.phone_in_talk, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - Gap(4), - Text( - isEnded - ? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}' - : 'Call started', - style: TextStyle(color: Theme.of(context).colorScheme.primary), - ), - ], - ); - } -} diff --git a/lib/widgets/chat/message_list_tile.dart b/lib/widgets/chat/message_list_tile.dart new file mode 100644 index 00000000..9c104699 --- /dev/null +++ b/lib/widgets/chat/message_list_tile.dart @@ -0,0 +1,87 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:island/database/message.dart'; +import 'package:island/models/embed.dart'; +import 'package:island/utils/mapping.dart'; +import 'package:island/widgets/chat/message_content.dart'; +import 'package:island/widgets/chat/message_sender_info.dart'; +import 'package:island/widgets/content/cloud_file_collection.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/embed/link.dart'; + +class MessageListTile extends StatelessWidget { + final LocalChatMessage message; + final Function(String messageId) onJump; + + const MessageListTile({ + super.key, + required this.message, + required this.onJump, + }); + + @override + Widget build(BuildContext context) { + final remoteMessage = message.toRemoteMessage(); + final sender = remoteMessage.sender; + + return ListTile( + leading: CircleAvatar( + radius: 20, + backgroundColor: Colors.transparent, + child: ProfilePictureWidget( + fileId: sender.account.profile.picture?.id, + radius: 20, + ), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MessageSenderInfo( + sender: sender, + createdAt: message.createdAt, + textColor: Theme.of(context).colorScheme.onSurfaceVariant, + compact: true, + ), + const SizedBox(height: 4), + MessageContent(item: remoteMessage), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (remoteMessage.attachments.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + return CloudFileList( + files: remoteMessage.attachments, + maxWidth: constraints.maxWidth, + padding: EdgeInsets.symmetric(vertical: 4), + ); + }, + ), + if (remoteMessage.meta['embeds'] != null) + ...((remoteMessage.meta['embeds'] as List) + .map((embed) => convertMapKeysToSnakeCase(embed)) + .where((embed) => embed['type'] == 'link') + .map((embed) => SnScrappedLink.fromJson(embed)) + .map( + (link) => LayoutBuilder( + builder: (context, constraints) { + return EmbedLinkWidget( + link: link, + maxWidth: math.min(constraints.maxWidth, 480), + margin: const EdgeInsets.symmetric(vertical: 4), + ); + }, + ), + ) + .toList()), + ], + ), + onTap: () => onJump(message.id), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + dense: true, + ); + } +} diff --git a/lib/widgets/chat/message_sender_info.dart b/lib/widgets/chat/message_sender_info.dart new file mode 100644 index 00000000..6bbee3f1 --- /dev/null +++ b/lib/widgets/chat/message_sender_info.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/widgets/account/account_name.dart'; +import 'package:island/widgets/account/account_pfc.dart'; +import 'package:island/widgets/content/cloud_files.dart'; + +class MessageSenderInfo extends StatelessWidget { + final SnChatMember sender; + final DateTime createdAt; + final Color textColor; + final bool compact; + + const MessageSenderInfo({ + super.key, + required this.sender, + required this.createdAt, + required this.textColor, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final timestamp = + DateTime.now().difference(createdAt).inDays > 365 + ? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal()) + : DateTime.now().difference(createdAt).inDays > 0 + ? DateFormat('MM/dd HH:mm').format(createdAt.toLocal()) + : DateFormat('HH:mm').format(createdAt.toLocal()); + + if (compact) { + return Row( + spacing: 8, + children: [ + if (!compact) + AccountPfcGestureDetector( + uname: sender.account.name, + child: ProfilePictureWidget( + fileId: sender.account.profile.picture?.id, + radius: 14, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AccountName( + account: sender.account, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: textColor, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Badge( + label: + Text( + sender.role >= 100 + ? 'permissionOwner' + : sender.role >= 50 + ? 'permissionModerator' + : 'permissionMember', + ).tr(), + ), + ], + ), + Text( + timestamp, + style: TextStyle( + fontSize: 10, + color: textColor.withOpacity(0.7), + ), + ), + ], + ), + ), + ], + ); + } + + return Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + AccountPfcGestureDetector( + uname: sender.account.name, + child: ProfilePictureWidget( + fileId: sender.account.profile.picture?.id, + radius: 16, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)), + Row( + mainAxisSize: MainAxisSize.min, + spacing: 5, + children: [ + AccountName( + account: sender.account, + style: Theme.of(context).textTheme.bodySmall, + ), + Badge( + label: + Text( + sender.role >= 100 + ? 'permissionOwner' + : sender.role >= 50 + ? 'permissionModerator' + : 'permissionMember', + ).tr(), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/chat/public_room_preview.dart b/lib/widgets/chat/public_room_preview.dart new file mode 100644 index 00000000..70ec6ad3 --- /dev/null +++ b/lib/widgets/chat/public_room_preview.dart @@ -0,0 +1,223 @@ +import "package:easy_localization/easy_localization.dart"; +import "package:flutter/material.dart"; +import "package:go_router/go_router.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:gap/gap.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:island/database/message.dart"; +import "package:island/models/chat.dart"; +import "package:island/pods/messages_notifier.dart"; +import "package:island/pods/network.dart"; +import "package:island/services/responsive.dart"; +import "package:island/widgets/alert.dart"; +import "package:island/widgets/app_scaffold.dart"; +import "package:island/widgets/chat/message_item.dart"; +import "package:island/widgets/content/cloud_files.dart"; +import "package:island/widgets/response.dart"; +import "package:material_symbols_icons/material_symbols_icons.dart"; +import "package:styled_widget/styled_widget.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; +import "package:material_symbols_icons/symbols.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; +import "package:island/screens/chat/chat.dart"; + +class PublicRoomPreview extends HookConsumerWidget { + final String id; + final SnChatRoom room; + + const PublicRoomPreview({super.key, required this.id, required this.room}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messages = ref.watch(messagesNotifierProvider(id)); + final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); + final scrollController = useScrollController(); + + final listController = useMemoized(() => ListController(), []); + + var isLoading = false; + + // Add scroll listener for pagination + useEffect(() { + void onScroll() { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + if (isLoading) return; + isLoading = true; + messagesNotifier.loadMore().then((_) => isLoading = false); + } + } + + scrollController.addListener(onScroll); + return () => scrollController.removeListener(onScroll); + }, [scrollController]); + + Widget chatMessageListWidget(List messageList) => + SuperListView.builder( + listController: listController, + padding: EdgeInsets.symmetric(vertical: 16), + controller: scrollController, + reverse: true, // Show newest messages at the bottom + itemCount: messageList.length, + findChildIndexCallback: (key) { + final valueKey = key as ValueKey; + final messageId = valueKey.value as String; + return messageList.indexWhere((m) => m.id == messageId); + }, + extentEstimation: (_, _) => 40, + itemBuilder: (context, index) { + final message = messageList[index]; + final nextMessage = + index < messageList.length - 1 ? messageList[index + 1] : null; + final isLastInGroup = + nextMessage == null || + nextMessage.senderId != message.senderId || + nextMessage.createdAt + .difference(message.createdAt) + .inMinutes + .abs() > + 3; + + return MessageItem( + message: message, + isCurrentUser: false, // User is not a member, so not current user + onAction: null, // No actions allowed in preview mode + onJump: (_) {}, // No jump functionality in preview + progress: null, + showAvatar: isLastInGroup, + ); + }, + ); + + final compactHeader = isWideScreen(context); + + Widget comfortHeaderWidget() => Column( + spacing: 4, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 26, + width: 26, + child: + (room.type == 1 && room.picture?.id == null) + ? SplitAvatarWidget( + filesId: + room.members! + .map((e) => e.account.profile.picture?.id) + .toList(), + ) + : room.picture?.id != null + ? ProfilePictureWidget( + fileId: room.picture?.id, + fallbackIcon: Symbols.chat, + ) + : CircleAvatar( + child: Text( + room.name![0].toUpperCase(), + style: const TextStyle(fontSize: 12), + ), + ), + ), + Text( + (room.type == 1 && room.name == null) + ? room.members!.map((e) => e.account.nick).join(', ') + : room.name!, + ).fontSize(15), + ], + ); + + Widget compactHeaderWidget() => Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 26, + width: 26, + child: + (room.type == 1 && room.picture?.id == null) + ? SplitAvatarWidget( + filesId: + room.members! + .map((e) => e.account.profile.picture?.id) + .toList(), + ) + : room.picture?.id != null + ? ProfilePictureWidget( + fileId: room.picture?.id, + fallbackIcon: Symbols.chat, + ) + : CircleAvatar( + child: Text( + room.name![0].toUpperCase(), + style: const TextStyle(fontSize: 12), + ), + ), + ), + Text( + (room.type == 1 && room.name == null) + ? room.members!.map((e) => e.account.nick).join(', ') + : room.name!, + ).fontSize(19), + ], + ); + + return AppScaffold( + appBar: AppBar( + leading: !compactHeader ? const Center(child: PageBackButton()) : null, + automaticallyImplyLeading: false, + toolbarHeight: compactHeader ? null : 64, + title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(), + actions: [ + IconButton( + icon: const Icon(Icons.more_vert), + onPressed: () { + context.pushNamed('chatDetail', pathParameters: {'id': id}); + }, + ), + const Gap(8), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: messages.when( + data: + (messageList) => + messageList.isEmpty + ? Center(child: Text('No messages yet'.tr())) + : chatMessageListWidget(messageList), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => messagesNotifier.loadInitial(), + ), + ), + ), + // Join button at the bottom for public rooms + Container( + padding: const EdgeInsets.all(16), + child: FilledButton.tonalIcon( + onPressed: () async { + try { + showLoadingModal(context); + final apiClient = ref.read(apiClientProvider); + await apiClient.post('/sphere/chat/${room.id}/members/me'); + ref.invalidate(chatroomIdentityProvider(id)); + } catch (err) { + showErrorAlert(err); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, + label: Text('chatJoin').tr(), + icon: const Icon(Icons.add), + ), + ), + ], + ), + ); + } +}