Chat input auto grow

This commit is contained in:
LittleSheep 2025-02-28 00:08:12 +08:00
parent 09ec9d4a0c
commit 93f41bb523
3 changed files with 137 additions and 67 deletions

View File

@ -44,7 +44,9 @@ class SnNetworkProvider {
settings: const TalkerDioLoggerSettings( settings: const TalkerDioLoggerSettings(
printRequestHeaders: false, printRequestHeaders: false,
printResponseHeaders: false, printResponseHeaders: false,
printResponseMessage: true, printResponseMessage: false,
printResponseData: false,
printRequestData: false,
), ),
), ),
); );

View File

@ -28,7 +28,8 @@ class ChatMessageInput extends StatefulWidget {
final ChatMessageController controller; final ChatMessageController controller;
final SnChannelMember? otherMember; final SnChannelMember? otherMember;
const ChatMessageInput({super.key, required this.controller, this.otherMember}); const ChatMessageInput(
{super.key, required this.controller, this.otherMember});
@override @override
State<ChatMessageInput> createState() => ChatMessageInputState(); State<ChatMessageInput> createState() => ChatMessageInputState();
@ -45,12 +46,20 @@ class ChatMessageInputState extends State<ChatMessageInput> {
final HotKey _pasteHotKey = HotKey( final HotKey _pasteHotKey = HotKey(
key: PhysicalKeyboardKey.keyV, key: PhysicalKeyboardKey.keyV,
modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control], modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
final HotKey _newLineHotKey = HotKey( final HotKey _newLineHotKey = HotKey(
key: PhysicalKeyboardKey.enter, key: PhysicalKeyboardKey.enter,
modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control], modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
@ -100,7 +109,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
void setEdit(SnChatMessage? value) { void setEdit(SnChatMessage? value) {
_contentController.text = value?.body['text'] ?? ''; _contentController.text = value?.body['text'] ?? '';
_attachments.clear(); _attachments.clear();
_attachments.addAll(value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []); _attachments.addAll(
value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []);
setState(() => _editingMessage = value); setState(() => _editingMessage = value);
} }
@ -139,7 +149,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
media.name, media.name,
'messaging', 'messaging',
null, null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
); );
final item = await attach.chunkedUploadParts( final item = await attach.chunkedUploadParts(
@ -171,7 +183,10 @@ class ChatMessageInputState extends State<ChatMessageInput> {
widget.controller.sendMessage( widget.controller.sendMessage(
_editingMessage != null ? 'messages.edit' : 'messages.new', _editingMessage != null ? 'messages.edit' : 'messages.new',
_contentController.text, _contentController.text,
attachments: _attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), attachments: _attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
relatedId: _editingMessage?.id, relatedId: _editingMessage?.id,
quoteId: _replyingMessage?.id, quoteId: _replyingMessage?.id,
editingMessage: _editingMessage, editingMessage: _editingMessage,
@ -232,12 +247,15 @@ class ChatMessageInputState extends State<ChatMessageInput> {
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress), tween: Tween(begin: 0, end: _progress),
duration: Duration(milliseconds: 300), duration: Duration(milliseconds: 300),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
) )
else if (_isBusy) else if (_isBusy)
const LinearProgressIndicator(value: null, minHeight: 2), const LinearProgressIndicator(value: null, minHeight: 2),
Padding( Padding(
padding: _attachments.isNotEmpty ? const EdgeInsets.only(top: 8) : EdgeInsets.zero, padding: _attachments.isNotEmpty
? const EdgeInsets.only(top: 8)
: EdgeInsets.zero,
child: PostMediaPendingList( child: PostMediaPendingList(
attachments: _attachments, attachments: _attachments,
isBusy: _isBusy, isBusy: _isBusy,
@ -249,9 +267,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
}, },
onUpdateBusy: (state) => setState(() => _isBusy = state), onUpdateBusy: (state) => setState(() => _isBusy = state),
), ),
) ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
.height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true) const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView( SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
child: _replyingMessage != null child: _replyingMessage != null
@ -272,7 +289,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
const Gap(8), const Gap(8),
Expanded( Expanded(
child: Text( child: Text(
_replyingMessage?.body['text'] ?? '${_replyingMessage?.sender.nick}', _replyingMessage?.body['text'] ??
'${_replyingMessage?.sender.nick}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -289,9 +307,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
).padding(vertical: 8), ).padding(vertical: 8),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
) ).height(_replyingMessage != null ? 38 : 0, animate: true).animate(
.height(_replyingMessage != null ? 38 : 0, animate: true) const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
SingleChildScrollView( SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
child: _editingMessage != null child: _editingMessage != null
@ -312,7 +329,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
const Gap(8), const Gap(8),
Expanded( Expanded(
child: Text( child: Text(
_editingMessage?.body['text'] ?? '${_editingMessage?.sender.nick}', _editingMessage?.body['text'] ??
'${_editingMessage?.sender.nick}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -330,12 +348,13 @@ class ChatMessageInputState extends State<ChatMessageInput> {
).padding(vertical: 8), ).padding(vertical: 8),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
) ).height(_editingMessage != null ? 38 : 0, animate: true).animate(
.height(_editingMessage != null ? 38 : 0, animate: true) const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), Container(
SizedBox( padding: EdgeInsets.symmetric(horizontal: 16),
height: 56, constraints: BoxConstraints(minHeight: 56, maxHeight: 240),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
@ -347,11 +366,14 @@ class ChatMessageInputState extends State<ChatMessageInput> {
? 'fieldChatMessageDirect'.tr(args: [ ? 'fieldChatMessageDirect'.tr(args: [
'@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}', '@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}',
]) ])
: 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]), : 'fieldChatMessage'.tr(args: [
widget.controller.channel?.name ?? 'loading'.tr()
]),
border: InputBorder.none, border: InputBorder.none,
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) { onSubmitted: (_) {
if (_isBusy) return; if (_isBusy) return;
_sendMessage(); _sendMessage();
@ -366,7 +388,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
Symbols.mood, Symbols.mood,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4), visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
onPressed: () { onPressed: () {
@ -386,13 +409,14 @@ class ChatMessageInputState extends State<ChatMessageInput> {
Symbols.send, Symbols.send,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4), visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
], ],
), ),
).padding(horizontal: 16), ),
], ],
); );
} }
@ -403,7 +427,8 @@ class _StickerPicker extends StatelessWidget {
final Function? onDismiss; final Function? onDismiss;
final Function(String)? onInsert; final Function(String)? onInsert;
const _StickerPicker({this.onDismiss, required this.originalText, this.onInsert}); const _StickerPicker(
{this.onDismiss, required this.originalText, this.onInsert});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -413,7 +438,9 @@ class _StickerPicker extends StatelessWidget {
onDismiss?.call(); onDismiss?.call();
}, },
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: min(360, MediaQuery.of(context).size.width), maxHeight: 240), constraints: BoxConstraints(
maxWidth: min(360, MediaQuery.of(context).size.width),
maxHeight: 240),
child: Material( child: Material(
elevation: 8, elevation: 8,
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@ -426,8 +453,10 @@ class _StickerPicker extends StatelessWidget {
return <Widget>[ return <Widget>[
Container( Container(
margin: EdgeInsets.only(bottom: 8), margin: EdgeInsets.only(bottom: 8),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding:
color: Theme.of(context).colorScheme.surfaceContainerHigh, EdgeInsets.symmetric(horizontal: 8, vertical: 4),
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -439,7 +468,8 @@ class _StickerPicker extends StatelessWidget {
), ),
GridView.builder( GridView.builder(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), padding:
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
shrinkWrap: true, shrinkWrap: true,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 48, maxCrossAxisExtent: 48,
@ -462,7 +492,8 @@ class _StickerPicker extends StatelessWidget {
richMessage: TextSpan( richMessage: TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: ':${element.pack.prefix}${element.alias}:\n', text:
':${element.pack.prefix}${element.alias}:\n',
style: GoogleFonts.robotoMono()), style: GoogleFonts.robotoMono()),
TextSpan(text: element.name).bold(), TextSpan(text: element.name).bold(),
], ],
@ -471,11 +502,15 @@ class _StickerPicker extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(
color: Theme.of(context).colorScheme.surfaceContainerHigh, Radius.circular(8)),
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(
Radius.circular(8)),
child: UniversalImage( child: UniversalImage(
sn.getAttachmentUrl(element.attachment.rid), sn.getAttachmentUrl(element.attachment.rid),
width: 48, width: 48,

View File

@ -67,7 +67,10 @@ class PostMediaPendingList extends StatelessWidget {
if (result == null) return; if (result == null) return;
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List(); final rawBytes =
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
.buffer
.asUint8List();
if (onUpdate != null) { if (onUpdate != null) {
final updatedMedia = PostWriteMedia.fromBytes( final updatedMedia = PostWriteMedia.fromBytes(
@ -133,7 +136,8 @@ class PostMediaPendingList extends StatelessWidget {
final result = await showDialog<SnAttachmentBoost?>( final result = await showDialog<SnAttachmentBoost?>(
context: context, context: context,
builder: (context) => PendingAttachmentBoostDialog(media: attachments[idx]), builder: (context) =>
PendingAttachmentBoostDialog(media: attachments[idx]),
); );
if (result == null) return; if (result == null) return;
@ -165,11 +169,15 @@ class PostMediaPendingList extends StatelessWidget {
onUpdate!(idx, PostWriteMedia(result)); onUpdate!(idx, PostWriteMedia(result));
} }
ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) { ContextMenu _createContextMenu(
final canCompressVideo = !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); BuildContext context, int idx, PostWriteMedia media) {
final canCompressVideo =
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
return ContextMenu( return ContextMenu(
entries: [ entries: [
if (media.attachment == null && media.type == SnMediaType.video && canCompressVideo) if (media.attachment == null &&
media.type == SnMediaType.video &&
canCompressVideo)
MenuItem( MenuItem(
label: 'attachmentCompressVideo'.tr(), label: 'attachmentCompressVideo'.tr(),
icon: Symbols.compress, icon: Symbols.compress,
@ -312,12 +320,15 @@ class _PostMediaPendingItem extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: switch (media.type) { child: switch (media.type) {
SnMediaType.image => LayoutBuilder(builder: (context, constraints) { SnMediaType.image =>
LayoutBuilder(builder: (context, constraints) {
return Image( return Image(
image: media.getImageProvider( image: media.getImageProvider(
context, context,
width: (constraints.maxWidth * devicePixelRatio).round(), width:
height: (constraints.maxHeight * devicePixelRatio).round(), (constraints.maxWidth * devicePixelRatio).round(),
height:
(constraints.maxHeight * devicePixelRatio).round(),
)!, )!,
fit: BoxFit.contain, fit: BoxFit.contain,
); );
@ -326,28 +337,34 @@ class _PostMediaPendingItem extends StatelessWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (media.attachment?.thumbnail != null) if (media.attachment?.thumbnail != null)
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)), AutoResizeUniversalImage(sn.getAttachmentUrl(
const Icon(Symbols.videocam, color: Colors.white, shadows: [ media.attachment!.thumbnail!.rid)),
Shadow( const Icon(Symbols.videocam,
offset: Offset(1, 1), color: Colors.white,
blurRadius: 8.0, shadows: [
color: Color.fromARGB(255, 0, 0, 0), Shadow(
), offset: Offset(1, 1),
]), blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
]),
], ],
), ),
SnMediaType.audio => Stack( SnMediaType.audio => Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (media.attachment?.thumbnail != null) if (media.attachment?.thumbnail != null)
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)), AutoResizeUniversalImage(sn.getAttachmentUrl(
const Icon(Symbols.audio_file, color: Colors.white, shadows: [ media.attachment!.thumbnail!.rid)),
Shadow( const Icon(Symbols.audio_file,
offset: Offset(1, 1), color: Colors.white,
blurRadius: 8.0, shadows: [
color: Color.fromARGB(255, 0, 0, 0), Shadow(
), offset: Offset(1, 1),
]), blurRadius: 8.0,
color: Color.fromARGB(255, 0, 0, 0),
),
]),
], ],
), ),
_ => Container( _ => Container(
@ -356,7 +373,8 @@ class _PostMediaPendingItem extends StatelessWidget {
), ),
}, },
), ),
if (media.type != SnMediaType.image) const VerticalDivider(width: 1, thickness: 1), if (media.type != SnMediaType.image)
const VerticalDivider(width: 1, thickness: 1),
if (media.type != SnMediaType.image) if (media.type != SnMediaType.image)
SizedBox( SizedBox(
width: 160, width: 160,
@ -374,7 +392,8 @@ class _PostMediaPendingItem extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
else if (media.file != null) else if (media.file != null)
Text(media.file!.name, maxLines: 1, overflow: TextOverflow.ellipsis) Text(media.file!.name,
maxLines: 1, overflow: TextOverflow.ellipsis)
else else
Text('unknown'.tr()), Text('unknown'.tr()),
if (media.attachment != null) if (media.attachment != null)
@ -387,7 +406,8 @@ class _PostMediaPendingItem extends StatelessWidget {
FutureBuilder<int?>( FutureBuilder<int?>(
future: media.length(), future: media.length(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink(); if (!snapshot.hasData)
return const SizedBox.shrink();
return Text( return Text(
snapshot.data!.formatBytes(), snapshot.data!.formatBytes(),
style: GoogleFonts.robotoMono(fontSize: 13), style: GoogleFonts.robotoMono(fontSize: 13),
@ -398,7 +418,8 @@ class _PostMediaPendingItem extends StatelessWidget {
], ],
), ),
), ),
if (media.attachment != null && media.attachment!.boosts.isNotEmpty) if (media.attachment != null &&
media.attachment!.boosts.isNotEmpty)
Row( Row(
children: [ children: [
Icon(Symbols.bolt, size: 16), Icon(Symbols.bolt, size: 16),
@ -406,7 +427,8 @@ class _PostMediaPendingItem extends StatelessWidget {
Text('attachmentGotBoosted').tr().fontSize(13), Text('attachmentGotBoosted').tr().fontSize(13),
], ],
), ),
if (media.attachment != null && media.attachment!.compressedId != null) if (media.attachment != null &&
media.attachment!.compressedId != null)
Row( Row(
children: [ children: [
Icon(Symbols.compress, size: 16), Icon(Symbols.compress, size: 16),
@ -527,12 +549,20 @@ class AddPostMediaButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton( return PopupMenuButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
icon: Icon( icon: Icon(
Symbols.add_photo_alternate, Symbols.add_photo_alternate,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
itemBuilder: (context) => [ itemBuilder: (context) => [
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) if (!kIsWeb &&
!Platform.isLinux &&
!Platform.isMacOS &&
!Platform.isWindows)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@ -545,7 +575,10 @@ class AddPostMediaButton extends StatelessWidget {
_takeMedia(false); _takeMedia(false);
}, },
), ),
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) if (!kIsWeb &&
!Platform.isLinux &&
!Platform.isMacOS &&
!Platform.isWindows)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [