This commit is contained in:
ericszimmermann 2026-04-08 08:41:27 +02:00 committed by GitHub
commit 3dce1151ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1384 additions and 156 deletions

View file

@ -151,6 +151,11 @@ class MeshCoreConnector extends ChangeNotifier {
Timer? _batteryPollTimer;
Timer? _radioStatsPollTimer;
int _radioStatsPollRefCount = 0;
Timer? _locationSharingTimer;
DateTime? _locationSharingEnd;
String? _locationSharingContactKey;
int? _locationSharingChannelIndex;
String? _lastSharedLocationPositionKey;
final ValueNotifier<CompanionRadioStats?> radioStatsNotifier =
ValueNotifier<CompanionRadioStats?>(null);
int _reconnectAttempts = 0;
@ -375,6 +380,8 @@ class MeshCoreConnector extends ChangeNotifier {
bool? get clientRepeat => _clientRepeat;
int? get firmwareVerCode => _firmwareVerCode;
Map<String, String>? get currentCustomVars => _currentCustomVars;
String? get locationSharingContactKey => _locationSharingContactKey;
int? get locationSharingChannelIndex => _locationSharingChannelIndex;
int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
int? get storageTotalKb => _storageTotalKb;
@ -2222,6 +2229,12 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling();
_stopRadioStatsPolling();
_locationSharingTimer?.cancel();
_locationSharingTimer = null;
_locationSharingEnd = null;
_locationSharingContactKey = null;
_locationSharingChannelIndex = null;
_lastSharedLocationPositionKey = null;
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
@ -2384,6 +2397,101 @@ class MeshCoreConnector extends ChangeNotifier {
_radioStatsPollTimer = null;
}
void startLocationSharing({
String? contactKey,
int? channelIndex,
required Duration duration,
}) {
_locationSharingTimer?.cancel();
_locationSharingEnd = DateTime.now().add(duration);
_locationSharingContactKey = contactKey;
_locationSharingChannelIndex = channelIndex;
_lastSharedLocationPositionKey = null;
final rawGpsInterval =
int.tryParse(_currentCustomVars?['gps_interval'] ?? '') ?? 900;
final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900;
_sendLocationOnce(contactKey, channelIndex);
_locationSharingTimer = Timer.periodic(Duration(seconds: gpsInterval), (_) {
if (!isConnected || DateTime.now().isAfter(_locationSharingEnd!)) {
stopLocationSharing();
return;
}
_sendLocationOnce(contactKey, channelIndex);
});
notifyListeners();
}
void stopLocationSharing() {
if (_locationSharingTimer == null &&
_locationSharingEnd == null &&
_locationSharingContactKey == null &&
_locationSharingChannelIndex == null &&
_lastSharedLocationPositionKey == null) {
return;
}
_locationSharingTimer?.cancel();
_locationSharingTimer = null;
_locationSharingEnd = null;
_locationSharingContactKey = null;
_locationSharingChannelIndex = null;
_lastSharedLocationPositionKey = null;
notifyListeners();
}
@visibleForTesting
static String sharedLocationPositionKey(double lat, double lon) {
return '${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}';
}
void _sendLocationOnce(String? contactKey, int? channelIndex) {
final lat = _selfLatitude;
final lon = _selfLongitude;
if (lat == null || lon == null) return;
final positionKey = sharedLocationPositionKey(lat, lon);
final label = deviceDisplayName.replaceAll('|', '/');
final text = 'm:$positionKey|$label|loc';
if (contactKey != null) {
final idx = _contacts.indexWhere((c) => c.publicKeyHex == contactKey);
if (idx < 0) return;
final contact = _contacts[idx];
final resolved = resolvePathSelection(contact);
// For timed direct sharing in flood mode, only send when location changes.
if (resolved.useFlood && _lastSharedLocationPositionKey == positionKey) {
return;
}
if (_retryService != null) {
// Timed direct shares should participate in ACK/timeout handling.
unawaited(
_retryService!.sendMessageWithRetry(
contact: contact,
text: text,
forceClearPathOnMaxRetry: true,
),
);
} else {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
unawaited(_sendMessageDirect(contact, text, 1, now));
}
if (resolved.useFlood) {
_lastSharedLocationPositionKey = positionKey;
}
} else if (channelIndex != null) {
// Keep channel traffic low by suppressing repeated unchanged location packets.
if (_lastSharedLocationPositionKey == positionKey) {
return;
}
_lastSharedLocationPositionKey = positionKey;
unawaited(
sendFrame(
buildSendChannelTextMsgFrame(
channelIndex,
prepareChannelOutboundText(channelIndex, text),
),
),
);
}
}
void acquireRadioStatsPolling() {
_radioStatsPollRefCount++;
if (_radioStatsPollRefCount == 1 && isConnected) {
@ -2942,13 +3050,7 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingChannelSentQueue.add(message.messageId);
notifyListeners();
final trimmed = text.trim();
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:');
final outboundText =
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
final outboundText = prepareChannelOutboundText(channel.index, text);
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
@ -4389,6 +4491,16 @@ class MeshCoreConnector extends ChangeNotifier {
return text;
}
String prepareChannelOutboundText(int channelIndex, String text) {
final trimmed = text.trim();
final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:');
if (!isStructuredPayload && isChannelSmazEnabled(channelIndex)) {
return Smaz.encodeIfSmaller(text);
}
return text;
}
String _channelDisplayName(int channelIndex) {
for (final channel in _channels) {
if (channel.index != channelIndex) continue;
@ -5434,6 +5546,12 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleDisconnection() {
_stopBatteryPolling();
_stopRadioStatsPolling();
_locationSharingTimer?.cancel();
_locationSharingTimer = null;
_locationSharingEnd = null;
_locationSharingContactKey = null;
_locationSharingChannelIndex = null;
_lastSharedLocationPositionKey = null;
_latestRadioStats = null;
radioStatsNotifier.value = null;
_prevTotalAirSecs = 0;

View file

@ -30,7 +30,7 @@ class GifHelper {
).firstMatch(trimmed);
return pageMatch?.group(1);
}
/// Encode a GIF in a format that parseGif() can parse.
static String encodeGif(String gifId) {
return 'g:$gifId';

View file

@ -109,7 +109,7 @@ class ReactionHelper {
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
}
/// Encode a reaction message that parseReaction() can parse.
static String encodeReaction(String hash, String emojiIndex) {
return 'r:$hash:$emojiIndex';

View file

@ -2,10 +2,32 @@ import 'dart:convert';
import 'package:flutter/services.dart';
String truncateToUtf8Bytes(String text, int maxBytes) {
if (maxBytes <= 0) return '';
final buffer = StringBuffer();
var usedBytes = 0;
for (final rune in text.runes) {
final character = String.fromCharCode(rune);
final characterBytes = utf8.encode(character).length;
if (usedBytes + characterBytes > maxBytes) break;
buffer.write(character);
usedBytes += characterBytes;
}
return buffer.toString();
}
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
final int maxBytes;
final String Function(String)? encoder;
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder});
int _effectiveByteLength(String text) {
final effective = encoder != null ? encoder!(text) : text;
return utf8.encode(effective).length;
}
@override
TextEditingValue formatEditUpdate(
@ -13,10 +35,9 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;
if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue;
final truncated = _truncateToMaxBytes(newValue.text, maxBytes);
final truncated = _truncate(newValue.text);
return TextEditingValue(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
@ -24,16 +45,13 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
);
}
String _truncateToMaxBytes(String text, int limit) {
final buffer = StringBuffer();
var used = 0;
for (final rune in text.runes) {
final char = String.fromCharCode(rune);
final charBytes = utf8.encode(char).length;
if (used + charBytes > limit) break;
buffer.write(char);
used += charBytes;
String _truncate(String text) {
if (encoder == null) return truncateToUtf8Bytes(text, maxBytes);
final runes = text.runes.toList();
while (runes.isNotEmpty &&
_effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) {
runes.removeLast();
}
return buffer.toString();
return String.fromCharCodes(runes);
}
}

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Изпрати GIF",
"chat_insertEmoji": "Вмъкнете емоджи",
"chat_shareLocation": "Споделете местоположение",
"chat_stopSharingLocationConfirm": "Да спра ли споделянето на местоположение?",
"chat_once": "Веднъж",
"chat_locationUnavailable": "Местоположението не е налично",
"chat_reply": "Отговори",
"chat_addReaction": "Добави Реакция",
"chat_me": "Аз",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Покажи споделени маркери",
"map_lastSeenTime": "Последна видяна дата",
"map_sharedPin": "Споделено копие",
"map_sharedAt": "Споделено",
"map_joinRoom": "Присъедини се към стаята",
"map_manageRepeater": "Управление на Повтарящ се Елемент",
"mapCache_title": "Кеш на офлайн карти",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "GIF senden",
"chat_insertEmoji": "Emoji einfügen",
"chat_shareLocation": "Standort teilen",
"chat_stopSharingLocationConfirm": "Standortfreigabe beenden?",
"chat_once": "Einmal",
"chat_locationUnavailable": "Standort nicht verfügbar",
"chat_reply": "Beantworten",
"chat_addReaction": "Reaktion hinzufügen",
"chat_me": "Ich",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Zeige gemeinsam genutzte Marker",
"map_lastSeenTime": "Letzte Sichtung",
"map_sharedPin": "Gemeinsames Passwort",
"map_sharedAt": "Geteilt",
"map_joinRoom": "Beitreten Sie dem Raum",
"map_manageRepeater": "Repeater verwalten",
"mapCache_title": "Offline-Karten-Cache",

View file

@ -654,6 +654,11 @@
}
},
"chat_sendGif": "Send GIF",
"chat_insertEmoji": "Insert emoji",
"chat_shareLocation": "Share location",
"chat_stopSharingLocationConfirm": "Stop sharing location?",
"chat_once": "Once",
"chat_locationUnavailable": "Location not available",
"chat_reply": "Reply",
"chat_addReaction": "Add Reaction",
"chat_me": "Me",
@ -890,6 +895,7 @@
"map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin",
"map_sharedAt": "Shared",
"map_joinRoom": "Join Room",
"map_manageRepeater": "Manage Repeater",
"map_tapToAdd": "Tap on nodes to add them to the path.",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Enviar GIF",
"chat_insertEmoji": "Insertar emoji",
"chat_shareLocation": "Compartir ubicación",
"chat_stopSharingLocationConfirm": "¿Dejar de compartir la ubicación?",
"chat_once": "Una vez",
"chat_locationUnavailable": "Ubicación no disponible",
"chat_reply": "Responder",
"chat_addReaction": "Añadir Reacción",
"chat_me": "Yo",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Mostrar marcadores compartidos",
"map_lastSeenTime": "Última vez que se vio",
"map_sharedPin": "Pin compartido",
"map_sharedAt": "Compartido",
"map_joinRoom": "Únete a la sala",
"map_manageRepeater": "Gestionar Repetidor",
"mapCache_title": "Caché de Mapa Offline",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Envoyer GIF",
"chat_insertEmoji": "Insérer un emoji",
"chat_shareLocation": "Partager la localisation",
"chat_stopSharingLocationConfirm": "Arrêter le partage de position ?",
"chat_once": "Une fois",
"chat_locationUnavailable": "Localisation non disponible",
"chat_reply": "Répondre",
"chat_addReaction": "Ajouter une Réaction",
"chat_me": "Moi",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Afficher les marqueurs partagés",
"map_lastSeenTime": "Dernière fois vu",
"map_sharedPin": "Clé partagée",
"map_sharedAt": "Partagé",
"map_joinRoom": "Rejoindre le room server",
"map_manageRepeater": "Gérer le répéteur",
"mapCache_title": "Cache de Carte Hors Ligne",

View file

@ -628,6 +628,11 @@
"chat_sendGif": "Küldj GIF-ot",
"chat_reply": "Válasz",
"chat_addReaction": "Hozzon létre reakciót",
"chat_insertEmoji": "Emoji beszúrása",
"chat_shareLocation": "Helyszín megosztása",
"chat_stopSharingLocationConfirm": "Leállítja a helymegosztást?",
"chat_once": "Egyszer",
"chat_locationUnavailable": "A helyszín nem elérhető",
"chat_me": "Én",
"emojiCategorySmileys": "Emoji",
"emojiCategoryGestures": "Testmozgások",
@ -861,6 +866,7 @@
"map_guessedLocation": "Tippolt hely",
"map_lastSeenTime": "Utoljára megjelent idő",
"map_sharedPin": "Gemeinsames PIN-kód",
"map_sharedAt": "Megosztva",
"map_joinRoom": "Csatlakozás a szobához",
"map_manageRepeater": "Ellenőriző eszköz kezelése",
"map_tapToAdd": "Nyomj meg a csomópontokhoz, hogy hozzáadd őket az útvonalhoz.",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Invia GIF",
"chat_insertEmoji": "Inserisci emoji",
"chat_shareLocation": "Condividi posizione",
"chat_stopSharingLocationConfirm": "Interrompere la condivisione della posizione?",
"chat_once": "Una volta",
"chat_locationUnavailable": "Posizione non disponibile",
"chat_reply": "Rispondi",
"chat_addReaction": "Aggiungi Reazione",
"chat_me": "Me",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Mostra i segnaposto condivisi",
"map_lastSeenTime": "Ultimo Tempo di Visualizzazione",
"map_sharedPin": "Condividi PIN",
"map_sharedAt": "Condiviso",
"map_joinRoom": "Unisciti alla stanza",
"map_manageRepeater": "Gestisci Ripetitore",
"mapCache_title": "Cache Mappa Offline",

View file

@ -628,6 +628,11 @@
"chat_sendGif": "GIFを送信する",
"chat_reply": "返信",
"chat_addReaction": "反応を追加",
"chat_insertEmoji": "絵文字を挿入",
"chat_shareLocation": "位置を共有する",
"chat_stopSharingLocationConfirm": "位置情報の共有を停止しますか?",
"chat_once": "1回",
"chat_locationUnavailable": "位置情報が利用できません",
"chat_me": "私",
"emojiCategorySmileys": "笑顔の絵文字",
"emojiCategoryGestures": "身振り、動作",
@ -861,6 +866,7 @@
"map_guessedLocation": "推測された場所",
"map_lastSeenTime": "最後に確認された時間",
"map_sharedPin": "共有パスワード",
"map_sharedAt": "共有済み",
"map_joinRoom": "部屋に参加する",
"map_manageRepeater": "リピーターの管理",
"map_tapToAdd": "ノードをクリックして、パスに追加します。",

View file

@ -628,6 +628,11 @@
"chat_sendGif": "GIF 보내기",
"chat_reply": "답변",
"chat_addReaction": "댓글 추가",
"chat_insertEmoji": "이모지 삽입",
"chat_shareLocation": "위치 공유",
"chat_stopSharingLocationConfirm": "위치 공유를 중지하시겠습니까?",
"chat_once": "한 번",
"chat_locationUnavailable": "위치를 사용할 수 없습니다",
"chat_me": "나",
"emojiCategorySmileys": "이모티콘",
"emojiCategoryGestures": "제스처",
@ -861,6 +866,7 @@
"map_guessedLocation": "추측된 위치",
"map_lastSeenTime": "마지막으로 확인된 시간",
"map_sharedPin": "공유 비밀번호",
"map_sharedAt": "공유됨",
"map_joinRoom": "방에 참여",
"map_manageRepeater": "리피터 관리",
"map_tapToAdd": "노드에 클릭하여 경로에 추가합니다.",

View file

@ -2374,6 +2374,36 @@ abstract class AppLocalizations {
/// **'Send GIF'**
String get chat_sendGif;
/// No description provided for @chat_insertEmoji.
///
/// In en, this message translates to:
/// **'Insert emoji'**
String get chat_insertEmoji;
/// No description provided for @chat_shareLocation.
///
/// In en, this message translates to:
/// **'Share location'**
String get chat_shareLocation;
/// No description provided for @chat_stopSharingLocationConfirm.
///
/// In en, this message translates to:
/// **'Stop sharing location?'**
String get chat_stopSharingLocationConfirm;
/// No description provided for @chat_once.
///
/// In en, this message translates to:
/// **'Once'**
String get chat_once;
/// No description provided for @chat_locationUnavailable.
///
/// In en, this message translates to:
/// **'Location not available'**
String get chat_locationUnavailable;
/// No description provided for @chat_reply.
///
/// In en, this message translates to:
@ -3124,6 +3154,12 @@ abstract class AppLocalizations {
/// **'Shared pin'**
String get map_sharedPin;
/// No description provided for @map_sharedAt.
///
/// In en, this message translates to:
/// **'Shared'**
String get map_sharedAt;
/// No description provided for @map_joinRoom.
///
/// In en, this message translates to:

View file

@ -1288,6 +1288,22 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get chat_sendGif => 'Изпрати GIF';
@override
String get chat_insertEmoji => 'Вмъкнете емоджи';
@override
String get chat_shareLocation => 'Споделете местоположение';
@override
String get chat_stopSharingLocationConfirm =>
'Да спра ли споделянето на местоположение?';
@override
String get chat_once => 'Веднъж';
@override
String get chat_locationUnavailable => 'Местоположението не е налично';
@override
String get chat_reply => 'Отговори';
@ -1723,6 +1739,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_sharedPin => 'Споделено копие';
@override
String get map_sharedAt => 'Споделено';
@override
String get map_joinRoom => 'Присъедини се към стаята';

View file

@ -1287,6 +1287,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get chat_sendGif => 'GIF senden';
@override
String get chat_insertEmoji => 'Emoji einfügen';
@override
String get chat_shareLocation => 'Standort teilen';
@override
String get chat_stopSharingLocationConfirm => 'Standortfreigabe beenden?';
@override
String get chat_once => 'Einmal';
@override
String get chat_locationUnavailable => 'Standort nicht verfügbar';
@override
String get chat_reply => 'Beantworten';
@ -1720,6 +1735,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_sharedPin => 'Gemeinsames Passwort';
@override
String get map_sharedAt => 'Geteilt';
@override
String get map_joinRoom => 'Beitreten Sie dem Raum';

View file

@ -1262,6 +1262,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chat_sendGif => 'Send GIF';
@override
String get chat_insertEmoji => 'Insert emoji';
@override
String get chat_shareLocation => 'Share location';
@override
String get chat_stopSharingLocationConfirm => 'Stop sharing location?';
@override
String get chat_once => 'Once';
@override
String get chat_locationUnavailable => 'Location not available';
@override
String get chat_reply => 'Reply';
@ -1689,6 +1704,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_sharedPin => 'Shared pin';
@override
String get map_sharedAt => 'Shared';
@override
String get map_joinRoom => 'Join Room';

View file

@ -1287,6 +1287,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get chat_sendGif => 'Enviar GIF';
@override
String get chat_insertEmoji => 'Insertar emoji';
@override
String get chat_shareLocation => 'Compartir ubicación';
@override
String get chat_stopSharingLocationConfirm =>
'¿Dejar de compartir la ubicación?';
@override
String get chat_once => 'Una vez';
@override
String get chat_locationUnavailable => 'Ubicación no disponible';
@override
String get chat_reply => 'Responder';
@ -1719,6 +1735,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_sharedPin => 'Pin compartido';
@override
String get map_sharedAt => 'Compartido';
@override
String get map_joinRoom => 'Únete a la sala';

View file

@ -1292,6 +1292,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get chat_sendGif => 'Envoyer GIF';
@override
String get chat_insertEmoji => 'Insérer un emoji';
@override
String get chat_shareLocation => 'Partager la localisation';
@override
String get chat_stopSharingLocationConfirm =>
'Arrêter le partage de position ?';
@override
String get chat_once => 'Une fois';
@override
String get chat_locationUnavailable => 'Localisation non disponible';
@override
String get chat_reply => 'Répondre';
@ -1729,6 +1745,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_sharedPin => 'Clé partagée';
@override
String get map_sharedAt => 'Partagé';
@override
String get map_joinRoom => 'Rejoindre le room server';

View file

@ -1295,6 +1295,21 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get chat_sendGif => 'Küldj GIF-ot';
@override
String get chat_insertEmoji => 'Emoji beszúrása';
@override
String get chat_shareLocation => 'Helyszín megosztása';
@override
String get chat_stopSharingLocationConfirm => 'Leállítja a helymegosztást?';
@override
String get chat_once => 'Egyszer';
@override
String get chat_locationUnavailable => 'A helyszín nem elérhető';
@override
String get chat_reply => 'Válasz';
@ -1732,6 +1747,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get map_sharedPin => 'Gemeinsames PIN-kód';
@override
String get map_sharedAt => 'Megosztva';
@override
String get map_joinRoom => 'Csatlakozás a szobához';

View file

@ -1288,6 +1288,22 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get chat_sendGif => 'Invia GIF';
@override
String get chat_insertEmoji => 'Inserisci emoji';
@override
String get chat_shareLocation => 'Condividi posizione';
@override
String get chat_stopSharingLocationConfirm =>
'Interrompere la condivisione della posizione?';
@override
String get chat_once => 'Una volta';
@override
String get chat_locationUnavailable => 'Posizione non disponibile';
@override
String get chat_reply => 'Rispondi';
@ -1720,6 +1736,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_sharedPin => 'Condividi PIN';
@override
String get map_sharedAt => 'Condiviso';
@override
String get map_joinRoom => 'Unisciti alla stanza';

View file

@ -1228,6 +1228,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get chat_sendGif => 'GIFを送信する';
@override
String get chat_insertEmoji => '絵文字を挿入';
@override
String get chat_shareLocation => '位置を共有する';
@override
String get chat_stopSharingLocationConfirm => '位置情報の共有を停止しますか?';
@override
String get chat_once => '1回';
@override
String get chat_locationUnavailable => '位置情報が利用できません';
@override
String get chat_reply => '返信';
@ -1647,6 +1662,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get map_sharedPin => '共有パスワード';
@override
String get map_sharedAt => '共有済み';
@override
String get map_joinRoom => '部屋に参加する';

View file

@ -1223,6 +1223,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get chat_sendGif => 'GIF 보내기';
@override
String get chat_insertEmoji => '이모지 삽입';
@override
String get chat_shareLocation => '위치 공유';
@override
String get chat_stopSharingLocationConfirm => '위치 공유를 중지하시겠습니까?';
@override
String get chat_once => '한 번';
@override
String get chat_locationUnavailable => '위치를 사용할 수 없습니다';
@override
String get chat_reply => '답변';
@ -1643,6 +1658,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get map_sharedPin => '공유 비밀번호';
@override
String get map_sharedAt => '공유됨';
@override
String get map_joinRoom => '방에 참여';

View file

@ -1276,6 +1276,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get chat_sendGif => 'GIF verzenden';
@override
String get chat_insertEmoji => 'Emoji invoegen';
@override
String get chat_shareLocation => 'Locatie delen';
@override
String get chat_stopSharingLocationConfirm => 'Locatie delen stoppen?';
@override
String get chat_once => 'Eenmalig';
@override
String get chat_locationUnavailable => 'Locatie niet beschikbaar';
@override
String get chat_reply => 'Reageren';
@ -1708,6 +1723,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_sharedPin => 'Gedeelde pin';
@override
String get map_sharedAt => 'Gedeeld';
@override
String get map_joinRoom => 'Kamer Toetreden';

View file

@ -1297,6 +1297,22 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get chat_sendGif => 'Wyślij GIF';
@override
String get chat_insertEmoji => 'Wstaw emoji';
@override
String get chat_shareLocation => 'Udostępnij lokalizację';
@override
String get chat_stopSharingLocationConfirm =>
'Zatrzymać udostępnianie lokalizacji?';
@override
String get chat_once => 'Raz';
@override
String get chat_locationUnavailable => 'Lokalizacja niedostępna';
@override
String get chat_reply => 'Odpowiedz';
@ -1732,6 +1748,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get map_sharedPin => 'Udostępniona pinezka';
@override
String get map_sharedAt => 'Udostępnione';
@override
String get map_joinRoom => 'Dołącz do pokoju';

View file

@ -1287,6 +1287,22 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get chat_sendGif => 'Enviar GIF';
@override
String get chat_insertEmoji => 'Inserir emoji';
@override
String get chat_shareLocation => 'Compartilhar local';
@override
String get chat_stopSharingLocationConfirm =>
'Parar de compartilhar a localização?';
@override
String get chat_once => 'Uma vez';
@override
String get chat_locationUnavailable => 'Localização indisponível';
@override
String get chat_reply => 'Responder';
@ -1720,6 +1736,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_sharedPin => 'Pin compartilhado';
@override
String get map_sharedAt => 'Compartilhado';
@override
String get map_joinRoom => 'Junte-se à Sala';

View file

@ -1287,6 +1287,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get chat_sendGif => 'Отправить GIF';
@override
String get chat_insertEmoji => 'Вставить эмодзи';
@override
String get chat_shareLocation => 'Поделиться местоположением';
@override
String get chat_stopSharingLocationConfirm =>
'Остановить передачу местоположения?';
@override
String get chat_once => 'Один раз';
@override
String get chat_locationUnavailable => 'Местоположение недоступно';
@override
String get chat_reply => 'Ответить';
@ -1723,6 +1739,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_sharedPin => 'Общая метка';
@override
String get map_sharedAt => 'Поделено';
@override
String get map_joinRoom => 'Присоединиться к комнате';

View file

@ -1275,6 +1275,21 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get chat_sendGif => 'Odoslať GIF';
@override
String get chat_insertEmoji => 'Vložiť emoji';
@override
String get chat_shareLocation => 'Zdieľať polohu';
@override
String get chat_stopSharingLocationConfirm => 'Zastaviť zdieľanie polohy?';
@override
String get chat_once => 'Raz';
@override
String get chat_locationUnavailable => 'Poloha nie je k dispozícii';
@override
String get chat_reply => 'Odpovedať';
@ -1709,6 +1724,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_sharedPin => 'Zdieľaný PIN';
@override
String get map_sharedAt => 'Zdieľané';
@override
String get map_joinRoom => 'Pripojiť miestnosť';

View file

@ -1274,6 +1274,21 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get chat_sendGif => 'Pošlji GIF';
@override
String get chat_insertEmoji => 'Vstavi emoji';
@override
String get chat_shareLocation => 'Deli lokacijo';
@override
String get chat_stopSharingLocationConfirm => 'Ustavim deljenje lokacije?';
@override
String get chat_once => 'Enkrat';
@override
String get chat_locationUnavailable => 'Lokacija ni na voljo';
@override
String get chat_reply => 'Odgovori';
@ -1704,6 +1719,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_sharedPin => 'Deljeno naslovno geslo';
@override
String get map_sharedAt => 'Deljeno';
@override
String get map_joinRoom => 'Pridružiti sobo';

View file

@ -1268,6 +1268,21 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get chat_sendGif => 'Skicka GIF';
@override
String get chat_insertEmoji => 'Infoga emoji';
@override
String get chat_shareLocation => 'Dela plats';
@override
String get chat_stopSharingLocationConfirm => 'Sluta dela plats?';
@override
String get chat_once => 'En gång';
@override
String get chat_locationUnavailable => 'Plats inte tillgänglig';
@override
String get chat_reply => 'Svara';
@ -1698,6 +1713,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_sharedPin => 'Delad PIN';
@override
String get map_sharedAt => 'Delad';
@override
String get map_joinRoom => 'Gå med i rum';

View file

@ -1280,6 +1280,22 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get chat_sendGif => 'Надіслати GIF';
@override
String get chat_insertEmoji => 'Вставити емодзі';
@override
String get chat_shareLocation => 'Поділитися місцезнаходженням';
@override
String get chat_stopSharingLocationConfirm =>
'Зупинити поширення місцезнаходження?';
@override
String get chat_once => 'Один раз';
@override
String get chat_locationUnavailable => 'Місцезнаходження недоступне';
@override
String get chat_reply => 'Відповісти';
@ -1718,6 +1734,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_sharedPin => 'Спільний пін';
@override
String get map_sharedAt => 'Поділено';
@override
String get map_joinRoom => 'Приєднатися до кімнати';

View file

@ -1210,6 +1210,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get chat_sendGif => '发送 GIF';
@override
String get chat_insertEmoji => '插入表情';
@override
String get chat_shareLocation => '分享位置';
@override
String get chat_stopSharingLocationConfirm => '停止共享位置?';
@override
String get chat_once => '一次';
@override
String get chat_locationUnavailable => '位置不可用';
@override
String get chat_reply => '回复';
@ -1615,6 +1630,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_sharedPin => '共享标记';
@override
String get map_sharedAt => '已分享';
@override
String get map_joinRoom => '加入房间';

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "GIF verzenden",
"chat_insertEmoji": "Emoji invoegen",
"chat_shareLocation": "Locatie delen",
"chat_stopSharingLocationConfirm": "Locatie delen stoppen?",
"chat_once": "Eenmalig",
"chat_locationUnavailable": "Locatie niet beschikbaar",
"chat_reply": "Reageren",
"chat_addReaction": "Reactie toevoegen",
"chat_me": "Mijn",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Toon gedeelde markeringen",
"map_lastSeenTime": "Laatste Bekeken Tijd",
"map_sharedPin": "Gedeelde pin",
"map_sharedAt": "Gedeeld",
"map_joinRoom": "Kamer Toetreden",
"map_manageRepeater": "Beheer Repeater",
"mapCache_title": "Offline Kaarten Cache",

View file

@ -464,6 +464,11 @@
}
},
"chat_sendGif": "Wyślij GIF",
"chat_insertEmoji": "Wstaw emoji",
"chat_shareLocation": "Udostępnij lokalizację",
"chat_stopSharingLocationConfirm": "Zatrzymać udostępnianie lokalizacji?",
"chat_once": "Raz",
"chat_locationUnavailable": "Lokalizacja niedostępna",
"chat_reply": "Odpowiedz",
"chat_addReaction": "Dodaj Reakcję",
"chat_me": "Ja",
@ -692,6 +697,7 @@
"map_showSharedMarkers": "Pokaż udostępnione znaczniki.",
"map_lastSeenTime": "Ostatni raz widziany",
"map_sharedPin": "Udostępniona pinezka",
"map_sharedAt": "Udostępnione",
"map_joinRoom": "Dołącz do pokoju",
"map_manageRepeater": "Zarządzaj przekaźnikiem",
"mapCache_title": "Pamięć podręczna map offline",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Enviar GIF",
"chat_insertEmoji": "Inserir emoji",
"chat_shareLocation": "Compartilhar local",
"chat_stopSharingLocationConfirm": "Parar de compartilhar a localização?",
"chat_once": "Uma vez",
"chat_locationUnavailable": "Localização indisponível",
"chat_reply": "Responder",
"chat_addReaction": "Adicionar Reação",
"chat_me": "Eu",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Mostrar marcadores compartilhados",
"map_lastSeenTime": "Último Tempo de Visualização",
"map_sharedPin": "Pin compartilhado",
"map_sharedAt": "Compartilhado",
"map_joinRoom": "Junte-se à Sala",
"map_manageRepeater": "Gerenciar Repetidor",
"mapCache_title": "Cache de Mapa Offline",

View file

@ -285,6 +285,11 @@
"chat_retryingMessage": "Повтор отправки сообщения",
"chat_retryCount": "Попытка {current}/{max}",
"chat_sendGif": "Отправить GIF",
"chat_insertEmoji": "Вставить эмодзи",
"chat_shareLocation": "Поделиться местоположением",
"chat_stopSharingLocationConfirm": "Остановить передачу местоположения?",
"chat_once": "Один раз",
"chat_locationUnavailable": "Местоположение недоступно",
"chat_reply": "Ответить",
"chat_addReaction": "Добавить реакцию",
"chat_me": "Я",
@ -397,6 +402,7 @@
"map_showSharedMarkers": "Показывать общие метки",
"map_lastSeenTime": "Время последнего появления",
"map_sharedPin": "Общая метка",
"map_sharedAt": "Поделено",
"map_joinRoom": "Присоединиться к комнате",
"map_manageRepeater": "Управление репитером",
"mapCache_title": "Кэш офлайн-карты",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Odoslať GIF",
"chat_insertEmoji": "Vložiť emoji",
"chat_shareLocation": "Zdieľať polohu",
"chat_stopSharingLocationConfirm": "Zastaviť zdieľanie polohy?",
"chat_once": "Raz",
"chat_locationUnavailable": "Poloha nie je k dispozícii",
"chat_reply": "Odpovedať",
"chat_addReaction": "Pridať Reakciu",
"chat_me": "Mne",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Zobraziť zdieľané značky",
"map_lastSeenTime": "Posledný čas sledovania",
"map_sharedPin": "Zdieľaný PIN",
"map_sharedAt": "Zdieľané",
"map_joinRoom": "Pripojiť miestnosť",
"map_manageRepeater": "Spravovať Opakovanie",
"mapCache_title": "Offline Mapa Pamäť",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Pošlji GIF",
"chat_insertEmoji": "Vstavi emoji",
"chat_shareLocation": "Deli lokacijo",
"chat_stopSharingLocationConfirm": "Ustavim deljenje lokacije?",
"chat_once": "Enkrat",
"chat_locationUnavailable": "Lokacija ni na voljo",
"chat_reply": "Odgovori",
"chat_addReaction": "Dodaj reakcijo",
"chat_me": "jaz",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Pokaži skupno označenja",
"map_lastSeenTime": "Datum zadnjega vpogleda",
"map_sharedPin": "Deljeno naslovno geslo",
"map_sharedAt": "Deljeno",
"map_joinRoom": "Pridružiti sobo",
"map_manageRepeater": "Upravljajte Ponovitve",
"mapCache_title": "Omrezni predpomnilnik zemljeških zemljejevskih slik",

View file

@ -454,6 +454,11 @@
}
},
"chat_sendGif": "Skicka GIF",
"chat_insertEmoji": "Infoga emoji",
"chat_shareLocation": "Dela plats",
"chat_stopSharingLocationConfirm": "Sluta dela plats?",
"chat_once": "En gång",
"chat_locationUnavailable": "Plats inte tillgänglig",
"chat_reply": "Svara",
"chat_addReaction": "Lägg till reaktion",
"chat_me": "Mig",
@ -682,6 +687,7 @@
"map_showSharedMarkers": "Visa delade markörer",
"map_lastSeenTime": "Senaste Visats Tid",
"map_sharedPin": "Delad PIN",
"map_sharedAt": "Delad",
"map_joinRoom": "Gå med i rum",
"map_manageRepeater": "Hantera Upprepare",
"mapCache_title": "Offline Kartcache",

View file

@ -455,6 +455,11 @@
}
},
"chat_sendGif": "Надіслати GIF",
"chat_insertEmoji": "Вставити емодзі",
"chat_shareLocation": "Поділитися місцезнаходженням",
"chat_stopSharingLocationConfirm": "Зупинити поширення місцезнаходження?",
"chat_once": "Один раз",
"chat_locationUnavailable": "Місцезнаходження недоступне",
"chat_reply": "Відповісти",
"chat_addReaction": "Додати реакцію",
"chat_me": "Я",
@ -683,6 +688,7 @@
"map_showSharedMarkers": "Показувати спільні маркери",
"map_lastSeenTime": "Час останньої активності",
"map_sharedPin": "Спільний пін",
"map_sharedAt": "Поділено",
"map_joinRoom": "Приєднатися до кімнати",
"map_manageRepeater": "Керувати ретранслятором",
"mapCache_title": "Офлайн-кеш карти",

View file

@ -483,6 +483,11 @@
"chat_sendGif": "发送 GIF",
"chat_reply": "回复",
"chat_addReaction": "添加表情",
"chat_insertEmoji": "插入表情",
"chat_shareLocation": "分享位置",
"chat_stopSharingLocationConfirm": "停止共享位置?",
"chat_once": "一次",
"chat_locationUnavailable": "位置不可用",
"chat_me": "我",
"emojiCategorySmileys": "表情",
"emojiCategoryGestures": "手势",
@ -709,6 +714,7 @@
"map_showSharedMarkers": "显示共享标记",
"map_lastSeenTime": "最后在线时间",
"map_sharedPin": "共享标记",
"map_sharedAt": "已分享",
"map_joinRoom": "加入房间",
"map_manageRepeater": "管理转发节点",
"mapCache_title": "离线地图缓存",

View file

@ -14,6 +14,7 @@ import '../connector/meshcore_protocol.dart';
import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/smaz.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@ -43,6 +44,8 @@ class ChannelChatScreen extends StatefulWidget {
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
enum _ChannelChatInputAction { sendGif, insertEmoji, shareLocation }
class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ChatScrollController _scrollController = ChatScrollController();
@ -898,6 +901,252 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
void _insertTextAtCursor(String text) {
final currentValue = _textController.value;
final selection = currentValue.selection;
final newText = selection.isValid
? currentValue.text.replaceRange(selection.start, selection.end, text)
: currentValue.text + text;
final caret =
(selection.isValid ? selection.start : currentValue.text.length) +
text.length;
_textController.value = currentValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: caret),
);
}
void _showEmojiPickerForComposer(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_insertEmoji,
onEmojiSelected: (emoji) {
_insertTextAtCursor(emoji);
_textFieldFocusNode.requestFocus();
},
),
);
}
Future<void> _shareLocation() async {
final connector = context.read<MeshCoreConnector>();
final lat = connector.selfLatitude;
final lon = connector.selfLongitude;
final allowsTimedLocationSharing =
!widget.channel.isPublicChannel && !widget.channel.name.startsWith('#');
if (lat == null || lon == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_locationUnavailable)),
);
return;
}
if (allowsTimedLocationSharing &&
connector.locationSharingChannelIndex == widget.channel.index) {
final stop = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.chat_shareLocation),
content: Text(context.l10n.chat_stopSharingLocationConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_ok),
),
],
),
);
if (stop == true) connector.stopLocationSharing();
return;
}
final maxBytes = maxChannelMessageBytes(connector.selfName);
final prefix = 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|';
const suffix = '|loc';
final maxLabelBytes =
maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length;
if (allowsTimedLocationSharing) {
final rawGpsInterval =
int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ??
900;
final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900;
final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999);
final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1)
.clamp(1, 99999);
final labelCtrl = TextEditingController(
text: truncateToUtf8Bytes(connector.deviceDisplayName, maxLabelBytes),
);
int sliderVal = 0;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) {
final actual = sliderVal == 0 ? 0 : sliderVal + minIntervals - 1;
final duration = Duration(seconds: actual * gpsInterval);
final durationLabel = sliderVal == 0
? context.l10n.chat_once
: (duration.inHours > 0
? '${duration.inHours}h ${duration.inMinutes.remainder(60)}m'
: '${duration.inMinutes}m');
return AlertDialog(
title: Text(context.l10n.chat_shareLocation),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(durationLabel),
Slider(
min: 0,
max: sliderMax.toDouble(),
divisions: sliderMax,
value: sliderVal.toDouble(),
onChanged: (value) =>
setDialogState(() => sliderVal = value.round()),
),
if (sliderVal == 0)
TextField(
controller: labelCtrl,
decoration: InputDecoration(
labelText: context.l10n.chat_location,
),
autofocus: true,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxLabelBytes),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_ok),
),
],
);
},
),
);
if (confirmed != true || !mounted) return;
if (sliderVal == 0) {
var label = labelCtrl.text.trim().replaceAll('|', '/');
if (label.isEmpty) return;
final markerText =
'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc';
if (utf8.encode(markerText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
connector.sendChannelMessage(widget.channel, markerText);
} else {
connector.startLocationSharing(
channelIndex: widget.channel.index,
duration: Duration(
seconds: (sliderVal + minIntervals - 1) * gpsInterval,
),
);
}
return;
}
final defaultLabel = connector.deviceDisplayName;
final controller = TextEditingController(
text: truncateToUtf8Bytes(defaultLabel, maxLabelBytes),
);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
var label = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.chat_shareLocation),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: context.l10n.chat_location),
autofocus: true,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxLabelBytes),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: Text(context.l10n.common_save),
),
],
),
);
if (label == null || label.isEmpty) return;
label = label.replaceAll('|', '/');
if (!mounted) return;
final markerText =
'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc';
if (utf8.encode(markerText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
if (widget.channel.isPublicChannel) {
final channelLabel = widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index)
: widget.channel.name;
final canShare = await _confirmPublicShare(channelLabel);
if (!mounted || !canShare) return;
}
connector.sendChannelMessage(widget.channel, markerText);
}
Future<bool> _confirmPublicShare(String channelLabel) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.map_publicLocationShare),
content: Text(
context.l10n.map_publicLocationShareConfirm(channelLabel),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.common_share),
),
],
),
);
return result ?? false;
}
Widget _buildAvatar(String senderName) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);
@ -1007,6 +1256,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final settings = context.watch<AppSettingsService>().settings;
final smazEncoder = connector.isChannelSmazEnabled(widget.channel.index)
? Smaz.encodeIfSmaller
: null;
final gpsEnabled = connector.currentCustomVars?['gps'] == '1';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -1019,6 +1272,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return _buildReplyBanner(textScale);
},
),
if (connector.locationSharingChannelIndex == widget.channel.index)
Container(
color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
child: Row(
children: [
const Icon(Icons.my_location, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.chat_shareLocation,
style: Theme.of(context).textTheme.bodySmall,
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: connector.stopLocationSharing,
),
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@ -1033,10 +1309,54 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
PopupMenuButton<_ChannelChatInputAction>(
icon: const Icon(Icons.add_circle_outline),
tooltip: context.l10n.common_add,
requestFocus: false,
position: PopupMenuPosition.over,
offset: const Offset(0, -180),
onSelected: (action) {
if (action == _ChannelChatInputAction.sendGif) {
_showGifPicker(context);
} else if (action == _ChannelChatInputAction.insertEmoji) {
_showEmojiPickerForComposer(context);
} else if (action == _ChannelChatInputAction.shareLocation) {
_shareLocation();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _ChannelChatInputAction.shareLocation,
enabled: gpsEnabled,
child: Row(
children: [
const Icon(Icons.location_on, size: 20),
const SizedBox(width: 8),
Text(context.l10n.chat_shareLocation),
],
),
),
PopupMenuItem(
value: _ChannelChatInputAction.sendGif,
child: Row(
children: [
const Icon(Icons.gif_box, size: 20),
const SizedBox(width: 8),
Text(context.l10n.chat_sendGif),
],
),
),
PopupMenuItem(
value: _ChannelChatInputAction.insertEmoji,
child: Row(
children: [
const Icon(Icons.emoji_emotions, size: 20),
const SizedBox(width: 8),
Text(context.l10n.chat_insertEmoji),
],
),
),
],
),
if (settings.translationEnabled)
MessageTranslationButton(
@ -1098,7 +1418,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
Utf8LengthLimitingTextInputFormatter(
maxBytes,
encoder: smazEncoder,
),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
@ -1193,7 +1516,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
final outboundText = connector.prepareChannelOutboundText(
widget.channel.index,
messageText,
);
if (utf8.encode(outboundText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
@ -1299,6 +1626,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_addReaction,
onEmojiSelected: (emoji) {
_sendReaction(message, emoji);
},

View file

@ -19,6 +19,7 @@ import '../helpers/chat_scroll_controller.dart';
import '../helpers/gif_helper.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../helpers/smaz.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
@ -45,6 +46,8 @@ import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
import 'telemetry_screen.dart';
enum _ChatInputAction { sendGif, insertEmoji, shareLocation }
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -500,103 +503,343 @@ class _ChatScreenState extends State<ChatScreen> {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
final settings = context.watch<AppSettingsService>().settings;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
final smazEncoder =
connector.isContactSmazEnabled(widget.contact.publicKeyHex)
? Smaz.encodeIfSmaller
: null;
final gpsEnabled = connector.currentCustomVars?['gps'] == '1';
final isRoomChat = _resolveContact(connector).type == advTypeRoom;
final sharingHere =
connector.locationSharingContactKey == widget.contact.publicKeyHex;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (sharingHere)
Container(
color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
child: Row(
children: [
const Icon(Icons.my_location, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.chat_shareLocation,
style: Theme.of(context).textTheme.bodySmall,
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: connector.stopLocationSharing,
),
],
),
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(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: SafeArea(
child: Row(
children: [
PopupMenuButton<_ChatInputAction>(
icon: const Icon(Icons.add_circle_outline),
tooltip: context.l10n.common_add,
requestFocus: false,
position: PopupMenuPosition.over,
offset: const Offset(0, -180),
onSelected: (action) {
if (action == _ChatInputAction.sendGif) {
_showGifPicker(context);
} else if (action == _ChatInputAction.insertEmoji) {
_showEmojiPicker(context);
} else if (action == _ChatInputAction.shareLocation) {
_shareLocation(context.read<MeshCoreConnector>());
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _ChatInputAction.shareLocation,
enabled: gpsEnabled && !isRoomChat,
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const Icon(Icons.my_location, size: 20),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
Text(context.l10n.chat_shareLocation),
],
),
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
PopupMenuItem(
value: _ChatInputAction.sendGif,
child: Row(
children: [
const Icon(Icons.gif_box, size: 20),
const SizedBox(width: 8),
Text(context.l10n.chat_sendGif),
],
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
PopupMenuItem(
value: _ChatInputAction.insertEmoji,
child: Row(
children: [
const Icon(Icons.emoji_emotions, size: 20),
const SizedBox(width: 8),
Text(context.l10n.chat_insertEmoji),
],
),
),
],
),
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(connector);
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:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(
maxBytes,
encoder: smazEncoder,
),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
);
},
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: () => _sendMessage(connector),
),
],
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: () => _sendMessage(connector),
),
),
],
);
}
void _insertTextAtCursor(String text) {
final currentValue = _textController.value;
final selection = currentValue.selection;
final newText = selection.isValid
? currentValue.text.replaceRange(selection.start, selection.end, text)
: currentValue.text + text;
final caret =
(selection.isValid ? selection.start : currentValue.text.length) +
text.length;
_textController.value = currentValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: caret),
);
}
void _showEmojiPicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_insertEmoji,
onEmojiSelected: (emoji) {
_insertTextAtCursor(emoji);
_textFieldFocusNode.requestFocus();
},
),
);
}
Future<void> _shareLocation(MeshCoreConnector connector) async {
if (_resolveContact(connector).type == advTypeRoom) {
return;
}
final lat = connector.selfLatitude;
final lon = connector.selfLongitude;
if (lat == null || lon == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_locationUnavailable)),
);
return;
}
if (connector.locationSharingContactKey == widget.contact.publicKeyHex) {
final stop = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.chat_shareLocation),
content: Text(context.l10n.chat_stopSharingLocationConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.common_ok),
),
],
),
);
if (stop == true) connector.stopLocationSharing();
return;
}
final maxBytes = maxContactMessageBytes();
final prefix = 'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|';
const suffix = '|loc';
final maxLabelBytes =
maxBytes - utf8.encode(prefix).length - utf8.encode(suffix).length;
final rawGpsInterval =
int.tryParse(connector.currentCustomVars?['gps_interval'] ?? '') ?? 900;
final gpsInterval = rawGpsInterval > 0 ? rawGpsInterval : 900;
final minIntervals = (300.0 / gpsInterval).ceil().clamp(2, 9999);
final sliderMax = ((86400 / gpsInterval).floor() - minIntervals + 1).clamp(
1,
99999,
);
final labelCtrl = TextEditingController(
text: truncateToUtf8Bytes(connector.deviceDisplayName, maxLabelBytes),
);
int sliderVal = 0;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx2, setS) {
final actual = sliderVal == 0 ? 0 : sliderVal + minIntervals - 1;
final dur = Duration(seconds: actual * gpsInterval);
final dLabel = sliderVal == 0
? context.l10n.chat_once
: (dur.inHours > 0
? '${dur.inHours}h ${dur.inMinutes.remainder(60)}m'
: '${dur.inMinutes}m');
return AlertDialog(
title: Text(context.l10n.chat_shareLocation),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(dLabel),
Slider(
min: 0,
max: sliderMax.toDouble(),
divisions: sliderMax,
value: sliderVal.toDouble(),
onChanged: (v) => setS(() => sliderVal = v.round()),
),
if (sliderVal == 0)
TextField(
controller: labelCtrl,
decoration: InputDecoration(
labelText: context.l10n.chat_location,
),
autofocus: true,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxLabelBytes),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.common_cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.common_ok),
),
],
);
},
),
);
if (confirmed != true || !mounted) return;
if (sliderVal == 0) {
var label = labelCtrl.text.trim().replaceAll('|', '/');
if (label.isEmpty) return;
final markerText =
'm:${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}|$label|loc';
if (utf8.encode(markerText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
connector.sendMessage(_resolveContact(connector), markerText);
} else {
connector.startLocationSharing(
contactKey: widget.contact.publicKeyHex,
duration: Duration(
seconds: (sliderVal + minIntervals - 1) * gpsInterval,
),
);
}
}
void _showGifPicker(BuildContext context) {
@ -667,7 +910,11 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final maxBytes = maxContactMessageBytes();
if (utf8.encode(outgoingText).length > maxBytes) {
final outboundText = connector.prepareContactOutboundText(
widget.contact,
outgoingText,
);
if (utf8.encode(outboundText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
@ -1427,7 +1674,7 @@ class _ChatScreenState extends State<ChatScreen> {
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message, contact);
_showReactionEmojiPicker(message, contact);
},
),
if (PlatformInfo.isDesktop)
@ -1509,11 +1756,12 @@ class _ChatScreenState extends State<ChatScreen> {
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
}
void _showEmojiPicker(Message message, Contact senderContact) {
void _showReactionEmojiPicker(Message message, Contact senderContact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
title: context.l10n.chat_addReaction,
onEmojiSelected: (emoji) {
_sendReaction(message, senderContact, emoji);
},

View file

@ -1222,7 +1222,7 @@ class _MapScreenState extends State<MapScreen> {
}
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
final markers = <_SharedMarker>[];
final markersByKey = <String, _SharedMarker>{};
final selfName = connector.selfName ?? 'Me';
for (final contact in connector.contacts) {
@ -1231,23 +1231,28 @@ class _MapScreenState extends State<MapScreen> {
final payload = _parseMarkerText(message.text);
if (payload == null) continue;
final fromName = message.isOutgoing ? selfName : contact.name;
final id = _buildMarkerId(
final key = _buildSharedMarkerKey(
sourceId: contact.publicKeyHex,
label: payload.label,
fromName: fromName,
flags: payload.flags,
isChannel: false,
);
final marker = _SharedMarker(
id: key,
position: payload.position,
label: payload.label,
flags: payload.flags,
fromName: fromName,
sourceLabel: contact.name,
timestamp: message.timestamp,
text: message.text,
);
markers.add(
_SharedMarker(
id: id,
position: payload.position,
label: payload.label,
flags: payload.flags,
fromName: fromName,
sourceLabel: contact.name,
isChannel: false,
isPublicChannel: false,
),
isChannel: false,
isPublicChannel: false,
);
final existing = markersByKey[key];
if (existing == null || marker.timestamp.isAfter(existing.timestamp)) {
markersByKey[key] = marker;
}
}
}
@ -1257,28 +1262,35 @@ class _MapScreenState extends State<MapScreen> {
for (final message in messages) {
final payload = _parseMarkerText(message.text);
if (payload == null) continue;
final id = _buildMarkerId(
final key = _buildSharedMarkerKey(
sourceId: 'channel:${channel.index}',
label: payload.label,
fromName: message.senderName,
flags: payload.flags,
isChannel: true,
);
final marker = _SharedMarker(
id: key,
position: payload.position,
label: payload.label,
flags: payload.flags,
fromName: message.senderName,
sourceLabel: channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name,
timestamp: message.timestamp,
text: message.text,
);
markers.add(
_SharedMarker(
id: id,
position: payload.position,
label: payload.label,
flags: payload.flags,
fromName: message.senderName,
sourceLabel: channel.name.isEmpty
? 'Channel ${channel.index}'
: channel.name,
isChannel: true,
isPublicChannel: isPublic,
),
isChannel: true,
isPublicChannel: isPublic,
);
final existing = markersByKey[key];
if (existing == null || marker.timestamp.isAfter(existing.timestamp)) {
markersByKey[key] = marker;
}
}
}
final markers = markersByKey.values.toList()
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
return markers;
}
@ -1298,17 +1310,23 @@ class _MapScreenState extends State<MapScreen> {
final flags = parts.length > 2 ? parts[2].trim() : '';
return _MarkerPayload(
position: LatLng(lat, lon),
label: label.isEmpty ? context.l10n.map_sharedPin : label,
label: label,
flags: flags,
);
}
String _buildMarkerId({
String _buildSharedMarkerKey({
required String sourceId,
required DateTime timestamp,
required String text,
required String label,
required String fromName,
required String flags,
required bool isChannel,
}) {
return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text';
final normalizedLabel = label.trim().toLowerCase();
final normalizedFrom = fromName.trim().toLowerCase();
final normalizedFlags = flags.trim().toLowerCase();
final scope = isChannel ? 'ch' : 'dm';
return '$scope|$sourceId|$normalizedFrom|$normalizedLabel|$normalizedFlags';
}
Marker _buildSharedMarker(_SharedMarker marker) {
@ -1521,13 +1539,19 @@ class _MapScreenState extends State<MapScreen> {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(marker.label),
title: Text(
marker.label.isEmpty ? context.l10n.map_sharedPin : marker.label,
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.map_from, marker.fromName),
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
_buildInfoRow(
context.l10n.map_sharedAt,
_formatLastSeen(marker.timestamp),
),
_buildInfoRow(
'Location',
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
@ -1690,6 +1714,10 @@ class _MapScreenState extends State<MapScreen> {
String defaultLabel,
) async {
final controller = TextEditingController(text: defaultLabel);
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
@ -2294,6 +2322,7 @@ class _SharedMarker {
final String flags;
final String fromName;
final String sourceLabel;
final DateTime timestamp;
final bool isChannel;
final bool isPublicChannel;
@ -2304,6 +2333,7 @@ class _SharedMarker {
required this.flags,
required this.fromName,
required this.sourceLabel,
required this.timestamp,
required this.isChannel,
required this.isPublicChannel,
});

View file

@ -88,6 +88,7 @@ class MessageRetryService extends ChangeNotifier {
final Set<String> _activeMessages = {};
final Set<String> _resolvedMessages = {};
final Map<String, String> _expectedHashToMessageId = {};
final Set<String> _forceClearPathOnMaxRetry = {};
RetryServiceConfig? _config;
@ -143,6 +144,7 @@ class MessageRetryService extends ChangeNotifier {
String? translationModelId,
Uint8List? pathBytes,
int? pathLength,
bool forceClearPathOnMaxRetry = false,
}) async {
final messageId = const Uuid().v4();
final resolved = resolvePathSelection(contact);
@ -167,6 +169,9 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (forceClearPathOnMaxRetry) {
_forceClearPathOnMaxRetry.add(messageId);
}
_config?.addMessage(contact.publicKeyHex, message);
@ -458,6 +463,7 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_attemptPathHistory.remove(messageId);
_forceClearPathOnMaxRetry.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
}
@ -519,8 +525,10 @@ class MessageRetryService extends ChangeNotifier {
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true &&
config?.clearContactPath != null) {
final shouldClearPathOnMaxRetry =
_forceClearPathOnMaxRetry.contains(messageId) ||
config?.appSettingsService?.settings.clearPathOnMaxRetry == true;
if (shouldClearPathOnMaxRetry && config?.clearContactPath != null) {
config!.clearContactPath!(contact);
}
@ -756,6 +764,7 @@ class MessageRetryService extends ChangeNotifier {
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
_forceClearPathOnMaxRetry.clear();
super.dispose();
}
}

View file

@ -4,8 +4,9 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
final String? title;
const EmojiPicker({super.key, required this.onEmojiSelected});
const EmojiPicker({super.key, required this.onEmojiSelected, this.title});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
@ -223,7 +224,7 @@ class EmojiPicker extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.chat_addReaction,
title ?? l10n.chat_addReaction,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,