Compare commits

...

2 Commits

Author SHA1 Message Date
1fbaac8d88 💄 Optimize chat input a step further 2025-09-27 15:31:57 +08:00
b9dc724f0b 🐛 Fix chat newline on desktop 2025-09-27 00:22:43 +08:00
3 changed files with 380 additions and 373 deletions

View File

@@ -541,7 +541,10 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget chatMessageListWidget(List<LocalChatMessage> messageList) => Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
SuperListView.builder( SuperListView.builder(
listController: listController, listController: listController,
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.only(
top: 16,
bottom: 96 + MediaQuery.of(context).padding.bottom,
),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse: true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
@@ -735,157 +738,160 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
body: Stack( body: Stack(
children: [ children: [
Column( // Messages
children: [ Positioned.fill(
Expanded( child: messages.when(
child: messages.when( data:
data: (messageList) =>
(messageList) => messageList.isEmpty
messageList.isEmpty ? Center(child: Text('No messages yet'.tr()))
? Center(child: Text('No messages yet'.tr())) : chatMessageListWidget(messageList),
: chatMessageListWidget(messageList), loading: () => const Center(child: CircularProgressIndicator()),
loading: error:
() => const Center(child: CircularProgressIndicator()), (error, _) => ResponseErrorWidget(
error: error: error,
(error, _) => ResponseErrorWidget( onRetry: () => messagesNotifier.loadInitial(),
error: error, ),
onRetry: () => messagesNotifier.loadInitial(), ),
), ),
), // Input
), Positioned(
chatRoom.when( bottom: 0,
data: left: 0,
(room) => Column( right: 0,
mainAxisSize: MainAxisSize.min, child: chatRoom.when(
children: [ data:
AnimatedSwitcher( (room) => Column(
duration: const Duration(milliseconds: 150), mainAxisSize: MainAxisSize.min,
switchInCurve: Curves.fastEaseInToSlowEaseOut, children: [
switchOutCurve: Curves.fastEaseInToSlowEaseOut, AnimatedSwitcher(
transitionBuilder: ( duration: const Duration(milliseconds: 150),
Widget child, switchInCurve: Curves.fastEaseInToSlowEaseOut,
Animation<double> animation, switchOutCurve: Curves.fastEaseInToSlowEaseOut,
) { transitionBuilder: (
return SlideTransition( Widget child,
position: Tween<Offset>( Animation<double> animation,
begin: const Offset(0, -0.3), ) {
end: Offset.zero, return SlideTransition(
).animate( position: Tween<Offset>(
CurvedAnimation( begin: const Offset(0, -0.3),
parent: animation, end: Offset.zero,
curve: Curves.easeOutCubic, ).animate(
), CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
), ),
child: SizeTransition( ),
sizeFactor: animation, child: SizeTransition(
axisAlignment: -1.0, sizeFactor: animation,
child: FadeTransition( axisAlignment: -1.0,
opacity: animation, child: FadeTransition(
child: child, opacity: animation,
), child: child,
), ),
); ),
}, );
child: },
typingStatuses.value.isNotEmpty child:
? Container( typingStatuses.value.isNotEmpty
key: const ValueKey('typing-indicator'), ? Container(
width: double.infinity, key: const ValueKey('typing-indicator'),
padding: const EdgeInsets.symmetric( width: double.infinity,
horizontal: 16, padding: const EdgeInsets.symmetric(
vertical: 4, 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: Row(
ChatInput( children: [
messageController: messageController, const Icon(
chatRoom: room!, Symbols.more_horiz,
onSend: sendMessage, size: 16,
onClear: () { ).padding(horizontal: 8),
if (messageEditingTo.value != null) { const Gap(8),
attachments.value.clear(); Expanded(
messageController.clear(); child: Text(
} 'typingHint'.plural(
messageEditingTo.value = null; typingStatuses.value.length,
messageReplyingTo.value = null; args: [
messageForwardingTo.value = null; typingStatuses.value
}, .map(
messageEditingTo: messageEditingTo.value, (x) =>
messageReplyingTo: messageReplyingTo.value, x.nick ??
messageForwardingTo: messageForwardingTo.value, x.account.nick,
onPickFile: (bool isPhoto) { )
if (isPhoto) { .join(', '),
pickPhotoMedia(); ],
} else { ),
pickVideoMedia(); style:
} Theme.of(
}, context,
attachments: attachments.value, ).textTheme.bodySmall,
onUploadAttachment: uploadAttachment, ),
onDeleteAttachment: (index) async { ),
final attachment = attachments.value[index]; ],
if (attachment.isOnCloud) { ),
final client = ref.watch(apiClientProvider); )
await client.delete( : const SizedBox.shrink(
'/drive/files/${attachment.data.id}', key: ValueKey('typing-indicator-none'),
); ),
} ),
final clone = List.of(attachments.value); ChatInput(
clone.removeAt(index); messageController: messageController,
attachments.value = clone; chatRoom: room!,
}, onSend: sendMessage,
onMoveAttachment: (idx, delta) { onClear: () {
if (idx + delta < 0 || if (messageEditingTo.value != null) {
idx + delta >= attachments.value.length) { attachments.value.clear();
return; messageController.clear();
} }
final clone = List.of(attachments.value); messageEditingTo.value = null;
clone.insert(idx + delta, clone.removeAt(idx)); messageReplyingTo.value = null;
attachments.value = clone; messageForwardingTo.value = null;
}, },
onAttachmentsChanged: (newAttachments) { messageEditingTo: messageEditingTo.value,
attachments.value = newAttachments; messageReplyingTo: messageReplyingTo.value,
}, messageForwardingTo: messageForwardingTo.value,
attachmentProgress: attachmentProgress.value, onPickFile: (bool isPhoto) {
), if (isPhoto) {
], pickPhotoMedia();
), } else {
error: (_, _) => const SizedBox.shrink(), pickVideoMedia();
loading: () => const SizedBox.shrink(), }
), },
], 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( Positioned(
left: 0, left: 0,

View File

@@ -1,7 +1,5 @@
import "dart:async"; import "dart:async";
import "dart:io";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
@@ -56,10 +54,6 @@ class ChatInput extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode(); final inputFocusNode = useFocusNode();
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
void send() { void send() {
onSend.call(); onSend.call();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -67,6 +61,18 @@ class ChatInput extends HookConsumerWidget {
}); });
} }
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 { Future<void> handlePaste() async {
final clipboard = await Pasteboard.image; final clipboard = await Pasteboard.image;
if (clipboard == null) return; if (clipboard == null) return;
@@ -80,212 +86,203 @@ class ChatInput extends HookConsumerWidget {
]); ]);
} }
void handleKeyPress( inputFocusNode.onKeyEvent = (node, event) {
BuildContext context, if (event is! KeyDownEvent) return KeyEventResult.ignored;
WidgetRef ref,
RawKeyEvent event,
) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isModifierPressed =
HardwareKeyboard.instance.isMetaPressed ||
HardwareKeyboard.instance.isControlPressed;
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
handlePaste(); handlePaste();
return; return KeyEventResult.handled;
} }
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter; final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
if (isEnter) { if (isEnter) {
if (enterToSend && !isModifierPressed) { if (isModifierPressed) {
send(); insertNewLine();
} else if (!enterToSend && isModifierPressed) { return KeyEventResult.handled;
} else if (enterToSend) {
send(); send();
return KeyEventResult.handled;
} }
} }
}
return Material( return KeyEventResult.ignored;
elevation: 8, };
color: Theme.of(context).colorScheme.surface,
child: Column( return Container(
children: [ margin: const EdgeInsets.all(16),
if (attachments.isNotEmpty) child: Material(
SizedBox( elevation: 2,
height: 280, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: ListView.separated( borderRadius: BorderRadius.circular(32),
padding: EdgeInsets.symmetric(horizontal: 12), child: Padding(
scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
itemCount: attachments.length, child: Column(
itemBuilder: (context, idx) { children: [
return SizedBox( if (attachments.isNotEmpty)
height: 280, SizedBox(
width: 280, height: 280,
child: AttachmentPreview( child: ListView.separated(
item: attachments[idx], padding: EdgeInsets.symmetric(horizontal: 12),
progress: attachmentProgress['chat-upload']?[idx], scrollDirection: Axis.horizontal,
onRequestUpload: () => onUploadAttachment(idx), itemCount: attachments.length,
onDelete: () => onDeleteAttachment(idx), itemBuilder: (context, idx) {
onUpdate: (value) { return SizedBox(
attachments[idx] = value; height: 280,
onAttachmentsChanged(attachments); width: 280,
}, child: AttachmentPreview(
onMove: (delta) => onMoveAttachment(idx, delta), item: attachments[idx],
), progress: attachmentProgress['chat-upload']?[idx],
); onRequestUpload: () => onUploadAttachment(idx),
}, onDelete: () => onDeleteAttachment(idx),
separatorBuilder: (_, _) => const Gap(8), onUpdate: (value) {
), attachments[idx] = value;
).padding(top: 12), onAttachmentsChanged(attachments);
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,
),
);
}, },
); onMove: (delta) => onMoveAttachment(idx, delta),
}, ),
), );
PopupMenuButton( },
icon: const Icon(Symbols.photo_library), separatorBuilder: (_, _) => const Gap(8),
itemBuilder: ),
(context) => [ ).padding(top: 12),
PopupMenuItem( if (messageReplyingTo != null ||
onTap: () => onPickFile(true), messageForwardingTo != null ||
child: Row( messageEditingTo != null)
spacing: 12, Container(
children: [ padding: const EdgeInsets.symmetric(
const Icon(Symbols.photo), horizontal: 16,
Text('addPhoto').tr(), vertical: 4,
], ),
), decoration: BoxDecoration(
), color: Theme.of(context).colorScheme.surfaceContainerHigh,
PopupMenuItem( borderRadius: BorderRadius.circular(32),
onTap: () => onPickFile(false), ),
child: Row( margin: const EdgeInsets.only(
spacing: 12, left: 8,
children: [ right: 8,
const Icon(Symbols.video_call), top: 8,
Text('addVideo').tr(), 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(),
),
),
],
),
), ),
Expanded( Row(
child: RawKeyboardListener( children: [
focusNode: FocusNode(), Row(
onKey: (event) => handleKeyPress(context, ref, event), 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( child: TextField(
focusNode: inputFocusNode, focusNode: inputFocusNode,
controller: messageController, controller: messageController,
onSubmitted: keyboardType: TextInputType.multiline,
(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( decoration: InputDecoration(
hintText: hintText:
(chatRoom.type == 1 && chatRoom.name == null) (chatRoom.type == 1 && chatRoom.name == null)
@@ -314,16 +311,16 @@ class ChatInput extends HookConsumerWidget {
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.send),
icon: const Icon(Icons.send), color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, onPressed: send,
onPressed: send, ),
), ],
], ),
).padding(bottom: MediaQuery.of(context).padding.bottom), ],
), ),
], ),
), ),
); );
} }

View File

@@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget {
case 'messages.update.links': case 'messages.update.links':
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Symbols.edit, Symbols.edit,
@@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget {
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
), ).padding(top: 2),
const Gap(4), const Gap(4),
if (item.meta['previous_content'] is String) if (item.meta['previous_content'] is String)
PrettyDiffText( Flexible(
oldText: item.meta['previous_content'], child: PrettyDiffText(
newText: item.content ?? 'Edited a message', oldText: item.meta['previous_content'],
defaultTextStyle: Theme.of( newText: item.content ?? 'Edited a message',
context, defaultTextStyle: Theme.of(
).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
addedTextStyle: TextStyle(
backgroundColor: Theme.of(
context, context,
).colorScheme.primaryFixedDim.withOpacity(0.4), ).textTheme.bodyMedium!.copyWith(
), color: Theme.of(context).colorScheme.onSurfaceVariant,
deletedTextStyle: TextStyle( ),
decoration: TextDecoration.lineThrough, addedTextStyle: TextStyle(
color: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.7), ).colorScheme.primaryFixedDim.withOpacity(0.4),
),
deletedTextStyle: TextStyle(
decoration: TextDecoration.lineThrough,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
), ),
) )
else else
@@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MarkdownTextContent( Flexible(
content: item.content ?? '*${item.type} has no content*', child: MarkdownTextContent(
isSelectable: true, content: item.content ?? '*${item.type} has no content*',
linesMargin: EdgeInsets.zero, isSelectable: true,
linesMargin: EdgeInsets.zero,
),
), ),
if (translatedText?.isNotEmpty ?? false) if (translatedText?.isNotEmpty ?? false)
...([ ...([