Fix message bubble layout

This commit is contained in:
Janez T
2026-03-07 08:28:22 +01:00
parent f1d56e2000
commit b0eded3780
4 changed files with 249 additions and 68 deletions

View File

@@ -64,6 +64,8 @@ class AppProvider with ChangeNotifier {
final Map<String, String> _imageSessionSenderKey6 = {}; final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {}; final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {}; final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _voiceFragmentAckWaiters = final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
FragmentAckWaitRegistry(); FragmentAckWaitRegistry();
final FragmentAckWaitRegistry _imageFragmentAckWaiters = final FragmentAckWaitRegistry _imageFragmentAckWaiters =
@@ -796,9 +798,7 @@ class AppProvider with ChangeNotifier {
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload); final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) { if (voiceFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex( final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
voiceFetchRequest.requesterKey6,
);
if (requester == null) { if (requester == null) {
debugPrint( debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)', '⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
@@ -901,6 +901,10 @@ class AppProvider with ChangeNotifier {
height: session?.height ?? 0, height: session?.height ?? 0,
); );
_sendImageFragmentAck(frag); _sendImageFragmentAck(frag);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return; return;
} }
@@ -1268,15 +1272,51 @@ class AppProvider with ChangeNotifier {
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase()); 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) { Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6); final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) { if (liveContact != null) {
return liveContact; return liveContact;
} }
return _resolveRequesterFromSentMessages(
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
tryParseEnvelope: ImageEnvelope.tryParse,
mediaLabel: 'image',
);
}
Contact? _resolveRequesterFromSentMessages<T>({
required String sessionId,
required String requesterKey6,
required T? Function(String text) tryParseEnvelope,
required String mediaLabel,
}) {
for (final message in messagesProvider.messages.reversed) { for (final message in messagesProvider.messages.reversed) {
final envelope = ImageEnvelope.tryParse(message.text); final envelope = tryParseEnvelope(message.text);
if (envelope == null || envelope.sessionId != request.sessionId) { if (envelope == null) {
continue;
}
final envelopeSessionId = switch (envelope) {
VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId,
ImageEnvelope imageEnvelope => imageEnvelope.sessionId,
_ => null,
};
if (envelopeSessionId != sessionId) {
continue; continue;
} }
final recipientKey = message.recipientPublicKey; final recipientKey = message.recipientPublicKey;
@@ -1291,12 +1331,13 @@ class AppProvider with ChangeNotifier {
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
if (recipientKey6 != request.requesterKey6) { if (recipientKey6 != requesterKey6) {
continue; continue;
} }
debugPrint( debugPrint(
'📷 [AppProvider] Resolved image requester from sent message metadata ' '${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved '
'for session ${request.sessionId}: ${recipient.advName}', '$mediaLabel requester from sent message metadata for session '
'$sessionId: ${recipient.advName}',
); );
return recipient; return recipient;
} }
@@ -1308,9 +1349,12 @@ class AppProvider with ChangeNotifier {
String sessionId, { String sessionId, {
required bool justComplete, required bool justComplete,
}) { }) {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (justComplete || voiceProvider.isComplete(sessionId)) { if (justComplete || voiceProvider.isComplete(sessionId)) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel(); _clearVoiceMissingRetry(sessionId);
_voiceMissingRetryAttempts.remove(sessionId);
return; 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<void> _requestMissingVoicePackets(String sessionId) async { Future<void> _requestMissingVoicePackets(String sessionId) async {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (voiceProvider.isComplete(sessionId)) { if (voiceProvider.isComplete(sessionId)) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel(); _clearVoiceMissingRetry(sessionId);
_voiceMissingRetryAttempts.remove(sessionId);
return; return;
} }
@@ -1333,7 +1410,7 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId', '⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
); );
_voiceMissingRetryTimers.remove(sessionId)?.cancel(); _clearVoiceMissingRetry(sessionId);
return; return;
} }
@@ -1345,8 +1422,7 @@ class AppProvider with ChangeNotifier {
final missing = voiceProvider.missingPacketIndices(sessionId); final missing = voiceProvider.missingPacketIndices(sessionId);
if (missing.isEmpty) { if (missing.isEmpty) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel(); _clearVoiceMissingRetry(sessionId);
_voiceMissingRetryAttempts.remove(sessionId);
return; return;
} }
@@ -1381,6 +1457,67 @@ class AppProvider with ChangeNotifier {
}); });
} }
Future<void> _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. /// Insert or update a voice placeholder message for binary raw-data packets.
/// ///
/// Binary voice packets arrive without a chat message, so we synthesise one /// 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) { for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_voiceMissingRetryTimers.clear(); _voiceMissingRetryTimers.clear();
_voiceMissingRetryAttempts.clear(); _voiceMissingRetryAttempts.clear();
_imageMissingRetryTimers.clear();
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
notifyListeners(); notifyListeners();
} }
@@ -1640,6 +1783,9 @@ class AppProvider with ChangeNotifier {
for (final timer in _voiceMissingRetryTimers.values) { for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel(); timer.cancel();
} }
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose(); super.dispose();
} }
} }

View File

@@ -1616,19 +1616,13 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
Widget _buildChannelEchoStatus(BuildContext context, Message message) { Widget _buildChannelEchoStatus(BuildContext context, Message message) {
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
final hasEcho = message.echoCount > 0; final hasEcho = message.echoCount > 0;
if (!hasEcho) { if (!hasEcho) {
return Text( return const SizedBox.shrink();
message.getLocalizedDeliveryStatus(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: statusColor,
fontStyle: FontStyle.italic,
),
);
} }
final statusColor = _getDeliveryStatusColor(message.deliveryStatus);
final rssi = message.lastEchoRssiDbm; final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0 ? message.lastEchoSnrRaw!.toSigned(8) / 4.0
@@ -1792,6 +1786,40 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
} }
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) { String _hopDebugLabel(Message message) {
if (message.pathLen >= 255 && message.isContactMessage) { if (message.pathLen >= 255 && message.isContactMessage) {
return 'Direct (raw: ${message.pathLen})'; return 'Direct (raw: ${message.pathLen})';
@@ -2097,6 +2125,9 @@ class _MessageBubbleState extends State<MessageBubble> {
isOwnMessage && message.isChannelMessage && recipientDisplayName != null isOwnMessage && message.isChannelMessage && recipientDisplayName != null
? '${l10n.channel}: $recipientDisplayName' ? '${l10n.channel}: $recipientDisplayName'
: recipientDisplayName; : recipientDisplayName;
final directCounterpartLabel = !message.isChannelMessage
? (isOwnMessage ? recipientSubtitle : l10n.you)
: null;
final receivedChannelSubtitle = final receivedChannelSubtitle =
!isOwnMessage && message.isChannelMessage && channelDisplayName != null !isOwnMessage && message.isChannelMessage && channelDisplayName != null
? '${l10n.channel}: $channelDisplayName' ? '${l10n.channel}: $channelDisplayName'
@@ -2293,7 +2324,7 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Flexible( Expanded(
child: Text( child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.labelMedium
@@ -2309,10 +2340,12 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
if (!widget.isCompact && if (!widget.isCompact &&
(recipientSubtitle != null || (recipientSubtitle != null ||
directCounterpartLabel != null ||
receivedChannelSubtitle != null)) ...[ receivedChannelSubtitle != null)) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
if (message.isChannelMessage) if (message.isChannelMessage)
Flexible( Align(
alignment: Alignment.centerRight,
child: _buildChannelHeaderPill( child: _buildChannelHeaderPill(
context, context,
label: isOwnMessage label: isOwnMessage
@@ -2322,41 +2355,10 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
else else
Expanded( Expanded(
child: Row( child: _buildDirectHeaderCounterpart(
children: [ context,
Icon( isOwnMessage: isOwnMessage,
isOwnMessage label: directCounterpartLabel!,
? 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,
),
),
],
), ),
), ),
], ],
@@ -2838,7 +2840,8 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
] ]
// Show single message delivery status // Show single message delivery status
else else if (!message.isChannelMessage ||
message.deliveryStatus == MessageDeliveryStatus.failed)
Row( Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [

View File

@@ -51,7 +51,7 @@ class MessagesComposer extends StatelessWidget {
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding), padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,

View File

@@ -281,12 +281,29 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(''); .join('');
final request = VoiceFetchRequest( final missing = voiceProvider.missingPacketIndices(sessionId);
final totalPackets = sessionPacketCount(
voiceProvider: voiceProvider,
sessionId: sessionId, sessionId: sessionId,
requesterKey6: requesterKey6, envelope: envelope,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
); );
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(() { setState(() {
_isRequesting = true; _isRequesting = true;
@@ -309,11 +326,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final effectivePathLen = sender.outPathLen >= 0 final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen ? sender.outPathLen
: pathLen; : pathLen;
final estimatedDurationMs =
envelope != null &&
totalPackets > 0 &&
missing.isNotEmpty &&
missing.length < totalPackets
? ((envelope.durationMs * missing.length) / totalPackets).round()
: envelope?.durationMs;
final txEstimate = envelope != null final txEstimate = envelope != null
? estimateVoiceTransmitDuration( ? estimateVoiceTransmitDuration(
packetCount: envelope.total, packetCount: isPartialResume ? missing.length : envelope.total,
mode: envelope.mode, mode: envelope.mode,
durationMs: envelope.durationMs, durationMs: estimatedDurationMs ?? envelope.durationMs,
pathLen: effectivePathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
@@ -331,6 +355,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
} }
int sessionPacketCount({
required VoiceProvider voiceProvider,
required String sessionId,
required VoiceEnvelope? envelope,
}) {
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
}
void _setUnavailable() { void _setUnavailable() {
if (!mounted) return; if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable); _showToast(AppLocalizations.of(context)!.voiceUnavailable);