From 4b32b65d1c63e54f4291adaa530f2ed39e81f984 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 27 Oct 2025 00:23:35 +0800 Subject: [PATCH] :sparkles: Think with messages --- lib/screens/chat/room.dart | 351 +++++++++++++++++++++-------- lib/widgets/chat/message_item.dart | 57 ++++- 2 files changed, 301 insertions(+), 107 deletions(-) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 596d65f2..1c21c921 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -34,6 +34,7 @@ import "package:island/widgets/chat/call_button.dart"; import "package:island/widgets/chat/chat_input.dart"; import "package:island/widgets/chat/chat_link_attachments.dart"; import "package:island/widgets/chat/public_room_preview.dart"; +import "package:island/screens/thought/think_sheet.dart"; class ChatRoomScreen extends HookConsumerWidget { final String id; @@ -145,6 +146,10 @@ class ChatRoomScreen extends HookConsumerWidget { final attachments = useState>([]); final attachmentProgress = useState>>({}); + // Selection mode state + final isSelectionMode = useState(false); + final selectedMessages = useState>({}); + var isLoading = false; var isScrollingToMessage = false; // Flag to prevent scroll conflicts @@ -284,6 +289,53 @@ class ChatRoomScreen extends HookConsumerWidget { return () => messageController.removeListener(onTextChange); }, [messageController]); + // Selection functions + void toggleSelectionMode() { + isSelectionMode.value = !isSelectionMode.value; + if (!isSelectionMode.value) { + selectedMessages.value = {}; + } + } + + void toggleMessageSelection(String messageId) { + final newSelection = Set.from(selectedMessages.value); + if (newSelection.contains(messageId)) { + newSelection.remove(messageId); + } else { + newSelection.add(messageId); + } + selectedMessages.value = newSelection; + } + + void openThinkingSheet() { + if (selectedMessages.value.isEmpty) return; + + // Convert selected message IDs to message data + final selectedMessageData = + messages.valueOrNull + ?.where((msg) => selectedMessages.value.contains(msg.id)) + .map( + (msg) => { + 'id': msg.id, + 'content': msg.content, + 'senderId': msg.senderId, + 'createdAt': msg.createdAt.toIso8601String(), + 'attachments': msg.attachments, + }, + ) + .toList() ?? + []; + + ThoughtSheet.show( + context, + attachedMessages: selectedMessageData, + attachedPosts: [], // Could be extended to include posts + ); + + // Exit selection mode after opening + toggleSelectionMode(); + } + final compactHeader = isWideScreen(context); Widget onlineIndicator() => Row( @@ -571,42 +623,106 @@ class ChatRoomScreen extends HookConsumerWidget { final messageWidget = chatIdentity.when( skipError: true, data: - (identity) => MessageItem( - key: settings.disableAnimation ? key : null, - message: message, - isCurrentUser: identity?.id == message.senderId, - onAction: (action) { - switch (action) { - case MessageItemAction.delete: - messagesNotifier.deleteMessage(message.id); - case MessageItemAction.edit: - messageEditingTo.value = message.toRemoteMessage(); - messageController.text = - messageEditingTo.value?.content ?? ''; - attachments.value = - messageEditingTo.value!.attachments - .map((e) => UniversalFile.fromAttachment(e)) - .toList(); - case MessageItemAction.forward: - messageForwardingTo.value = message.toRemoteMessage(); - case MessageItemAction.reply: - messageReplyingTo.value = message.toRemoteMessage(); - case MessageItemAction.resend: - messagesNotifier.retryMessage(message.id); + (identity) => GestureDetector( + onLongPress: () { + if (!isSelectionMode.value) { + toggleSelectionMode(); + toggleMessageSelection(message.id); } }, - onJump: (messageId) { - scrollToMessage( - messageId: messageId, - messageList: messageList, - messagesNotifier: messagesNotifier, - listController: listController, - scrollController: scrollController, - ref: ref, - ); + onTap: () { + if (isSelectionMode.value) { + toggleMessageSelection(message.id); + } }, - progress: attachmentProgress.value[message.id], - showAvatar: isLastInGroup, + child: Container( + color: + selectedMessages.value.contains(message.id) + ? Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.3) + : null, + child: Stack( + children: [ + MessageItem( + key: settings.disableAnimation ? key : null, + message: message, + isCurrentUser: identity?.id == message.senderId, + onAction: + isSelectionMode.value + ? null + : (action) { + switch (action) { + case MessageItemAction.delete: + messagesNotifier.deleteMessage( + message.id, + ); + case MessageItemAction.edit: + messageEditingTo.value = + message.toRemoteMessage(); + messageController.text = + messageEditingTo.value?.content ?? ''; + attachments.value = + messageEditingTo.value!.attachments + .map( + (e) => + UniversalFile.fromAttachment( + e, + ), + ) + .toList(); + case MessageItemAction.forward: + messageForwardingTo.value = + message.toRemoteMessage(); + case MessageItemAction.reply: + messageReplyingTo.value = + message.toRemoteMessage(); + case MessageItemAction.resend: + messagesNotifier.retryMessage(message.id); + } + }, + onJump: (messageId) { + scrollToMessage( + messageId: messageId, + messageList: messageList, + messagesNotifier: messagesNotifier, + listController: listController, + scrollController: scrollController, + ref: ref, + ); + }, + progress: attachmentProgress.value[message.id], + showAvatar: isLastInGroup, + isSelectionMode: isSelectionMode.value, + isSelected: selectedMessages.value.contains(message.id), + onToggleSelection: toggleMessageSelection, + onEnterSelectionMode: () { + if (!isSelectionMode.value) { + toggleSelectionMode(); + } + }, + ), + if (selectedMessages.value.contains(message.id)) + Positioned( + top: 8, + right: 8, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ], + ), + ), ), loading: () => MessageItem( @@ -756,71 +872,73 @@ class ChatRoomScreen extends HookConsumerWidget { ), ), ), - chatRoom.when( - data: - (room) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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 isPhoto) { - if (isPhoto) { - pickPhotoMedia(); - } else { - pickVideoMedia(); - } - }, - onPickAudio: pickAudioMedia, - onPickGeneralFile: pickGeneralFile, - onLinkAttachment: linkAttachment, - attachments: attachments.value, - onUploadAttachment: uploadAttachment, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud && !attachment.isLink) { - final client = ref.watch(apiClientProvider); - await client.delete( - '/drive/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; - }, - onAttachmentsChanged: (newAttachments) { - attachments.value = newAttachments; - }, - attachmentProgress: attachmentProgress.value, - ), - Gap(MediaQuery.of(context).padding.bottom), - ], - ), - error: (_, _) => const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), - ), + if (!isSelectionMode.value) + chatRoom.when( + data: + (room) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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 isPhoto) { + if (isPhoto) { + pickPhotoMedia(); + } else { + pickVideoMedia(); + } + }, + onPickAudio: pickAudioMedia, + onPickGeneralFile: pickGeneralFile, + onLinkAttachment: linkAttachment, + attachments: attachments.value, + onUploadAttachment: uploadAttachment, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud && + !attachment.isLink) { + final client = ref.watch(apiClientProvider); + await client.delete( + '/drive/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; + }, + onAttachmentsChanged: (newAttachments) { + attachments.value = newAttachments; + }, + attachmentProgress: attachmentProgress.value, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), + error: (_, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), ], ), ), @@ -859,6 +977,43 @@ class ChatRoomScreen extends HookConsumerWidget { ), ), ), + // Selection mode toolbar + if (isSelectionMode.value) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + color: Theme.of(context).colorScheme.surface, + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: MediaQuery.of(context).padding.bottom + 8, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: toggleSelectionMode, + tooltip: 'Cancel selection', + ), + const SizedBox(width: 8), + Text( + '${selectedMessages.value.length} selected', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + if (selectedMessages.value.isNotEmpty) + FilledButton.icon( + onPressed: openThinkingSheet, + icon: Icon(Symbols.smart_toy), + label: const Text('AI Think'), + ), + ], + ), + ), + ), ], ), ); diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 74eb6a05..ae8ecc41 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -43,6 +43,10 @@ class MessageItem extends HookConsumerWidget { final Map? progress; final bool showAvatar; final Function(String messageId) onJump; + final bool isSelectionMode; + final bool isSelected; + final Function(String messageId)? onToggleSelection; + final Function()? onEnterSelectionMode; const MessageItem({ super.key, @@ -52,6 +56,10 @@ class MessageItem extends HookConsumerWidget { required this.progress, required this.showAvatar, required this.onJump, + this.isSelectionMode = false, + this.isSelected = false, + this.onToggleSelection, + this.onEnterSelectionMode, }); static const kFlashDuration = 300; @@ -110,6 +118,8 @@ class MessageItem extends HookConsumerWidget { isMobile: isMobile, remoteMessage: remoteMessage, message: message, + onToggleSelection: onToggleSelection, + onEnterSelectionMode: onEnterSelectionMode, ), ); } @@ -182,17 +192,27 @@ class MessageItem extends HookConsumerWidget { child: InkWell( mouseCursor: MouseCursor.defer, focusColor: Colors.transparent, - onLongPress: showActionMenu, + onLongPress: () { + if (isSelectionMode && onToggleSelection != null) { + onToggleSelection!(message.id); + } else { + showActionMenu(); + } + }, onSecondaryTap: showActionMenu, onTap: () { - // Jump to related message - if ([ - 'messages.update', - 'messages.delete', - ].contains(message.type) && - message.meta['message_id'] is String && - message.meta['message_id'] != null) { - onJump(message.meta['message_id']); + if (isSelectionMode && onToggleSelection != null) { + onToggleSelection!(message.id); + } else { + // Jump to related message + if ([ + 'messages.update', + 'messages.delete', + ].contains(message.type) && + message.meta['message_id'] is String && + message.meta['message_id'] != null) { + onJump(message.meta['message_id']); + } } }, child: SizedBox( @@ -271,6 +291,8 @@ class MessageActionSheet extends StatefulWidget { final bool isMobile; final dynamic remoteMessage; final LocalChatMessage message; + final Function(String messageId)? onToggleSelection; + final Function()? onEnterSelectionMode; const MessageActionSheet({ super.key, @@ -283,6 +305,8 @@ class MessageActionSheet extends StatefulWidget { required this.isMobile, required this.remoteMessage, required this.message, + this.onToggleSelection, + this.onEnterSelectionMode, }); @override @@ -461,6 +485,21 @@ class _MessageActionSheetState extends State { }, ), + // AI Selection action + _ActionListTile( + leading: Icon(Symbols.smart_toy), + title: Text('Select for AI'), + onTap: () { + if (widget.onEnterSelectionMode != null) { + widget.onEnterSelectionMode!(); + if (widget.onToggleSelection != null) { + widget.onToggleSelection!(widget.message.id); + } + } + Navigator.pop(context); + }, + ), + if (widget.translatableLanguage) const Divider(), if (widget.translatableLanguage) _ActionListTile(