💄 Optimize chat room

This commit is contained in:
2025-08-17 00:11:28 +08:00
parent 6892afb974
commit d220d43cd2
5 changed files with 638 additions and 522 deletions

View File

@@ -837,5 +837,6 @@
"pollAddOption": "Add option", "pollAddOption": "Add option",
"pollOptionLabel": "Option label", "pollOptionLabel": "Option label",
"pollLongTextAnswerPreview": "Long text answer (preview)", "pollLongTextAnswerPreview": "Long text answer (preview)",
"pollShortTextAnswerPreview": "Short text answer (preview)" "pollShortTextAnswerPreview": "Short text answer (preview)",
"messageJumpNotLoaded": "The referenced message was not loaded, unable to jump to it."
} }

View File

@@ -812,5 +812,6 @@
"filesListAdditional": { "filesListAdditional": {
"one": "+{} 个文件被折叠", "one": "+{} 个文件被折叠",
"other": "+{} 个文件被折叠" "other": "+{} 个文件被折叠"
} },
"messageJumpNotLoaded": "引用的消息没有被加载,无法跳转。"
} }

View File

@@ -1,44 +1,45 @@
import 'dart:async'; import "dart:async";
import 'dart:convert'; import "dart:convert";
import 'dart:developer' as developer; import "dart:developer" as developer;
import 'dart:io'; import "dart:io";
import 'package:dio/dio.dart'; import "package:dio/dio.dart";
import 'package:easy_localization/easy_localization.dart'; import "package:easy_localization/easy_localization.dart";
import 'package:flutter/foundation.dart'; import "package:flutter/foundation.dart";
import 'package:flutter/material.dart'; import "package:flutter/material.dart";
import 'package:go_router/go_router.dart'; import "package:go_router/go_router.dart";
import 'package:flutter/services.dart'; import "package:flutter/services.dart";
import 'package:flutter_hooks/flutter_hooks.dart'; import "package:flutter_hooks/flutter_hooks.dart";
import 'package:gap/gap.dart'; import "package:gap/gap.dart";
import 'package:hooks_riverpod/hooks_riverpod.dart'; import "package:hooks_riverpod/hooks_riverpod.dart";
import 'package:image_picker/image_picker.dart'; import "package:image_picker/image_picker.dart";
import 'package:island/database/drift_db.dart'; import "package:island/database/drift_db.dart";
import 'package:island/database/message.dart'; import "package:island/database/message.dart";
import 'package:island/models/chat.dart'; import "package:island/models/chat.dart";
import 'package:island/models/file.dart'; import "package:island/models/file.dart";
import 'package:island/pods/config.dart'; import "package:island/pods/config.dart";
import 'package:island/pods/database.dart'; import "package:island/pods/database.dart";
import 'package:island/pods/network.dart'; import "package:island/pods/network.dart";
import 'package:island/pods/websocket.dart'; import "package:island/pods/websocket.dart";
import 'package:island/services/file.dart'; import "package:island/services/file.dart";
import 'package:island/services/responsive.dart'; import "package:island/services/responsive.dart";
import 'package:island/widgets/alert.dart'; import "package:island/widgets/alert.dart";
import 'package:island/widgets/app_scaffold.dart'; import "package:island/widgets/app_scaffold.dart";
import 'package:island/widgets/chat/call_overlay.dart'; import "package:island/widgets/chat/call_overlay.dart";
import 'package:island/widgets/chat/message_item.dart'; import "package:island/widgets/chat/message_item.dart";
import 'package:island/widgets/content/attachment_preview.dart'; import "package:island/widgets/content/attachment_preview.dart";
import 'package:island/widgets/content/cloud_files.dart'; import "package:island/widgets/content/cloud_files.dart";
import 'package:island/widgets/response.dart'; import "package:island/widgets/response.dart";
import 'package:material_symbols_icons/material_symbols_icons.dart'; import "package:material_symbols_icons/material_symbols_icons.dart";
import 'package:pasteboard/pasteboard.dart'; import "package:pasteboard/pasteboard.dart";
import 'package:styled_widget/styled_widget.dart'; import "package:styled_widget/styled_widget.dart";
import 'package:super_sliver_list/super_sliver_list.dart'; import "package:super_sliver_list/super_sliver_list.dart";
import 'package:uuid/uuid.dart';
import 'package:material_symbols_icons/symbols.dart'; import "package:uuid/uuid.dart";
import 'package:riverpod_annotation/riverpod_annotation.dart'; import "package:material_symbols_icons/symbols.dart";
import 'chat.dart'; import "package:riverpod_annotation/riverpod_annotation.dart";
import 'package:island/widgets/chat/call_button.dart'; import "chat.dart";
import 'package:island/widgets/stickers/picker.dart'; import "package:island/widgets/chat/call_button.dart";
import "package:island/widgets/stickers/picker.dart";
part 'room.g.dart'; part 'room.g.dart';
@@ -86,6 +87,7 @@ class MessagesNotifier extends _$MessagesNotifier {
int _currentPage = 0; int _currentPage = 0;
static const int _pageSize = 20; static const int _pageSize = 20;
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
@@ -100,11 +102,17 @@ class MessagesNotifier extends _$MessagesNotifier {
_room = room; _room = room;
_identity = identity; _identity = identity;
developer.log('MessagesNotifier built for room $roomId', name: 'MessagesNotifier'); developer.log(
'MessagesNotifier built for room $roomId',
name: 'MessagesNotifier',
);
ref.listen(appLifecycleStateProvider, (_, next) { ref.listen(appLifecycleStateProvider, (_, next) {
if (next.hasValue && next.value == AppLifecycleState.resumed) { if (next.hasValue && next.value == AppLifecycleState.resumed) {
developer.log('App resumed, syncing messages', name: 'MessagesNotifier'); developer.log(
'App resumed, syncing messages',
name: 'MessagesNotifier',
);
syncMessages(); syncMessages();
} }
}); });
@@ -116,7 +124,10 @@ class MessagesNotifier extends _$MessagesNotifier {
int offset = 0, int offset = 0,
int take = 20, int take = 20,
}) async { }) async {
developer.log('Getting cached messages from offset $offset, take $take', name: 'MessagesNotifier'); developer.log(
'Getting cached messages from offset $offset, take $take',
name: 'MessagesNotifier',
);
final dbMessages = await _database.getMessagesForRoom( final dbMessages = await _database.getMessagesForRoom(
_roomId, _roomId,
offset: offset, offset: offset,
@@ -127,7 +138,9 @@ class MessagesNotifier extends _$MessagesNotifier {
if (offset == 0) { if (offset == 0) {
final pendingForRoom = final pendingForRoom =
_pendingMessages.values.where((msg) => msg.roomId == _roomId).toList(); _pendingMessages.values
.where((msg) => msg.roomId == _roomId)
.toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages]; final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -149,7 +162,10 @@ class MessagesNotifier extends _$MessagesNotifier {
int offset = 0, int offset = 0,
int take = 20, int take = 20,
}) async { }) async {
developer.log('Fetching messages from API, offset $offset, take $take', name: 'MessagesNotifier'); developer.log(
'Fetching messages from API, offset $offset, take $take',
name: 'MessagesNotifier',
);
if (_totalCount == null) { if (_totalCount == null) {
final response = await _apiClient.get( final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages', '/sphere/chat/$_roomId/messages',
@@ -170,7 +186,8 @@ class MessagesNotifier extends _$MessagesNotifier {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0'); _totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
final messages = data.map((json) { final messages =
data.map((json) {
final remoteMessage = SnChatMessage.fromJson(json); final remoteMessage = SnChatMessage.fromJson(json);
return LocalChatMessage.fromRemoteMessage( return LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
@@ -191,6 +208,15 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> syncMessages() async { Future<void> syncMessages() async {
if (_isSyncing) {
developer.log(
'Sync already in progress, skipping.',
name: 'MessagesNotifier',
);
return;
}
_isSyncing = true;
developer.log('Starting message sync', name: 'MessagesNotifier'); developer.log('Starting message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = true; ref.read(isSyncingProvider.notifier).state = true;
try { try {
@@ -200,11 +226,19 @@ class MessagesNotifier extends _$MessagesNotifier {
limit: 1, limit: 1,
); );
final lastMessage = final lastMessage =
dbMessages.isEmpty ? null : _database.companionToMessage(dbMessages.first); dbMessages.isEmpty
? null
: _database.companionToMessage(dbMessages.first);
if (lastMessage == null) { if (lastMessage == null) {
developer.log('No local messages, fetching from network', name: 'MessagesNotifier'); developer.log(
final newMessages = await _fetchAndCacheMessages(offset: 0, take: _pageSize); 'No local messages, fetching from network',
name: 'MessagesNotifier',
);
final newMessages = await _fetchAndCacheMessages(
offset: 0,
take: _pageSize,
);
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
return; return;
} }
@@ -218,7 +252,10 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
final response = MessageSyncResponse.fromJson(resp.data); final response = MessageSyncResponse.fromJson(resp.data);
developer.log('Sync response: ${response.changes.length} changes', name: 'MessagesNotifier'); developer.log(
'Sync response: ${response.changes.length} changes',
name: 'MessagesNotifier',
);
for (final change in response.changes) { for (final change in response.changes) {
switch (change.action) { switch (change.action) {
case MessageChangeAction.create: case MessageChangeAction.create:
@@ -233,11 +270,17 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log('Error syncing messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); developer.log(
'Error syncing messages',
name: 'MessagesNotifier',
error: err,
stackTrace: stackTrace,
);
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
developer.log('Finished message sync', name: 'MessagesNotifier'); developer.log('Finished message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = false; ref.read(isSyncingProvider.notifier).state = false;
_isSyncing = false;
} }
} }
@@ -279,7 +322,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<List<LocalChatMessage>> loadInitial() async { Future<List<LocalChatMessage>> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier'); developer.log('Loading initial messages', name: 'MessagesNotifier');
syncMessages(); syncMessages();
final messages = await _getCachedMessages(offset: 0, take: _pageSize); final messages = await _getCachedMessages(offset: 0, take: 100);
_currentPage = 0; _currentPage = 0;
_hasMore = messages.length == _pageSize; _hasMore = messages.length == _pageSize;
return messages; return messages;
@@ -303,7 +346,12 @@ class MessagesNotifier extends _$MessagesNotifier {
state = AsyncValue.data([...currentMessages, ...newMessages]); state = AsyncValue.data([...currentMessages, ...newMessages]);
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log('Error loading more messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); developer.log(
'Error loading more messages',
name: 'MessagesNotifier',
error: err,
stackTrace: stackTrace,
);
showErrorAlert(err); showErrorAlert(err);
_currentPage--; _currentPage--;
} }
@@ -318,7 +366,10 @@ class MessagesNotifier extends _$MessagesNotifier {
Function(String, Map<int, double>)? onProgress, Function(String, Map<int, double>)? onProgress,
}) async { }) async {
final nonce = const Uuid().v4(); final nonce = const Uuid().v4();
developer.log('Sending message with nonce $nonce', name: 'MessagesNotifier'); developer.log(
'Sending message with nonce $nonce',
name: 'MessagesNotifier',
);
final baseUrl = ref.read(serverUrlProvider); final baseUrl = ref.read(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider)); final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null'); if (token == null) throw ArgumentError('Access token is null');
@@ -349,12 +400,14 @@ class MessagesNotifier extends _$MessagesNotifier {
try { try {
var cloudAttachments = List.empty(growable: true); var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) { for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = await putMediaToCloud( final cloudFile =
await putMediaToCloud(
fileData: attachments[idx], fileData: attachments[idx],
atk: token, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: attachments[idx].data.name ?? 'Post media', filename: attachments[idx].data.name ?? 'Post media',
mimetype: attachments[idx].data.mimeType ?? mimetype:
attachments[idx].data.mimeType ??
switch (attachments[idx].type) { switch (attachments[idx].type) {
UniversalFileType.image => 'image/unknown', UniversalFileType.image => 'image/unknown',
UniversalFileType.video => 'video/unknown', UniversalFileType.video => 'video/unknown',
@@ -400,23 +453,33 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.deleteMessage(localMessage.id); await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) { final newMessages =
(state.value ?? []).map((m) {
if (m.id == localMessage.id) { if (m.id == localMessage.id) {
return updatedMessage; return updatedMessage;
} }
return m; return m;
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
developer.log('Message with nonce $nonce sent successfully', name: 'MessagesNotifier'); developer.log(
'Message with nonce $nonce sent successfully',
name: 'MessagesNotifier',
);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log('Failed to send message with nonce $nonce', name: 'MessagesNotifier', error: e, stackTrace: stackTrace); developer.log(
'Failed to send message with nonce $nonce',
name: 'MessagesNotifier',
error: e,
stackTrace: stackTrace,
);
localMessage.status = MessageStatus.failed; localMessage.status = MessageStatus.failed;
_pendingMessages[localMessage.id] = localMessage; _pendingMessages[localMessage.id] = localMessage;
await _database.updateMessageStatus( await _database.updateMessageStatus(
localMessage.id, localMessage.id,
MessageStatus.failed, MessageStatus.failed,
); );
final newMessages = (state.value ?? []).map((m) { final newMessages =
(state.value ?? []).map((m) {
if (m.id == localMessage.id) { if (m.id == localMessage.id) {
return m..status = MessageStatus.failed; return m..status = MessageStatus.failed;
} }
@@ -428,7 +491,10 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> retryMessage(String pendingMessageId) async { Future<void> retryMessage(String pendingMessageId) async {
developer.log('Retrying message $pendingMessageId', name: 'MessagesNotifier'); developer.log(
'Retrying message $pendingMessageId',
name: 'MessagesNotifier',
);
final message = await fetchMessageById(pendingMessageId); final message = await fetchMessageById(pendingMessageId);
if (message == null) { if (message == null) {
throw Exception('Message not found'); throw Exception('Message not found');
@@ -463,7 +529,8 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.deleteMessage(pendingMessageId); await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) { final newMessages =
(state.value ?? []).map((m) {
if (m.id == pendingMessageId) { if (m.id == pendingMessageId) {
return updatedMessage; return updatedMessage;
} }
@@ -471,14 +538,20 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log('Failed to retry message $pendingMessageId', name: 'MessagesNotifier', error: e, stackTrace: stackTrace); developer.log(
'Failed to retry message $pendingMessageId',
name: 'MessagesNotifier',
error: e,
stackTrace: stackTrace,
);
message.status = MessageStatus.failed; message.status = MessageStatus.failed;
_pendingMessages[pendingMessageId] = message; _pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus( await _database.updateMessageStatus(
pendingMessageId, pendingMessageId,
MessageStatus.failed, MessageStatus.failed,
); );
final newMessages = (state.value ?? []).map((m) { final newMessages =
(state.value ?? []).map((m) {
if (m.id == pendingMessageId) { if (m.id == pendingMessageId) {
return m..status = MessageStatus.failed; return m..status = MessageStatus.failed;
} }
@@ -491,7 +564,10 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> receiveMessage(SnChatMessage remoteMessage) async { Future<void> receiveMessage(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log('Received new message ${remoteMessage.id}', name: 'MessagesNotifier'); developer.log(
'Received new message ${remoteMessage.id}',
name: 'MessagesNotifier',
);
final localMessage = LocalChatMessage.fromRemoteMessage( final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
@@ -524,7 +600,10 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log('Received message update ${remoteMessage.id}', name: 'MessagesNotifier'); developer.log(
'Received message update ${remoteMessage.id}',
name: 'MessagesNotifier',
);
final updatedMessage = LocalChatMessage.fromRemoteMessage( final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
@@ -533,9 +612,7 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.updateMessage(_database.messageToCompanion(updatedMessage)); await _database.updateMessage(_database.messageToCompanion(updatedMessage));
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere( final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
(m) => m.id == updatedMessage.id,
);
if (index >= 0) { if (index >= 0) {
final newList = [...currentMessages]; final newList = [...currentMessages];
@@ -545,7 +622,10 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> receiveMessageDeletion(String messageId) async { Future<void> receiveMessageDeletion(String messageId) async {
developer.log('Received message deletion $messageId', name: 'MessagesNotifier'); developer.log(
'Received message deletion $messageId',
name: 'MessagesNotifier',
);
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
await _database.deleteMessage(messageId); await _database.deleteMessage(messageId);
@@ -564,17 +644,25 @@ class MessagesNotifier extends _$MessagesNotifier {
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
await receiveMessageDeletion(messageId); await receiveMessageDeletion(messageId);
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log('Error deleting message $messageId', name: 'MessagesNotifier', error: err, stackTrace: stackTrace); developer.log(
'Error deleting message $messageId',
name: 'MessagesNotifier',
error: err,
stackTrace: stackTrace,
);
showErrorAlert(err); showErrorAlert(err);
} }
} }
Future<LocalChatMessage?> fetchMessageById(String messageId) async { Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log('Fetching message by id $messageId', name: 'MessagesNotifier'); developer.log(
'Fetching message by id $messageId',
name: 'MessagesNotifier',
);
try { try {
final localMessage = await (_database.select(_database.chatMessages) final localMessage =
..where((tbl) => tbl.id.equals(messageId))) await (_database.select(_database.chatMessages)
.getSingleOrNull(); ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) { if (localMessage != null) {
return _database.companionToMessage(localMessage); return _database.companionToMessage(localMessage);
} }
@@ -617,7 +705,8 @@ class ChatRoomScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
appBar: AppBar(leading: const PageBackButton()), appBar: AppBar(leading: const PageBackButton()),
body: Center( body: Center(
child: ConstrainedBox( child:
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280), constraints: const BoxConstraints(maxWidth: 280),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -726,8 +815,10 @@ class ChatRoomScreen extends HookConsumerWidget {
if (typingStatuses.value.isNotEmpty) { if (typingStatuses.value.isNotEmpty) {
// Remove typing statuses older than 5 seconds // Remove typing statuses older than 5 seconds
final now = DateTime.now(); final now = DateTime.now();
typingStatuses.value = typingStatuses.value.where((member) { typingStatuses.value =
final lastTyped = member.lastTyped ?? typingStatuses.value.where((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350)); DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds < 5; return now.difference(lastTyped).inSeconds < 5;
}).toList(); }).toList();
@@ -895,48 +986,7 @@ class ChatRoomScreen extends HookConsumerWidget {
final listController = useMemoized(() => ListController(), []); final listController = useMemoized(() => ListController(), []);
return AppScaffold( Widget comfortHeaderWidget(SnChatRoom? room) => Column(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64,
title: chatRoom.when(
data: (room) => compactHeader
? Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 26,
width: 26,
child: (room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget(
filesId: room.members!
.map(
(e) => e.account.profile.picture?.id,
)
.toList(),
)
: room.picture?.id != null
? ProfilePictureWidget(
fileId: room.picture?.id,
fallbackIcon: Symbols.chat,
)
: CircleAvatar(
child: Text(
room.name![0].toUpperCase(),
style: const TextStyle(fontSize: 12),
),
),
),
Text(
(room.type == 1 && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(19),
],
)
: Column(
spacing: 4, spacing: 4,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -944,12 +994,12 @@ class ChatRoomScreen extends HookConsumerWidget {
SizedBox( SizedBox(
height: 26, height: 26,
width: 26, width: 26,
child: (room!.type == 1 && room.picture?.id == null) child:
(room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget( ? SplitAvatarWidget(
filesId: room.members! filesId:
.map( room.members!
(e) => e.account.profile.picture?.id, .map((e) => e.account.profile.picture?.id)
)
.toList(), .toList(),
) )
: room.picture?.id != null : room.picture?.id != null
@@ -970,9 +1020,142 @@ class ChatRoomScreen extends HookConsumerWidget {
: room.name!, : room.name!,
).fontSize(15), ).fontSize(15),
], ],
);
Widget compactHeaderWidget(SnChatRoom? room) => Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 26,
width: 26,
child:
(room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget(
filesId:
room.members!
.map((e) => e.account.profile.picture?.id)
.toList(),
)
: room.picture?.id != null
? ProfilePictureWidget(
fileId: room.picture?.id,
fallbackIcon: Symbols.chat,
)
: CircleAvatar(
child: Text(
room.name![0].toUpperCase(),
style: const TextStyle(fontSize: 12),
), ),
),
),
Text(
(room.type == 1 && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(19),
],
);
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
SuperListView.builder(
listController: listController,
padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
final valueKey = key as ValueKey;
final messageId = valueKey.value as String;
return messageList.indexWhere((m) => m.id == messageId);
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
return chatIdentity.when(
skipError: true,
data:
(identity) => MessageItem(
key: ValueKey(message.id),
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage();
}
},
onJump: (messageId) {
final messageIndex = messageList.indexWhere(
(m) => m.id == messageId,
);
if (messageIndex == -1) {
showSnackBar('messageJumpNotLoaded'.tr());
return;
}
listController.animateToItem(
index: messageIndex,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) => Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
},
);
return AppScaffold(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64,
title: chatRoom.when(
data:
(room) =>
compactHeader
? compactHeaderWidget(room)
: comfortHeaderWidget(room),
loading: () => const Text('Loading...'), loading: () => const Text('Loading...'),
error: (err, _) => ResponseErrorWidget( error:
(err, _) => ResponseErrorWidget(
error: err, error: err,
onRetry: () => messagesNotifier.loadInitial(), onRetry: () => messagesNotifier.loadInitial(),
), ),
@@ -987,10 +1170,13 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
const Gap(8), const Gap(8),
], ],
bottom: isSyncing bottom:
isSyncing
? const PreferredSize( ? const PreferredSize(
preferredSize: Size.fromHeight(4.0), preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(), child: LinearProgressIndicator(
borderRadius: BorderRadius.zero,
),
) )
: null, : null,
), ),
@@ -1000,102 +1186,23 @@ class ChatRoomScreen extends HookConsumerWidget {
children: [ children: [
Expanded( Expanded(
child: messages.when( child: messages.when(
data: (messageList) => messageList.isEmpty data:
(messageList) =>
messageList.isEmpty
? Center(child: Text('No messages yet'.tr())) ? Center(child: Text('No messages yet'.tr()))
: SuperListView.builder( : chatMessageListWidget(messageList),
listController: listController, loading:
padding: EdgeInsets.symmetric(vertical: 16), () => const Center(child: CircularProgressIndicator()),
controller: scrollController, error:
reverse: true, // Show newest messages at the bottom (error, _) => ResponseErrorWidget(
itemCount: messageList.length,
findChildIndexCallback: (key) {
final valueKey = key as ValueKey;
final messageId = valueKey.value as String;
return messageList.indexWhere(
(m) => m.id == messageId,
);
},
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage = index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup = nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
return chatIdentity.when(
skipError: true,
data: (identity) => MessageItem(
key: ValueKey(message.id),
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
attachments.value = messageEditingTo
.value!.attachments
.map(
(e) => UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
}
},
onJump: (messageId) {
final messageIndex = messageList.indexWhere(
(m) => m.id == messageId,
);
listController.jumpToItem(
index: messageIndex,
scrollController:
scrollController,
alignment: 0.5,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
),
loading: () => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error, error: error,
onRetry: () => messagesNotifier.loadInitial(), onRetry: () => messagesNotifier.loadInitial(),
), ),
), ),
), ),
chatRoom.when( chatRoom.when(
data: (room) => Column( data:
(room) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
AnimatedSwitcher( AnimatedSwitcher(
@@ -1126,7 +1233,8 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
); );
}, },
child: typingStatuses.value.isNotEmpty child:
typingStatuses.value.isNotEmpty
? Container( ? Container(
key: const ValueKey('typing-indicator'), key: const ValueKey('typing-indicator'),
width: double.infinity, width: double.infinity,
@@ -1149,12 +1257,14 @@ class ChatRoomScreen extends HookConsumerWidget {
typingStatuses.value typingStatuses.value
.map( .map(
(x) => (x) =>
x.nick ?? x.account.nick, x.nick ??
x.account.nick,
) )
.join(', '), .join(', '),
], ],
), ),
style: Theme.of( style:
Theme.of(
context, context,
).textTheme.bodySmall, ).textTheme.bodySmall,
), ),
@@ -1423,11 +1533,14 @@ class _ChatInput extends HookConsumerWidget {
// Insert placeholder at current cursor position // Insert placeholder at current cursor position
final text = messageController.text; final text = messageController.text;
final selection = messageController.selection; final selection = messageController.selection;
final start = selection.start >= 0 final start =
selection.start >= 0
? selection.start ? selection.start
: text.length; : text.length;
final end = final end =
selection.end >= 0 ? selection.end : text.length; selection.end >= 0
? selection.end
: text.length;
final newText = text.replaceRange( final newText = text.replaceRange(
start, start,
end, end,
@@ -1445,7 +1558,8 @@ class _ChatInput extends HookConsumerWidget {
), ),
PopupMenuButton( PopupMenuButton(
icon: const Icon(Symbols.photo_library), icon: const Icon(Symbols.photo_library),
itemBuilder: (context) => [ itemBuilder:
(context) => [
PopupMenuItem( PopupMenuItem(
onTap: () => onPickFile(true), onTap: () => onPickFile(true),
child: Row( child: Row(
@@ -1516,8 +1630,8 @@ class _ChatInput extends HookConsumerWidget {
), ),
), ),
maxLines: null, maxLines: null,
onTapOutside: (_) => onTapOutside:
FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
), ),

View File

@@ -1233,50 +1233,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.2.0"
image_picker_android: image_picker_android:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker_android name: image_picker_android
sha256: b08e9a04d0f8d91f4a6e767a745b9871bfbc585410205c311d0492de20a7ccd6 sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+25" version: "0.8.13"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
name: image_picker_for_web name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.1.0"
image_picker_ios: image_picker_ios:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+2" version: "0.8.13"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
name: image_picker_linux name: image_picker_linux
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1+2" version: "0.2.2"
image_picker_macos: image_picker_macos:
dependency: transitive dependency: transitive
description: description:
name: image_picker_macos name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1+2" version: "0.2.2"
image_picker_platform_interface: image_picker_platform_interface:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1289,10 +1289,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_windows name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1+1" version: "0.2.2"
intl: intl:
dependency: transitive dependency: transitive
description: description:

View File

@@ -72,11 +72,11 @@ dependencies:
tus_client_dart: tus_client_dart:
git: https://github.com/LittleSheep2Code/tus_client.git git: https://github.com/LittleSheep2Code/tus_client.git
cross_file: ^0.3.4+2 cross_file: ^0.3.4+2
image_picker: ^1.1.2 image_picker: ^1.2.0
file_picker: ^10.3.1 file_picker: ^10.3.1
riverpod_annotation: ^2.6.1 riverpod_annotation: ^2.6.1
image_picker_platform_interface: ^2.11.0 image_picker_platform_interface: ^2.11.0
image_picker_android: ^0.8.12+25 image_picker_android: ^0.8.13
super_context_menu: ^0.9.1 super_context_menu: ^0.9.1
modal_bottom_sheet: ^3.0.0 modal_bottom_sheet: ^3.0.0
firebase_messaging: ^16.0.0 firebase_messaging: ^16.0.0