mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-29 13:47:34 +10:00
Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
6.2.4
|
||||||
@@ -199,6 +199,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
int _queueSyncRetries = 0;
|
int _queueSyncRetries = 0;
|
||||||
static const int _maxQueueSyncRetries = 3;
|
static const int _maxQueueSyncRetries = 3;
|
||||||
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||||
|
// Serializes path operations (setContactPath/clearContactPath) to prevent
|
||||||
|
// interleaved async calls from leaving in-memory state inconsistent with device.
|
||||||
|
Future<void> _pathOpLock = Future.value();
|
||||||
Map<String, String>? _currentCustomVars;
|
Map<String, String>? _currentCustomVars;
|
||||||
|
|
||||||
// Channel syncing state (sequential pattern)
|
// Channel syncing state (sequential pattern)
|
||||||
@@ -558,6 +561,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
Map<String, int>.from(_contactUnreadCount),
|
Map<String, int>.from(_contactUnreadCount),
|
||||||
);
|
);
|
||||||
|
_notificationService.clearContactNotification(
|
||||||
|
contactKeyHex,
|
||||||
|
getTotalUnreadCount(),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,6 +583,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_channels.isNotEmpty ? _channels : _cachedChannels,
|
_channels.isNotEmpty ? _channels : _cachedChannels,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_notificationService.clearChannelNotification(
|
||||||
|
channelIndex,
|
||||||
|
getTotalUnreadCount(),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1740,18 +1751,33 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
Uint8List customPath,
|
Uint8List customPath,
|
||||||
int pathLen,
|
int pathLen,
|
||||||
) async {
|
) async {
|
||||||
if (!isConnected) return;
|
// Serialize path operations to prevent interleaved async calls from
|
||||||
|
// leaving in-memory state inconsistent with the device.
|
||||||
|
final prev = _pathOpLock;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_pathOpLock = completer.future;
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
await sendFrame(
|
await sendFrame(
|
||||||
buildUpdateContactPathFrame(
|
buildUpdateContactPathFrame(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
customPath,
|
customPath,
|
||||||
pathLen,
|
pathLen,
|
||||||
type: contact.type,
|
type: contact.type,
|
||||||
flags: contact.flags,
|
flags: contact.flags,
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// USB writes return instantly (no BLE flow control), so give the firmware
|
||||||
|
// time to persist the path change before subsequent commands.
|
||||||
|
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
||||||
@@ -2136,25 +2162,34 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearContactPath(Contact contact) async {
|
Future<void> clearContactPath(Contact contact) async {
|
||||||
if (!isConnected) return;
|
// Serialize path operations to prevent interleaved async calls.
|
||||||
|
final prev = _pathOpLock;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_pathOpLock = completer.future;
|
||||||
|
await prev;
|
||||||
|
try {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||||
final existingIndex = _contacts.indexWhere(
|
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
);
|
}
|
||||||
if (existingIndex >= 0) {
|
final existingIndex = _contacts.indexWhere(
|
||||||
final existing = _contacts[existingIndex];
|
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||||
// Use copyWith to preserve pathOverride and pathOverrideBytes
|
|
||||||
_contacts[existingIndex] = existing.copyWith(
|
|
||||||
pathOverride: null,
|
|
||||||
pathOverrideBytes: null,
|
|
||||||
pathLength: -1,
|
|
||||||
path: Uint8List(0),
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
if (existingIndex >= 0) {
|
||||||
unawaited(_persistContacts());
|
final existing = _contacts[existingIndex];
|
||||||
|
// Preserve pathOverride and pathOverrideBytes — only reset device path
|
||||||
|
_contacts[existingIndex] = existing.copyWith(
|
||||||
|
pathLength: -1,
|
||||||
|
path: Uint8List(0),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(_persistContacts());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
completer.complete();
|
||||||
}
|
}
|
||||||
// The device will send updated contact info with path_len = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateContactInMemory(
|
void updateContactInMemory(
|
||||||
@@ -2490,6 +2525,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_isLoadingContacts = true;
|
_isLoadingContacts = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
break;
|
break;
|
||||||
|
case pushCodeAdvert:
|
||||||
|
// Known contact was seen again - just a pub key, no action needed
|
||||||
|
break;
|
||||||
case pushCodeNewAdvert:
|
case pushCodeNewAdvert:
|
||||||
debugPrint('Got New CONTACT');
|
debugPrint('Got New CONTACT');
|
||||||
// It's the same format as respCodeContact, so we can reuse the handler
|
// It's the same format as respCodeContact, so we can reuse the handler
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||||
"map_showDiscoveryContacts": "Покажи контакти за откриване"
|
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||||
|
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1915,5 +1915,6 @@
|
|||||||
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
|
||||||
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
|
||||||
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
|
||||||
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
|
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
|
||||||
|
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -807,6 +807,7 @@
|
|||||||
"map_source": "Source",
|
"map_source": "Source",
|
||||||
"map_flags": "Flags",
|
"map_flags": "Flags",
|
||||||
"map_shareMarkerHere": "Share marker here",
|
"map_shareMarkerHere": "Share marker here",
|
||||||
|
"map_setAsMyLocation": "Set as my location",
|
||||||
"map_pinLabel": "Pin label",
|
"map_pinLabel": "Pin label",
|
||||||
"map_label": "Label",
|
"map_label": "Label",
|
||||||
"map_pointOfInterest": "Point of interest",
|
"map_pointOfInterest": "Point of interest",
|
||||||
|
|||||||
+2
-1
@@ -1915,5 +1915,6 @@
|
|||||||
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento"
|
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||||
|
"map_setAsMyLocation": "Establecer mi ubicación"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
|
||||||
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
"tcpErrorTimedOut": "La connexion TCP a expiré.",
|
||||||
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
|
||||||
"map_showDiscoveryContacts": "Afficher les contacts de découverte"
|
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
|
||||||
|
"map_setAsMyLocation": "Définir comme ma localisation"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery"
|
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||||
|
"map_setAsMyLocation": "Imposta come la mia posizione"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2746,6 +2746,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Share marker here'**
|
/// **'Share marker here'**
|
||||||
String get map_shareMarkerHere;
|
String get map_shareMarkerHere;
|
||||||
|
|
||||||
|
/// No description provided for @map_setAsMyLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Set as my location'**
|
||||||
|
String get map_setAsMyLocation;
|
||||||
|
|
||||||
/// No description provided for @map_pinLabel.
|
/// No description provided for @map_pinLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -1511,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Споделете маркер тук';
|
String get map_shareMarkerHere => 'Споделете маркер тук';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Задайте като моя местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Етикетиране на пин';
|
String get map_pinLabel => 'Етикетиране на пин';
|
||||||
|
|
||||||
|
|||||||
@@ -1513,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin Name';
|
String get map_pinLabel => 'Pin Name';
|
||||||
|
|
||||||
|
|||||||
@@ -1487,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Share marker here';
|
String get map_shareMarkerHere => 'Share marker here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Set as my location';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Pin label';
|
String get map_pinLabel => 'Pin label';
|
||||||
|
|
||||||
|
|||||||
@@ -1509,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Establecer mi ubicación';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etiqueta de marcador';
|
String get map_pinLabel => 'Etiqueta de marcador';
|
||||||
|
|
||||||
|
|||||||
@@ -1518,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Définir comme ma localisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Étiquete de repin';
|
String get map_pinLabel => 'Étiquete de repin';
|
||||||
|
|
||||||
|
|||||||
@@ -1510,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Imposta come la mia posizione';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Etichetta PIN';
|
String get map_pinLabel => 'Etichetta PIN';
|
||||||
|
|
||||||
|
|||||||
@@ -1502,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Deel marker hier';
|
String get map_shareMarkerHere => 'Deel marker hier';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Label vastzetten';
|
String get map_pinLabel => 'Label vastzetten';
|
||||||
|
|
||||||
|
|||||||
@@ -1512,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznacz etykietę';
|
String get map_pinLabel => 'Oznacz etykietę';
|
||||||
|
|
||||||
|
|||||||
@@ -1511,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Defina minha localização';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Rótulo de marcador';
|
String get map_pinLabel => 'Rótulo de marcador';
|
||||||
|
|
||||||
|
|||||||
@@ -1513,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Установить мое местоположение';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Метка';
|
String get map_pinLabel => 'Метка';
|
||||||
|
|
||||||
|
|||||||
@@ -1504,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Označka upozornenia';
|
String get map_pinLabel => 'Označka upozornenia';
|
||||||
|
|
||||||
|
|||||||
@@ -1498,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Oznaka za pritrditev';
|
String get map_pinLabel => 'Oznaka za pritrditev';
|
||||||
|
|
||||||
|
|||||||
@@ -1494,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Dela markeringen här';
|
String get map_shareMarkerHere => 'Dela markeringen här';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Ange som min plats';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Fästetikett';
|
String get map_pinLabel => 'Fästetikett';
|
||||||
|
|
||||||
|
|||||||
@@ -1510,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => 'Мітка піна';
|
String get map_pinLabel => 'Мітка піна';
|
||||||
|
|
||||||
|
|||||||
@@ -1421,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_shareMarkerHere => '在此分享标记';
|
String get map_shareMarkerHere => '在此分享标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_setAsMyLocation => '设置为我的位置';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_pinLabel => '标签';
|
String get map_pinLabel => '标签';
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
|
||||||
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
|
||||||
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
|
||||||
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
|
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
|
||||||
|
"map_setAsMyLocation": "Stel dit in als mijn locatie"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||||
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||||
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania"
|
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||||
|
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta"
|
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||||
|
"map_setAsMyLocation": "Defina minha localização"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1127,5 +1127,6 @@
|
|||||||
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Показать контакты Discovery"
|
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||||
|
"map_setAsMyLocation": "Установить мое местоположение"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov"
|
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||||
|
"map_setAsMyLocation": "Nastavte ako moju polohu"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
|
||||||
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
|
||||||
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov"
|
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
|
||||||
|
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
|
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||||
|
"map_setAsMyLocation": "Ange som min plats"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1887,5 +1887,6 @@
|
|||||||
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||||
"map_showDiscoveryContacts": "Показати контакти Відкриття"
|
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||||
|
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1892,5 +1892,6 @@
|
|||||||
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||||
"tcpErrorTimedOut": "TCP 连接超时。",
|
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||||
"map_showDiscoveryContacts": "显示发现联系人"
|
"map_showDiscoveryContacts": "显示发现联系人",
|
||||||
|
"map_setAsMyLocation": "设置为我的位置"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||||
final pathLabel = _currentPathLabel(contact);
|
final pathLabel = _currentPathLabel(contact);
|
||||||
|
|
||||||
// Show path details if we have path data (from device or override)
|
// Show path details if we have non-empty path data (from device or override)
|
||||||
final hasPathData =
|
|
||||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
|
||||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||||
|
final hasPathData = effectivePath.isNotEmpty;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final contact = _resolveContact(connector);
|
final contact = _resolveContact(connector);
|
||||||
final isFloodMode = contact.pathOverride == -1;
|
final isFloodMode = contact.pathOverride == -1;
|
||||||
|
|
||||||
|
final isDirectMode = contact.pathOverride == 0;
|
||||||
|
final activeMode = isFloodMode
|
||||||
|
? 'flood'
|
||||||
|
: isDirectMode
|
||||||
|
? 'direct'
|
||||||
|
: 'auto';
|
||||||
|
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
tooltip: context.l10n.chat_routingMode,
|
tooltip: context.l10n.chat_routingMode,
|
||||||
onSelected: (mode) async {
|
onSelected: (mode) async {
|
||||||
if (mode == 'flood') {
|
if (mode == 'flood') {
|
||||||
await connector.setPathOverride(contact, pathLen: -1);
|
await connector.setPathOverride(contact, pathLen: -1);
|
||||||
|
} else if (mode == 'direct') {
|
||||||
|
await connector.setPathOverride(
|
||||||
|
contact,
|
||||||
|
pathLen: 0,
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await connector.setPathOverride(contact, pathLen: null);
|
await connector.setPathOverride(contact, pathLen: null);
|
||||||
}
|
}
|
||||||
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.auto_mode,
|
Icons.auto_mode,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: !isFloodMode
|
color: activeMode == 'auto'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_autoUseSavedPath,
|
context.l10n.chat_autoUseSavedPath,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: !isFloodMode
|
fontWeight: activeMode == 'auto'
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'direct',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.near_me,
|
||||||
|
size: 20,
|
||||||
|
color: activeMode == 'direct'
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context.l10n.chat_direct,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: activeMode == 'direct'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.waves,
|
Icons.waves,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFloodMode
|
color: activeMode == 'flood'
|
||||||
? Theme.of(context).primaryColor
|
? Theme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Text(
|
Text(
|
||||||
context.l10n.chat_forceFloodMode,
|
context.l10n.chat_forceFloodMode,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isFloodMode
|
fontWeight: activeMode == 'flood'
|
||||||
? FontWeight.bold
|
? FontWeight.bold
|
||||||
: FontWeight.normal,
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.chat_sendMessageTo(widget.contact.name),
|
context.l10n.chat_sendMessageTo(
|
||||||
|
_resolveContact(context.read<MeshCoreConnector>()).name,
|
||||||
|
),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Auto-scroll to bottom if user is already at bottom
|
// Auto-scroll to bottom if user is already at bottom
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
_scrollController.scrollToBottomIfAtBottom();
|
_scrollController.scrollToBottomIfAtBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final messageIndex = index;
|
final messageIndex = index;
|
||||||
Contact contact = widget.contact;
|
Contact contact = _resolveContact(connector);
|
||||||
final message = reversedMessages[messageIndex];
|
final message = reversedMessages[messageIndex];
|
||||||
String fourByteHex = '';
|
String fourByteHex = '';
|
||||||
if (widget.contact.type == advTypeRoom) {
|
if (contact.type == advTypeRoom) {
|
||||||
contact = _resolveContactFrom4Bytes(
|
contact = _resolveContactFrom4Bytes(
|
||||||
connector,
|
connector,
|
||||||
message.fourByteRoomContactKey.isEmpty
|
message.fourByteRoomContactKey.isEmpty
|
||||||
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final textScale = context.select<ChatTextScaleService, double>(
|
final textScale = context.select<ChatTextScaleService, double>(
|
||||||
(service) => service.scale,
|
(service) => service.scale,
|
||||||
);
|
);
|
||||||
|
final resolvedContact = _resolveContact(connector);
|
||||||
return _MessageBubble(
|
return _MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
senderName: widget.contact.type == advTypeRoom
|
senderName: resolvedContact.type == advTypeRoom
|
||||||
? "${contact.name} [$fourByteHex]"
|
? "${contact.name} [$fourByteHex]"
|
||||||
: contact.name,
|
: contact.name,
|
||||||
isRoomServer: widget.contact.type == advTypeRoom,
|
isRoomServer: resolvedContact.type == advTypeRoom,
|
||||||
textScale: textScale,
|
textScale: textScale,
|
||||||
onTap: () => _openMessagePath(message, contact),
|
onTap: () => _openMessagePath(message, contact),
|
||||||
onLongPress: () => _showMessageActions(message, contact),
|
onLongPress: () => _showMessageActions(message, contact),
|
||||||
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connector.sendMessage(widget.contact, text);
|
connector.sendMessage(_resolveContact(connector), text);
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_textFieldFocusNode.requestFocus();
|
_textFieldFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// Set the path override to persist user's choice
|
// Set the path override to persist user's choice
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: pathLength,
|
pathLen: pathLength,
|
||||||
pathBytes: pathBytes,
|
pathBytes: pathBytes,
|
||||||
);
|
);
|
||||||
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
await _notifyPathSet(
|
await _notifyPathSet(
|
||||||
connector,
|
connector,
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathBytes,
|
pathBytes,
|
||||||
path.hopCount,
|
path.hopCount,
|
||||||
);
|
);
|
||||||
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: const TextStyle(fontSize: 11),
|
style: const TextStyle(fontSize: 11),
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.clearContactPath(widget.contact);
|
await connector.clearContactPath(
|
||||||
|
_resolveContact(connector),
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: -1,
|
pathLen: -1,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
appLogger.info(
|
return; // Cancelled — keep existing path
|
||||||
'PathSelectionDialog was cancelled or returned null',
|
|
||||||
tag: 'ChatScreen',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
tag: 'ChatScreen',
|
tag: 'ChatScreen',
|
||||||
);
|
);
|
||||||
await connector.setPathOverride(
|
await connector.setPathOverride(
|
||||||
widget.contact,
|
_resolveContact(connector),
|
||||||
pathLen: result.length,
|
pathLen: result.length,
|
||||||
pathBytes: result,
|
pathBytes: result,
|
||||||
);
|
);
|
||||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
await _notifyPathSet(
|
||||||
|
connector,
|
||||||
|
_resolveContact(connector),
|
||||||
|
result,
|
||||||
|
result.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openMessagePath(Message message, Contact contact) {
|
void _openMessagePath(Message message, Contact contact) {
|
||||||
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final String senderName;
|
final String senderName;
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||||
} else if (widget.contact.type == advTypeRoom) {
|
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||||
senderName = "${contact.name} [$fourByteHex]";
|
senderName = "${contact.name} [$fourByteHex]";
|
||||||
} else {
|
} else {
|
||||||
senderName = widget.contact.name;
|
senderName = _resolveContact(connector).name;
|
||||||
}
|
}
|
||||||
final pathMessage = ChannelMessage(
|
final pathMessage = ChannelMessage(
|
||||||
senderKey: null,
|
senderKey: null,
|
||||||
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_retryMessage(message);
|
_retryMessage(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (widget.contact.type == advTypeRoom)
|
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
||||||
|
advTypeRoom)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.chat),
|
leading: const Icon(Icons.chat),
|
||||||
title: Text(context.l10n.contacts_openChat),
|
title: Text(context.l10n.contacts_openChat),
|
||||||
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void _retryMessage(Message message) {
|
void _retryMessage(Message message) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
// Retry using the contact's current path override setting
|
// Retry using the contact's current path override setting
|
||||||
connector.sendMessage(widget.contact, message.text);
|
connector.sendMessage(_resolveContact(connector), message.text);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||||
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
// For room servers, include sender name (like channels) since multiple users
|
// For room servers, include sender name (like channels) since multiple users
|
||||||
// For 1:1 chats, sender is implicit (null)
|
// For 1:1 chats, sender is implicit (null)
|
||||||
final senderName = widget.contact.type == advTypeRoom
|
final liveContact = _resolveContact(connector);
|
||||||
|
final senderName = liveContact.type == advTypeRoom
|
||||||
? senderContact.name
|
? senderContact.name
|
||||||
: null;
|
: null;
|
||||||
final hash = ReactionHelper.computeReactionHash(
|
final hash = ReactionHelper.computeReactionHash(
|
||||||
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
message.text,
|
message.text,
|
||||||
);
|
);
|
||||||
final reactionText = 'r:$hash:$emojiIndex';
|
final reactionText = 'r:$hash:$emojiIndex';
|
||||||
connector.sendMessage(widget.contact, reactionText);
|
connector.sendMessage(_resolveContact(connector), reactionText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
|
import 'package:meshcore_open/services/notification_service.dart';
|
||||||
import 'package:meshcore_open/utils/app_logger.dart';
|
import 'package:meshcore_open/utils/app_logger.dart';
|
||||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -64,6 +65,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_loadGroups();
|
_loadGroups();
|
||||||
_setupFrameListener();
|
_setupFrameListener();
|
||||||
|
_clearAdvertNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearAdvertNotifications() {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
|
||||||
|
NotificationService().clearAdvertNotifications(contactIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1509,6 +1509,23 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.my_location),
|
||||||
|
title: Text(context.l10n.map_setAsMyLocation),
|
||||||
|
onTap: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final successMsg = context.l10n.settings_locationUpdated;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
if (!connector.isConnected) return;
|
||||||
|
await connector.setNodeLocation(
|
||||||
|
lat: position.latitude,
|
||||||
|
lon: position.longitude,
|
||||||
|
);
|
||||||
|
await connector.refreshDeviceInfo();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.close),
|
leading: const Icon(Icons.close),
|
||||||
title: Text(context.l10n.common_cancel),
|
title: Text(context.l10n.common_cancel),
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
[]; // Rolling buffer of recent ACK hashes
|
[]; // Rolling buffer of recent ACK hashes
|
||||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||||
|
final Map<String, List<String>> _sendQueue =
|
||||||
|
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||||
|
final Set<String> _activeMessages =
|
||||||
|
{}; // messageIds currently in-flight (sent/retrying)
|
||||||
|
final Set<String> _resolvedMessages =
|
||||||
|
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||||
final Map<String, String> _expectedHashToMessageId =
|
final Map<String, String> _expectedHashToMessageId =
|
||||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||||
|
|
||||||
@@ -156,7 +162,49 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_addMessageCallback!(contact.publicKeyHex, message);
|
_addMessageCallback!(contact.publicKeyHex, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _attemptSend(messageId);
|
// Queue per contact — only one message in-flight at a time to avoid
|
||||||
|
// overflowing the firmware's 8-entry expected_ack_table.
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_sendQueue[contactKey] ??= [];
|
||||||
|
_sendQueue[contactKey]!.add(messageId);
|
||||||
|
|
||||||
|
if (!_activeMessages.any(
|
||||||
|
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
|
||||||
|
)) {
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendNextForContact(String contactKey) {
|
||||||
|
final queue = _sendQueue[contactKey];
|
||||||
|
if (queue == null) return;
|
||||||
|
|
||||||
|
// Drain stale entries iteratively instead of recursing.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
|
final messageId = queue.removeAt(0);
|
||||||
|
if (_pendingMessages.containsKey(messageId)) {
|
||||||
|
_activeMessages.add(messageId);
|
||||||
|
_attemptSend(messageId).catchError((e) {
|
||||||
|
debugPrint('_attemptSend threw for $messageId: $e');
|
||||||
|
final msg = _pendingMessages[messageId];
|
||||||
|
if (msg != null) {
|
||||||
|
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failed;
|
||||||
|
_updateMessageCallback?.call(failed);
|
||||||
|
}
|
||||||
|
_onMessageResolved(messageId, contactKey);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Message was cancelled/cleaned up while queued — try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessageResolved(String messageId, String contactKey) {
|
||||||
|
if (_resolvedMessages.contains(messageId)) return;
|
||||||
|
_resolvedMessages.add(messageId);
|
||||||
|
_activeMessages.remove(messageId);
|
||||||
|
_sendNextForContact(contactKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attemptSend(String messageId) async {
|
Future<void> _attemptSend(String messageId) async {
|
||||||
@@ -169,13 +217,11 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
// Use the path that was captured when the message was first sent
|
// Use the path that was captured when the message was first sent
|
||||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||||
if (message.pathLength != null && message.pathLength! < 0) {
|
if (message.pathLength != null && message.pathLength! < 0) {
|
||||||
// Flood mode - clear the path
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||||
);
|
);
|
||||||
_clearContactPathCallback!(contact);
|
await _clearContactPathCallback!(contact);
|
||||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||||
// Specific path (including direct neighbor with pathLength=0)
|
|
||||||
final pathStr = message.pathBytes.isEmpty
|
final pathStr = message.pathBytes.isEmpty
|
||||||
? 'direct'
|
? 'direct'
|
||||||
: message.pathBytes
|
: message.pathBytes
|
||||||
@@ -192,6 +238,24 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate after async gap — a timer or ACK could have resolved/retried
|
||||||
|
// this message while we were awaiting the path callback.
|
||||||
|
final currentMessage = _pendingMessages[messageId];
|
||||||
|
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId resolved during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the message was retried by a timer during our await, the retryCount
|
||||||
|
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||||
|
if (currentMessage.retryCount != message.retryCount) {
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final attempt = message.retryCount.clamp(0, 3);
|
final attempt = message.retryCount.clamp(0, 3);
|
||||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
@@ -231,6 +295,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (_sendMessageCallback != null) {
|
if (_sendMessageCallback != null) {
|
||||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||||
|
} else {
|
||||||
|
// No send callback — message would be stuck forever. Fail it immediately.
|
||||||
|
debugPrint(
|
||||||
|
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||||
|
);
|
||||||
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
|
_updateMessageCallback?.call(failedMessage);
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +354,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||||
|
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||||
if (messageId == null && allowQueueFallback) {
|
if (messageId == null && allowQueueFallback) {
|
||||||
_debugLogService?.warn(
|
_debugLogService?.warn(
|
||||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||||
@@ -290,13 +364,16 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
// Search all contact queues so concurrent chats don't miss matches.
|
||||||
|
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||||
|
|
||||||
|
for (var entry in queuesToSearch.entries) {
|
||||||
final contactKey = entry.key;
|
final contactKey = entry.key;
|
||||||
final queue = entry.value;
|
final queue = entry.value;
|
||||||
|
|
||||||
if (queue.isNotEmpty) {
|
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||||
|
while (queue.isNotEmpty) {
|
||||||
final candidateMessageId = queue.removeAt(0);
|
final candidateMessageId = queue.removeAt(0);
|
||||||
|
|
||||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||||
messageId = candidateMessageId;
|
messageId = candidateMessageId;
|
||||||
contact = _pendingContacts[candidateMessageId];
|
contact = _pendingContacts[candidateMessageId];
|
||||||
@@ -304,21 +381,10 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} else {
|
|
||||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
|
||||||
if (queue.isNotEmpty) {
|
|
||||||
final nextMessageId = queue.removeAt(0);
|
|
||||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
|
||||||
messageId = nextMessageId;
|
|
||||||
contact = _pendingContacts[nextMessageId];
|
|
||||||
debugPrint(
|
|
||||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||||
}
|
}
|
||||||
|
if (messageId != null) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,22 +529,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
// Max retries reached - mark as failed
|
// Max retries reached - mark as failed
|
||||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||||
|
_pendingMessages[messageId] = failedMessage;
|
||||||
// Move ACK hashes to history before removing
|
|
||||||
_moveAckHashesToHistory(messageId);
|
|
||||||
|
|
||||||
_pendingMessages.remove(messageId);
|
|
||||||
_pendingContacts.remove(messageId);
|
|
||||||
_pendingPathSelections.remove(messageId);
|
|
||||||
_timeoutTimers[messageId]?.cancel();
|
|
||||||
_timeoutTimers.remove(messageId);
|
|
||||||
|
|
||||||
// Clean up the queue entry for this contact
|
|
||||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
|
||||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
|
||||||
false) {
|
|
||||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should clear the path on max retry
|
// Check if we should clear the path on max retry
|
||||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||||
@@ -499,6 +550,30 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// Message is done retrying — send next queued message for this contact
|
||||||
|
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||||
|
|
||||||
|
// Keep message in pending maps for 30s grace period so late ACKs
|
||||||
|
// can still match and update the message to delivered.
|
||||||
|
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||||
|
_moveAckHashesToHistory(messageId);
|
||||||
|
// Clean up ALL hash mappings for this message
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == messageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||||
|
_pendingMessages.remove(messageId);
|
||||||
|
_pendingContacts.remove(messageId);
|
||||||
|
_pendingPathSelections.remove(messageId);
|
||||||
|
_timeoutTimers.remove(messageId);
|
||||||
|
_resolvedMessages.remove(messageId);
|
||||||
|
final contactKey = contact.publicKeyHex;
|
||||||
|
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||||
|
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||||
|
_pendingMessageQueuePerContact.remove(contactKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +669,15 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matchedMessageId != null) {
|
if (matchedMessageId != null) {
|
||||||
final message = _pendingMessages[matchedMessageId]!;
|
final message = _pendingMessages[matchedMessageId];
|
||||||
|
if (message == null) {
|
||||||
|
// Message was already cleaned up (e.g. grace period expired)
|
||||||
|
_ackHashToMessageId.remove(ackHashHex);
|
||||||
|
debugPrint(
|
||||||
|
'ACK matched $matchedMessageId but message already cleaned up',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final contact = _pendingContacts[matchedMessageId];
|
final contact = _pendingContacts[matchedMessageId];
|
||||||
final selection = _pendingPathSelections[matchedMessageId];
|
final selection = _pendingPathSelections[matchedMessageId];
|
||||||
|
|
||||||
@@ -616,12 +699,21 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
tripTimeMs: tripTimeMs,
|
tripTimeMs: tripTimeMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||||
|
_ackHashToMessageId.removeWhere(
|
||||||
|
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||||
|
);
|
||||||
|
_expectedHashToMessageId.removeWhere(
|
||||||
|
(_, msgId) => msgId == matchedMessageId,
|
||||||
|
);
|
||||||
|
|
||||||
// Move ACK hashes to history before removing
|
// Move ACK hashes to history before removing
|
||||||
_moveAckHashesToHistory(matchedMessageId);
|
_moveAckHashesToHistory(matchedMessageId);
|
||||||
|
|
||||||
_pendingMessages.remove(matchedMessageId);
|
_pendingMessages.remove(matchedMessageId);
|
||||||
_pendingContacts.remove(matchedMessageId);
|
_pendingContacts.remove(matchedMessageId);
|
||||||
_pendingPathSelections.remove(matchedMessageId);
|
_pendingPathSelections.remove(matchedMessageId);
|
||||||
|
_resolvedMessages.remove(matchedMessageId);
|
||||||
|
|
||||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -646,6 +738,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
true,
|
true,
|
||||||
tripTimeMs,
|
tripTimeMs,
|
||||||
);
|
);
|
||||||
|
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
_ackHistory.clear();
|
_ackHistory.clear();
|
||||||
_ackHashToMessageId.clear();
|
_ackHashToMessageId.clear();
|
||||||
_pendingMessageQueuePerContact.clear();
|
_pendingMessageQueuePerContact.clear();
|
||||||
|
_sendQueue.clear();
|
||||||
|
_activeMessages.clear();
|
||||||
|
_resolvedMessages.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,9 @@ class NotificationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _notifications.show(
|
await _notifications.show(
|
||||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
id: contactId != null
|
||||||
|
? 'advert:$contactId'.hashCode
|
||||||
|
: DateTime.now().millisecondsSinceEpoch,
|
||||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||||
body: contactName,
|
body: contactName,
|
||||||
notificationDetails: notificationDetails,
|
notificationDetails: notificationDetails,
|
||||||
@@ -331,6 +333,61 @@ class NotificationService {
|
|||||||
await _notifications.cancel(id: id);
|
await _notifications.cancel(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific contact and update the app badge.
|
||||||
|
Future<void> clearContactNotification(
|
||||||
|
String contactId,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: contactId.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the notification for a specific channel and update the app badge.
|
||||||
|
Future<void> clearChannelNotification(
|
||||||
|
int channelIndex,
|
||||||
|
int totalUnreadCount,
|
||||||
|
) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
await _notifications.cancel(id: channelIndex.hashCode);
|
||||||
|
await _updateBadge(totalUnreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel advert notifications for the given contact public key hexes.
|
||||||
|
Future<void> clearAdvertNotifications(List<String> contactIds) async {
|
||||||
|
if (!await _ensureInitialized()) return;
|
||||||
|
for (final id in contactIds) {
|
||||||
|
await _notifications.cancel(id: 'advert:$id'.hashCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateBadge(int count) async {
|
||||||
|
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||||
|
// On Apple platforms, set the badge number directly via a silent update.
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: false,
|
||||||
|
presentSound: false,
|
||||||
|
presentBadge: true,
|
||||||
|
badgeNumber: count,
|
||||||
|
);
|
||||||
|
final details = NotificationDetails(
|
||||||
|
iOS: darwinDetails,
|
||||||
|
macOS: darwinDetails,
|
||||||
|
);
|
||||||
|
// Use a fixed ID so each update replaces the previous one.
|
||||||
|
await _notifications.show(
|
||||||
|
id: 'badge_update'.hashCode,
|
||||||
|
title: null,
|
||||||
|
body: null,
|
||||||
|
notificationDetails: details,
|
||||||
|
);
|
||||||
|
// Immediately cancel the silent notification so it doesn't appear in tray.
|
||||||
|
await _notifications.cancel(id: 'badge_update'.hashCode);
|
||||||
|
}
|
||||||
|
// On Android, badge count is derived from active notifications,
|
||||||
|
// so cancelling the specific notification above is sufficient.
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// Public notification methods (rate limiting is enforced automatically)
|
// Public notification methods (rate limiting is enforced automatically)
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user