1044 lines
36 KiB
Dart
1044 lines
36 KiB
Dart
import 'package:auto_route/auto_route.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
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:image_picker/image_picker.dart';
|
|
import 'package:island/database/message.dart';
|
|
import 'package:island/database/message_repository.dart';
|
|
import 'package:island/models/chat.dart';
|
|
import 'package:island/models/file.dart';
|
|
import 'package:island/pods/config.dart';
|
|
import 'package:island/pods/message.dart';
|
|
import 'package:island/pods/network.dart';
|
|
import 'package:island/pods/websocket.dart';
|
|
import 'package:island/route.gr.dart';
|
|
import 'package:island/screens/posts/compose.dart';
|
|
import 'package:island/widgets/alert.dart';
|
|
import 'package:island/widgets/content/cloud_file_collection.dart';
|
|
import 'package:island/widgets/content/cloud_files.dart';
|
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
import 'package:super_context_menu/super_context_menu.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'chat.dart';
|
|
|
|
final messageRepositoryProvider = FutureProvider.family<MessageRepository, int>(
|
|
(ref, roomId) async {
|
|
final room = await ref.watch(chatroomProvider(roomId).future);
|
|
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
|
final apiClient = ref.watch(apiClientProvider);
|
|
final database = ref.watch(databaseProvider);
|
|
return MessageRepository(room!, identity!, apiClient, database);
|
|
},
|
|
);
|
|
|
|
// Provider for messages with pagination
|
|
final messagesProvider = StateNotifierProvider.family<
|
|
MessagesNotifier,
|
|
AsyncValue<List<LocalChatMessage>>,
|
|
int
|
|
>((ref, roomId) => MessagesNotifier(ref, roomId));
|
|
|
|
class MessagesNotifier
|
|
extends StateNotifier<AsyncValue<List<LocalChatMessage>>> {
|
|
final Ref _ref;
|
|
final int _roomId;
|
|
int _currentPage = 0;
|
|
static const int _pageSize = 20;
|
|
bool _hasMore = true;
|
|
|
|
MessagesNotifier(this._ref, this._roomId)
|
|
: super(const AsyncValue.loading()) {
|
|
loadInitial();
|
|
}
|
|
|
|
Future<void> loadInitial() async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
final synced = await repository.syncMessages();
|
|
final messages = await repository.listMessages(
|
|
offset: 0,
|
|
take: _pageSize,
|
|
synced: synced,
|
|
);
|
|
state = AsyncValue.data(messages);
|
|
_currentPage = 0;
|
|
_hasMore = messages.length == _pageSize;
|
|
} catch (e, stack) {
|
|
state = AsyncValue.error(e, stack);
|
|
}
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (!_hasMore || state is AsyncLoading) return;
|
|
|
|
try {
|
|
final currentMessages = state.value ?? [];
|
|
_currentPage++;
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
final newMessages = await repository.listMessages(
|
|
offset: _currentPage * _pageSize,
|
|
take: _pageSize,
|
|
);
|
|
|
|
if (newMessages.isEmpty || newMessages.length < _pageSize) {
|
|
_hasMore = false;
|
|
}
|
|
|
|
state = AsyncValue.data([...currentMessages, ...newMessages]);
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
_currentPage--;
|
|
}
|
|
}
|
|
|
|
Future<void> sendMessage(
|
|
String content,
|
|
List<UniversalFile> attachments, {
|
|
SnChatMessage? replyingTo,
|
|
SnChatMessage? forwardingTo,
|
|
SnChatMessage? editingTo,
|
|
}) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
final nonce = const Uuid().v4();
|
|
|
|
final baseUrl = _ref.read(serverUrlProvider);
|
|
final atk = await getFreshAtk(
|
|
_ref.watch(tokenPairProvider),
|
|
baseUrl,
|
|
onRefreshed: (atk, rtk) {
|
|
setTokenPair(_ref.watch(sharedPreferencesProvider), atk, rtk);
|
|
_ref.invalidate(tokenPairProvider);
|
|
},
|
|
);
|
|
if (atk == null) throw Exception("Unauthorized");
|
|
|
|
final messageTask = repository.sendMessage(
|
|
atk,
|
|
baseUrl,
|
|
_roomId,
|
|
content,
|
|
nonce,
|
|
attachments: attachments,
|
|
replyingTo: replyingTo,
|
|
forwardingTo: forwardingTo,
|
|
editingTo: editingTo,
|
|
);
|
|
final pendingMessage = repository.pendingMessages.values.firstWhereOrNull(
|
|
(m) => m.roomId == _roomId && m.nonce == nonce,
|
|
);
|
|
if (pendingMessage != null) {
|
|
final currentMessages = state.value ?? [];
|
|
state = AsyncValue.data([pendingMessage, ...currentMessages]);
|
|
}
|
|
|
|
final message = await messageTask;
|
|
|
|
final updatedMessages = state.value ?? [];
|
|
if (pendingMessage != null) {
|
|
final index = updatedMessages.indexWhere(
|
|
(m) => m.id == pendingMessage.id,
|
|
);
|
|
if (index >= 0) {
|
|
final newList = [...updatedMessages];
|
|
newList[index] = message;
|
|
state = AsyncValue.data(newList);
|
|
}
|
|
} else {
|
|
state = AsyncValue.data([message, ...updatedMessages]);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> retryMessage(String pendingMessageId) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
final updatedMessage = await repository.retryMessage(pendingMessageId);
|
|
|
|
// Update the message in the list
|
|
final currentMessages = state.value ?? [];
|
|
final index = currentMessages.indexWhere((m) => m.id == pendingMessageId);
|
|
if (index >= 0) {
|
|
final newList = [...currentMessages];
|
|
newList[index] = updatedMessage;
|
|
state = AsyncValue.data(newList);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
// Skip if this message is not for this room
|
|
if (remoteMessage.chatRoomId != _roomId) return;
|
|
|
|
final localMessage = await repository.receiveMessage(remoteMessage);
|
|
|
|
// Add the new message to the state
|
|
final currentMessages = state.value ?? [];
|
|
|
|
// Check if the message already exists (by id or nonce)
|
|
final existingIndex = currentMessages.indexWhere(
|
|
(m) =>
|
|
m.id == localMessage.id ||
|
|
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
// Replace existing message
|
|
final newList = [...currentMessages];
|
|
newList[existingIndex] = localMessage;
|
|
state = AsyncValue.data(newList);
|
|
} else {
|
|
// Add new message at the beginning (newest first)
|
|
state = AsyncValue.data([localMessage, ...currentMessages]);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
// Skip if this message is not for this room
|
|
if (remoteMessage.chatRoomId != _roomId) return;
|
|
|
|
final updatedMessage = await repository.receiveMessageUpdate(
|
|
remoteMessage,
|
|
);
|
|
|
|
// Update the message in the list
|
|
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(newList);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> receiveMessageDeletion(String messageId) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
await repository.receiveMessageDeletion(messageId);
|
|
|
|
// Remove the message from the list
|
|
final currentMessages = state.value ?? [];
|
|
final filteredMessages =
|
|
currentMessages.where((m) => m.id != messageId).toList();
|
|
|
|
if (filteredMessages.length != currentMessages.length) {
|
|
state = AsyncValue.data(filteredMessages);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> updateMessage(
|
|
String messageId,
|
|
String content, {
|
|
List<SnCloudFile>? attachments,
|
|
Map<String, dynamic>? meta,
|
|
}) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
final updatedMessage = await repository.updateMessage(
|
|
messageId,
|
|
content,
|
|
attachments: attachments,
|
|
meta: meta,
|
|
);
|
|
|
|
// Update the message in the list
|
|
final currentMessages = state.value ?? [];
|
|
final index = currentMessages.indexWhere((m) => m.id == messageId);
|
|
|
|
if (index >= 0) {
|
|
final newList = [...currentMessages];
|
|
newList[index] = updatedMessage;
|
|
state = AsyncValue.data(newList);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<void> deleteMessage(String messageId) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
|
|
await repository.deleteMessage(messageId);
|
|
|
|
// Remove the message from the list
|
|
final currentMessages = state.value ?? [];
|
|
final filteredMessages =
|
|
currentMessages.where((m) => m.id != messageId).toList();
|
|
|
|
if (filteredMessages.length != currentMessages.length) {
|
|
state = AsyncValue.data(filteredMessages);
|
|
}
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
}
|
|
}
|
|
|
|
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
|
|
try {
|
|
final repository = await _ref.read(
|
|
messageRepositoryProvider(_roomId).future,
|
|
);
|
|
return await repository.getMessageById(messageId);
|
|
} catch (err) {
|
|
showErrorAlert(err);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
@RoutePage()
|
|
class ChatRoomScreen extends HookConsumerWidget {
|
|
final int id;
|
|
const ChatRoomScreen({super.key, @PathParam("id") required this.id});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final chatRoom = ref.watch(chatroomProvider(id));
|
|
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
|
final messages = ref.watch(messagesProvider(id));
|
|
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
|
final ws = ref.watch(websocketProvider);
|
|
|
|
final messageController = useTextEditingController();
|
|
final scrollController = useScrollController();
|
|
|
|
final messageReplyingTo = useState<SnChatMessage?>(null);
|
|
final messageForwardingTo = useState<SnChatMessage?>(null);
|
|
final messageEditingTo = useState<SnChatMessage?>(null);
|
|
|
|
// Add scroll listener for pagination
|
|
useEffect(() {
|
|
void onScroll() {
|
|
if (scrollController.position.pixels >=
|
|
scrollController.position.maxScrollExtent - 200) {
|
|
messagesNotifier.loadMore();
|
|
}
|
|
}
|
|
|
|
scrollController.addListener(onScroll);
|
|
return () => scrollController.removeListener(onScroll);
|
|
}, [scrollController]);
|
|
|
|
// Add websocket listener
|
|
// Add websocket listener for new messages
|
|
useEffect(() {
|
|
void onMessage(WebSocketPacket pkt) {
|
|
if (!pkt.type.startsWith('messages')) return;
|
|
final message = SnChatMessage.fromJson(pkt.data!);
|
|
if (message.chatRoomId != chatRoom.value?.id) return;
|
|
switch (pkt.type) {
|
|
case 'messages.new':
|
|
messagesNotifier.receiveMessage(message);
|
|
case 'messages.update':
|
|
messagesNotifier.receiveMessageUpdate(message);
|
|
case 'messages.delete':
|
|
messagesNotifier.receiveMessageDeletion(message.id);
|
|
}
|
|
}
|
|
|
|
final subscription = ws.dataStream.listen(onMessage);
|
|
return () => subscription.cancel();
|
|
}, [ws, chatRoom]);
|
|
|
|
final attachments = useState<List<UniversalFile>>([]);
|
|
|
|
Future<void> pickPhotoMedia() async {
|
|
final result = await ref
|
|
.watch(imagePickerProvider)
|
|
.pickMultiImage(requestFullMetadata: true);
|
|
if (result.isEmpty) return;
|
|
attachments.value = [
|
|
...attachments.value,
|
|
...result.map(
|
|
(e) => UniversalFile(data: e, type: UniversalFileType.image),
|
|
),
|
|
];
|
|
}
|
|
|
|
Future<void> pickVideoMedia() async {
|
|
final result = await ref
|
|
.watch(imagePickerProvider)
|
|
.pickVideo(source: ImageSource.gallery);
|
|
if (result == null) return;
|
|
attachments.value = [
|
|
...attachments.value,
|
|
UniversalFile(data: result, type: UniversalFileType.video),
|
|
];
|
|
}
|
|
|
|
void sendMessage() {
|
|
if (messageController.text.trim().isNotEmpty) {
|
|
messagesNotifier.sendMessage(
|
|
messageController.text.trim(),
|
|
attachments.value,
|
|
editingTo: messageEditingTo.value,
|
|
forwardingTo: messageForwardingTo.value,
|
|
replyingTo: messageReplyingTo.value,
|
|
);
|
|
messageController.clear();
|
|
messageEditingTo.value = null;
|
|
messageReplyingTo.value = null;
|
|
messageForwardingTo.value = null;
|
|
attachments.value = [];
|
|
}
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: chatRoom.when(
|
|
data:
|
|
(room) => Row(
|
|
spacing: 8,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
height: 26,
|
|
width: 26,
|
|
child:
|
|
room?.pictureId != null
|
|
? ProfilePictureWidget(
|
|
fileId: room?.pictureId,
|
|
fallbackIcon: Symbols.chat,
|
|
)
|
|
: CircleAvatar(
|
|
child: Text(
|
|
room?.name[0].toUpperCase() ?? '',
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
Text(room?.name ?? 'unknown'.tr()).fontSize(19),
|
|
],
|
|
),
|
|
loading: () => const Text('Loading...'),
|
|
error: (_, __) => const Text('Error'),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.more_vert),
|
|
onPressed: () {
|
|
context.router.push(ChatDetailRoute(id: id));
|
|
},
|
|
),
|
|
const Gap(8),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: messages.when(
|
|
data:
|
|
(messageList) =>
|
|
messageList.isEmpty
|
|
? Center(child: Text('No messages yet'.tr()))
|
|
: ListView.builder(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
controller: scrollController,
|
|
reverse: true, // Show newest messages at the bottom
|
|
itemCount: messageList.length,
|
|
itemBuilder: (context, index) {
|
|
final message = messageList[index];
|
|
return chatIdentity.when(
|
|
skipError: true,
|
|
data:
|
|
(identity) => _MessageBubble(
|
|
message: message,
|
|
isCurrentUser:
|
|
identity?.id == message.senderId,
|
|
onAction: (action) {
|
|
switch (action) {
|
|
case _MessageBubbleAction.delete:
|
|
messagesNotifier.deleteMessage(
|
|
message.id,
|
|
);
|
|
case _MessageBubbleAction.edit:
|
|
messageEditingTo.value =
|
|
message.toRemoteMessage();
|
|
messageController.text =
|
|
messageEditingTo
|
|
.value
|
|
?.content ??
|
|
'';
|
|
attachments.value =
|
|
messageEditingTo
|
|
.value!
|
|
.attachments
|
|
.map(
|
|
(e) =>
|
|
UniversalFile.fromAttachment(
|
|
e,
|
|
),
|
|
)
|
|
.toList();
|
|
case _MessageBubbleAction.forward:
|
|
messageForwardingTo.value =
|
|
message.toRemoteMessage();
|
|
case _MessageBubbleAction.reply:
|
|
messageReplyingTo.value =
|
|
message.toRemoteMessage();
|
|
}
|
|
},
|
|
),
|
|
loading:
|
|
() => _MessageBubble(
|
|
message: message,
|
|
isCurrentUser: false,
|
|
onAction: null,
|
|
),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
);
|
|
},
|
|
),
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error:
|
|
(error, stack) => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('Error: $error'),
|
|
ElevatedButton(
|
|
onPressed: () => messagesNotifier.loadInitial(),
|
|
child: Text('Retry'.tr()),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
chatRoom.when(
|
|
data:
|
|
(room) => _ChatInput(
|
|
messageController: messageController,
|
|
chatRoom: room!,
|
|
onSend: sendMessage,
|
|
onClear: () {
|
|
if (messageEditingTo.value != null) {
|
|
attachments.value.clear();
|
|
messageController.clear();
|
|
}
|
|
messageEditingTo.value = null;
|
|
messageReplyingTo.value = null;
|
|
messageForwardingTo.value = null;
|
|
},
|
|
messageEditingTo: messageEditingTo.value,
|
|
messageReplyingTo: messageReplyingTo.value,
|
|
messageForwardingTo: messageForwardingTo.value,
|
|
onPickFile: (bool isVideo) {
|
|
if (isVideo) {
|
|
pickPhotoMedia();
|
|
} else {
|
|
pickVideoMedia();
|
|
}
|
|
},
|
|
attachments: attachments.value,
|
|
onUploadAttachment: (_) {
|
|
// not going to do anything, only upload when send the message
|
|
},
|
|
onDeleteAttachment: (index) async {
|
|
final attachment = attachments.value[index];
|
|
if (attachment.isOnCloud) {
|
|
final client = ref.watch(apiClientProvider);
|
|
await client.delete('/files/${attachment.data.id}');
|
|
}
|
|
final clone = List.of(attachments.value);
|
|
clone.removeAt(index);
|
|
attachments.value = clone;
|
|
},
|
|
onMoveAttachment: (idx, delta) {
|
|
if (idx + delta < 0 ||
|
|
idx + delta >= attachments.value.length) {
|
|
return;
|
|
}
|
|
final clone = List.of(attachments.value);
|
|
clone.insert(idx + delta, clone.removeAt(idx));
|
|
attachments.value = clone;
|
|
},
|
|
),
|
|
error: (_, __) => const SizedBox.shrink(),
|
|
loading: () => const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatInput extends StatelessWidget {
|
|
final TextEditingController messageController;
|
|
final SnChat chatRoom;
|
|
final VoidCallback onSend;
|
|
final VoidCallback onClear;
|
|
final Function(bool isVideo) 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;
|
|
|
|
const _ChatInput({
|
|
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,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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 AttachmentPreview(
|
|
item: attachments[idx],
|
|
onRequestUpload: () => onUploadAttachment(idx),
|
|
onDelete: () => onDeleteAttachment(idx),
|
|
onMove: (delta) => onMoveAttachment(idx, delta),
|
|
);
|
|
},
|
|
separatorBuilder: (_, __) => const Gap(8),
|
|
),
|
|
),
|
|
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: [
|
|
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(true),
|
|
child: Row(
|
|
spacing: 12,
|
|
children: [
|
|
const Icon(Symbols.video_call),
|
|
Text('addVideo').tr(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: messageController,
|
|
decoration: InputDecoration(
|
|
hintText: 'chatMessageHint'.tr(args: [chatRoom.name]),
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 4,
|
|
),
|
|
),
|
|
maxLines: null,
|
|
onTapOutside:
|
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
onSubmitted: (_) => onSend(),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.send),
|
|
color: Theme.of(context).colorScheme.primary,
|
|
onPressed: onSend,
|
|
),
|
|
],
|
|
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageBubbleAction {
|
|
static const String edit = "edit";
|
|
static const String delete = "delete";
|
|
static const String reply = "reply";
|
|
static const String forward = "forward";
|
|
}
|
|
|
|
class _MessageBubble extends HookConsumerWidget {
|
|
final LocalChatMessage message;
|
|
final bool isCurrentUser;
|
|
final Function(String action)? onAction;
|
|
|
|
const _MessageBubble({
|
|
required this.message,
|
|
required this.isCurrentUser,
|
|
required this.onAction,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final textColor =
|
|
isCurrentUser
|
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
|
: Theme.of(context).colorScheme.onSurfaceVariant;
|
|
final containerColor =
|
|
isCurrentUser
|
|
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
|
|
: Theme.of(context).colorScheme.surfaceContainer;
|
|
|
|
return ContextMenuWidget(
|
|
menuProvider: (_) {
|
|
if (onAction == null) return Menu(children: []);
|
|
return Menu(
|
|
children: [
|
|
if (isCurrentUser)
|
|
MenuAction(
|
|
title: 'edit'.tr(),
|
|
image: MenuImage.icon(Symbols.edit),
|
|
callback: () {
|
|
onAction!.call(_MessageBubbleAction.edit);
|
|
},
|
|
),
|
|
if (isCurrentUser)
|
|
MenuAction(
|
|
title: 'delete'.tr(),
|
|
image: MenuImage.icon(Symbols.delete),
|
|
callback: () {
|
|
onAction!.call(_MessageBubbleAction.delete);
|
|
},
|
|
),
|
|
if (isCurrentUser) MenuSeparator(),
|
|
MenuAction(
|
|
title: 'reply'.tr(),
|
|
image: MenuImage.icon(Symbols.reply),
|
|
callback: () {
|
|
onAction!.call(_MessageBubbleAction.reply);
|
|
},
|
|
),
|
|
MenuAction(
|
|
title: 'forward'.tr(),
|
|
image: MenuImage.icon(Symbols.forward),
|
|
callback: () {
|
|
onAction!.call(_MessageBubbleAction.forward);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
child: Material(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
mainAxisAlignment:
|
|
isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
|
children: [
|
|
if (!isCurrentUser)
|
|
ProfilePictureWidget(
|
|
fileId:
|
|
message
|
|
.toRemoteMessage()
|
|
.sender
|
|
.account
|
|
.profile
|
|
.pictureId,
|
|
radius: 18,
|
|
),
|
|
const Gap(8),
|
|
Flexible(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: containerColor,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (message.toRemoteMessage().repliedMessageId != null)
|
|
_MessageQuoteWidget(
|
|
message: message,
|
|
textColor: textColor,
|
|
isReply: true,
|
|
),
|
|
if (message.toRemoteMessage().forwardedMessageId != null)
|
|
_MessageQuoteWidget(
|
|
message: message,
|
|
textColor: textColor,
|
|
isReply: false,
|
|
),
|
|
Text(
|
|
message.toRemoteMessage().content ?? '',
|
|
style: TextStyle(color: textColor),
|
|
),
|
|
if (message.toRemoteMessage().attachments.isNotEmpty)
|
|
CloudFileList(
|
|
files: message.toRemoteMessage().attachments,
|
|
).padding(top: 4),
|
|
const Gap(4),
|
|
Row(
|
|
spacing: 4,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
DateFormat.Hm().format(message.createdAt.toLocal()),
|
|
style: TextStyle(fontSize: 10, color: textColor),
|
|
),
|
|
if (message.toRemoteMessage().editedAt != null)
|
|
Text(
|
|
'edited'.tr().toLowerCase(),
|
|
style: TextStyle(fontSize: 10, color: textColor),
|
|
),
|
|
if (isCurrentUser)
|
|
_buildStatusIcon(
|
|
context,
|
|
message.status,
|
|
textColor,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const Gap(8),
|
|
if (isCurrentUser)
|
|
ProfilePictureWidget(
|
|
fileId:
|
|
message
|
|
.toRemoteMessage()
|
|
.sender
|
|
.account
|
|
.profile
|
|
.pictureId,
|
|
radius: 18,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(messagesProvider(message.roomId).notifier)
|
|
.retryMessage(message.id);
|
|
},
|
|
child: const Icon(
|
|
Icons.error_outline,
|
|
size: 12,
|
|
color: Colors.red,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _MessageQuoteWidget extends HookConsumerWidget {
|
|
final LocalChatMessage message;
|
|
final Color textColor;
|
|
final bool isReply;
|
|
|
|
const _MessageQuoteWidget({
|
|
Key? key,
|
|
required this.message,
|
|
required this.textColor,
|
|
required this.isReply,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final messagesNotifier = ref.watch(
|
|
messagesProvider(message.roomId).notifier,
|
|
);
|
|
|
|
return FutureBuilder<LocalChatMessage?>(
|
|
future: messagesNotifier.fetchMessageById(
|
|
isReply
|
|
? message.toRemoteMessage().repliedMessageId!
|
|
: message.toRemoteMessage().forwardedMessageId!,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasData) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (isReply)
|
|
Row(
|
|
spacing: 4,
|
|
children: [
|
|
Icon(Symbols.reply, size: 16, color: textColor),
|
|
Text(
|
|
'Replying to ${snapshot.data!.toRemoteMessage().sender.account.nick}',
|
|
).textColor(textColor).bold(),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
spacing: 4,
|
|
children: [
|
|
Icon(Symbols.forward, size: 16, color: textColor),
|
|
Text(
|
|
'Forwarded from ${snapshot.data!.toRemoteMessage().sender.account.nick}',
|
|
).textColor(textColor).bold(),
|
|
],
|
|
),
|
|
Text(
|
|
snapshot.data!.toRemoteMessage().content ?? "",
|
|
style: TextStyle(color: textColor),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
).padding(bottom: 4);
|
|
} else {
|
|
return SizedBox.shrink();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|