Chat message sending and receiving

This commit is contained in:
LittleSheep 2024-11-17 21:30:02 +08:00
parent 285bb42b09
commit 2065350698
9 changed files with 200 additions and 55 deletions

View File

@ -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 {}"
}

View File

@ -157,5 +157,6 @@
"realmEditingNotice": "您正在编辑领域 {}",
"realmDeleted": "领域 {} 已被删除" ,
"realmDelete": "删除领域 {}",
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!"
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
"fieldChatMessage": "在 {} 中发消息"
}

View File

@ -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<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>();
}
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<SnChatMessage> messages = List.empty(growable: true);
/// Messages are the all the messages that in the channel
final List<SnChatMessage> 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<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box =>
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
Future<void> initialize(SnChannel channel) async {
_channel = channel;
_boxKey = '$kChatMessageBoxPrefix${channel.id}';
Future<void> initialize(SnChannel chan) async {
channel = chan;
// Initialize local data
_boxKey = '$kChatMessageBoxPrefix${chan.id}';
await Hive.openBox<SnChatMessage>(_boxKey!);
// Fetch channel profile
final resp = await _sn.client.get(
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(
resp.data as Map<String, dynamic>,
);
_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<void> _addUnconfirmedMessage(SnChatMessage message) async {
messages.insert(0, message);
unconfirmedMessages.add(message.uuid);
notifyListeners();
}
Future<void> _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<void> _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<void> sendMessage(
String type,
String content, {
@ -66,27 +174,46 @@ class ChatMessageController extends ChangeNotifier {
int? relatedId,
List<String>? 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<String, dynamic>,
);
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();
}
}

View File

@ -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(

View File

@ -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) {

View File

@ -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']!,

View File

@ -75,6 +75,11 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
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<ChatRoomScreen> {
},
itemBuilder: (context, idx) {
final message = _messageController.messages[idx];
return ChatMessage(data: message);
return ChatMessage(
data: message,
isPending: _messageController.unconfirmedMessages
.contains(message.uuid),
);
},
),
),

View File

@ -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);
}
}

View File

@ -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<ChatMessageInput> {
@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<ChatMessageInput> {
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<ChatMessageInput> {
),
),
],
).padding(horizontal: 16, vertical: 12),
],
),
),
).padding(horizontal: 16),
],
);
}
}