From b0eded378015a40a900fd238de7604a4dd617b7b Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 7 Mar 2026 08:28:22 +0100 Subject: [PATCH] Fix message bubble layout --- lib/providers/app_provider.dart | 176 ++++++++++++++++-- lib/widgets/messages/message_bubble.dart | 95 +++++----- lib/widgets/messages/messages_composer.dart | 2 +- .../messages/voice_message_bubble.dart | 44 ++++- 4 files changed, 249 insertions(+), 68 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 7c38b3e..7ee5c36 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -64,6 +64,8 @@ class AppProvider with ChangeNotifier { final Map _imageSessionSenderKey6 = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; + final Map _imageMissingRetryTimers = {}; + final Map _imageMissingRetryAttempts = {}; final FragmentAckWaitRegistry _voiceFragmentAckWaiters = FragmentAckWaitRegistry(); final FragmentAckWaitRegistry _imageFragmentAckWaiters = @@ -796,9 +798,7 @@ class AppProvider with ChangeNotifier { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); if (voiceFetchRequest != null) { - final requester = contactsProvider.findContactByPrefixHex( - voiceFetchRequest.requesterKey6, - ); + final requester = _resolveVoiceFetchRequester(voiceFetchRequest); if (requester == null) { debugPrint( '⚠️ [AppProvider] Voice fetch requester contact not found (binary)', @@ -901,6 +901,10 @@ class AppProvider with ChangeNotifier { height: session?.height ?? 0, ); _sendImageFragmentAck(frag); + _scheduleImageMissingRetry( + frag.sessionId, + justComplete: imageProvider.isComplete(frag.sessionId), + ); return; } @@ -1268,15 +1272,51 @@ class AppProvider with ChangeNotifier { return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); } + Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) { + final liveContact = _resolveContactByPrefixHex(request.requesterKey6); + if (liveContact != null) { + return liveContact; + } + + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: VoiceEnvelope.tryParseText, + mediaLabel: 'voice', + ); + } + Contact? _resolveImageFetchRequester(ImageFetchRequest request) { final liveContact = _resolveContactByPrefixHex(request.requesterKey6); if (liveContact != null) { return liveContact; } + return _resolveRequesterFromSentMessages( + sessionId: request.sessionId, + requesterKey6: request.requesterKey6, + tryParseEnvelope: ImageEnvelope.tryParse, + mediaLabel: 'image', + ); + } + + Contact? _resolveRequesterFromSentMessages({ + required String sessionId, + required String requesterKey6, + required T? Function(String text) tryParseEnvelope, + required String mediaLabel, + }) { for (final message in messagesProvider.messages.reversed) { - final envelope = ImageEnvelope.tryParse(message.text); - if (envelope == null || envelope.sessionId != request.sessionId) { + final envelope = tryParseEnvelope(message.text); + if (envelope == null) { + continue; + } + final envelopeSessionId = switch (envelope) { + VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId, + ImageEnvelope imageEnvelope => imageEnvelope.sessionId, + _ => null, + }; + if (envelopeSessionId != sessionId) { continue; } final recipientKey = message.recipientPublicKey; @@ -1291,12 +1331,13 @@ class AppProvider with ChangeNotifier { .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - if (recipientKey6 != request.requesterKey6) { + if (recipientKey6 != requesterKey6) { continue; } debugPrint( - '📷 [AppProvider] Resolved image requester from sent message metadata ' - 'for session ${request.sessionId}: ${recipient.advName}', + '${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved ' + '$mediaLabel requester from sent message metadata for session ' + '$sessionId: ${recipient.advName}', ); return recipient; } @@ -1308,9 +1349,12 @@ class AppProvider with ChangeNotifier { String sessionId, { required bool justComplete, }) { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } if (justComplete || voiceProvider.isComplete(sessionId)) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); return; } @@ -1321,10 +1365,43 @@ class AppProvider with ChangeNotifier { }); } + void _scheduleImageMissingRetry( + String sessionId, { + required bool justComplete, + }) { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (justComplete || imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + _imageMissingRetryAttempts[sessionId] = 0; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + + void _clearVoiceMissingRetry(String sessionId) { + _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _voiceMissingRetryAttempts.remove(sessionId); + } + + void _clearImageMissingRetry(String sessionId) { + _imageMissingRetryTimers.remove(sessionId)?.cancel(); + _imageMissingRetryAttempts.remove(sessionId); + } + Future _requestMissingVoicePackets(String sessionId) async { + if (voiceProvider.isReceiveCanceled(sessionId)) { + _clearVoiceMissingRetry(sessionId); + return; + } if (voiceProvider.isComplete(sessionId)) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); return; } @@ -1333,7 +1410,7 @@ class AppProvider with ChangeNotifier { debugPrint( '⚠️ [AppProvider] Voice re-request limit reached for $sessionId', ); - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); + _clearVoiceMissingRetry(sessionId); return; } @@ -1345,8 +1422,7 @@ class AppProvider with ChangeNotifier { final missing = voiceProvider.missingPacketIndices(sessionId); if (missing.isEmpty) { - _voiceMissingRetryTimers.remove(sessionId)?.cancel(); - _voiceMissingRetryAttempts.remove(sessionId); + _clearVoiceMissingRetry(sessionId); return; } @@ -1381,6 +1457,67 @@ class AppProvider with ChangeNotifier { }); } + Future _requestMissingImageFragments(String sessionId) async { + if (imageProvider.isReceiveCanceled(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + if (imageProvider.isComplete(sessionId)) { + _clearImageMissingRetry(sessionId); + return; + } + + final attempt = _imageMissingRetryAttempts[sessionId] ?? 0; + if (attempt >= _maxPacketRetryAttempts) { + debugPrint( + '⚠️ [AppProvider] Image re-request limit reached for $sessionId', + ); + _clearImageMissingRetry(sessionId); + return; + } + + final senderKey6 = _imageSessionSenderKey6[sessionId]; + if (senderKey6 == null) return; + final sender = _resolveContactByPrefixHex(senderKey6); + final deviceKey = connectionProvider.deviceInfo.publicKey; + if (sender == null || deviceKey == null || deviceKey.length < 6) return; + + final missing = imageProvider.missingFragmentIndices(sessionId); + if (missing.isEmpty) { + _clearImageMissingRetry(sessionId); + return; + } + + final requesterKey6 = deviceKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + + final request = ImageFetchRequest( + sessionId: sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + try { + await connectionProvider.sendRawVoicePacket( + contactPath: sender.outPath, + contactPathLen: sender.outPathLen, + payload: request.encodeBinary(), + ); + } catch (_) { + return; + } + + _imageMissingRetryAttempts[sessionId] = attempt + 1; + _imageMissingRetryTimers[sessionId]?.cancel(); + _imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () { + unawaited(_requestMissingImageFragments(sessionId)); + }); + } + /// Insert or update a voice placeholder message for binary raw-data packets. /// /// Binary voice packets arrive without a chat message, so we synthesise one @@ -1604,9 +1741,15 @@ class AppProvider with ChangeNotifier { for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } _voiceMissingRetryTimers.clear(); _voiceMissingRetryAttempts.clear(); + _imageMissingRetryTimers.clear(); + _imageMissingRetryAttempts.clear(); _voiceSessionSenderKey6.clear(); + _imageSessionSenderKey6.clear(); notifyListeners(); } @@ -1640,6 +1783,9 @@ class AppProvider with ChangeNotifier { for (final timer in _voiceMissingRetryTimers.values) { timer.cancel(); } + for (final timer in _imageMissingRetryTimers.values) { + timer.cancel(); + } super.dispose(); } } diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index dbb4128..1adf01a 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1616,19 +1616,13 @@ class _MessageBubbleState extends State { } Widget _buildChannelEchoStatus(BuildContext context, Message message) { - final statusColor = _getDeliveryStatusColor(message.deliveryStatus); final hasEcho = message.echoCount > 0; if (!hasEcho) { - return Text( - message.getLocalizedDeliveryStatus(context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: statusColor, - fontStyle: FontStyle.italic, - ), - ); + return const SizedBox.shrink(); } + final statusColor = _getDeliveryStatusColor(message.deliveryStatus); final rssi = message.lastEchoRssiDbm; final snr = message.lastEchoSnrRaw != null ? message.lastEchoSnrRaw!.toSigned(8) / 4.0 @@ -1792,6 +1786,40 @@ class _MessageBubbleState extends State { ); } + Widget _buildDirectHeaderCounterpart( + BuildContext context, { + required bool isOwnMessage, + required String label, + }) { + final textColor = Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.75); + + return Row( + children: [ + Icon( + isOwnMessage ? Icons.arrow_forward : Icons.arrow_back, + size: 12, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: textColor, + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + String _hopDebugLabel(Message message) { if (message.pathLen >= 255 && message.isContactMessage) { return 'Direct (raw: ${message.pathLen})'; @@ -2097,6 +2125,9 @@ class _MessageBubbleState extends State { isOwnMessage && message.isChannelMessage && recipientDisplayName != null ? '${l10n.channel}: $recipientDisplayName' : recipientDisplayName; + final directCounterpartLabel = !message.isChannelMessage + ? (isOwnMessage ? recipientSubtitle : l10n.you) + : null; final receivedChannelSubtitle = !isOwnMessage && message.isChannelMessage && channelDisplayName != null ? '${l10n.channel}: $channelDisplayName' @@ -2293,7 +2324,7 @@ class _MessageBubbleState extends State { Expanded( child: Row( children: [ - Flexible( + Expanded( child: Text( displayName, style: Theme.of(context).textTheme.labelMedium @@ -2309,10 +2340,12 @@ class _MessageBubbleState extends State { ), if (!widget.isCompact && (recipientSubtitle != null || + directCounterpartLabel != null || receivedChannelSubtitle != null)) ...[ const SizedBox(width: 8), if (message.isChannelMessage) - Flexible( + Align( + alignment: Alignment.centerRight, child: _buildChannelHeaderPill( context, label: isOwnMessage @@ -2322,41 +2355,10 @@ class _MessageBubbleState extends State { ) else Expanded( - child: Row( - children: [ - Icon( - isOwnMessage - ? Icons.arrow_forward - : Icons.arrow_back, - size: 12, - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.7), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - isOwnMessage - ? recipientSubtitle! - : receivedChannelSubtitle!, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: Theme.of(context) - .textTheme - .labelSmall - ?.color - ?.withValues(alpha: 0.75), - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + child: _buildDirectHeaderCounterpart( + context, + isOwnMessage: isOwnMessage, + label: directCounterpartLabel!, ), ), ], @@ -2838,7 +2840,8 @@ class _MessageBubbleState extends State { ), ] // Show single message delivery status - else + else if (!message.isChannelMessage || + message.deliveryStatus == MessageDeliveryStatus.failed) Row( mainAxisSize: MainAxisSize.max, children: [ diff --git a/lib/widgets/messages/messages_composer.dart b/lib/widgets/messages/messages_composer.dart index 4f43815..0ba321c 100644 --- a/lib/widgets/messages/messages_composer.dart +++ b/lib/widgets/messages/messages_composer.dart @@ -51,7 +51,7 @@ class MessagesComposer extends StatelessWidget { SafeArea( top: false, child: Padding( - padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding), + padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index dd7eef6..8f11bc3 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -281,12 +281,29 @@ class _VoiceMessageBubbleState extends State { .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); - final request = VoiceFetchRequest( + final missing = voiceProvider.missingPacketIndices(sessionId); + final totalPackets = sessionPacketCount( + voiceProvider: voiceProvider, sessionId: sessionId, - requesterKey6: requesterKey6, - timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, - version: 2, + envelope: envelope, ); + final isPartialResume = + missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets; + final request = isPartialResume + ? VoiceFetchRequest( + sessionId: sessionId, + want: 'missing', + missingIndices: missing, + requesterKey6: requesterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + version: 2, + ) + : VoiceFetchRequest( + sessionId: sessionId, + requesterKey6: requesterKey6, + timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000, + version: 2, + ); setState(() { _isRequesting = true; @@ -309,11 +326,18 @@ class _VoiceMessageBubbleState extends State { final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; + final estimatedDurationMs = + envelope != null && + totalPackets > 0 && + missing.isNotEmpty && + missing.length < totalPackets + ? ((envelope.durationMs * missing.length) / totalPackets).round() + : envelope?.durationMs; final txEstimate = envelope != null ? estimateVoiceTransmitDuration( - packetCount: envelope.total, + packetCount: isPartialResume ? missing.length : envelope.total, mode: envelope.mode, - durationMs: envelope.durationMs, + durationMs: estimatedDurationMs ?? envelope.durationMs, pathLen: effectivePathLen, radioBw: radioBw, radioSf: radioSf, @@ -331,6 +355,14 @@ class _VoiceMessageBubbleState extends State { ); } + int sessionPacketCount({ + required VoiceProvider voiceProvider, + required String sessionId, + required VoiceEnvelope? envelope, + }) { + return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0; + } + void _setUnavailable() { if (!mounted) return; _showToast(AppLocalizations.of(context)!.voiceUnavailable);