From 7edfd56bf78be03c24ae2c006576956d5cb3736b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 3 May 2025 23:02:44 +0800 Subject: [PATCH] :sparkles: Message sync & realtime --- lib/database/drift_db.dart | 5 + lib/database/message_repository.dart | 146 ++++++++++++- lib/main.dart | 2 +- lib/models/chat.dart | 30 +++ lib/models/chat.freezed.dart | 308 +++++++++++++++++++++++++++ lib/models/chat.g.dart | 36 ++++ lib/screens/chat/room.dart | 300 ++++++++++++++++++++------ 7 files changed, 761 insertions(+), 66 deletions(-) diff --git a/lib/database/drift_db.dart b/lib/database/drift_db.dart index 8c60424..6c893e7 100644 --- a/lib/database/drift_db.dart +++ b/lib/database/drift_db.dart @@ -33,6 +33,11 @@ class AppDatabase extends _$AppDatabase { return into(chatMessages).insert(message, mode: InsertMode.insertOrReplace); } + Future updateMessage(ChatMessagesCompanion message) { + return (update(chatMessages) + ..where((m) => m.id.equals(message.id.value))).write(message); + } + Future updateMessageStatus(String id, MessageStatus status) { return (update(chatMessages)..where( (m) => m.id.equals(id), diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index f6c4b71..97b1db4 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -3,6 +3,7 @@ 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/widgets/alert.dart'; import 'package:uuid/uuid.dart'; class MessageRepository { @@ -15,9 +16,56 @@ class MessageRepository { MessageRepository(this.room, this.identity, this._apiClient, this._database); + Future getLastMessages() async { + final dbMessages = await _database.getMessagesForRoom( + room.id, + offset: 0, + limit: 1, + ); + + if (dbMessages.isEmpty) { + return null; + } + + return _database.companionToMessage(dbMessages.first); + } + + Future syncMessages() async { + final lastMessage = await getLastMessages(); + if (lastMessage == null) return false; + try { + final resp = await _apiClient.post( + '/chat/${room.id}/sync', + data: { + 'last_sync_timestamp': + lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, + }, + ); + + final response = MessageSyncResponse.fromJson(resp.data); + for (final change in response.changes) { + switch (change.action) { + case MessageChangeAction.create: + await receiveMessage(change.message!); + break; + case MessageChangeAction.update: + await receiveMessageUpdate(change.message!); + break; + case MessageChangeAction.delete: + await receiveMessageDeletion(change.messageId.toString()); + break; + } + } + } catch (err) { + showErrorAlert(err); + } + return true; + } + Future> listMessages({ int offset = 0, int take = 20, + bool synced = false, }) async { try { final localMessages = await _getCachedMessages( @@ -26,8 +74,9 @@ class MessageRepository { take: take, ); - if (offset == 0) { - // Always fetch latest messages in background if we're loading the first page + // If it already synced with the remote, skip this + if (offset == 0 && !synced) { + // Fetch latest messages _fetchAndCacheMessages(room.id, offset: offset, take: take); if (localMessages.isNotEmpty) { @@ -238,4 +287,97 @@ class MessageRepository { rethrow; } } + + Future receiveMessage(SnChatMessage remoteMessage) async { + final localMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + if (remoteMessage.nonce != null) { + pendingMessages.removeWhere( + (_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce, + ); + } + + await _database.saveMessage(_database.messageToCompanion(localMessage)); + return localMessage; + } + + Future receiveMessageUpdate( + SnChatMessage remoteMessage, + ) async { + final localMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + + await _database.updateMessage(_database.messageToCompanion(localMessage)); + return localMessage; + } + + Future receiveMessageDeletion(String messageId) async { + // Remove from pending messages if exists + pendingMessages.remove(messageId); + + // Delete from local database + await _database.deleteMessage(messageId); + } + + Future updateMessage( + String messageId, + String content, { + List? attachments, + Map? meta, + }) async { + final message = pendingMessages[messageId]; + if (message != null) { + // Update pending message + final rmMessage = message.toRemoteMessage(); + final updatedRemoteMessage = rmMessage.copyWith( + content: content, + meta: meta ?? rmMessage.meta, + ); + final updatedLocalMessage = LocalChatMessage.fromRemoteMessage( + updatedRemoteMessage, + MessageStatus.pending, + ); + pendingMessages[messageId] = updatedLocalMessage; + await _database.updateMessage( + _database.messageToCompanion(updatedLocalMessage), + ); + return message; + } + + try { + // Update on server + final response = await _apiClient.put( + '/chat/${room.id}/messages/$messageId', + data: {'content': content, 'attachments': attachments, 'meta': meta}, + ); + + // Update local copy + final remoteMessage = SnChatMessage.fromJson(response.data); + final updatedMessage = LocalChatMessage.fromRemoteMessage( + remoteMessage, + MessageStatus.sent, + ); + await _database.updateMessage( + _database.messageToCompanion(updatedMessage), + ); + return updatedMessage; + } catch (e) { + rethrow; + } + } + + Future deleteMessage(String messageId) async { + try { + await _apiClient.delete('/chat/${room.id}/messages/$messageId'); + pendingMessages.remove(messageId); + await _database.deleteMessage(messageId); + } catch (e) { + rethrow; + } + } } diff --git a/lib/main.dart b/lib/main.dart index 8e5ef4d..dab4be9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,7 +31,7 @@ void main() async { if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { doWhenWindowReady(() { - const initialSize = Size(600, 450); + const initialSize = Size(360, 640); appWindow.minSize = initialSize; appWindow.size = initialSize; appWindow.alignment = Alignment.center; diff --git a/lib/models/chat.dart b/lib/models/chat.dart index c44cee9..1a0e020 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -94,3 +94,33 @@ abstract class SnChatMember with _$SnChatMember { factory SnChatMember.fromJson(Map json) => _$SnChatMemberFromJson(json); } + +class MessageChangeAction { + static const String create = "create"; + static const String update = "update"; + static const String delete = "delete"; +} + +@freezed +abstract class MessageChange with _$MessageChange { + const factory MessageChange({ + required String messageId, + required String action, + SnChatMessage? message, + required DateTime timestamp, + }) = _MessageChange; + + factory MessageChange.fromJson(Map json) => + _$MessageChangeFromJson(json); +} + +@freezed +abstract class MessageSyncResponse with _$MessageSyncResponse { + const factory MessageSyncResponse({ + @Default([]) List changes, + required DateTime currentTimestamp, + }) = _MessageSyncResponse; + + factory MessageSyncResponse.fromJson(Map json) => + _$MessageSyncResponseFromJson(json); +} diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index 2b4b50a..1f541d6 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -916,4 +916,312 @@ $SnAccountCopyWith<$Res> get account { } } + +/// @nodoc +mixin _$MessageChange { + + String get messageId; String get action; SnChatMessage? get message; DateTime get timestamp; +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MessageChangeCopyWith get copyWith => _$MessageChangeCopyWithImpl(this as MessageChange, _$identity); + + /// Serializes this MessageChange to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp); + +@override +String toString() { + return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class $MessageChangeCopyWith<$Res> { + factory $MessageChangeCopyWith(MessageChange value, $Res Function(MessageChange) _then) = _$MessageChangeCopyWithImpl; +@useResult +$Res call({ + String messageId, String action, SnChatMessage? message, DateTime timestamp +}); + + +$SnChatMessageCopyWith<$Res>? get message; + +} +/// @nodoc +class _$MessageChangeCopyWithImpl<$Res> + implements $MessageChangeCopyWith<$Res> { + _$MessageChangeCopyWithImpl(this._self, this._then); + + final MessageChange _self; + final $Res Function(MessageChange) _then; + +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) { + return _then(_self.copyWith( +messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable +as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnChatMessageCopyWith<$Res>? get message { + if (_self.message == null) { + return null; + } + + return $SnChatMessageCopyWith<$Res>(_self.message!, (value) { + return _then(_self.copyWith(message: value)); + }); +} +} + + +/// @nodoc +@JsonSerializable() + +class _MessageChange implements MessageChange { + const _MessageChange({required this.messageId, required this.action, this.message, required this.timestamp}); + factory _MessageChange.fromJson(Map json) => _$MessageChangeFromJson(json); + +@override final String messageId; +@override final String action; +@override final SnChatMessage? message; +@override final DateTime timestamp; + +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MessageChangeCopyWith<_MessageChange> get copyWith => __$MessageChangeCopyWithImpl<_MessageChange>(this, _$identity); + +@override +Map toJson() { + return _$MessageChangeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageChange&&(identical(other.messageId, messageId) || other.messageId == messageId)&&(identical(other.action, action) || other.action == action)&&(identical(other.message, message) || other.message == message)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,messageId,action,message,timestamp); + +@override +String toString() { + return 'MessageChange(messageId: $messageId, action: $action, message: $message, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class _$MessageChangeCopyWith<$Res> implements $MessageChangeCopyWith<$Res> { + factory _$MessageChangeCopyWith(_MessageChange value, $Res Function(_MessageChange) _then) = __$MessageChangeCopyWithImpl; +@override @useResult +$Res call({ + String messageId, String action, SnChatMessage? message, DateTime timestamp +}); + + +@override $SnChatMessageCopyWith<$Res>? get message; + +} +/// @nodoc +class __$MessageChangeCopyWithImpl<$Res> + implements _$MessageChangeCopyWith<$Res> { + __$MessageChangeCopyWithImpl(this._self, this._then); + + final _MessageChange _self; + final $Res Function(_MessageChange) _then; + +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? messageId = null,Object? action = null,Object? message = freezed,Object? timestamp = null,}) { + return _then(_MessageChange( +messageId: null == messageId ? _self.messageId : messageId // ignore: cast_nullable_to_non_nullable +as String,action: null == action ? _self.action : action // ignore: cast_nullable_to_non_nullable +as String,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as SnChatMessage?,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +/// Create a copy of MessageChange +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SnChatMessageCopyWith<$Res>? get message { + if (_self.message == null) { + return null; + } + + return $SnChatMessageCopyWith<$Res>(_self.message!, (value) { + return _then(_self.copyWith(message: value)); + }); +} +} + + +/// @nodoc +mixin _$MessageSyncResponse { + + List get changes; DateTime get currentTimestamp; +/// Create a copy of MessageSyncResponse +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MessageSyncResponseCopyWith get copyWith => _$MessageSyncResponseCopyWithImpl(this as MessageSyncResponse, _$identity); + + /// Serializes this MessageSyncResponse to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MessageSyncResponse&&const DeepCollectionEquality().equals(other.changes, changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(changes),currentTimestamp); + +@override +String toString() { + return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)'; +} + + +} + +/// @nodoc +abstract mixin class $MessageSyncResponseCopyWith<$Res> { + factory $MessageSyncResponseCopyWith(MessageSyncResponse value, $Res Function(MessageSyncResponse) _then) = _$MessageSyncResponseCopyWithImpl; +@useResult +$Res call({ + List changes, DateTime currentTimestamp +}); + + + + +} +/// @nodoc +class _$MessageSyncResponseCopyWithImpl<$Res> + implements $MessageSyncResponseCopyWith<$Res> { + _$MessageSyncResponseCopyWithImpl(this._self, this._then); + + final MessageSyncResponse _self; + final $Res Function(MessageSyncResponse) _then; + +/// Create a copy of MessageSyncResponse +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? changes = null,Object? currentTimestamp = null,}) { + return _then(_self.copyWith( +changes: null == changes ? _self.changes : changes // ignore: cast_nullable_to_non_nullable +as List,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _MessageSyncResponse implements MessageSyncResponse { + const _MessageSyncResponse({final List changes = const [], required this.currentTimestamp}): _changes = changes; + factory _MessageSyncResponse.fromJson(Map json) => _$MessageSyncResponseFromJson(json); + + final List _changes; +@override@JsonKey() List get changes { + if (_changes is EqualUnmodifiableListView) return _changes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_changes); +} + +@override final DateTime currentTimestamp; + +/// Create a copy of MessageSyncResponse +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MessageSyncResponseCopyWith<_MessageSyncResponse> get copyWith => __$MessageSyncResponseCopyWithImpl<_MessageSyncResponse>(this, _$identity); + +@override +Map toJson() { + return _$MessageSyncResponseToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MessageSyncResponse&&const DeepCollectionEquality().equals(other._changes, _changes)&&(identical(other.currentTimestamp, currentTimestamp) || other.currentTimestamp == currentTimestamp)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_changes),currentTimestamp); + +@override +String toString() { + return 'MessageSyncResponse(changes: $changes, currentTimestamp: $currentTimestamp)'; +} + + +} + +/// @nodoc +abstract mixin class _$MessageSyncResponseCopyWith<$Res> implements $MessageSyncResponseCopyWith<$Res> { + factory _$MessageSyncResponseCopyWith(_MessageSyncResponse value, $Res Function(_MessageSyncResponse) _then) = __$MessageSyncResponseCopyWithImpl; +@override @useResult +$Res call({ + List changes, DateTime currentTimestamp +}); + + + + +} +/// @nodoc +class __$MessageSyncResponseCopyWithImpl<$Res> + implements _$MessageSyncResponseCopyWith<$Res> { + __$MessageSyncResponseCopyWithImpl(this._self, this._then); + + final _MessageSyncResponse _self; + final $Res Function(_MessageSyncResponse) _then; + +/// Create a copy of MessageSyncResponse +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? changes = null,Object? currentTimestamp = null,}) { + return _then(_MessageSyncResponse( +changes: null == changes ? _self._changes : changes // ignore: cast_nullable_to_non_nullable +as List,currentTimestamp: null == currentTimestamp ? _self.currentTimestamp : currentTimestamp // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + // dart format on diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index 4e32ab5..e51f72a 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -195,3 +195,39 @@ Map _$SnChatMemberToJson(_SnChatMember instance) => 'joined_at': instance.joinedAt?.toIso8601String(), 'is_bot': instance.isBot, }; + +_MessageChange _$MessageChangeFromJson(Map json) => + _MessageChange( + messageId: json['message_id'] as String, + action: json['action'] as String, + message: + json['message'] == null + ? null + : SnChatMessage.fromJson(json['message'] as Map), + timestamp: DateTime.parse(json['timestamp'] as String), + ); + +Map _$MessageChangeToJson(_MessageChange instance) => + { + 'message_id': instance.messageId, + 'action': instance.action, + 'message': instance.message?.toJson(), + 'timestamp': instance.timestamp.toIso8601String(), + }; + +_MessageSyncResponse _$MessageSyncResponseFromJson(Map json) => + _MessageSyncResponse( + changes: + (json['changes'] as List?) + ?.map((e) => MessageChange.fromJson(e as Map)) + .toList() ?? + const [], + currentTimestamp: DateTime.parse(json['current_timestamp'] as String), + ); + +Map _$MessageSyncResponseToJson( + _MessageSyncResponse instance, +) => { + 'changes': instance.changes.map((e) => e.toJson()).toList(), + 'current_timestamp': instance.currentTimestamp.toIso8601String(), +}; diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index d895373..5bfb77e 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -7,8 +7,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/database/message_repository.dart'; +import 'package:island/models/chat.dart'; +import 'package:island/models/file.dart'; import 'package:island/pods/message.dart'; import 'package:island/pods/network.dart'; +import 'package:island/pods/websocket.dart'; import 'package:island/route.gr.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_files.dart'; @@ -52,9 +55,11 @@ class MessagesNotifier final repository = await _ref.read( messageRepositoryProvider(_roomId).future, ); + final synced = await repository.syncMessages(); final messages = await repository.listMessages( offset: 0, take: _pageSize, + synced: synced, ); state = AsyncValue.data(messages); _currentPage = 0; @@ -145,6 +150,144 @@ class MessagesNotifier showErrorAlert(err); } } + + Future receiveMessage(SnChatMessage remoteMessage) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + + // Skip if this message is not for this room + if (remoteMessage.chatRoomId != _roomId) return; + + final localMessage = await repository.receiveMessage(remoteMessage); + + // Add the new message to the state + final currentMessages = state.value ?? []; + + // Check if the message already exists (by id or nonce) + final existingIndex = currentMessages.indexWhere( + (m) => + m.id == localMessage.id || + (localMessage.nonce != null && m.nonce == localMessage.nonce), + ); + + if (existingIndex >= 0) { + // Replace existing message + final newList = [...currentMessages]; + newList[existingIndex] = localMessage; + state = AsyncValue.data(newList); + } else { + // Add new message at the beginning (newest first) + state = AsyncValue.data([localMessage, ...currentMessages]); + } + } catch (err) { + showErrorAlert(err); + } + } + + Future receiveMessageUpdate(SnChatMessage remoteMessage) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + + // Skip if this message is not for this room + if (remoteMessage.chatRoomId != _roomId) return; + + final updatedMessage = await repository.receiveMessageUpdate( + remoteMessage, + ); + + // Update the message in the list + 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(newList); + } + } catch (err) { + showErrorAlert(err); + } + } + + Future receiveMessageDeletion(String messageId) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + + await repository.receiveMessageDeletion(messageId); + + // Remove the message from the list + final currentMessages = state.value ?? []; + final filteredMessages = + currentMessages.where((m) => m.id != messageId).toList(); + + if (filteredMessages.length != currentMessages.length) { + state = AsyncValue.data(filteredMessages); + } + } catch (err) { + showErrorAlert(err); + } + } + + Future updateMessage( + String messageId, + String content, { + List? attachments, + Map? meta, + }) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + + final updatedMessage = await repository.updateMessage( + messageId, + content, + attachments: attachments, + meta: meta, + ); + + // Update the message in the list + final currentMessages = state.value ?? []; + final index = currentMessages.indexWhere((m) => m.id == messageId); + + if (index >= 0) { + final newList = [...currentMessages]; + newList[index] = updatedMessage; + state = AsyncValue.data(newList); + } + } catch (err) { + showErrorAlert(err); + } + } + + Future deleteMessage(String messageId) async { + try { + final repository = await _ref.read( + messageRepositoryProvider(_roomId).future, + ); + + await repository.deleteMessage(messageId); + + // Remove the message from the list + final currentMessages = state.value ?? []; + final filteredMessages = + currentMessages.where((m) => m.id != messageId).toList(); + + if (filteredMessages.length != currentMessages.length) { + state = AsyncValue.data(filteredMessages); + } + } catch (err) { + showErrorAlert(err); + } + } } @RoutePage() @@ -158,7 +301,7 @@ class ChatRoomScreen extends HookConsumerWidget { final chatIdentity = ref.watch(chatroomIdentityProvider(id)); final messages = ref.watch(messagesProvider(id)); final messagesNotifier = ref.read(messagesProvider(id).notifier); - final messagesRepo = ref.watch(messageRepositoryProvider(id)); + final ws = ref.watch(websocketProvider); final messageController = useTextEditingController(); final scrollController = useScrollController(); @@ -176,6 +319,34 @@ class ChatRoomScreen extends HookConsumerWidget { return () => scrollController.removeListener(onScroll); }, [scrollController]); + // Add websocket listener + // Add websocket listener for new messages + useEffect(() { + void onMessage(WebSocketPacket pkt) { + if (!pkt.type.startsWith('messages')) return; + final message = SnChatMessage.fromJson(pkt.data!); + if (message.chatRoomId != chatRoom.value?.id) return; + switch (pkt.type) { + case 'messages.new': + messagesNotifier.receiveMessage(message); + case 'messages.update': + messagesNotifier.receiveMessageUpdate(message); + case 'messages.delete': + messagesNotifier.receiveMessageDeletion(message.id); + } + } + + final subscription = ws.dataStream.listen(onMessage); + return () => subscription.cancel(); + }, [ws, chatRoom]); + + void sendMessage() { + if (messageController.text.trim().isNotEmpty) { + messagesNotifier.sendMessage(messageController.text.trim()); + messageController.clear(); + } + } + return Scaffold( appBar: AppBar( title: chatRoom.when( @@ -235,13 +406,13 @@ class ChatRoomScreen extends HookConsumerWidget { return chatIdentity.when( skipError: true, data: - (identity) => MessageBubble( + (identity) => _MessageBubble( message: message, isCurrentUser: identity?.id == message.senderId, ), loading: - () => MessageBubble( + () => _MessageBubble( message: message, isCurrentUser: false, ), @@ -265,58 +436,15 @@ class ChatRoomScreen extends HookConsumerWidget { ), ), ), - Material( - elevation: 2, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.2), - spreadRadius: 1, - blurRadius: 3, - ), - ], - ), - child: Row( - children: [ - IconButton(icon: const Icon(Icons.add), onPressed: () {}), - Expanded( - child: TextField( - controller: messageController, - decoration: InputDecoration( - hintText: 'chatMessageHint'.tr( - args: [chatRoom.value?.name ?? 'unknown'.tr()], - ), - border: OutlineInputBorder(), - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - maxLines: null, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - ), - const Gap(8), - IconButton( - icon: const Icon(Icons.send), - color: Theme.of(context).colorScheme.primary, - onPressed: () { - if (messageController.text.trim().isNotEmpty) { - messagesNotifier.sendMessage( - messageController.text.trim(), - ); - messageController.clear(); - } - }, - ), - ], - ).padding(bottom: MediaQuery.of(context).padding.bottom), - ), + chatRoom.when( + data: + (room) => _ChatInput( + messageController: messageController, + chatRoom: room!, + onSend: sendMessage, + ), + error: (_, __) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), ), ], ), @@ -324,15 +452,61 @@ class ChatRoomScreen extends HookConsumerWidget { } } -class MessageBubble extends StatelessWidget { +class _ChatInput extends StatelessWidget { + final TextEditingController messageController; + final SnChat chatRoom; + final VoidCallback onSend; + + const _ChatInput({ + required this.messageController, + required this.chatRoom, + required this.onSend, + }); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 8, + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: messageController, + decoration: InputDecoration( + hintText: 'chatMessageHint'.tr(args: [chatRoom.name]), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + ), + maxLines: null, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + onSubmitted: (_) => onSend(), + ), + ), + IconButton( + icon: const Icon(Icons.send), + color: Theme.of(context).colorScheme.primary, + onPressed: onSend, + ), + ], + ).padding(bottom: MediaQuery.of(context).padding.bottom), + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { final LocalChatMessage message; final bool isCurrentUser; - const MessageBubble({ - super.key, - required this.message, - required this.isCurrentUser, - }); + const _MessageBubble({required this.message, required this.isCurrentUser}); @override Widget build(BuildContext context) { @@ -346,7 +520,7 @@ class MessageBubble extends StatelessWidget { ProfilePictureWidget( fileId: message.toRemoteMessage().sender.account.profile.pictureId, - radius: 16, + radius: 18, ), const Gap(8), Flexible( @@ -394,7 +568,7 @@ class MessageBubble extends StatelessWidget { ProfilePictureWidget( fileId: message.toRemoteMessage().sender.account.profile.pictureId, - radius: 16, + radius: 18, ), ], ),