✨ Chat message sending and receiving
This commit is contained in:
parent
285bb42b09
commit
2065350698
@ -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 {}"
|
||||
}
|
||||
|
@ -157,5 +157,6 @@
|
||||
"realmEditingNotice": "您正在编辑领域 {}",
|
||||
"realmDeleted": "领域 {} 已被删除" ,
|
||||
"realmDelete": "删除领域 {}",
|
||||
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!"
|
||||
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
|
||||
"fieldChatMessage": "在 {} 中发消息"
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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']!,
|
||||
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user