Think with messages

This commit is contained in:
2025-10-27 00:23:35 +08:00
parent 50ac7109bb
commit 4b32b65d1c
2 changed files with 301 additions and 107 deletions

View File

@@ -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_input.dart";
import "package:island/widgets/chat/chat_link_attachments.dart"; import "package:island/widgets/chat/chat_link_attachments.dart";
import "package:island/widgets/chat/public_room_preview.dart"; import "package:island/widgets/chat/public_room_preview.dart";
import "package:island/screens/thought/think_sheet.dart";
class ChatRoomScreen extends HookConsumerWidget { class ChatRoomScreen extends HookConsumerWidget {
final String id; final String id;
@@ -145,6 +146,10 @@ class ChatRoomScreen extends HookConsumerWidget {
final attachments = useState<List<UniversalFile>>([]); final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({}); final attachmentProgress = useState<Map<String, Map<int, double>>>({});
// Selection mode state
final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({});
var isLoading = false; var isLoading = false;
var isScrollingToMessage = false; // Flag to prevent scroll conflicts var isScrollingToMessage = false; // Flag to prevent scroll conflicts
@@ -284,6 +289,53 @@ class ChatRoomScreen extends HookConsumerWidget {
return () => messageController.removeListener(onTextChange); return () => messageController.removeListener(onTextChange);
}, [messageController]); }, [messageController]);
// Selection functions
void toggleSelectionMode() {
isSelectionMode.value = !isSelectionMode.value;
if (!isSelectionMode.value) {
selectedMessages.value = {};
}
}
void toggleMessageSelection(String messageId) {
final newSelection = Set<String>.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); final compactHeader = isWideScreen(context);
Widget onlineIndicator() => Row( Widget onlineIndicator() => Row(
@@ -571,42 +623,106 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageWidget = chatIdentity.when( final messageWidget = chatIdentity.when(
skipError: true, skipError: true,
data: data:
(identity) => MessageItem( (identity) => GestureDetector(
key: settings.disableAnimation ? key : null, onLongPress: () {
message: message, if (!isSelectionMode.value) {
isCurrentUser: identity?.id == message.senderId, toggleSelectionMode();
onAction: (action) { toggleMessageSelection(message.id);
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) { onTap: () {
scrollToMessage( if (isSelectionMode.value) {
messageId: messageId, toggleMessageSelection(message.id);
messageList: messageList, }
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
}, },
progress: attachmentProgress.value[message.id], child: Container(
showAvatar: isLastInGroup, 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: loading:
() => MessageItem( () => MessageItem(
@@ -756,71 +872,73 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
), ),
chatRoom.when( if (!isSelectionMode.value)
data: chatRoom.when(
(room) => Column( data:
mainAxisSize: MainAxisSize.min, (room) => Column(
children: [ mainAxisSize: MainAxisSize.min,
ChatInput( children: [
messageController: messageController, ChatInput(
chatRoom: room!, messageController: messageController,
onSend: sendMessage, chatRoom: room!,
onClear: () { onSend: sendMessage,
if (messageEditingTo.value != null) { onClear: () {
attachments.value.clear(); if (messageEditingTo.value != null) {
messageController.clear(); attachments.value.clear();
} messageController.clear();
messageEditingTo.value = null; }
messageReplyingTo.value = null; messageEditingTo.value = null;
messageForwardingTo.value = null; messageReplyingTo.value = null;
}, messageForwardingTo.value = null;
messageEditingTo: messageEditingTo.value, },
messageReplyingTo: messageReplyingTo.value, messageEditingTo: messageEditingTo.value,
messageForwardingTo: messageForwardingTo.value, messageReplyingTo: messageReplyingTo.value,
onPickFile: (bool isPhoto) { messageForwardingTo: messageForwardingTo.value,
if (isPhoto) { onPickFile: (bool isPhoto) {
pickPhotoMedia(); if (isPhoto) {
} else { pickPhotoMedia();
pickVideoMedia(); } else {
} pickVideoMedia();
}, }
onPickAudio: pickAudioMedia, },
onPickGeneralFile: pickGeneralFile, onPickAudio: pickAudioMedia,
onLinkAttachment: linkAttachment, onPickGeneralFile: pickGeneralFile,
attachments: attachments.value, onLinkAttachment: linkAttachment,
onUploadAttachment: uploadAttachment, attachments: attachments.value,
onDeleteAttachment: (index) async { onUploadAttachment: uploadAttachment,
final attachment = attachments.value[index]; onDeleteAttachment: (index) async {
if (attachment.isOnCloud && !attachment.isLink) { final attachment = attachments.value[index];
final client = ref.watch(apiClientProvider); if (attachment.isOnCloud &&
await client.delete( !attachment.isLink) {
'/drive/files/${attachment.data.id}', 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; final clone = List.of(attachments.value);
}, clone.removeAt(index);
onMoveAttachment: (idx, delta) { attachments.value = clone;
if (idx + delta < 0 || },
idx + delta >= attachments.value.length) { onMoveAttachment: (idx, delta) {
return; if (idx + delta < 0 ||
} idx + delta >= attachments.value.length) {
final clone = List.of(attachments.value); return;
clone.insert(idx + delta, clone.removeAt(idx)); }
attachments.value = clone; final clone = List.of(attachments.value);
}, clone.insert(idx + delta, clone.removeAt(idx));
onAttachmentsChanged: (newAttachments) { attachments.value = clone;
attachments.value = newAttachments; },
}, onAttachmentsChanged: (newAttachments) {
attachmentProgress: attachmentProgress.value, attachments.value = newAttachments;
), },
Gap(MediaQuery.of(context).padding.bottom), attachmentProgress: attachmentProgress.value,
], ),
), Gap(MediaQuery.of(context).padding.bottom),
error: (_, _) => const SizedBox.shrink(), ],
loading: () => const SizedBox.shrink(), ),
), 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'),
),
],
),
),
),
], ],
), ),
); );

View File

@@ -43,6 +43,10 @@ class MessageItem extends HookConsumerWidget {
final Map<int, double>? progress; final Map<int, double>? progress;
final bool showAvatar; final bool showAvatar;
final Function(String messageId) onJump; final Function(String messageId) onJump;
final bool isSelectionMode;
final bool isSelected;
final Function(String messageId)? onToggleSelection;
final Function()? onEnterSelectionMode;
const MessageItem({ const MessageItem({
super.key, super.key,
@@ -52,6 +56,10 @@ class MessageItem extends HookConsumerWidget {
required this.progress, required this.progress,
required this.showAvatar, required this.showAvatar,
required this.onJump, required this.onJump,
this.isSelectionMode = false,
this.isSelected = false,
this.onToggleSelection,
this.onEnterSelectionMode,
}); });
static const kFlashDuration = 300; static const kFlashDuration = 300;
@@ -110,6 +118,8 @@ class MessageItem extends HookConsumerWidget {
isMobile: isMobile, isMobile: isMobile,
remoteMessage: remoteMessage, remoteMessage: remoteMessage,
message: message, message: message,
onToggleSelection: onToggleSelection,
onEnterSelectionMode: onEnterSelectionMode,
), ),
); );
} }
@@ -182,17 +192,27 @@ class MessageItem extends HookConsumerWidget {
child: InkWell( child: InkWell(
mouseCursor: MouseCursor.defer, mouseCursor: MouseCursor.defer,
focusColor: Colors.transparent, focusColor: Colors.transparent,
onLongPress: showActionMenu, onLongPress: () {
if (isSelectionMode && onToggleSelection != null) {
onToggleSelection!(message.id);
} else {
showActionMenu();
}
},
onSecondaryTap: showActionMenu, onSecondaryTap: showActionMenu,
onTap: () { onTap: () {
// Jump to related message if (isSelectionMode && onToggleSelection != null) {
if ([ onToggleSelection!(message.id);
'messages.update', } else {
'messages.delete', // Jump to related message
].contains(message.type) && if ([
message.meta['message_id'] is String && 'messages.update',
message.meta['message_id'] != null) { 'messages.delete',
onJump(message.meta['message_id']); ].contains(message.type) &&
message.meta['message_id'] is String &&
message.meta['message_id'] != null) {
onJump(message.meta['message_id']);
}
} }
}, },
child: SizedBox( child: SizedBox(
@@ -271,6 +291,8 @@ class MessageActionSheet extends StatefulWidget {
final bool isMobile; final bool isMobile;
final dynamic remoteMessage; final dynamic remoteMessage;
final LocalChatMessage message; final LocalChatMessage message;
final Function(String messageId)? onToggleSelection;
final Function()? onEnterSelectionMode;
const MessageActionSheet({ const MessageActionSheet({
super.key, super.key,
@@ -283,6 +305,8 @@ class MessageActionSheet extends StatefulWidget {
required this.isMobile, required this.isMobile,
required this.remoteMessage, required this.remoteMessage,
required this.message, required this.message,
this.onToggleSelection,
this.onEnterSelectionMode,
}); });
@override @override
@@ -461,6 +485,21 @@ class _MessageActionSheetState extends State<MessageActionSheet> {
}, },
), ),
// 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) const Divider(),
if (widget.translatableLanguage) if (widget.translatableLanguage)
_ActionListTile( _ActionListTile(