Chat message sending and receiving

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

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