From 2065350698ccf1ab957d930e4b2cc5cca06d22c4 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 17 Nov 2024 21:30:02 +0800 Subject: [PATCH] :sparkles: Chat message sending and receiving --- assets/translations/en.json | 3 +- assets/translations/zh.json | 3 +- lib/controllers/chat_message_controller.dart | 190 ++++++++++++++++--- lib/providers/sn_network.dart | 18 +- lib/providers/websocket.dart | 1 + lib/router.dart | 1 + lib/screens/chat/room.dart | 11 +- lib/widgets/chat/chat_message.dart | 5 +- lib/widgets/chat/chat_message_input.dart | 23 ++- 9 files changed, 200 insertions(+), 55 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index d2eb612..9ba725b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -157,5 +157,6 @@ "realmEditingNotice": "You are editing realm {}", "realmDeleted": "Realm {} has been deleted.", "realmDelete": "Delete realm {}", - "realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!" + "realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!", + "fieldChatMessage": "Message in {}" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 16565f1..85e69e9 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -157,5 +157,6 @@ "realmEditingNotice": "您正在编辑领域 {}", "realmDeleted": "领域 {} 已被删除" , "realmDelete": "删除领域 {}", - "realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!" + "realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!", + "fieldChatMessage": "在 {} 中发消息" } diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 94e64d1..60984a3 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:hive/hive.dart'; import 'package:provider/provider.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/user_directory.dart'; +import 'package:surface/providers/websocket.dart'; import 'package:surface/types/chat.dart'; import 'package:uuid/uuid.dart'; @@ -14,10 +16,14 @@ class ChatMessageController extends ChangeNotifier { late final SnNetworkProvider _sn; late final UserDirectoryProvider _ud; + late final WebSocketProvider _ws; + + StreamSubscription? _wsSubscription; ChatMessageController(BuildContext context) { _sn = context.read(); _ud = context.read(); + _ws = context.read(); } bool isPending = true; @@ -29,17 +35,75 @@ class ChatMessageController extends ChangeNotifier { messageTotal != null && messages.length >= messageTotal!; String? _boxKey; - SnChannel? _channel; + SnChannel? channel; + SnChannelMember? profile; - List messages = List.empty(growable: true); + /// Messages are the all the messages that in the channel + final List messages = List.empty(growable: true); + + /// Unconfirmed messages are the messages that sent by client but did not receive the reply from websocket server. + /// Stored as a list of nonce to provide the loading state + final List unconfirmedMessages = List.empty(growable: true); Box? get _box => (_boxKey == null || isPending) ? null : Hive.box(_boxKey!); - Future initialize(SnChannel channel) async { - _channel = channel; - _boxKey = '$kChatMessageBoxPrefix${channel.id}'; + Future initialize(SnChannel chan) async { + channel = chan; + + // Initialize local data + _boxKey = '$kChatMessageBoxPrefix${chan.id}'; await Hive.openBox(_boxKey!); + + // Fetch channel profile + final resp = await _sn.client.get( + '/cgi/im/channels/${chan.keyPath}/me', + ); + profile = SnChannelMember.fromJson( + resp.data as Map, + ); + + _wsSubscription = _ws.stream.stream.listen((event) { + switch (event.method) { + case 'events.new': + final payload = SnChatMessage.fromJson(event.payload!); + _addMessage(payload); + break; + case 'calls.new': + final payload = SnChatMessage.fromJson(event.payload!); + if (payload.channel.id == channel?.id) { + // TODO impl call + } + break; + case 'calls.end': + final payload = SnChatMessage.fromJson(event.payload!); + if (payload.channel.id == channel?.id) { + // TODO impl call + } + break; + case 'status.typing': + if (event.payload?['channel_id'] != channel?.id) break; + final member = SnChannelMember.fromJson(event.payload!['member']); + if (member.id == profile?.id) break; + // TODO impl typing users + // if (!_typingUsers.any((x) => x.id == member.id)) { + // setState(() { + // _typingUsers.add(member); + // }); + // } + // _typingInactiveTimer[member.id]?.cancel(); + // _typingInactiveTimer[member.id] = Timer( + // const Duration(seconds: 3), + // () { + // setState(() { + // _typingUsers.removeWhere((x) => x.id == member.id); + // _typingInactiveTimer.remove(member.id); + // }); + // }, + // ); + } + }); + isPending = false; notifyListeners(); } @@ -51,14 +115,58 @@ class ChatMessageController extends ChangeNotifier { }); } + Future _addUnconfirmedMessage(SnChatMessage message) async { + messages.insert(0, message); + unconfirmedMessages.add(message.uuid); + notifyListeners(); + } + Future _addMessage(SnChatMessage message) async { - messages.add(message); + final idx = messages.indexWhere((e) => e.uuid == message.uuid); + if (idx != -1) { + unconfirmedMessages.remove(message.uuid); + messages[idx] = message; + } else { + messages.insert(0, message); + } + await _applyMessage(message); notifyListeners(); if (_box == null) return; await _box!.put(message.id, message); } + Future _applyMessage(SnChatMessage message) async { + if (message.channelId != channel?.id) return; + + switch (message.type) { + case 'messages.edit': + final body = message.body; + if (body['related_event'] != null) { + final idx = messages.indexWhere((x) => x.id == body['related_event']); + if (idx != -1) { + final newBody = message.body; + newBody.remove('related_event'); + messages[idx] = messages[idx].copyWith( + body: newBody, + updatedAt: message.updatedAt, + ); + if (_box!.containsKey(body['related_event'])) { + await _box!.put(body['related_event'], messages[idx]); + } + } + } + case 'messages.delete': + final body = message.body; + if (body['related_event'] != null) { + messages.removeWhere((x) => x.id == body['related_event']); + if (_box!.containsKey(body['related_event'])) { + await _box!.delete(body['related_event']); + } + } + } + } + Future sendMessage( String type, String content, { @@ -66,27 +174,46 @@ class ChatMessageController extends ChangeNotifier { int? relatedId, List? attachments, }) async { - if (_channel == null) return; + if (channel == null) return; const uuid = Uuid(); final nonce = uuid.v4(); - final resp = await _sn.client.post( - '/cgi/im/channels/${_channel!.keyPath}/messages', - data: { - 'type': type, - 'uuid': nonce, - 'body': { - 'text': content, - 'algorithm': 'plain', - if (quoteId != null) 'quote_id': quoteId, - if (relatedId != null) 'related_id': relatedId, - if (attachments != null) 'attachments': attachments, + final body = { + 'text': content, + 'algorithm': 'plain', + if (quoteId != null) 'quote_id': quoteId, + if (relatedId != null) 'related_id': relatedId, + if (attachments != null) 'attachments': attachments, + }; + + // Mock the message locally + final message = SnChatMessage( + id: 0, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + uuid: nonce, + body: body, + type: type, + channel: channel!, + channelId: channel!.id, + sender: profile!, + senderId: profile!.id, + ); + _addUnconfirmedMessage(message); + + // Send to server + try { + await _sn.client.post( + '/cgi/im/channels/${channel!.keyPath}/messages', + data: { + 'type': type, + 'uuid': nonce, + 'body': body, }, - }, - ); - final out = SnChatMessage.fromJson( - resp.data['data'] as Map, - ); - await _addMessage(out); + ); + } catch (err) { + print(err); + } } /// Check the local storage is up to date with the server. @@ -100,15 +227,12 @@ class ChatMessageController extends ChangeNotifier { try { final resp = await _sn.client.get( - '/cgi/im/channels/${_channel!.keyPath}/events/update', + '/cgi/im/channels/${channel!.keyPath}/events/update', queryParameters: { 'pivot': _box!.values.last.id, }, ); - if (resp.data['up_to_date'] == true) { - await loadMessages(); - return; - } + if (resp.data['up_to_date'] == true) return; // Only preload the first 100 messages to prevent first time check update cause load to server and waste local storage. // FIXME If the local is missing more than 100 messages, it won't be fetched, this is a problem, we need to fix it. final countToFetch = math.min(resp.data['count'] as int, 100); @@ -119,6 +243,7 @@ class ChatMessageController extends ChangeNotifier { } catch (err) { rethrow; } finally { + await loadMessages(); isLoading = false; notifyListeners(); } @@ -140,7 +265,7 @@ class ChatMessageController extends ChangeNotifier { } final resp = await _sn.client.get( - '/cgi/im/channels/${_channel!.keyPath}/events', + '/cgi/im/channels/${channel!.keyPath}/events', queryParameters: { 'take': take, 'offset': offset, @@ -177,7 +302,10 @@ class ChatMessageController extends ChangeNotifier { } } - close() { + @override + void dispose() { _box?.close(); + _wsSubscription?.cancel(); + super.dispose(); } } diff --git a/lib/providers/sn_network.dart b/lib/providers/sn_network.dart index bcdea9e..4a127b4 100644 --- a/lib/providers/sn_network.dart +++ b/lib/providers/sn_network.dart @@ -28,15 +28,15 @@ class SnNetworkProvider { SnNetworkProvider() { client = Dio(); - client.interceptors.add(RetryInterceptor( - dio: client, - retries: 3, - retryDelays: const [ - Duration(milliseconds: 300), - Duration(milliseconds: 1000), - Duration(milliseconds: 3000), - ], - )); + // client.interceptors.add(RetryInterceptor( + // dio: client, + // retries: 3, + // retryDelays: const [ + // Duration(milliseconds: 300), + // Duration(milliseconds: 1000), + // Duration(milliseconds: 3000), + // ], + // )); client.interceptors.add( InterceptorsWrapper( diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart index 03a336b..3421983 100644 --- a/lib/providers/websocket.dart +++ b/lib/providers/websocket.dart @@ -52,6 +52,7 @@ class WebSocketProvider extends ChangeNotifier { try { conn = WebSocketChannel.connect(uri); await conn!.ready; + listen(); log('[WebSocket] Connected to server!'); isConnected = true; } catch (err) { diff --git a/lib/router.dart b/lib/router.dart index 92bf3a9..f5d0611 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -107,6 +107,7 @@ final _appRoutes = [ path: '/chat/:scope/:alias', name: 'chatRoom', builder: (context, state) => AppBackground( + isLessOptimization: true, child: ChatRoomScreen( scope: state.pathParameters['scope']!, alias: state.pathParameters['alias']!, diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 4d52423..4218377 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -75,6 +75,11 @@ class _ChatRoomScreenState extends State { if (!_messageController.isPending) Expanded( child: InfiniteList( + reverse: true, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), hasReachedMax: _messageController.isAllLoaded, itemCount: _messageController.messages.length, isLoading: _messageController.isLoading, @@ -83,7 +88,11 @@ class _ChatRoomScreenState extends State { }, itemBuilder: (context, idx) { final message = _messageController.messages[idx]; - return ChatMessage(data: message); + return ChatMessage( + data: message, + isPending: _messageController.unconfirmedMessages + .contains(message.uuid), + ); }, ), ), diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index 9991d54..148d270 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -9,7 +9,8 @@ import 'package:surface/widgets/markdown_content.dart'; class ChatMessage extends StatelessWidget { final SnChatMessage data; - const ChatMessage({super.key, required this.data}); + final bool isPending; + const ChatMessage({super.key, required this.data, this.isPending = false}); @override Widget build(BuildContext context) { @@ -38,6 +39,6 @@ class ChatMessage extends StatelessWidget { ), ) ], - ); + ).opacity(isPending ? 0.5 : 1); } } diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 39808ed..987ea3a 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -33,12 +34,12 @@ class _ChatMessageInputState extends State { @override Widget build(BuildContext context) { - return SizedBox( - height: 72, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 56, + child: Row( children: [ Expanded( child: TextField( @@ -46,7 +47,9 @@ class _ChatMessageInputState extends State { controller: _contentController, decoration: InputDecoration( isCollapsed: true, - hintText: 'Type a message...', + hintText: 'fieldChatMessage'.tr(args: [ + widget.controller.channel?.name ?? 'loading'.tr() + ]), border: InputBorder.none, ), onSubmitted: (_) { @@ -68,9 +71,9 @@ class _ChatMessageInputState extends State { ), ), ], - ).padding(horizontal: 16, vertical: 12), - ], - ), + ), + ).padding(horizontal: 16), + ], ); } }