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,26 +623,60 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageWidget = chatIdentity.when( final messageWidget = chatIdentity.when(
skipError: true, skipError: true,
data: data:
(identity) => MessageItem( (identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
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, key: settings.disableAnimation ? key : null,
message: message, message: message,
isCurrentUser: identity?.id == message.senderId, isCurrentUser: identity?.id == message.senderId,
onAction: (action) { onAction:
isSelectionMode.value
? null
: (action) {
switch (action) { switch (action) {
case MessageItemAction.delete: case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id); messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit: case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage(); messageEditingTo.value =
message.toRemoteMessage();
messageController.text = messageController.text =
messageEditingTo.value?.content ?? ''; messageEditingTo.value?.content ?? '';
attachments.value = attachments.value =
messageEditingTo.value!.attachments messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e)) .map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList(); .toList();
case MessageItemAction.forward: case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage(); messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply: case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage(); messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend: case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id); messagesNotifier.retryMessage(message.id);
} }
@@ -607,6 +693,36 @@ class ChatRoomScreen extends HookConsumerWidget {
}, },
progress: attachmentProgress.value[message.id], progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup, 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,6 +872,7 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
), ),
if (!isSelectionMode.value)
chatRoom.when( chatRoom.when(
data: data:
(room) => Column( (room) => Column(
@@ -791,7 +908,8 @@ class ChatRoomScreen extends HookConsumerWidget {
onUploadAttachment: uploadAttachment, onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async { onDeleteAttachment: (index) async {
final attachment = attachments.value[index]; final attachment = attachments.value[index];
if (attachment.isOnCloud && !attachment.isLink) { if (attachment.isOnCloud &&
!attachment.isLink) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
await client.delete( await client.delete(
'/drive/files/${attachment.data.id}', '/drive/files/${attachment.data.id}',
@@ -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,9 +192,18 @@ 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: () {
if (isSelectionMode && onToggleSelection != null) {
onToggleSelection!(message.id);
} else {
// Jump to related message // Jump to related message
if ([ if ([
'messages.update', 'messages.update',
@@ -194,6 +213,7 @@ class MessageItem extends HookConsumerWidget {
message.meta['message_id'] != null) { message.meta['message_id'] != null) {
onJump(message.meta['message_id']); onJump(message.meta['message_id']);
} }
}
}, },
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
@@ -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(