Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-14 01:12:49 +03:00
115 changed files with 25324 additions and 12783 deletions
+299 -226
View File
@@ -26,7 +26,6 @@ import '../models/translation_support.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../helpers/contact_ui.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart';
@@ -40,6 +39,8 @@ import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
@@ -109,7 +110,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
totalMessages: messages.length,
onJumped: () {
if (!mounted) return;
_scrollToMessage(anchor!.messageId);
_scrollToMessage(anchor!.messageId, quiet: true);
},
);
});
@@ -194,9 +195,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
});
}
Future<void> _scrollToMessage(String messageId) async {
Future<void> _scrollToMessage(String messageId, {bool quiet = false}) async {
final key = _messageKeys[messageId];
if (key == null) {
// The auto unread-jump can resolve a frame after navigating away;
// a deactivated context can't host a snackbar.
if (quiet || !mounted || !context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_originalMessageNotFound),
@@ -492,6 +496,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text);
final translatedDisplayText =
@@ -511,9 +516,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.pathHashWidth ??
context.read<MeshCoreConnector>().pathHashByteWidth;
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow;
final bubbleBorder = isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0;
// Asymmetric radius matching chat_screen bubbles.
final borderRadius = isOutgoing
? const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.lg),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.xs),
)
: const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.xs),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.lg),
);
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = LayoutBuilder(
builder: (context, constraints) => Column(
crossAxisAlignment: isOutgoing
@@ -524,11 +554,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
Flexible(
child: GestureDetector(
@@ -544,15 +574,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65,
maxWidth: constraints.maxWidth * 0.72,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
color: bubbleColor,
borderRadius: borderRadius,
border: Border.all(color: bubbleBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -568,14 +595,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _colorForName(message.senderName),
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 2),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message, textScale),
@@ -598,13 +625,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
fallbackTextColor: textColor.withValues(
alpha: 0.7,
),
),
),
],
@@ -619,43 +642,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
displayText: translatedDisplayText,
originalText: originalDisplayText,
style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale,
),
originalStyle: TextStyle(
fontSize: bodyFontSize * textScale,
fontStyle: FontStyle.italic,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.72),
color: textColor.withValues(alpha: 0.72),
),
),
),
],
),
if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(
displayPath,
displayPathHashWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RouteChip(
isDirect: (message.pathLength ?? -1) >= 0,
hops: (message.pathLength ?? -1) >= 0
? message.pathLength
: null,
),
),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
context.l10n.channels_via(
_formatPathPrefixes(
displayPath,
displayPathHashWidth,
),
),
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
),
),
],
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.only(
@@ -669,30 +700,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
if (enableTracing && message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
size: 11 * textScale,
color: metaColor,
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
],
@@ -712,6 +737,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isFailed:
message.status ==
ChannelMessageStatus.failed,
onColor: metaColor,
),
],
],
@@ -727,7 +753,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(message),
),
],
@@ -746,7 +772,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: messageBody,
);
}
@@ -843,7 +869,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border(
left: BorderSide(color: colorScheme.primary, width: 3),
),
@@ -856,9 +882,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
style: TextStyle(
fontSize: 11 * textScale,
fontWeight: FontWeight.bold,
color: isOwnNode
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
),
),
const SizedBox(height: 2),
@@ -870,6 +894,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildReactionsDisplay(ChannelMessage message) {
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 6,
runSpacing: 6,
@@ -880,27 +905,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: scheme.outlineVariant, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
Text(
emoji,
style: MeshTheme.emoji(fontSize: 16),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
@@ -919,20 +946,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
String senderName, {
Widget? trailing,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final scheme = Theme.of(context).colorScheme;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
icon: Icon(Icons.location_on_outlined, color: scheme.primary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: () {
@@ -998,41 +1020,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildAvatar(String senderName) {
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
return AvatarCircle(name: senderName, size: 32);
}
Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!;
final scheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color: scheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
Icon(Icons.reply, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -1040,10 +1045,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12 * textScale,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11 * textScale,
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
Text(
@@ -1052,9 +1057,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
color: scheme.onSurfaceVariant,
),
),
],
@@ -1063,7 +1066,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer,
color: scheme.onSurfaceVariant,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
@@ -1075,6 +1078,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final settings = context.watch<AppSettingsService>().settings;
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1088,123 +1092,166 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
},
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
color: scheme.surface,
border: Border(
top: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey ==
LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
scheme.surfaceContainerHighest,
fallbackTextColor: scheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
(connector.isChannelSmazEnabled(
widget.channel.index,
) ||
connector.isChannelCyr2LatEnabled(
widget.channel.index,
))
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
],
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.primary,
width: 1.5,
),
),
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
);
},
),
),
const SizedBox(width: 6),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, _) {
final hasText = value.text.trim().isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
child: IconButton.filled(
icon: const Icon(Icons.send, size: 20),
tooltip: context.l10n.chat_sendMessage,
style: IconButton.styleFrom(
backgroundColor: hasText
? scheme.primary
: scheme.surfaceContainerHighest,
foregroundColor: hasText
? scheme.onPrimary
: scheme.onSurfaceVariant,
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
onPressed: hasText
? () {
HapticFeedback.lightImpact();
_sendMessage();
}
: null,
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
(connector.isChannelSmazEnabled(
widget.channel.index,
) ||
connector.isChannelCyr2LatEnabled(
widget.channel.index,
))
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
),
);
},
),
},
),
],
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
tooltip: context.l10n.chat_sendMessage,
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
],
@@ -1345,12 +1392,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
subtitle: message.senderName.isNotEmpty
? message.senderName
: null,
),
ListTile(
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
@@ -1409,7 +1464,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_markAsUnread(message);
},
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.delete_outline,
@@ -1424,6 +1479,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await _deleteMessage(message);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1507,6 +1563,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
pathHashByteWidth,
).map(PathHelper.formatHopHex).join(',');
}
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
Color _colorForName(String name) {
const hues = [
MeshPalette.blue,
MeshPalette.magenta,
MeshPalette.signal,
MeshPalette.warn,
Color(0xFF8FA8F0),
Color(0xFF6FD9CE),
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return hues[h % hues.length];
}
}
class _SwipeReplyBubble extends StatefulWidget {
@@ -1648,7 +1721,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
child: Stack(
alignment: Alignment.center,
children: [