✨ Search and jump to message
This commit is contained in:
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user