✨ Search and jump to message
This commit is contained in:
852
lib/pods/messages_notifier.dart
Normal file
852
lib/pods/messages_notifier.dart
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
import "dart:async";
|
||||||
|
import "dart:developer" as developer;
|
||||||
|
import "package:dio/dio.dart";
|
||||||
|
import "package:drift/drift.dart" show Variable;
|
||||||
|
import "package:easy_localization/easy_localization.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:island/database/drift_db.dart";
|
||||||
|
import "package:island/database/message.dart";
|
||||||
|
import "package:island/models/chat.dart";
|
||||||
|
import "package:island/models/file.dart";
|
||||||
|
import "package:island/pods/config.dart";
|
||||||
|
import "package:island/pods/database.dart";
|
||||||
|
import "package:island/pods/network.dart";
|
||||||
|
import "package:island/services/file.dart";
|
||||||
|
import "package:island/widgets/alert.dart";
|
||||||
|
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||||
|
import "package:uuid/uuid.dart";
|
||||||
|
import "package:island/screens/chat/chat.dart";
|
||||||
|
import "package:island/pods/room_providers.dart";
|
||||||
|
|
||||||
|
part 'messages_notifier.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class MessagesNotifier extends _$MessagesNotifier {
|
||||||
|
late final Dio _apiClient;
|
||||||
|
late final AppDatabase _database;
|
||||||
|
late final SnChatRoom _room;
|
||||||
|
late final SnChatMember _identity;
|
||||||
|
|
||||||
|
final Map<String, LocalChatMessage> _pendingMessages = {};
|
||||||
|
final Map<String, Map<int, double>> _fileUploadProgress = {};
|
||||||
|
int? _totalCount;
|
||||||
|
String? _searchQuery;
|
||||||
|
bool? _withLinks;
|
||||||
|
bool? _withAttachments;
|
||||||
|
|
||||||
|
late final String _roomId;
|
||||||
|
static const int _pageSize = 20;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _isSyncing = false;
|
||||||
|
bool _isJumping = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||||
|
_roomId = roomId;
|
||||||
|
_apiClient = ref.watch(apiClientProvider);
|
||||||
|
_database = ref.watch(databaseProvider);
|
||||||
|
final room = await ref.watch(chatroomProvider(roomId).future);
|
||||||
|
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
||||||
|
|
||||||
|
if (room == null) {
|
||||||
|
throw Exception('Room not found');
|
||||||
|
}
|
||||||
|
_room = room;
|
||||||
|
|
||||||
|
// Allow building even if identity is null for public rooms
|
||||||
|
if (identity != null) {
|
||||||
|
_identity = identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log(
|
||||||
|
'MessagesNotifier built for room $roomId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only setup sync and lifecycle listeners if user is a member
|
||||||
|
if (identity != null) {
|
||||||
|
ref.listen(appLifecycleStateProvider, (_, next) {
|
||||||
|
if (next.hasValue && next.value == AppLifecycleState.resumed) {
|
||||||
|
developer.log(
|
||||||
|
'App resumed, syncing messages',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
syncMessages();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitial();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocalChatMessage> _sortMessages(List<LocalChatMessage> messages) {
|
||||||
|
messages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LocalChatMessage>> _getCachedMessages({
|
||||||
|
int offset = 0,
|
||||||
|
int take = 20,
|
||||||
|
}) async {
|
||||||
|
developer.log(
|
||||||
|
'Getting cached messages from offset $offset, take $take',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
final List<LocalChatMessage> dbMessages;
|
||||||
|
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
|
||||||
|
dbMessages = await _database.searchMessages(
|
||||||
|
_roomId,
|
||||||
|
_searchQuery ?? '',
|
||||||
|
withAttachments: _withAttachments,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||||
|
_roomId,
|
||||||
|
offset: offset,
|
||||||
|
limit: take,
|
||||||
|
);
|
||||||
|
dbMessages =
|
||||||
|
chatMessagesFromDb.map(_database.companionToMessage).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LocalChatMessage> filteredMessages = dbMessages;
|
||||||
|
|
||||||
|
if (_withLinks == true) {
|
||||||
|
filteredMessages =
|
||||||
|
filteredMessages.where((msg) => _hasLink(msg)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbLocalMessages = filteredMessages;
|
||||||
|
|
||||||
|
// Always ensure unique messages to prevent duplicate keys
|
||||||
|
final uniqueMessages = <LocalChatMessage>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
for (final message in dbLocalMessages) {
|
||||||
|
if (seenIds.add(message.id)) {
|
||||||
|
uniqueMessages.add(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset == 0) {
|
||||||
|
final pendingForRoom =
|
||||||
|
_pendingMessages.values
|
||||||
|
.where((msg) => msg.roomId == _roomId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final allMessages = [...pendingForRoom, ...uniqueMessages];
|
||||||
|
_sortMessages(allMessages); // Use the helper function
|
||||||
|
|
||||||
|
final finalUniqueMessages = <LocalChatMessage>[];
|
||||||
|
final finalSeenIds = <String>{};
|
||||||
|
for (final message in allMessages) {
|
||||||
|
if (finalSeenIds.add(message.id)) {
|
||||||
|
finalUniqueMessages.add(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalUniqueMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LocalChatMessage>> _fetchAndCacheMessages({
|
||||||
|
int offset = 0,
|
||||||
|
int take = 20,
|
||||||
|
}) async {
|
||||||
|
developer.log(
|
||||||
|
'Fetching messages from API, offset $offset, take $take',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
if (_totalCount == null) {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/sphere/chat/$_roomId/messages',
|
||||||
|
queryParameters: {'offset': 0, 'take': 1},
|
||||||
|
);
|
||||||
|
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset >= _totalCount!) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/sphere/chat/$_roomId/messages',
|
||||||
|
queryParameters: {'offset': offset, 'take': take},
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
|
||||||
|
|
||||||
|
final messages =
|
||||||
|
data.map((json) {
|
||||||
|
final remoteMessage = SnChatMessage.fromJson(json);
|
||||||
|
return LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
for (final message in messages) {
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(message));
|
||||||
|
if (message.nonce != null) {
|
||||||
|
_pendingMessages.removeWhere(
|
||||||
|
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||||
|
try {
|
||||||
|
final dbMessages = await _database.getMessagesForRoom(
|
||||||
|
_room.id,
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
final lastMessage =
|
||||||
|
dbMessages.isEmpty
|
||||||
|
? null
|
||||||
|
: _database.companionToMessage(dbMessages.first);
|
||||||
|
|
||||||
|
if (lastMessage == null) {
|
||||||
|
developer.log(
|
||||||
|
'No local messages, fetching from network',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
final newMessages = await _fetchAndCacheMessages(
|
||||||
|
offset: 0,
|
||||||
|
take: _pageSize,
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resp = await _apiClient.post(
|
||||||
|
'/sphere/chat/${_room.id}/sync',
|
||||||
|
data: {
|
||||||
|
'last_sync_timestamp':
|
||||||
|
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = MessageSyncResponse.fromJson(resp.data);
|
||||||
|
developer.log(
|
||||||
|
'Sync response: ${response.messages.length} changes',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
for (final message in response.messages) {
|
||||||
|
switch (message.type) {
|
||||||
|
case "messages.update":
|
||||||
|
case "messages.update.links":
|
||||||
|
await receiveMessageUpdate(message);
|
||||||
|
break;
|
||||||
|
case "messages.delete":
|
||||||
|
await receiveMessageDeletion(message.id.toString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Still need receive the message to show the history actions
|
||||||
|
await receiveMessage(message);
|
||||||
|
}
|
||||||
|
} catch (err, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Error syncing messages',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
error: err,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
developer.log('Finished message sync', name: 'MessagesNotifier');
|
||||||
|
Future.microtask(
|
||||||
|
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||||
|
);
|
||||||
|
_isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<LocalChatMessage>> listMessages({
|
||||||
|
int offset = 0,
|
||||||
|
int take = 20,
|
||||||
|
bool synced = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
if (offset == 0 &&
|
||||||
|
!synced &&
|
||||||
|
(_searchQuery == null || _searchQuery!.isEmpty)) {
|
||||||
|
_fetchAndCacheMessages(offset: 0, take: take).catchError((_) {
|
||||||
|
return <LocalChatMessage>[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final localMessages = await _getCachedMessages(
|
||||||
|
offset: offset,
|
||||||
|
take: take,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localMessages.isNotEmpty) {
|
||||||
|
return localMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||||
|
return await _fetchAndCacheMessages(offset: offset, take: take);
|
||||||
|
} else {
|
||||||
|
return []; // If searching, and no local messages, don't fetch from network
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
final localMessages = await _getCachedMessages(
|
||||||
|
offset: offset,
|
||||||
|
take: take,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localMessages.isNotEmpty) {
|
||||||
|
return localMessages;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadInitial() async {
|
||||||
|
developer.log('Loading initial messages', name: 'MessagesNotifier');
|
||||||
|
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||||
|
syncMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
final messages = await _getCachedMessages(offset: 0, take: _pageSize);
|
||||||
|
|
||||||
|
_hasMore = messages.length == _pageSize;
|
||||||
|
|
||||||
|
state = AsyncValue.data(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (!_hasMore || state is AsyncLoading) return;
|
||||||
|
developer.log('Loading more messages', name: 'MessagesNotifier');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final offset = currentMessages.length;
|
||||||
|
|
||||||
|
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
||||||
|
|
||||||
|
if (newMessages.isEmpty || newMessages.length < _pageSize) {
|
||||||
|
_hasMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = AsyncValue.data(
|
||||||
|
_sortMessages([...currentMessages, ...newMessages]),
|
||||||
|
);
|
||||||
|
} catch (err, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Error loading more messages',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
error: err,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendMessage(
|
||||||
|
String content,
|
||||||
|
List<UniversalFile> attachments, {
|
||||||
|
SnChatMessage? editingTo,
|
||||||
|
SnChatMessage? forwardingTo,
|
||||||
|
SnChatMessage? replyingTo,
|
||||||
|
Function(String, Map<int, double>)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final nonce = const Uuid().v4();
|
||||||
|
developer.log(
|
||||||
|
'Sending message with nonce $nonce',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
final baseUrl = ref.read(serverUrlProvider);
|
||||||
|
final token = await getToken(ref.watch(tokenProvider));
|
||||||
|
if (token == null) throw ArgumentError('Access token is null');
|
||||||
|
|
||||||
|
final mockMessage = SnChatMessage(
|
||||||
|
id: 'pending_$nonce',
|
||||||
|
chatRoomId: _roomId,
|
||||||
|
senderId: _identity.id,
|
||||||
|
content: content,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
nonce: nonce,
|
||||||
|
sender: _identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
mockMessage,
|
||||||
|
MessageStatus.pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages[localMessage.id] = localMessage;
|
||||||
|
_fileUploadProgress[localMessage.id] = {};
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||||
|
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var cloudAttachments = List.empty(growable: true);
|
||||||
|
for (var idx = 0; idx < attachments.length; idx++) {
|
||||||
|
final cloudFile =
|
||||||
|
await putFileToCloud(
|
||||||
|
fileData: attachments[idx],
|
||||||
|
atk: token,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
filename: attachments[idx].data.name ?? 'Post media',
|
||||||
|
mimetype:
|
||||||
|
attachments[idx].data.mimeType ??
|
||||||
|
switch (attachments[idx].type) {
|
||||||
|
UniversalFileType.image => 'image/unknown',
|
||||||
|
UniversalFileType.video => 'video/unknown',
|
||||||
|
UniversalFileType.audio => 'audio/unknown',
|
||||||
|
UniversalFileType.file => 'application/octet-stream',
|
||||||
|
},
|
||||||
|
onProgress: (progress, _) {
|
||||||
|
_fileUploadProgress[localMessage.id]?[idx] = progress;
|
||||||
|
onProgress?.call(
|
||||||
|
localMessage.id,
|
||||||
|
_fileUploadProgress[localMessage.id] ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).future;
|
||||||
|
if (cloudFile == null) {
|
||||||
|
throw ArgumentError('Failed to upload the file...');
|
||||||
|
}
|
||||||
|
cloudAttachments.add(cloudFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _apiClient.request(
|
||||||
|
editingTo == null
|
||||||
|
? '/sphere/chat/$_roomId/messages'
|
||||||
|
: '/sphere/chat/$_roomId/messages/${editingTo.id}',
|
||||||
|
data: {
|
||||||
|
'content': content,
|
||||||
|
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
|
||||||
|
'replied_message_id': replyingTo?.id,
|
||||||
|
'forwarded_message_id': forwardingTo?.id,
|
||||||
|
'meta': {},
|
||||||
|
'nonce': nonce,
|
||||||
|
},
|
||||||
|
options: Options(method: editingTo == null ? 'POST' : 'PATCH'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final remoteMessage = SnChatMessage.fromJson(response.data);
|
||||||
|
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages.remove(localMessage.id);
|
||||||
|
await _database.deleteMessage(localMessage.id);
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||||
|
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
if (editingTo != null) {
|
||||||
|
final newMessages =
|
||||||
|
currentMessages
|
||||||
|
.where((m) => m.id != localMessage.id) // remove pending message
|
||||||
|
.map(
|
||||||
|
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||||
|
) // update original message
|
||||||
|
.toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
} else {
|
||||||
|
final newMessages =
|
||||||
|
currentMessages.map((m) {
|
||||||
|
if (m.id == localMessage.id) {
|
||||||
|
return updatedMessage;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
}
|
||||||
|
developer.log(
|
||||||
|
'Message with nonce $nonce sent successfully',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Failed to send message with nonce $nonce',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
localMessage.status = MessageStatus.failed;
|
||||||
|
_pendingMessages[localMessage.id] = localMessage;
|
||||||
|
await _database.updateMessageStatus(
|
||||||
|
localMessage.id,
|
||||||
|
MessageStatus.failed,
|
||||||
|
);
|
||||||
|
final newMessages =
|
||||||
|
(state.value ?? []).map((m) {
|
||||||
|
if (m.id == localMessage.id) {
|
||||||
|
return m..status = MessageStatus.failed;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> retryMessage(String pendingMessageId) async {
|
||||||
|
developer.log(
|
||||||
|
'Retrying message $pendingMessageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
final message = await fetchMessageById(pendingMessageId);
|
||||||
|
if (message == null) {
|
||||||
|
throw Exception('Message not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
message.status = MessageStatus.pending;
|
||||||
|
_pendingMessages[pendingMessageId] = message;
|
||||||
|
await _database.updateMessageStatus(
|
||||||
|
pendingMessageId,
|
||||||
|
MessageStatus.pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var remoteMessage = message.toRemoteMessage();
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/sphere/chat/${message.roomId}/messages',
|
||||||
|
data: {
|
||||||
|
'content': remoteMessage.content,
|
||||||
|
'attachments_id': remoteMessage.attachments,
|
||||||
|
'meta': remoteMessage.meta,
|
||||||
|
'nonce': message.nonce,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
remoteMessage = SnChatMessage.fromJson(response.data);
|
||||||
|
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingMessages.remove(pendingMessageId);
|
||||||
|
await _database.deleteMessage(pendingMessageId);
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||||
|
|
||||||
|
final newMessages =
|
||||||
|
(state.value ?? []).map((m) {
|
||||||
|
if (m.id == pendingMessageId) {
|
||||||
|
return updatedMessage;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
state = AsyncValue.data(newMessages);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Failed to retry message $pendingMessageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
message.status = MessageStatus.failed;
|
||||||
|
_pendingMessages[pendingMessageId] = message;
|
||||||
|
await _database.updateMessageStatus(
|
||||||
|
pendingMessageId,
|
||||||
|
MessageStatus.failed,
|
||||||
|
);
|
||||||
|
final newMessages =
|
||||||
|
(state.value ?? []).map((m) {
|
||||||
|
if (m.id == pendingMessageId) {
|
||||||
|
return m..status = MessageStatus.failed;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
state = AsyncValue.data(_sortMessages(newMessages));
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
||||||
|
if (remoteMessage.chatRoomId != _roomId) return;
|
||||||
|
developer.log(
|
||||||
|
'Received new message ${remoteMessage.id}',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
|
||||||
|
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteMessage.nonce != null) {
|
||||||
|
_pendingMessages.removeWhere(
|
||||||
|
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||||
|
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final existingIndex = currentMessages.indexWhere(
|
||||||
|
(m) =>
|
||||||
|
m.id == localMessage.id ||
|
||||||
|
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[existingIndex] = localMessage;
|
||||||
|
state = AsyncValue.data(_sortMessages(newList));
|
||||||
|
} else {
|
||||||
|
state = AsyncValue.data(
|
||||||
|
_sortMessages([localMessage, ...currentMessages]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
||||||
|
if (remoteMessage.chatRoomId != _roomId) return;
|
||||||
|
developer.log(
|
||||||
|
'Received message update ${remoteMessage.id}',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||||
|
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[index] = updatedMessage;
|
||||||
|
state = AsyncValue.data(_sortMessages(newList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> receiveMessageDeletion(String messageId) async {
|
||||||
|
developer.log(
|
||||||
|
'Received message deletion $messageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
_pendingMessages.remove(messageId);
|
||||||
|
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
||||||
|
|
||||||
|
LocalChatMessage? messageToUpdate;
|
||||||
|
if (messageIndex != -1) {
|
||||||
|
messageToUpdate = currentMessages[messageIndex];
|
||||||
|
} else {
|
||||||
|
messageToUpdate = await fetchMessageById(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageToUpdate == null) return;
|
||||||
|
|
||||||
|
final remote = messageToUpdate.toRemoteMessage();
|
||||||
|
final updatedRemote = remote.copyWith(
|
||||||
|
content: 'This message was deleted',
|
||||||
|
deletedAt: DateTime.now(),
|
||||||
|
attachments: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final deletedMessage = LocalChatMessage.fromRemoteMessage(
|
||||||
|
updatedRemote,
|
||||||
|
messageToUpdate.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
|
||||||
|
|
||||||
|
if (messageIndex != -1) {
|
||||||
|
final newList = [...currentMessages];
|
||||||
|
newList[messageIndex] = deletedMessage;
|
||||||
|
state = AsyncValue.data(newList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMessage(String messageId) async {
|
||||||
|
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
|
||||||
|
try {
|
||||||
|
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
|
||||||
|
await receiveMessageDeletion(messageId);
|
||||||
|
} catch (err, stackTrace) {
|
||||||
|
developer.log(
|
||||||
|
'Error deleting message $messageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
error: err,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) {
|
||||||
|
_searchQuery = query.trim();
|
||||||
|
_withLinks = withLinks;
|
||||||
|
_withAttachments = withAttachments;
|
||||||
|
loadInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSearch() {
|
||||||
|
_searchQuery = null;
|
||||||
|
_withLinks = null;
|
||||||
|
_withAttachments = null;
|
||||||
|
loadInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
|
||||||
|
developer.log(
|
||||||
|
'Fetching message by id $messageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final localMessage =
|
||||||
|
await (_database.select(_database.chatMessages)
|
||||||
|
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
|
||||||
|
if (localMessage != null) {
|
||||||
|
return _database.companionToMessage(localMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/sphere/chat/$_roomId/messages/$messageId',
|
||||||
|
);
|
||||||
|
final remoteMessage = SnChatMessage.fromJson(response.data);
|
||||||
|
final message = LocalChatMessage.fromRemoteMessage(
|
||||||
|
remoteMessage,
|
||||||
|
MessageStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _database.saveMessage(_database.messageToCompanion(message));
|
||||||
|
return message;
|
||||||
|
} catch (e) {
|
||||||
|
if (e is DioException) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> jumpToMessage(String messageId) async {
|
||||||
|
developer.log(
|
||||||
|
'Starting jump to message $messageId',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
if (_isJumping) {
|
||||||
|
developer.log(
|
||||||
|
'Jump already in progress, skipping',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
_isJumping = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
developer.log('Fetching message $messageId', name: 'MessagesNotifier');
|
||||||
|
final message = await fetchMessageById(messageId);
|
||||||
|
if (message == null) {
|
||||||
|
developer.log('Message $messageId not found', name: 'MessagesNotifier');
|
||||||
|
showSnackBar('messageNotFound'.tr());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if message is already in current state to avoid duplicate loading
|
||||||
|
final currentMessages = state.value ?? [];
|
||||||
|
final existingIndex = currentMessages.indexWhere(
|
||||||
|
(m) => m.id == messageId,
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
developer.log(
|
||||||
|
'Message $messageId already in current state at index $existingIndex, jumping directly',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
return existingIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log(
|
||||||
|
'Message $messageId not in current state, loading messages around it',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count messages newer than this one
|
||||||
|
final query = _database.customSelect(
|
||||||
|
'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
|
||||||
|
variables: [
|
||||||
|
Variable.withString(_roomId),
|
||||||
|
Variable.withDateTime(message.createdAt),
|
||||||
|
],
|
||||||
|
readsFrom: {_database.chatMessages},
|
||||||
|
);
|
||||||
|
final result = await query.getSingle();
|
||||||
|
final newerCount = result.read<int>('count');
|
||||||
|
|
||||||
|
// Load messages around this position
|
||||||
|
final offset =
|
||||||
|
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
|
||||||
|
developer.log(
|
||||||
|
'Loading messages with offset $offset, take $_pageSize',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
final loadedMessages = await _getCachedMessages(
|
||||||
|
offset: offset,
|
||||||
|
take: _pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if loaded messages are already in current state
|
||||||
|
final currentIds = currentMessages.map((m) => m.id).toSet();
|
||||||
|
final newMessages =
|
||||||
|
loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
|
||||||
|
developer.log(
|
||||||
|
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newMessages.isNotEmpty) {
|
||||||
|
// Merge with current messages
|
||||||
|
final allMessages = [...currentMessages, ...newMessages];
|
||||||
|
final uniqueMessages = <LocalChatMessage>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
for (final message in allMessages) {
|
||||||
|
if (seenIds.add(message.id)) {
|
||||||
|
uniqueMessages.add(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_sortMessages(uniqueMessages);
|
||||||
|
state = AsyncValue.data(uniqueMessages);
|
||||||
|
developer.log(
|
||||||
|
'Updated state with ${uniqueMessages.length} total messages',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final finalIndex = (state.value ?? []).indexWhere(
|
||||||
|
(m) => m.id == messageId,
|
||||||
|
);
|
||||||
|
developer.log(
|
||||||
|
'Final index for message $messageId is $finalIndex',
|
||||||
|
name: 'MessagesNotifier',
|
||||||
|
);
|
||||||
|
return finalIndex;
|
||||||
|
} finally {
|
||||||
|
_isJumping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasLink(LocalChatMessage message) {
|
||||||
|
final content = message.toRemoteMessage().content;
|
||||||
|
if (content == null) return false;
|
||||||
|
final urlRegex = RegExp(r'https?://[^\s/$.?#].[^\s]*');
|
||||||
|
return urlRegex.hasMatch(content);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,12 +1,12 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
part of 'room.dart';
|
part of 'messages_notifier.dart';
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$messagesNotifierHash() => r'5787fcac9f6c77062aaf854daf2365464f771c2f';
|
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
34
lib/pods/room_providers.dart
Normal file
34
lib/pods/room_providers.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import "dart:async";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
|
||||||
|
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
|
||||||
|
|
||||||
|
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
|
||||||
|
|
||||||
|
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
|
||||||
|
final controller = StreamController<AppLifecycleState>();
|
||||||
|
|
||||||
|
final observer = _AppLifecycleObserver((state) {
|
||||||
|
if (controller.isClosed) return;
|
||||||
|
controller.add(state);
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addObserver(observer);
|
||||||
|
|
||||||
|
ref.onDispose(() {
|
||||||
|
WidgetsBinding.instance.removeObserver(observer);
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return controller.stream;
|
||||||
|
});
|
||||||
|
|
||||||
|
class _AppLifecycleObserver extends WidgetsBindingObserver {
|
||||||
|
final ValueChanged<AppLifecycleState> onChange;
|
||||||
|
_AppLifecycleObserver(this.onChange);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
onChange(state);
|
||||||
|
}
|
||||||
|
}
|
221
lib/screens/chat/public_room_preview.dart
Normal file
221
lib/screens/chat/public_room_preview.dart
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:gap/gap.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:island/database/message.dart";
|
||||||
|
import "package:island/screens/chat/chat.dart";
|
||||||
|
import "package:island/widgets/content/cloud_files.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
import "package:easy_localization/easy_localization.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:material_symbols_icons/symbols.dart";
|
||||||
|
import "package:styled_widget/styled_widget.dart";
|
||||||
|
import "package:island/models/chat.dart";
|
||||||
|
import "package:island/widgets/alert.dart";
|
||||||
|
import "package:island/widgets/app_scaffold.dart";
|
||||||
|
import "package:island/widgets/chat/message_item.dart";
|
||||||
|
import "package:island/widgets/response.dart";
|
||||||
|
import "package:island/pods/network.dart";
|
||||||
|
import "package:island/services/responsive.dart";
|
||||||
|
import "package:island/pods/messages_notifier.dart";
|
||||||
|
|
||||||
|
class PublicRoomPreview extends HookConsumerWidget {
|
||||||
|
final String id;
|
||||||
|
final SnChatRoom room;
|
||||||
|
|
||||||
|
const PublicRoomPreview({super.key, required this.id, required this.room});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final messages = ref.watch(messagesNotifierProvider(id));
|
||||||
|
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
final listController = useMemoized(() => ListController(), []);
|
||||||
|
|
||||||
|
var isLoading = false;
|
||||||
|
|
||||||
|
// Add scroll listener for pagination
|
||||||
|
useEffect(() {
|
||||||
|
void onScroll() {
|
||||||
|
if (scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent - 200) {
|
||||||
|
if (isLoading) return;
|
||||||
|
isLoading = true;
|
||||||
|
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(onScroll);
|
||||||
|
return () => scrollController.removeListener(onScroll);
|
||||||
|
}, [scrollController]);
|
||||||
|
|
||||||
|
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 MessageItem(
|
||||||
|
message: message,
|
||||||
|
isCurrentUser: false, // User is not a member, so not current user
|
||||||
|
onAction: null, // No actions allowed in preview mode
|
||||||
|
onJump: (_) {}, // No jump functionality in preview
|
||||||
|
progress: null,
|
||||||
|
showAvatar: isLastInGroup,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final compactHeader = isWideScreen(context);
|
||||||
|
|
||||||
|
Widget comfortHeaderWidget() => Column(
|
||||||
|
spacing: 4,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
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(15),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget compactHeaderWidget() => 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
toolbarHeight: compactHeader ? null : 64,
|
||||||
|
title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onPressed: () {
|
||||||
|
context.pushNamed('chatDetail', pathParameters: {'id': id});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: messages.when(
|
||||||
|
data:
|
||||||
|
(messageList) =>
|
||||||
|
messageList.isEmpty
|
||||||
|
? Center(child: Text('No messages yet'.tr()))
|
||||||
|
: chatMessageListWidget(messageList),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error:
|
||||||
|
(error, _) => ResponseErrorWidget(
|
||||||
|
error: error,
|
||||||
|
onRetry: () => messagesNotifier.loadInitial(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Join button at the bottom for public rooms
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
showLoadingModal(context);
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
await apiClient.post('/sphere/chat/${room.id}/members/me');
|
||||||
|
ref.invalidate(chatroomIdentityProvider(id));
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: Text('chatJoin').tr(),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:island/pods/database.dart';
|
import 'package:island/pods/database.dart';
|
||||||
|
import 'package:island/screens/chat/search_messages.dart';
|
||||||
|
|
||||||
part 'room_detail.freezed.dart';
|
part 'room_detail.freezed.dart';
|
||||||
part 'room_detail.g.dart';
|
part 'room_detail.g.dart';
|
||||||
@@ -401,11 +402,17 @@ class ChatDetailScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
context.pushNamed(
|
final result = await context.pushNamed(
|
||||||
'searchMessages',
|
'searchMessages',
|
||||||
pathParameters: {'id': id},
|
pathParameters: {'id': id},
|
||||||
);
|
);
|
||||||
|
if (result is SearchMessagesResult) {
|
||||||
|
// Navigate back to room screen with message to jump to
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pop(result.messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@@ -1,13 +1,20 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/screens/chat/room.dart';
|
import 'package:island/pods/messages_notifier.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/chat/message_item.dart';
|
import 'package:island/widgets/chat/message_list_tile.dart';
|
||||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||||
|
|
||||||
|
// Class to represent the result when popping from search messages
|
||||||
|
class SearchMessagesResult {
|
||||||
|
final String messageId;
|
||||||
|
const SearchMessagesResult(this.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
class SearchMessagesScreen extends HookConsumerWidget {
|
class SearchMessagesScreen extends HookConsumerWidget {
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
|
||||||
@@ -116,15 +123,12 @@ class SearchMessagesScreen extends HookConsumerWidget {
|
|||||||
itemCount: messageList.length,
|
itemCount: messageList.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final message = messageList[index];
|
final message = messageList[index];
|
||||||
// Simplified MessageItem for search results, no grouping logic
|
return MessageListTile(
|
||||||
return MessageItem(
|
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser:
|
onJump: (messageId) {
|
||||||
false, // Or determine based on actual user
|
// Return the search result and pop back to room detail
|
||||||
onAction: null,
|
context.pop(SearchMessagesResult(messageId));
|
||||||
onJump: (_) {},
|
},
|
||||||
progress: null,
|
|
||||||
showAvatar: true,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
327
lib/widgets/chat/chat_input.dart
Normal file
327
lib/widgets/chat/chat_input.dart
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import "dart:async";
|
||||||
|
import "dart:io";
|
||||||
|
import "package:easy_localization/easy_localization.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:gap/gap.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:image_picker/image_picker.dart";
|
||||||
|
import "package:island/models/chat.dart";
|
||||||
|
import "package:island/models/file.dart";
|
||||||
|
import "package:island/pods/config.dart";
|
||||||
|
import "package:island/widgets/content/attachment_preview.dart";
|
||||||
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
|
import "package:pasteboard/pasteboard.dart";
|
||||||
|
import "package:styled_widget/styled_widget.dart";
|
||||||
|
import "package:material_symbols_icons/symbols.dart";
|
||||||
|
import "package:island/widgets/stickers/picker.dart";
|
||||||
|
|
||||||
|
class ChatInput extends HookConsumerWidget {
|
||||||
|
final TextEditingController messageController;
|
||||||
|
final SnChatRoom chatRoom;
|
||||||
|
final VoidCallback onSend;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final Function(bool isPhoto) onPickFile;
|
||||||
|
final SnChatMessage? messageReplyingTo;
|
||||||
|
final SnChatMessage? messageForwardingTo;
|
||||||
|
final SnChatMessage? messageEditingTo;
|
||||||
|
final List<UniversalFile> attachments;
|
||||||
|
final Function(int) onUploadAttachment;
|
||||||
|
final Function(int) onDeleteAttachment;
|
||||||
|
final Function(int, int) onMoveAttachment;
|
||||||
|
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||||
|
|
||||||
|
const ChatInput({
|
||||||
|
super.key,
|
||||||
|
required this.messageController,
|
||||||
|
required this.chatRoom,
|
||||||
|
required this.onSend,
|
||||||
|
required this.onClear,
|
||||||
|
required this.onPickFile,
|
||||||
|
required this.messageReplyingTo,
|
||||||
|
required this.messageForwardingTo,
|
||||||
|
required this.messageEditingTo,
|
||||||
|
required this.attachments,
|
||||||
|
required this.onUploadAttachment,
|
||||||
|
required this.onDeleteAttachment,
|
||||||
|
required this.onMoveAttachment,
|
||||||
|
required this.onAttachmentsChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final inputFocusNode = useFocusNode();
|
||||||
|
|
||||||
|
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
|
||||||
|
|
||||||
|
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||||
|
|
||||||
|
void send() {
|
||||||
|
onSend.call();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
inputFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handlePaste() async {
|
||||||
|
final clipboard = await Pasteboard.image;
|
||||||
|
if (clipboard == null) return;
|
||||||
|
|
||||||
|
onAttachmentsChanged([
|
||||||
|
...attachments,
|
||||||
|
UniversalFile(
|
||||||
|
data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
|
||||||
|
type: UniversalFileType.image,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleKeyPress(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
RawKeyEvent event,
|
||||||
|
) {
|
||||||
|
if (event is! RawKeyDownEvent) return;
|
||||||
|
|
||||||
|
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||||
|
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
|
||||||
|
|
||||||
|
if (isPaste && isModifierPressed) {
|
||||||
|
handlePaste();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
|
||||||
|
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
|
||||||
|
|
||||||
|
if (isEnter) {
|
||||||
|
if (enterToSend && !isModifierPressed) {
|
||||||
|
send();
|
||||||
|
} else if (!enterToSend && isModifierPressed) {
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
elevation: 8,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (attachments.isNotEmpty)
|
||||||
|
SizedBox(
|
||||||
|
height: 280,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: attachments.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 280,
|
||||||
|
width: 280,
|
||||||
|
child: AttachmentPreview(
|
||||||
|
item: attachments[idx],
|
||||||
|
onRequestUpload: () => onUploadAttachment(idx),
|
||||||
|
onDelete: () => onDeleteAttachment(idx),
|
||||||
|
onUpdate: (value) {
|
||||||
|
attachments[idx] = value;
|
||||||
|
onAttachmentsChanged(attachments);
|
||||||
|
},
|
||||||
|
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, _) => const Gap(8),
|
||||||
|
),
|
||||||
|
).padding(top: 12),
|
||||||
|
if (messageReplyingTo != null ||
|
||||||
|
messageForwardingTo != null ||
|
||||||
|
messageEditingTo != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.only(left: 8, right: 8, top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
messageReplyingTo != null
|
||||||
|
? Symbols.reply
|
||||||
|
: messageForwardingTo != null
|
||||||
|
? Symbols.forward
|
||||||
|
: Symbols.edit,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
messageReplyingTo != null
|
||||||
|
? 'Replying to ${messageReplyingTo?.sender.account.nick}'
|
||||||
|
: messageForwardingTo != null
|
||||||
|
? 'Forwarding message'
|
||||||
|
: 'Editing message',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: onClear,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: ButtonStyle(
|
||||||
|
minimumSize: WidgetStatePropertyAll(Size(28, 28)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'stickers'.tr(),
|
||||||
|
icon: const Icon(Symbols.add_reaction),
|
||||||
|
onPressed: () {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
showStickerPickerPopover(
|
||||||
|
context,
|
||||||
|
Offset(
|
||||||
|
20,
|
||||||
|
size.height -
|
||||||
|
480 -
|
||||||
|
MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
|
onPick: (placeholder) {
|
||||||
|
// Insert placeholder at current cursor position
|
||||||
|
final text = messageController.text;
|
||||||
|
final selection = messageController.selection;
|
||||||
|
final start =
|
||||||
|
selection.start >= 0
|
||||||
|
? selection.start
|
||||||
|
: text.length;
|
||||||
|
final end =
|
||||||
|
selection.end >= 0
|
||||||
|
? selection.end
|
||||||
|
: text.length;
|
||||||
|
final newText = text.replaceRange(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
placeholder,
|
||||||
|
);
|
||||||
|
messageController.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: start + placeholder.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuButton(
|
||||||
|
icon: const Icon(Symbols.photo_library),
|
||||||
|
itemBuilder:
|
||||||
|
(context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => onPickFile(true),
|
||||||
|
child: Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.photo),
|
||||||
|
Text('addPhoto').tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => onPickFile(false),
|
||||||
|
child: Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.video_call),
|
||||||
|
Text('addVideo').tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RawKeyboardListener(
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
onKey: (event) => handleKeyPress(context, ref, event),
|
||||||
|
child: TextField(
|
||||||
|
focusNode: inputFocusNode,
|
||||||
|
controller: messageController,
|
||||||
|
onSubmitted:
|
||||||
|
(enterToSend && isMobile)
|
||||||
|
? (_) {
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
keyboardType:
|
||||||
|
(enterToSend && isMobile)
|
||||||
|
? TextInputType.text
|
||||||
|
: TextInputType.multiline,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
inputFormatters: [
|
||||||
|
if (enterToSend && !isMobile)
|
||||||
|
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||||
|
if (newValue.text.endsWith('\n')) {
|
||||||
|
return oldValue;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText:
|
||||||
|
(chatRoom.type == 1 && chatRoom.name == null)
|
||||||
|
? 'chatDirectMessageHint'.tr(
|
||||||
|
args: [
|
||||||
|
chatRoom.members!
|
||||||
|
.map((e) => e.account.nick)
|
||||||
|
.join(', '),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
counterText:
|
||||||
|
messageController.text.length > 1024
|
||||||
|
? '${messageController.text.length}/4096'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
minLines: 1,
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
onPressed: send,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
169
lib/widgets/chat/message_content.dart
Normal file
169
lib/widgets/chat/message_content.dart
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/models/chat.dart';
|
||||||
|
import 'package:island/pods/call.dart';
|
||||||
|
import 'package:island/widgets/content/markdown.dart';
|
||||||
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
|
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
class MessageContent extends StatelessWidget {
|
||||||
|
final SnChatMessage item;
|
||||||
|
final String? translatedText;
|
||||||
|
|
||||||
|
const MessageContent({super.key, required this.item, this.translatedText});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'call.start':
|
||||||
|
case 'call.ended':
|
||||||
|
return _MessageContentCall(
|
||||||
|
isEnded: item.type == 'call.ended',
|
||||||
|
duration: item.meta['duration']?.toDouble(),
|
||||||
|
);
|
||||||
|
case 'messages.update':
|
||||||
|
case 'messages.update.links':
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.edit,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
if (item.meta['previous_content'] is String)
|
||||||
|
PrettyDiffText(
|
||||||
|
oldText: item.meta['previous_content'],
|
||||||
|
newText: item.content ?? 'Edited a message',
|
||||||
|
defaultTextStyle: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
addedTextStyle: TextStyle(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||||
|
),
|
||||||
|
deletedTextStyle: TextStyle(
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
item.content ?? 'Edited a message',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case 'messages.delete':
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.delete,
|
||||||
|
size: 14,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
item.content ?? 'Deleted a message',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MarkdownTextContent(
|
||||||
|
content: item.content ?? '*${item.type} has no content*',
|
||||||
|
isSelectable: true,
|
||||||
|
linesMargin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (translatedText?.isNotEmpty ?? false)
|
||||||
|
...([
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: math.min(
|
||||||
|
280,
|
||||||
|
MediaQuery.of(context).size.width * 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('translated').tr().fontSize(11).opacity(0.75),
|
||||||
|
const Gap(8),
|
||||||
|
Flexible(child: Divider()),
|
||||||
|
],
|
||||||
|
).padding(vertical: 4),
|
||||||
|
),
|
||||||
|
MarkdownTextContent(
|
||||||
|
content: translatedText!,
|
||||||
|
isSelectable: true,
|
||||||
|
linesMargin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasContent(SnChatMessage item) {
|
||||||
|
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageContentCall extends StatelessWidget {
|
||||||
|
final bool isEnded;
|
||||||
|
final double? duration;
|
||||||
|
|
||||||
|
const _MessageContentCall({required this.isEnded, this.duration});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
Gap(4),
|
||||||
|
Text(
|
||||||
|
isEnded
|
||||||
|
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
|
||||||
|
: 'Call started',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
69
lib/widgets/chat/message_indicators.dart
Normal file
69
lib/widgets/chat/message_indicators.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/database/message.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
class MessageIndicators extends StatelessWidget {
|
||||||
|
final DateTime? editedAt;
|
||||||
|
final MessageStatus? status;
|
||||||
|
final bool isCurrentUser;
|
||||||
|
final Color textColor;
|
||||||
|
|
||||||
|
const MessageIndicators({
|
||||||
|
super.key,
|
||||||
|
this.editedAt,
|
||||||
|
this.status,
|
||||||
|
required this.isCurrentUser,
|
||||||
|
required this.textColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
spacing: 4,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (editedAt != null)
|
||||||
|
Text(
|
||||||
|
'edited'.tr().toLowerCase(),
|
||||||
|
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||||
|
),
|
||||||
|
if (isCurrentUser && status != null)
|
||||||
|
_buildStatusIcon(
|
||||||
|
context,
|
||||||
|
status!,
|
||||||
|
textColor.withOpacity(0.7),
|
||||||
|
).padding(bottom: 3),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(
|
||||||
|
BuildContext context,
|
||||||
|
MessageStatus status,
|
||||||
|
Color textColor,
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case MessageStatus.pending:
|
||||||
|
return Icon(Icons.access_time, size: 12, color: textColor);
|
||||||
|
case MessageStatus.sent:
|
||||||
|
return Icon(Icons.check, size: 12, color: textColor);
|
||||||
|
case MessageStatus.failed:
|
||||||
|
return Consumer(
|
||||||
|
builder:
|
||||||
|
(context, ref, _) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// This would need to be passed in or accessed differently
|
||||||
|
// For now, just show the error icon
|
||||||
|
},
|
||||||
|
child: const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -10,22 +10,19 @@ 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:island/database/message.dart';
|
import 'package:island/database/message.dart';
|
||||||
import 'package:island/models/chat.dart';
|
|
||||||
import 'package:island/models/embed.dart';
|
import 'package:island/models/embed.dart';
|
||||||
import 'package:island/pods/call.dart';
|
import 'package:island/pods/messages_notifier.dart';
|
||||||
import 'package:island/pods/translate.dart';
|
import 'package:island/pods/translate.dart';
|
||||||
import 'package:island/screens/chat/room.dart';
|
import 'package:island/screens/chat/room.dart';
|
||||||
import 'package:island/utils/mapping.dart';
|
import 'package:island/utils/mapping.dart';
|
||||||
import 'package:island/widgets/account/account_name.dart';
|
|
||||||
import 'package:island/widgets/account/account_pfc.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
|
import 'package:island/widgets/chat/message_content.dart';
|
||||||
|
import 'package:island/widgets/chat/message_indicators.dart';
|
||||||
|
import 'package:island/widgets/chat/message_sender_info.dart';
|
||||||
import 'package:island/widgets/content/alert.native.dart';
|
import 'package:island/widgets/content/alert.native.dart';
|
||||||
import 'package:island/widgets/content/cloud_file_collection.dart';
|
import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
|
||||||
import 'package:island/widgets/content/embed/link.dart';
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
import 'package:island/widgets/content/markdown.dart';
|
|
||||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:super_context_menu/super_context_menu.dart';
|
import 'package:super_context_menu/super_context_menu.dart';
|
||||||
|
|
||||||
@@ -228,62 +225,10 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (showAvatar) ...[
|
if (showAvatar) ...[
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Row(
|
MessageSenderInfo(
|
||||||
spacing: 8,
|
sender: sender,
|
||||||
mainAxisSize: MainAxisSize.min,
|
createdAt: message.createdAt,
|
||||||
children: [
|
textColor: textColor,
|
||||||
AccountPfcGestureDetector(
|
|
||||||
uname: sender.account.name,
|
|
||||||
child: ProfilePictureWidget(
|
|
||||||
fileId: sender.account.profile.picture?.id,
|
|
||||||
radius: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 2,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
DateTime.now().difference(message.createdAt).inDays >
|
|
||||||
365
|
|
||||||
? DateFormat(
|
|
||||||
'yyyy/MM/dd HH:mm',
|
|
||||||
).format(message.createdAt.toLocal())
|
|
||||||
: DateTime.now()
|
|
||||||
.difference(message.createdAt)
|
|
||||||
.inDays >
|
|
||||||
0
|
|
||||||
? DateFormat(
|
|
||||||
'MM/dd HH:mm',
|
|
||||||
).format(message.createdAt.toLocal())
|
|
||||||
: DateFormat(
|
|
||||||
'HH:mm',
|
|
||||||
).format(message.createdAt.toLocal()),
|
|
||||||
style: TextStyle(fontSize: 10, color: textColor),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
spacing: 5,
|
|
||||||
children: [
|
|
||||||
AccountName(
|
|
||||||
account: sender.account,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Badge(
|
|
||||||
label:
|
|
||||||
Text(
|
|
||||||
sender.role >= 100
|
|
||||||
? 'permissionOwner'
|
|
||||||
: sender.role >= 50
|
|
||||||
? 'permissionModerator'
|
|
||||||
: 'permissionMember',
|
|
||||||
).tr(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
],
|
],
|
||||||
@@ -319,8 +264,8 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
isReply: false,
|
isReply: false,
|
||||||
).padding(vertical: 4),
|
).padding(vertical: 4),
|
||||||
if (_MessageItemContent.hasContent(remoteMessage))
|
if (MessageContent.hasContent(remoteMessage))
|
||||||
_MessageItemContent(
|
MessageContent(
|
||||||
item: remoteMessage,
|
item: remoteMessage,
|
||||||
translatedText: translatedText.value,
|
translatedText: translatedText.value,
|
||||||
),
|
),
|
||||||
@@ -406,12 +351,11 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildMessageIndicators(
|
MessageIndicators(
|
||||||
context,
|
editedAt: remoteMessage.editedAt,
|
||||||
textColor,
|
status: message.status,
|
||||||
remoteMessage,
|
isCurrentUser: isCurrentUser,
|
||||||
message,
|
textColor: textColor,
|
||||||
isCurrentUser,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -421,61 +365,6 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessageIndicators(
|
|
||||||
BuildContext context,
|
|
||||||
Color textColor,
|
|
||||||
SnChatMessage remoteMessage,
|
|
||||||
LocalChatMessage message,
|
|
||||||
bool isCurrentUser,
|
|
||||||
) {
|
|
||||||
return Row(
|
|
||||||
spacing: 4,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (remoteMessage.editedAt != null)
|
|
||||||
Text(
|
|
||||||
'edited'.tr().toLowerCase(),
|
|
||||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
|
||||||
),
|
|
||||||
if (isCurrentUser)
|
|
||||||
_buildStatusIcon(
|
|
||||||
context,
|
|
||||||
message.status,
|
|
||||||
textColor.withOpacity(0.7),
|
|
||||||
).padding(bottom: 3),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatusIcon(
|
|
||||||
BuildContext context,
|
|
||||||
MessageStatus status,
|
|
||||||
Color textColor,
|
|
||||||
) {
|
|
||||||
switch (status) {
|
|
||||||
case MessageStatus.pending:
|
|
||||||
return Icon(Icons.access_time, size: 12, color: textColor);
|
|
||||||
case MessageStatus.sent:
|
|
||||||
return Icon(Icons.check, size: 12, color: textColor);
|
|
||||||
case MessageStatus.failed:
|
|
||||||
return Consumer(
|
|
||||||
builder:
|
|
||||||
(context, ref, _) => GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
ref
|
|
||||||
.read(messagesNotifierProvider(message.roomId).notifier)
|
|
||||||
.retryMessage(message.id);
|
|
||||||
},
|
|
||||||
child: const Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessageQuoteWidget extends HookConsumerWidget {
|
class MessageQuoteWidget extends HookConsumerWidget {
|
||||||
@@ -552,8 +441,8 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
|||||||
).textColor(textColor).bold(),
|
).textColor(textColor).bold(),
|
||||||
],
|
],
|
||||||
).padding(right: 8),
|
).padding(right: 8),
|
||||||
if (_MessageItemContent.hasContent(remoteMessage))
|
if (MessageContent.hasContent(remoteMessage))
|
||||||
_MessageItemContent(item: remoteMessage),
|
MessageContent(item: remoteMessage),
|
||||||
if (remoteMessage.attachments.isNotEmpty)
|
if (remoteMessage.attachments.isNotEmpty)
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -580,159 +469,3 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MessageItemContent extends StatelessWidget {
|
|
||||||
final SnChatMessage item;
|
|
||||||
final String? translatedText;
|
|
||||||
const _MessageItemContent({required this.item, this.translatedText});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'call.start':
|
|
||||||
case 'call.ended':
|
|
||||||
return _MessageContentCall(
|
|
||||||
isEnded: item.type == 'call.ended',
|
|
||||||
duration: item.meta['duration']?.toDouble(),
|
|
||||||
);
|
|
||||||
case 'messages.update':
|
|
||||||
case 'messages.update.links':
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.edit,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
if (item.meta['previous_content'] is String)
|
|
||||||
PrettyDiffText(
|
|
||||||
oldText: item.meta['previous_content'],
|
|
||||||
newText: item.content ?? 'Edited a message',
|
|
||||||
defaultTextStyle: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall!.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
addedTextStyle: TextStyle(
|
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
|
||||||
),
|
|
||||||
deletedTextStyle: TextStyle(
|
|
||||||
decoration: TextDecoration.lineThrough,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
item.content ?? 'Edited a message',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
case 'messages.delete':
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.delete,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
item.content ?? 'Deleted a message',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
case 'text':
|
|
||||||
default:
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MarkdownTextContent(
|
|
||||||
content: item.content ?? '*${item.type} has no content*',
|
|
||||||
isSelectable: true,
|
|
||||||
linesMargin: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
if (translatedText?.isNotEmpty ?? false)
|
|
||||||
...([
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.min(
|
|
||||||
280,
|
|
||||||
MediaQuery.of(context).size.width * 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('translated').tr().fontSize(11).opacity(0.75),
|
|
||||||
const Gap(8),
|
|
||||||
Flexible(child: Divider()),
|
|
||||||
],
|
|
||||||
).padding(vertical: 4),
|
|
||||||
),
|
|
||||||
MarkdownTextContent(
|
|
||||||
content: translatedText!,
|
|
||||||
isSelectable: true,
|
|
||||||
linesMargin: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool hasContent(SnChatMessage item) {
|
|
||||||
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MessageContentCall extends StatelessWidget {
|
|
||||||
final bool isEnded;
|
|
||||||
final double? duration;
|
|
||||||
const _MessageContentCall({required this.isEnded, this.duration});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
Gap(4),
|
|
||||||
Text(
|
|
||||||
isEnded
|
|
||||||
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
|
|
||||||
: 'Call started',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
87
lib/widgets/chat/message_list_tile.dart
Normal file
87
lib/widgets/chat/message_list_tile.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:island/database/message.dart';
|
||||||
|
import 'package:island/models/embed.dart';
|
||||||
|
import 'package:island/utils/mapping.dart';
|
||||||
|
import 'package:island/widgets/chat/message_content.dart';
|
||||||
|
import 'package:island/widgets/chat/message_sender_info.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
|
|
||||||
|
class MessageListTile extends StatelessWidget {
|
||||||
|
final LocalChatMessage message;
|
||||||
|
final Function(String messageId) onJump;
|
||||||
|
|
||||||
|
const MessageListTile({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.onJump,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final remoteMessage = message.toRemoteMessage();
|
||||||
|
final sender = remoteMessage.sender;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: ProfilePictureWidget(
|
||||||
|
fileId: sender.account.profile.picture?.id,
|
||||||
|
radius: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MessageSenderInfo(
|
||||||
|
sender: sender,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MessageContent(item: remoteMessage),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (remoteMessage.attachments.isNotEmpty)
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return CloudFileList(
|
||||||
|
files: remoteMessage.attachments,
|
||||||
|
maxWidth: constraints.maxWidth,
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (remoteMessage.meta['embeds'] != null)
|
||||||
|
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||||
|
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||||
|
.where((embed) => embed['type'] == 'link')
|
||||||
|
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||||
|
.map(
|
||||||
|
(link) => LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return EmbedLinkWidget(
|
||||||
|
link: link,
|
||||||
|
maxWidth: math.min(constraints.maxWidth, 480),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => onJump(message.id),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
124
lib/widgets/chat/message_sender_info.dart
Normal file
124
lib/widgets/chat/message_sender_info.dart
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:island/models/chat.dart';
|
||||||
|
import 'package:island/widgets/account/account_name.dart';
|
||||||
|
import 'package:island/widgets/account/account_pfc.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
|
||||||
|
class MessageSenderInfo extends StatelessWidget {
|
||||||
|
final SnChatMember sender;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final Color textColor;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
const MessageSenderInfo({
|
||||||
|
super.key,
|
||||||
|
required this.sender,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.textColor,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final timestamp =
|
||||||
|
DateTime.now().difference(createdAt).inDays > 365
|
||||||
|
? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal())
|
||||||
|
: DateTime.now().difference(createdAt).inDays > 0
|
||||||
|
? DateFormat('MM/dd HH:mm').format(createdAt.toLocal())
|
||||||
|
: DateFormat('HH:mm').format(createdAt.toLocal());
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (!compact)
|
||||||
|
AccountPfcGestureDetector(
|
||||||
|
uname: sender.account.name,
|
||||||
|
child: ProfilePictureWidget(
|
||||||
|
fileId: sender.account.profile.picture?.id,
|
||||||
|
radius: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
AccountName(
|
||||||
|
account: sender.account,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Badge(
|
||||||
|
label:
|
||||||
|
Text(
|
||||||
|
sender.role >= 100
|
||||||
|
? 'permissionOwner'
|
||||||
|
: sender.role >= 50
|
||||||
|
? 'permissionModerator'
|
||||||
|
: 'permissionMember',
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
timestamp,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: textColor.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
spacing: 8,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AccountPfcGestureDetector(
|
||||||
|
uname: sender.account.name,
|
||||||
|
child: ProfilePictureWidget(
|
||||||
|
fileId: sender.account.profile.picture?.id,
|
||||||
|
radius: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 2,
|
||||||
|
children: [
|
||||||
|
Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 5,
|
||||||
|
children: [
|
||||||
|
AccountName(
|
||||||
|
account: sender.account,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
Badge(
|
||||||
|
label:
|
||||||
|
Text(
|
||||||
|
sender.role >= 100
|
||||||
|
? 'permissionOwner'
|
||||||
|
: sender.role >= 50
|
||||||
|
? 'permissionModerator'
|
||||||
|
: 'permissionMember',
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
223
lib/widgets/chat/public_room_preview.dart
Normal file
223
lib/widgets/chat/public_room_preview.dart
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import "package:easy_localization/easy_localization.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:go_router/go_router.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:gap/gap.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:island/database/message.dart";
|
||||||
|
import "package:island/models/chat.dart";
|
||||||
|
import "package:island/pods/messages_notifier.dart";
|
||||||
|
import "package:island/pods/network.dart";
|
||||||
|
import "package:island/services/responsive.dart";
|
||||||
|
import "package:island/widgets/alert.dart";
|
||||||
|
import "package:island/widgets/app_scaffold.dart";
|
||||||
|
import "package:island/widgets/chat/message_item.dart";
|
||||||
|
import "package:island/widgets/content/cloud_files.dart";
|
||||||
|
import "package:island/widgets/response.dart";
|
||||||
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
|
import "package:styled_widget/styled_widget.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
import "package:material_symbols_icons/symbols.dart";
|
||||||
|
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||||
|
import "package:island/screens/chat/chat.dart";
|
||||||
|
|
||||||
|
class PublicRoomPreview extends HookConsumerWidget {
|
||||||
|
final String id;
|
||||||
|
final SnChatRoom room;
|
||||||
|
|
||||||
|
const PublicRoomPreview({super.key, required this.id, required this.room});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final messages = ref.watch(messagesNotifierProvider(id));
|
||||||
|
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
final listController = useMemoized(() => ListController(), []);
|
||||||
|
|
||||||
|
var isLoading = false;
|
||||||
|
|
||||||
|
// Add scroll listener for pagination
|
||||||
|
useEffect(() {
|
||||||
|
void onScroll() {
|
||||||
|
if (scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent - 200) {
|
||||||
|
if (isLoading) return;
|
||||||
|
isLoading = true;
|
||||||
|
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(onScroll);
|
||||||
|
return () => scrollController.removeListener(onScroll);
|
||||||
|
}, [scrollController]);
|
||||||
|
|
||||||
|
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 MessageItem(
|
||||||
|
message: message,
|
||||||
|
isCurrentUser: false, // User is not a member, so not current user
|
||||||
|
onAction: null, // No actions allowed in preview mode
|
||||||
|
onJump: (_) {}, // No jump functionality in preview
|
||||||
|
progress: null,
|
||||||
|
showAvatar: isLastInGroup,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final compactHeader = isWideScreen(context);
|
||||||
|
|
||||||
|
Widget comfortHeaderWidget() => Column(
|
||||||
|
spacing: 4,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
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(15),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget compactHeaderWidget() => 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
toolbarHeight: compactHeader ? null : 64,
|
||||||
|
title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onPressed: () {
|
||||||
|
context.pushNamed('chatDetail', pathParameters: {'id': id});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: messages.when(
|
||||||
|
data:
|
||||||
|
(messageList) =>
|
||||||
|
messageList.isEmpty
|
||||||
|
? Center(child: Text('No messages yet'.tr()))
|
||||||
|
: chatMessageListWidget(messageList),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error:
|
||||||
|
(error, _) => ResponseErrorWidget(
|
||||||
|
error: error,
|
||||||
|
onRetry: () => messagesNotifier.loadInitial(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Join button at the bottom for public rooms
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
showLoadingModal(context);
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
await apiClient.post('/sphere/chat/${room.id}/members/me');
|
||||||
|
ref.invalidate(chatroomIdentityProvider(id));
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: Text('chatJoin').tr(),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user