💄 Optimize chat input a step further
This commit is contained in:
		@@ -541,7 +541,10 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
    Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
 | 
			
		||||
        SuperListView.builder(
 | 
			
		||||
          listController: listController,
 | 
			
		||||
          padding: EdgeInsets.symmetric(vertical: 16),
 | 
			
		||||
          padding: EdgeInsets.only(
 | 
			
		||||
            top: 16,
 | 
			
		||||
            bottom: 96 + MediaQuery.of(context).padding.bottom,
 | 
			
		||||
          ),
 | 
			
		||||
          controller: scrollController,
 | 
			
		||||
          reverse: true, // Show newest messages at the bottom
 | 
			
		||||
          itemCount: messageList.length,
 | 
			
		||||
@@ -735,157 +738,160 @@ class ChatRoomScreen extends HookConsumerWidget {
 | 
			
		||||
      ),
 | 
			
		||||
      body: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          Column(
 | 
			
		||||
            children: [
 | 
			
		||||
              Expanded(
 | 
			
		||||
                child: messages.when(
 | 
			
		||||
                  data:
 | 
			
		||||
                      (messageList) =>
 | 
			
		||||
                          messageList.isEmpty
 | 
			
		||||
                              ? Center(child: Text('No messages yet'.tr()))
 | 
			
		||||
                              : chatMessageListWidget(messageList),
 | 
			
		||||
                  loading:
 | 
			
		||||
                      () => const Center(child: CircularProgressIndicator()),
 | 
			
		||||
                  error:
 | 
			
		||||
                      (error, _) => ResponseErrorWidget(
 | 
			
		||||
                        error: error,
 | 
			
		||||
                        onRetry: () => messagesNotifier.loadInitial(),
 | 
			
		||||
                      ),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              chatRoom.when(
 | 
			
		||||
                data:
 | 
			
		||||
                    (room) => Column(
 | 
			
		||||
                      mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                      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,
 | 
			
		||||
                                ),
 | 
			
		||||
          // Messages
 | 
			
		||||
          Positioned.fill(
 | 
			
		||||
            child: messages.when(
 | 
			
		||||
              data:
 | 
			
		||||
                  (messageList) =>
 | 
			
		||||
                      messageList.isEmpty
 | 
			
		||||
                          ? Center(child: Text('No messages yet'.tr()))
 | 
			
		||||
                          : chatMessageListWidget(messageList),
 | 
			
		||||
              loading: () => const Center(child: CircularProgressIndicator()),
 | 
			
		||||
              error:
 | 
			
		||||
                  (error, _) => ResponseErrorWidget(
 | 
			
		||||
                    error: error,
 | 
			
		||||
                    onRetry: () => messagesNotifier.loadInitial(),
 | 
			
		||||
                  ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          // Input
 | 
			
		||||
          Positioned(
 | 
			
		||||
            bottom: 0,
 | 
			
		||||
            left: 0,
 | 
			
		||||
            right: 0,
 | 
			
		||||
            child: chatRoom.when(
 | 
			
		||||
              data:
 | 
			
		||||
                  (room) => Column(
 | 
			
		||||
                    mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                    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: SizeTransition(
 | 
			
		||||
                              sizeFactor: animation,
 | 
			
		||||
                              axisAlignment: -1.0,
 | 
			
		||||
                              child: FadeTransition(
 | 
			
		||||
                                opacity: animation,
 | 
			
		||||
                                child: child,
 | 
			
		||||
                              ),
 | 
			
		||||
                            );
 | 
			
		||||
                          },
 | 
			
		||||
                          child:
 | 
			
		||||
                              typingStatuses.value.isNotEmpty
 | 
			
		||||
                                  ? Container(
 | 
			
		||||
                                    key: const ValueKey('typing-indicator'),
 | 
			
		||||
                                    width: double.infinity,
 | 
			
		||||
                                    padding: const EdgeInsets.symmetric(
 | 
			
		||||
                                      horizontal: 16,
 | 
			
		||||
                                      vertical: 4,
 | 
			
		||||
                                    ),
 | 
			
		||||
                                    child: Row(
 | 
			
		||||
                                      children: [
 | 
			
		||||
                                        const Icon(
 | 
			
		||||
                                          Symbols.more_horiz,
 | 
			
		||||
                                          size: 16,
 | 
			
		||||
                                        ).padding(horizontal: 8),
 | 
			
		||||
                                        const Gap(8),
 | 
			
		||||
                                        Expanded(
 | 
			
		||||
                                          child: Text(
 | 
			
		||||
                                            'typingHint'.plural(
 | 
			
		||||
                                              typingStatuses.value.length,
 | 
			
		||||
                                              args: [
 | 
			
		||||
                                                typingStatuses.value
 | 
			
		||||
                                                    .map(
 | 
			
		||||
                                                      (x) =>
 | 
			
		||||
                                                          x.nick ??
 | 
			
		||||
                                                          x.account.nick,
 | 
			
		||||
                                                    )
 | 
			
		||||
                                                    .join(', '),
 | 
			
		||||
                                              ],
 | 
			
		||||
                                            ),
 | 
			
		||||
                                            style:
 | 
			
		||||
                                                Theme.of(
 | 
			
		||||
                                                  context,
 | 
			
		||||
                                                ).textTheme.bodySmall,
 | 
			
		||||
                                          ),
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ],
 | 
			
		||||
                                    ),
 | 
			
		||||
                                  )
 | 
			
		||||
                                  : const SizedBox.shrink(
 | 
			
		||||
                                    key: ValueKey('typing-indicator-none'),
 | 
			
		||||
                            ),
 | 
			
		||||
                          );
 | 
			
		||||
                        },
 | 
			
		||||
                        child:
 | 
			
		||||
                            typingStatuses.value.isNotEmpty
 | 
			
		||||
                                ? Container(
 | 
			
		||||
                                  key: const ValueKey('typing-indicator'),
 | 
			
		||||
                                  width: double.infinity,
 | 
			
		||||
                                  padding: const EdgeInsets.symmetric(
 | 
			
		||||
                                    horizontal: 16,
 | 
			
		||||
                                    vertical: 4,
 | 
			
		||||
                                  ),
 | 
			
		||||
                        ),
 | 
			
		||||
                        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();
 | 
			
		||||
                            }
 | 
			
		||||
                          },
 | 
			
		||||
                          attachments: attachments.value,
 | 
			
		||||
                          onUploadAttachment: uploadAttachment,
 | 
			
		||||
                          onDeleteAttachment: (index) async {
 | 
			
		||||
                            final attachment = attachments.value[index];
 | 
			
		||||
                            if (attachment.isOnCloud) {
 | 
			
		||||
                              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,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ],
 | 
			
		||||
                    ),
 | 
			
		||||
                error: (_, _) => const SizedBox.shrink(),
 | 
			
		||||
                loading: () => const SizedBox.shrink(),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
                                  child: Row(
 | 
			
		||||
                                    children: [
 | 
			
		||||
                                      const Icon(
 | 
			
		||||
                                        Symbols.more_horiz,
 | 
			
		||||
                                        size: 16,
 | 
			
		||||
                                      ).padding(horizontal: 8),
 | 
			
		||||
                                      const Gap(8),
 | 
			
		||||
                                      Expanded(
 | 
			
		||||
                                        child: Text(
 | 
			
		||||
                                          'typingHint'.plural(
 | 
			
		||||
                                            typingStatuses.value.length,
 | 
			
		||||
                                            args: [
 | 
			
		||||
                                              typingStatuses.value
 | 
			
		||||
                                                  .map(
 | 
			
		||||
                                                    (x) =>
 | 
			
		||||
                                                        x.nick ??
 | 
			
		||||
                                                        x.account.nick,
 | 
			
		||||
                                                  )
 | 
			
		||||
                                                  .join(', '),
 | 
			
		||||
                                            ],
 | 
			
		||||
                                          ),
 | 
			
		||||
                                          style:
 | 
			
		||||
                                              Theme.of(
 | 
			
		||||
                                                context,
 | 
			
		||||
                                              ).textTheme.bodySmall,
 | 
			
		||||
                                        ),
 | 
			
		||||
                                      ),
 | 
			
		||||
                                    ],
 | 
			
		||||
                                  ),
 | 
			
		||||
                                )
 | 
			
		||||
                                : const SizedBox.shrink(
 | 
			
		||||
                                  key: ValueKey('typing-indicator-none'),
 | 
			
		||||
                                ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      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();
 | 
			
		||||
                          }
 | 
			
		||||
                        },
 | 
			
		||||
                        attachments: attachments.value,
 | 
			
		||||
                        onUploadAttachment: uploadAttachment,
 | 
			
		||||
                        onDeleteAttachment: (index) async {
 | 
			
		||||
                          final attachment = attachments.value[index];
 | 
			
		||||
                          if (attachment.isOnCloud) {
 | 
			
		||||
                            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(),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          Positioned(
 | 
			
		||||
            left: 0,
 | 
			
		||||
 
 | 
			
		||||
@@ -115,200 +115,212 @@ class ChatInput extends HookConsumerWidget {
 | 
			
		||||
      return KeyEventResult.ignored;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    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],
 | 
			
		||||
                      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(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,
 | 
			
		||||
    return Container(
 | 
			
		||||
      margin: const EdgeInsets.all(16),
 | 
			
		||||
      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: [
 | 
			
		||||
              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],
 | 
			
		||||
                          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(top: 12),
 | 
			
		||||
              if (messageReplyingTo != null ||
 | 
			
		||||
                  messageForwardingTo != null ||
 | 
			
		||||
                  messageEditingTo != null)
 | 
			
		||||
                Container(
 | 
			
		||||
                  padding: const EdgeInsets.symmetric(
 | 
			
		||||
                    horizontal: 16,
 | 
			
		||||
                    vertical: 4,
 | 
			
		||||
                  ),
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    color: Theme.of(context).colorScheme.surfaceContainerHigh,
 | 
			
		||||
                    borderRadius: BorderRadius.circular(32),
 | 
			
		||||
                  ),
 | 
			
		||||
                  margin: const EdgeInsets.only(
 | 
			
		||||
                    left: 8,
 | 
			
		||||
                    right: 8,
 | 
			
		||||
                    top: 8,
 | 
			
		||||
                    bottom: 4,
 | 
			
		||||
                  ),
 | 
			
		||||
                  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,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      SizedBox(
 | 
			
		||||
                        width: 28,
 | 
			
		||||
                        height: 28,
 | 
			
		||||
                        child: InkWell(
 | 
			
		||||
                          onTap: onClear,
 | 
			
		||||
                          child: const Icon(Icons.close, size: 20).center(),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              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(),
 | 
			
		||||
                                  ],
 | 
			
		||||
                                ),
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  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,
 | 
			
		||||
                    child: TextField(
 | 
			
		||||
                      focusNode: inputFocusNode,
 | 
			
		||||
                      controller: messageController,
 | 
			
		||||
                      keyboardType: TextInputType.multiline,
 | 
			
		||||
                      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.close, size: 20),
 | 
			
		||||
                    onPressed: onClear,
 | 
			
		||||
                    padding: EdgeInsets.zero,
 | 
			
		||||
                    style: ButtonStyle(
 | 
			
		||||
                      minimumSize: WidgetStatePropertyAll(Size(28, 28)),
 | 
			
		||||
                    ),
 | 
			
		||||
                    icon: const Icon(Icons.send),
 | 
			
		||||
                    color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                    onPressed: send,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          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: TextField(
 | 
			
		||||
                    focusNode: inputFocusNode,
 | 
			
		||||
                    controller: messageController,
 | 
			
		||||
                    keyboardType: TextInputType.multiline,
 | 
			
		||||
                    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),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget {
 | 
			
		||||
      case 'messages.update.links':
 | 
			
		||||
        return Row(
 | 
			
		||||
          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Icon(
 | 
			
		||||
              Symbols.edit,
 | 
			
		||||
@@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget {
 | 
			
		||||
              color: Theme.of(
 | 
			
		||||
                context,
 | 
			
		||||
              ).colorScheme.onSurfaceVariant.withOpacity(0.6),
 | 
			
		||||
            ),
 | 
			
		||||
            ).padding(top: 2),
 | 
			
		||||
            const Gap(4),
 | 
			
		||||
            if (item.meta['previous_content'] is String)
 | 
			
		||||
              PrettyDiffText(
 | 
			
		||||
                oldText: item.meta['previous_content'],
 | 
			
		||||
                newText: item.content ?? 'Edited a message',
 | 
			
		||||
                defaultTextStyle: Theme.of(
 | 
			
		||||
                  context,
 | 
			
		||||
                ).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                ),
 | 
			
		||||
                addedTextStyle: TextStyle(
 | 
			
		||||
                  backgroundColor: Theme.of(
 | 
			
		||||
              Flexible(
 | 
			
		||||
                child: PrettyDiffText(
 | 
			
		||||
                  oldText: item.meta['previous_content'],
 | 
			
		||||
                  newText: item.content ?? 'Edited a message',
 | 
			
		||||
                  defaultTextStyle: Theme.of(
 | 
			
		||||
                    context,
 | 
			
		||||
                  ).colorScheme.primaryFixedDim.withOpacity(0.4),
 | 
			
		||||
                ),
 | 
			
		||||
                deletedTextStyle: TextStyle(
 | 
			
		||||
                  decoration: TextDecoration.lineThrough,
 | 
			
		||||
                  color: Theme.of(
 | 
			
		||||
                    context,
 | 
			
		||||
                  ).colorScheme.onSurfaceVariant.withOpacity(0.7),
 | 
			
		||||
                  ).textTheme.bodyMedium!.copyWith(
 | 
			
		||||
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
 | 
			
		||||
                  ),
 | 
			
		||||
                  addedTextStyle: TextStyle(
 | 
			
		||||
                    backgroundColor: Theme.of(
 | 
			
		||||
                      context,
 | 
			
		||||
                    ).colorScheme.primaryFixedDim.withOpacity(0.4),
 | 
			
		||||
                  ),
 | 
			
		||||
                  deletedTextStyle: TextStyle(
 | 
			
		||||
                    decoration: TextDecoration.lineThrough,
 | 
			
		||||
                    color: Theme.of(
 | 
			
		||||
                      context,
 | 
			
		||||
                    ).colorScheme.onSurfaceVariant.withOpacity(0.7),
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
            else
 | 
			
		||||
@@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget {
 | 
			
		||||
          mainAxisSize: MainAxisSize.min,
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            MarkdownTextContent(
 | 
			
		||||
              content: item.content ?? '*${item.type} has no content*',
 | 
			
		||||
              isSelectable: true,
 | 
			
		||||
              linesMargin: EdgeInsets.zero,
 | 
			
		||||
            Flexible(
 | 
			
		||||
              child: MarkdownTextContent(
 | 
			
		||||
                content: item.content ?? '*${item.type} has no content*',
 | 
			
		||||
                isSelectable: true,
 | 
			
		||||
                linesMargin: EdgeInsets.zero,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            if (translatedText?.isNotEmpty ?? false)
 | 
			
		||||
              ...([
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user