669 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			669 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import "dart:async";
 | 
						|
import "package:easy_localization/easy_localization.dart";
 | 
						|
import "package:flutter/material.dart";
 | 
						|
import "package:flutter/services.dart";
 | 
						|
import "package:flutter_hooks/flutter_hooks.dart";
 | 
						|
import "package:flutter_typeahead/flutter_typeahead.dart";
 | 
						|
import "package:gap/gap.dart";
 | 
						|
import "package:hooks_riverpod/hooks_riverpod.dart";
 | 
						|
import "package:image_picker/image_picker.dart";
 | 
						|
import "package:island/models/account.dart";
 | 
						|
import "package:island/models/autocomplete_response.dart";
 | 
						|
import "package:island/models/chat.dart";
 | 
						|
import "package:island/models/file.dart";
 | 
						|
import "package:island/models/publisher.dart";
 | 
						|
import "package:island/models/realm.dart";
 | 
						|
import "package:island/models/sticker.dart";
 | 
						|
import "package:island/pods/config.dart";
 | 
						|
import "package:island/services/autocomplete_service.dart";
 | 
						|
import "package:island/services/responsive.dart";
 | 
						|
import "package:island/widgets/content/attachment_preview.dart";
 | 
						|
import "package:island/widgets/content/cloud_files.dart";
 | 
						|
import "package:island/widgets/shared/upload_menu.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/sticker_picker.dart";
 | 
						|
import "package:island/pods/chat/chat_subscribe.dart";
 | 
						|
 | 
						|
class ChatInput extends HookConsumerWidget {
 | 
						|
  final TextEditingController messageController;
 | 
						|
  final SnChatRoom chatRoom;
 | 
						|
  final VoidCallback onSend;
 | 
						|
  final VoidCallback onClear;
 | 
						|
  final Function(bool isPhoto) onPickFile;
 | 
						|
  final VoidCallback onPickAudio;
 | 
						|
  final VoidCallback onPickGeneralFile;
 | 
						|
  final VoidCallback? onLinkAttachment;
 | 
						|
  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;
 | 
						|
  final Map<String, Map<int, double>> attachmentProgress;
 | 
						|
 | 
						|
  const ChatInput({
 | 
						|
    super.key,
 | 
						|
    required this.messageController,
 | 
						|
    required this.chatRoom,
 | 
						|
    required this.onSend,
 | 
						|
    required this.onClear,
 | 
						|
    required this.onPickFile,
 | 
						|
    required this.onPickAudio,
 | 
						|
    required this.onPickGeneralFile,
 | 
						|
    this.onLinkAttachment,
 | 
						|
    required this.messageReplyingTo,
 | 
						|
    required this.messageForwardingTo,
 | 
						|
    required this.messageEditingTo,
 | 
						|
    required this.attachments,
 | 
						|
    required this.onUploadAttachment,
 | 
						|
    required this.onDeleteAttachment,
 | 
						|
    required this.onMoveAttachment,
 | 
						|
    required this.onAttachmentsChanged,
 | 
						|
    required this.attachmentProgress,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final inputFocusNode = useFocusNode();
 | 
						|
    final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
 | 
						|
 | 
						|
    void send() {
 | 
						|
      inputFocusNode.requestFocus();
 | 
						|
      onSend.call();
 | 
						|
    }
 | 
						|
 | 
						|
    void insertNewLine() {
 | 
						|
      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, '\n');
 | 
						|
      messageController.value = TextEditingValue(
 | 
						|
        text: newText,
 | 
						|
        selection: TextSelection.collapsed(offset: start + 1),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    Future<void> handlePaste() async {
 | 
						|
      final image = await Pasteboard.image;
 | 
						|
      if (image != null) {
 | 
						|
        onAttachmentsChanged([
 | 
						|
          ...attachments,
 | 
						|
          UniversalFile(
 | 
						|
            data: XFile.fromData(image, mimeType: "image/jpeg"),
 | 
						|
            type: UniversalFileType.image,
 | 
						|
          ),
 | 
						|
        ]);
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      final textData = await Clipboard.getData(Clipboard.kTextPlain);
 | 
						|
      if (textData != null && textData.text != null) {
 | 
						|
        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, textData.text!);
 | 
						|
        messageController.value = TextEditingValue(
 | 
						|
          text: newText,
 | 
						|
          selection: TextSelection.collapsed(
 | 
						|
            offset: start + textData.text!.length,
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    final settings = ref.watch(appSettingsNotifierProvider);
 | 
						|
 | 
						|
    inputFocusNode.onKeyEvent = (node, event) {
 | 
						|
      if (event is! KeyDownEvent) return KeyEventResult.ignored;
 | 
						|
 | 
						|
      final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
 | 
						|
      final isModifierPressed =
 | 
						|
          HardwareKeyboard.instance.isMetaPressed ||
 | 
						|
          HardwareKeyboard.instance.isControlPressed;
 | 
						|
 | 
						|
      if (isPaste && isModifierPressed) {
 | 
						|
        handlePaste();
 | 
						|
        return KeyEventResult.handled;
 | 
						|
      }
 | 
						|
 | 
						|
      final enterToSend = settings.enterToSend;
 | 
						|
      final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
 | 
						|
 | 
						|
      if (isEnter) {
 | 
						|
        if (isModifierPressed) {
 | 
						|
          insertNewLine();
 | 
						|
          return KeyEventResult.handled;
 | 
						|
        } else if (enterToSend) {
 | 
						|
          send();
 | 
						|
          return KeyEventResult.handled;
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      return KeyEventResult.ignored;
 | 
						|
    };
 | 
						|
 | 
						|
    final double leftMargin = isWideScreen(context) ? 8 : 16;
 | 
						|
    final double rightMargin = isWideScreen(context) ? leftMargin + 8 : 16;
 | 
						|
    const double bottomMargin = 16;
 | 
						|
 | 
						|
    return Container(
 | 
						|
      margin: EdgeInsets.only(
 | 
						|
        left: leftMargin,
 | 
						|
        right: rightMargin,
 | 
						|
        bottom: bottomMargin,
 | 
						|
      ),
 | 
						|
      child: Material(
 | 
						|
        elevation: 2,
 | 
						|
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
 | 
						|
        borderRadius: BorderRadius.circular(32),
 | 
						|
        child: Padding(
 | 
						|
          padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
 | 
						|
          child: Column(
 | 
						|
            children: [
 | 
						|
              AnimatedSwitcher(
 | 
						|
                duration: const Duration(milliseconds: 150),
 | 
						|
                switchInCurve: Curves.fastEaseInToSlowEaseOut,
 | 
						|
                switchOutCurve: Curves.fastEaseInToSlowEaseOut,
 | 
						|
                transitionBuilder: (Widget child, Animation<double> animation) {
 | 
						|
                  return SlideTransition(
 | 
						|
                    position: Tween<Offset>(
 | 
						|
                      begin: const Offset(0, -0.3),
 | 
						|
                      end: Offset.zero,
 | 
						|
                    ).animate(
 | 
						|
                      CurvedAnimation(
 | 
						|
                        parent: animation,
 | 
						|
                        curve: Curves.easeOutCubic,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                    child: SizeTransition(
 | 
						|
                      sizeFactor: animation,
 | 
						|
                      axisAlignment: -1.0,
 | 
						|
                      child: FadeTransition(opacity: animation, child: child),
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                },
 | 
						|
                child:
 | 
						|
                    chatSubscribe.isNotEmpty
 | 
						|
                        ? Container(
 | 
						|
                          key: const ValueKey('typing-indicator'),
 | 
						|
                          width: double.infinity,
 | 
						|
                          padding: const EdgeInsets.symmetric(
 | 
						|
                            horizontal: 12,
 | 
						|
                            vertical: 4,
 | 
						|
                          ),
 | 
						|
                          child: Row(
 | 
						|
                            children: [
 | 
						|
                              const Icon(
 | 
						|
                                Symbols.more_horiz,
 | 
						|
                                size: 16,
 | 
						|
                              ).padding(horizontal: 8),
 | 
						|
                              const Gap(8),
 | 
						|
                              Expanded(
 | 
						|
                                child: Text(
 | 
						|
                                  'typingHint'.plural(
 | 
						|
                                    chatSubscribe.length,
 | 
						|
                                    args: [
 | 
						|
                                      chatSubscribe
 | 
						|
                                          .map((x) => x.nick ?? x.account.nick)
 | 
						|
                                          .join(', '),
 | 
						|
                                    ],
 | 
						|
                                  ),
 | 
						|
                                  style: Theme.of(context).textTheme.bodySmall,
 | 
						|
                                ),
 | 
						|
                              ),
 | 
						|
                            ],
 | 
						|
                          ),
 | 
						|
                        )
 | 
						|
                        : const SizedBox.shrink(
 | 
						|
                          key: ValueKey('typing-indicator-none'),
 | 
						|
                        ),
 | 
						|
              ),
 | 
						|
              AnimatedSwitcher(
 | 
						|
                duration: const Duration(milliseconds: 250),
 | 
						|
                switchInCurve: Curves.easeOutCubic,
 | 
						|
                switchOutCurve: Curves.easeInCubic,
 | 
						|
                transitionBuilder: (Widget child, Animation<double> animation) {
 | 
						|
                  return SlideTransition(
 | 
						|
                    position: Tween<Offset>(
 | 
						|
                      begin: const Offset(0, 0.1),
 | 
						|
                      end: Offset.zero,
 | 
						|
                    ).animate(animation),
 | 
						|
                    child: FadeTransition(
 | 
						|
                      opacity: animation,
 | 
						|
                      child: SizeTransition(
 | 
						|
                        sizeFactor: animation,
 | 
						|
                        axisAlignment: -1.0,
 | 
						|
                        child: child,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                },
 | 
						|
                child:
 | 
						|
                    attachments.isNotEmpty
 | 
						|
                        ? SizedBox(
 | 
						|
                          key: ValueKey('attachments-${attachments.length}'),
 | 
						|
                          height: 180,
 | 
						|
                          child: ListView.separated(
 | 
						|
                            padding: EdgeInsets.symmetric(horizontal: 12),
 | 
						|
                            scrollDirection: Axis.horizontal,
 | 
						|
                            itemCount: attachments.length,
 | 
						|
                            itemBuilder: (context, idx) {
 | 
						|
                              return SizedBox(
 | 
						|
                                width: 180,
 | 
						|
                                child: AttachmentPreview(
 | 
						|
                                  isCompact: true,
 | 
						|
                                  item: attachments[idx],
 | 
						|
                                  progress:
 | 
						|
                                      attachmentProgress['chat-upload']?[idx],
 | 
						|
                                  onRequestUpload:
 | 
						|
                                      () => onUploadAttachment(idx),
 | 
						|
                                  onDelete: () => onDeleteAttachment(idx),
 | 
						|
                                  onUpdate: (value) {
 | 
						|
                                    attachments[idx] = value;
 | 
						|
                                    onAttachmentsChanged(attachments);
 | 
						|
                                  },
 | 
						|
                                  onMove:
 | 
						|
                                      (delta) => onMoveAttachment(idx, delta),
 | 
						|
                                ),
 | 
						|
                              );
 | 
						|
                            },
 | 
						|
                            separatorBuilder: (_, _) => const Gap(8),
 | 
						|
                          ),
 | 
						|
                        ).padding(vertical: 12)
 | 
						|
                        : const SizedBox.shrink(
 | 
						|
                          key: ValueKey('no-attachments'),
 | 
						|
                        ),
 | 
						|
              ),
 | 
						|
              AnimatedSwitcher(
 | 
						|
                duration: const Duration(milliseconds: 200),
 | 
						|
                switchInCurve: Curves.easeOutCubic,
 | 
						|
                switchOutCurve: Curves.easeInCubic,
 | 
						|
                transitionBuilder: (Widget child, Animation<double> animation) {
 | 
						|
                  return SlideTransition(
 | 
						|
                    position: Tween<Offset>(
 | 
						|
                      begin: const Offset(0, -0.2),
 | 
						|
                      end: Offset.zero,
 | 
						|
                    ).animate(animation),
 | 
						|
                    child: FadeTransition(
 | 
						|
                      opacity: animation,
 | 
						|
                      child: SizeTransition(
 | 
						|
                        sizeFactor: animation,
 | 
						|
                        axisAlignment: -1.0,
 | 
						|
                        child: child,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                },
 | 
						|
                child:
 | 
						|
                    (messageReplyingTo != null ||
 | 
						|
                            messageForwardingTo != null ||
 | 
						|
                            messageEditingTo != null)
 | 
						|
                        ? Container(
 | 
						|
                          key: ValueKey(
 | 
						|
                            messageReplyingTo?.id ??
 | 
						|
                                messageForwardingTo?.id ??
 | 
						|
                                messageEditingTo?.id ??
 | 
						|
                                'action',
 | 
						|
                          ),
 | 
						|
                          padding: const EdgeInsets.symmetric(
 | 
						|
                            horizontal: 16,
 | 
						|
                            vertical: 8,
 | 
						|
                          ),
 | 
						|
                          decoration: BoxDecoration(
 | 
						|
                            color:
 | 
						|
                                Theme.of(
 | 
						|
                                  context,
 | 
						|
                                ).colorScheme.surfaceContainerHigh,
 | 
						|
                            borderRadius: BorderRadius.circular(24),
 | 
						|
                            border: Border.all(
 | 
						|
                              color: Theme.of(
 | 
						|
                                context,
 | 
						|
                              ).colorScheme.outline.withOpacity(0.2),
 | 
						|
                              width: 1,
 | 
						|
                            ),
 | 
						|
                          ),
 | 
						|
                          margin: const EdgeInsets.only(
 | 
						|
                            left: 8,
 | 
						|
                            right: 8,
 | 
						|
                            top: 8,
 | 
						|
                            bottom: 8,
 | 
						|
                          ),
 | 
						|
                          child: Column(
 | 
						|
                            mainAxisSize: MainAxisSize.min,
 | 
						|
                            crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                            children: [
 | 
						|
                              Row(
 | 
						|
                                children: [
 | 
						|
                                  Icon(
 | 
						|
                                    messageReplyingTo != null
 | 
						|
                                        ? Symbols.reply
 | 
						|
                                        : messageForwardingTo != null
 | 
						|
                                        ? Symbols.forward
 | 
						|
                                        : Symbols.edit,
 | 
						|
                                    size: 18,
 | 
						|
                                    color:
 | 
						|
                                        Theme.of(context).colorScheme.primary,
 | 
						|
                                  ),
 | 
						|
                                  const Gap(8),
 | 
						|
                                  Expanded(
 | 
						|
                                    child: Text(
 | 
						|
                                      messageReplyingTo != null
 | 
						|
                                          ? 'chatReplyingTo'.tr(
 | 
						|
                                            args: [
 | 
						|
                                              messageReplyingTo
 | 
						|
                                                      ?.sender
 | 
						|
                                                      .account
 | 
						|
                                                      .nick ??
 | 
						|
                                                  'unknown'.tr(),
 | 
						|
                                            ],
 | 
						|
                                          )
 | 
						|
                                          : messageForwardingTo != null
 | 
						|
                                          ? 'chatForwarding'.tr()
 | 
						|
                                          : 'chatEditing'.tr(),
 | 
						|
                                      style: Theme.of(
 | 
						|
                                        context,
 | 
						|
                                      ).textTheme.bodySmall!.copyWith(
 | 
						|
                                        fontWeight: FontWeight.w500,
 | 
						|
                                      ),
 | 
						|
                                      maxLines: 1,
 | 
						|
                                      overflow: TextOverflow.ellipsis,
 | 
						|
                                    ),
 | 
						|
                                  ),
 | 
						|
                                  SizedBox(
 | 
						|
                                    width: 24,
 | 
						|
                                    height: 24,
 | 
						|
                                    child: IconButton(
 | 
						|
                                      padding: EdgeInsets.zero,
 | 
						|
                                      icon: const Icon(Icons.close, size: 18),
 | 
						|
                                      onPressed: onClear,
 | 
						|
                                      tooltip: 'clear'.tr(),
 | 
						|
                                    ),
 | 
						|
                                  ),
 | 
						|
                                ],
 | 
						|
                              ),
 | 
						|
                              if (messageReplyingTo != null ||
 | 
						|
                                  messageForwardingTo != null ||
 | 
						|
                                  messageEditingTo != null)
 | 
						|
                                Padding(
 | 
						|
                                  padding: const EdgeInsets.only(
 | 
						|
                                    top: 6,
 | 
						|
                                    left: 26,
 | 
						|
                                  ),
 | 
						|
                                  child: Text(
 | 
						|
                                    (messageReplyingTo ??
 | 
						|
                                                messageForwardingTo ??
 | 
						|
                                                messageEditingTo)
 | 
						|
                                            ?.content ??
 | 
						|
                                        'chatNoContent'.tr(),
 | 
						|
                                    style: Theme.of(
 | 
						|
                                      context,
 | 
						|
                                    ).textTheme.bodySmall!.copyWith(
 | 
						|
                                      color:
 | 
						|
                                          Theme.of(
 | 
						|
                                            context,
 | 
						|
                                          ).colorScheme.onSurfaceVariant,
 | 
						|
                                    ),
 | 
						|
                                    maxLines: 2,
 | 
						|
                                    overflow: TextOverflow.ellipsis,
 | 
						|
                                  ),
 | 
						|
                                ),
 | 
						|
                            ],
 | 
						|
                          ),
 | 
						|
                        )
 | 
						|
                        : const SizedBox.shrink(key: ValueKey('no-action')),
 | 
						|
              ),
 | 
						|
              Row(
 | 
						|
                crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                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,
 | 
						|
                                ),
 | 
						|
                              );
 | 
						|
                            },
 | 
						|
                          );
 | 
						|
                        },
 | 
						|
                      ),
 | 
						|
                      UploadMenu(
 | 
						|
                        items: [
 | 
						|
                          UploadMenuItemData(
 | 
						|
                            Symbols.add_a_photo,
 | 
						|
                            'addPhoto',
 | 
						|
                            () => onPickFile(true),
 | 
						|
                          ),
 | 
						|
                          UploadMenuItemData(
 | 
						|
                            Symbols.videocam,
 | 
						|
                            'addVideo',
 | 
						|
                            () => onPickFile(false),
 | 
						|
                          ),
 | 
						|
                          UploadMenuItemData(
 | 
						|
                            Symbols.mic,
 | 
						|
                            'addAudio',
 | 
						|
                            onPickAudio,
 | 
						|
                          ),
 | 
						|
                          UploadMenuItemData(
 | 
						|
                            Symbols.file_upload,
 | 
						|
                            'uploadFile',
 | 
						|
                            onPickGeneralFile,
 | 
						|
                          ),
 | 
						|
                          if (onLinkAttachment != null)
 | 
						|
                            UploadMenuItemData(
 | 
						|
                              Symbols.attach_file,
 | 
						|
                              'linkAttachment',
 | 
						|
                              onLinkAttachment!,
 | 
						|
                            ),
 | 
						|
                        ],
 | 
						|
                        iconColor: Theme.of(context).colorScheme.onSurface,
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                  Expanded(
 | 
						|
                    child: TypeAheadField<AutocompleteSuggestion>(
 | 
						|
                      controller: messageController,
 | 
						|
                      focusNode: inputFocusNode,
 | 
						|
                      builder: (context, controller, focusNode) {
 | 
						|
                        return TextField(
 | 
						|
                          focusNode: focusNode,
 | 
						|
                          controller: controller,
 | 
						|
                          keyboardType: TextInputType.multiline,
 | 
						|
                          decoration: InputDecoration(
 | 
						|
                            hintMaxLines: 1,
 | 
						|
                            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: 12,
 | 
						|
                            ),
 | 
						|
                            counterText:
 | 
						|
                                messageController.text.length > 1024
 | 
						|
                                    ? '${messageController.text.length}/4096'
 | 
						|
                                    : null,
 | 
						|
                          ),
 | 
						|
                          maxLines: 5,
 | 
						|
                          minLines: 1,
 | 
						|
                          onTapOutside:
 | 
						|
                              (_) =>
 | 
						|
                                  FocusManager.instance.primaryFocus?.unfocus(),
 | 
						|
                          textInputAction:
 | 
						|
                              settings.enterToSend
 | 
						|
                                  ? TextInputAction.send
 | 
						|
                                  : null,
 | 
						|
                          onSubmitted:
 | 
						|
                              settings.enterToSend ? (_) => send() : null,
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                      suggestionsCallback: (pattern) async {
 | 
						|
                        // Only trigger on @ or :
 | 
						|
                        final atIndex = pattern.lastIndexOf('@');
 | 
						|
                        final colonIndex = pattern.lastIndexOf(':');
 | 
						|
                        final triggerIndex =
 | 
						|
                            atIndex > colonIndex ? atIndex : colonIndex;
 | 
						|
                        if (triggerIndex == -1) return [];
 | 
						|
                        final chopped = pattern.substring(triggerIndex);
 | 
						|
                        if (chopped.contains(' ')) return [];
 | 
						|
                        final service = ref.read(autocompleteServiceProvider);
 | 
						|
                        try {
 | 
						|
                          return await service.getSuggestions(
 | 
						|
                            chatRoom.id,
 | 
						|
                            chopped,
 | 
						|
                          );
 | 
						|
                        } catch (e) {
 | 
						|
                          return [];
 | 
						|
                        }
 | 
						|
                      },
 | 
						|
                      itemBuilder: (context, suggestion) {
 | 
						|
                        String title = 'unknown'.tr();
 | 
						|
                        Widget leading = Icon(Symbols.help);
 | 
						|
                        switch (suggestion.type) {
 | 
						|
                          case 'user':
 | 
						|
                            final user = SnAccount.fromJson(suggestion.data);
 | 
						|
                            title = user.nick;
 | 
						|
                            leading = ProfilePictureWidget(
 | 
						|
                              file: user.profile.picture,
 | 
						|
                              radius: 18,
 | 
						|
                            );
 | 
						|
                            break;
 | 
						|
                          case 'chatroom':
 | 
						|
                            final chatRoom = SnChatRoom.fromJson(
 | 
						|
                              suggestion.data,
 | 
						|
                            );
 | 
						|
                            title = chatRoom.name ?? 'Chat Room';
 | 
						|
                            leading = ProfilePictureWidget(
 | 
						|
                              file: chatRoom.picture,
 | 
						|
                              radius: 18,
 | 
						|
                            );
 | 
						|
                            break;
 | 
						|
                          case 'realm':
 | 
						|
                            final realm = SnRealm.fromJson(suggestion.data);
 | 
						|
                            title = realm.name;
 | 
						|
                            leading = ProfilePictureWidget(
 | 
						|
                              file: realm.picture,
 | 
						|
                              radius: 18,
 | 
						|
                            );
 | 
						|
                            break;
 | 
						|
                          case 'publisher':
 | 
						|
                            final publisher = SnPublisher.fromJson(
 | 
						|
                              suggestion.data,
 | 
						|
                            );
 | 
						|
                            title = publisher.name;
 | 
						|
                            leading = ProfilePictureWidget(
 | 
						|
                              file: publisher.picture,
 | 
						|
                              radius: 18,
 | 
						|
                            );
 | 
						|
                            break;
 | 
						|
                          case 'sticker':
 | 
						|
                            final sticker = SnSticker.fromJson(suggestion.data);
 | 
						|
                            title = sticker.slug;
 | 
						|
                            leading = ClipRRect(
 | 
						|
                              borderRadius: BorderRadius.circular(8),
 | 
						|
                              child: SizedBox(
 | 
						|
                                width: 28,
 | 
						|
                                height: 28,
 | 
						|
                                child: CloudImageWidget(
 | 
						|
                                  fileId: sticker.image.id,
 | 
						|
                                ),
 | 
						|
                              ),
 | 
						|
                            );
 | 
						|
                            break;
 | 
						|
                          default:
 | 
						|
                        }
 | 
						|
                        return ListTile(
 | 
						|
                          leading: leading,
 | 
						|
                          title: Text(title),
 | 
						|
                          subtitle: Text(suggestion.keyword),
 | 
						|
                          dense: true,
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                      onSelected: (suggestion) {
 | 
						|
                        final text = messageController.text;
 | 
						|
                        final atIndex = text.lastIndexOf('@');
 | 
						|
                        final colonIndex = text.lastIndexOf(':');
 | 
						|
                        final triggerIndex =
 | 
						|
                            atIndex > colonIndex ? atIndex : colonIndex;
 | 
						|
                        if (triggerIndex == -1) return;
 | 
						|
                        final newText = text.replaceRange(
 | 
						|
                          triggerIndex,
 | 
						|
                          text.length,
 | 
						|
                          suggestion.keyword,
 | 
						|
                        );
 | 
						|
                        messageController.value = TextEditingValue(
 | 
						|
                          text: newText,
 | 
						|
                          selection: TextSelection.collapsed(
 | 
						|
                            offset: triggerIndex + suggestion.keyword.length,
 | 
						|
                          ),
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                      direction: VerticalDirection.up,
 | 
						|
                      hideOnEmpty: true,
 | 
						|
                      hideOnLoading: true,
 | 
						|
                      debounceDuration: const Duration(milliseconds: 1000),
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                  IconButton(
 | 
						|
                    icon: const Icon(Icons.send),
 | 
						|
                    color: Theme.of(context).colorScheme.primary,
 | 
						|
                    onPressed: send,
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |