fix(security): treat a new identity key as a message request, never as a known contact
A stranger could pick the display name of an existing contact ("Mom")
and appear as that contact. Messages from a key that is not in the
contact list now open a message request labelled "(unknown)": no
replies, read receipts, typing indicators, calls or name in
notifications until the user accepts it and names the contact
themselves. Names that look like an existing contact (case, diacritics,
Cyrillic/Greek homoglyphs, invisible characters) also show a warning in
the real contact's chat.
When the relay account of a known contact shows up with a different
key, the chat shows "safety number changed", verification is cleared
and the session that now belongs to the other key is dropped before
sending, so a reply never goes to the new key.
This commit is contained in:
@@ -331,6 +331,18 @@ class SepotCrypto {
|
||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||
_stores.session.containsSession(remote);
|
||||
|
||||
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
|
||||
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
|
||||
/// Null = zatím žádný.
|
||||
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
|
||||
final key = await _stores.identity.getIdentity(remote);
|
||||
return key == null ? null : Uint8List.fromList(key.serialize().toList());
|
||||
}
|
||||
|
||||
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
|
||||
Future<void> dropSession(ProtocolAddress remote) =>
|
||||
_stores.session.deleteSession(remote);
|
||||
|
||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||
Future<void> processRemoteBundle(
|
||||
ProtocolAddress remote,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
|
||||
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
|
||||
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
|
||||
String contactName(AppLocalizations l10n, Contact contact) {
|
||||
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
|
||||
return contact.accepted
|
||||
? contact.displayName
|
||||
: l10n.contactRequestName(contact.displayName);
|
||||
}
|
||||
@@ -75,6 +75,19 @@ class PushNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
|
||||
/// si zvolil sám.
|
||||
static Future<void> showMessageRequestNotification() async {
|
||||
await _ensureInit();
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.show(
|
||||
_notificationId,
|
||||
l10n.commonAppName,
|
||||
l10n.pushMessageRequest,
|
||||
_details(l10n),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||
/// WS připojení → fronta se vyleje normální cestou).
|
||||
static Future<void> showWakeNotification() async {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
|
||||
///
|
||||
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
|
||||
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
|
||||
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
|
||||
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
|
||||
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
|
||||
library;
|
||||
|
||||
import 'package:sepot/core/utils/text_fold.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
|
||||
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
|
||||
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
|
||||
// shoda tu nic nerozbije, jen přidá varování navíc.
|
||||
const _confusables = {
|
||||
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
|
||||
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
|
||||
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
|
||||
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
|
||||
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
|
||||
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
|
||||
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
|
||||
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
|
||||
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
|
||||
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
|
||||
'ż': 'z',
|
||||
};
|
||||
|
||||
final _invisible = RegExp(
|
||||
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
|
||||
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
|
||||
final _combining = RegExp('[\u0300-\u036F]');
|
||||
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
|
||||
|
||||
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
|
||||
String nameSkeleton(String name) {
|
||||
final folded = fold(name
|
||||
.replaceAll(_invisible, '')
|
||||
.replaceAll(_combining, '')
|
||||
.replaceAll(_separators, ''));
|
||||
final out = StringBuffer();
|
||||
for (final ch in folded.split('')) {
|
||||
out.write(_confusables[ch] ?? ch);
|
||||
}
|
||||
// „rn“ se v bezpatkovém písmu čte jako „m“.
|
||||
return out.toString().replaceAll('rn', 'm');
|
||||
}
|
||||
|
||||
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
|
||||
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
|
||||
Contact? lookalikeContact(
|
||||
String name,
|
||||
Iterable<Contact> contacts, {
|
||||
String? exceptId,
|
||||
}) {
|
||||
final skeleton = nameSkeleton(name);
|
||||
if (skeleton.isEmpty) return null;
|
||||
for (final c in contacts) {
|
||||
if (!c.accepted || c.id == exceptId) continue;
|
||||
if (nameSkeleton(c.displayName) == skeleton) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/network/relay_transport.dart';
|
||||
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||
import 'package:sepot/core/services/group_admin_policy.dart';
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
import 'package:sepot/core/services/typing_tracker.dart';
|
||||
@@ -218,7 +219,7 @@ class MessagingService {
|
||||
final targets = contacts.where((c) =>
|
||||
!c.blocked &&
|
||||
(group == null
|
||||
? c.id == convId
|
||||
? c.id == convId && c.accepted
|
||||
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
||||
for (final c in targets) {
|
||||
final until = _typingBackoffUntil[c.id];
|
||||
@@ -993,6 +994,8 @@ class MessagingService {
|
||||
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
||||
{String? gid}) async {
|
||||
if (messageIds.isEmpty) return;
|
||||
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
|
||||
if (gid == null && !contact.accepted) return;
|
||||
final enabled =
|
||||
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
||||
if (!enabled) return;
|
||||
@@ -1948,19 +1951,26 @@ class MessagingService {
|
||||
verified: known.verified,
|
||||
notes: known.notes,
|
||||
blocked: known.blocked,
|
||||
avatar: known.avatar,
|
||||
accepted: known.accepted,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final name = (e['n'] as String?)?.trim();
|
||||
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
||||
identityKey: base64Decode(e['ik'] as String),
|
||||
createdAt: DateTime.now(),
|
||||
accountId: aid,
|
||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||
)));
|
||||
// Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
|
||||
// kdo roster poslal). Jako u příchozí zprávy je to žádost.
|
||||
await storage.saveContact(Contact.fromPublicIdentity(
|
||||
PublicIdentity(
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||
identityKey: base64Decode(e['ik'] as String),
|
||||
createdAt: DateTime.now(),
|
||||
accountId: aid,
|
||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||
),
|
||||
accepted: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2215,12 +2225,14 @@ class MessagingService {
|
||||
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
||||
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||
final sealed = await _serial<Uint8List?>(() async {
|
||||
await _dropForeignSession(crypto, peerAddr, contact);
|
||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
||||
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||
if (!await crypto.hasSession(peerAddr)) {
|
||||
if (ephemeral) return null;
|
||||
final bundle = await relay.fetchBundle(peerDa);
|
||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||
await _noteIdentityChanged(contact);
|
||||
throw const IdentityKeyMismatchError();
|
||||
}
|
||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||
@@ -2245,6 +2257,29 @@ class MessagingService {
|
||||
return receipt.status;
|
||||
}
|
||||
|
||||
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
|
||||
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
|
||||
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
|
||||
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
|
||||
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
|
||||
/// a kontakt dostane varování.
|
||||
Future<void> _dropForeignSession(
|
||||
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
|
||||
if (!await crypto.hasSession(peerAddr)) return;
|
||||
final bound = await crypto.sessionIdentity(peerAddr);
|
||||
if (bound == null ||
|
||||
_listEquals(bound, contact.publicIdentity.identityKey)) {
|
||||
return;
|
||||
}
|
||||
await crypto.dropSession(peerAddr);
|
||||
await _noteIdentityChanged(contact);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugDropForeignSession(
|
||||
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
|
||||
_dropForeignSession(crypto, peerAddr, contact);
|
||||
|
||||
// ── Příjem ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
||||
@@ -2279,7 +2314,17 @@ class MessagingService {
|
||||
// (může jít o dočasný stav — např. cert refresh).
|
||||
return;
|
||||
}
|
||||
await _processOpened(opened, envelope.msgUuid);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugProcessOpened(
|
||||
SepotSealedMessage opened, List<int> msgUuid) =>
|
||||
_processOpened(opened, msgUuid);
|
||||
|
||||
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
|
||||
Future<void> _processOpened(
|
||||
SepotSealedMessage opened, List<int> msgUuid) async {
|
||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||
@@ -2292,7 +2337,7 @@ class MessagingService {
|
||||
if (content['v'] != 1 || !known.contains(type)) {
|
||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2302,15 +2347,19 @@ class MessagingService {
|
||||
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
||||
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
||||
if (contact.blocked) {
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
||||
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
||||
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
|
||||
// s vlastním zvoleným jménem („Máma volá“).
|
||||
if (type == 'call') {
|
||||
if (content['gid'] == null) onCallSignal?.call(contact, content);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
if (content['gid'] == null && contact.accepted) {
|
||||
onCallSignal?.call(contact, content);
|
||||
}
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2320,7 +2369,7 @@ class MessagingService {
|
||||
// nepomůže).
|
||||
if (_groupControlTypes.contains(type)) {
|
||||
await _handleGroupControl(senderFp, type, content);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2330,7 +2379,7 @@ class MessagingService {
|
||||
final group =
|
||||
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
||||
if (gid != null && group == null) {
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
if (group != null && _noteCapability(group, senderFp, content)) {
|
||||
@@ -2345,7 +2394,7 @@ class MessagingService {
|
||||
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
||||
TypingTracker.showFor;
|
||||
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2380,7 +2429,7 @@ class MessagingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
onUpdate?.call();
|
||||
return;
|
||||
}
|
||||
@@ -2389,7 +2438,7 @@ class MessagingService {
|
||||
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
||||
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
||||
await _applyIncomingControl(convId, type!, content, opened);
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
onUpdate?.call();
|
||||
return;
|
||||
}
|
||||
@@ -2407,7 +2456,7 @@ class MessagingService {
|
||||
if (raw is! String) {
|
||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
||||
@@ -2441,7 +2490,7 @@ class MessagingService {
|
||||
}
|
||||
} on Object {
|
||||
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
return;
|
||||
}
|
||||
message = Message(
|
||||
@@ -2466,7 +2515,7 @@ class MessagingService {
|
||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
||||
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
|
||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
||||
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||
@@ -2499,11 +2548,11 @@ class MessagingService {
|
||||
} on FormatException {
|
||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
} on TypeError {
|
||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||
// trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
_relay?.ack(msgUuid);
|
||||
} catch (_) {
|
||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||
@@ -2733,6 +2782,7 @@ class MessagingService {
|
||||
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
||||
blocked: c.blocked,
|
||||
avatar: avatarChanged ? avatar : c.avatar,
|
||||
accepted: c.accepted,
|
||||
);
|
||||
await storage.saveContact(updated);
|
||||
if (avatarChanged) {
|
||||
@@ -2743,7 +2793,9 @@ class MessagingService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Neznámý odesílatel → nový (neověřený) kontakt
|
||||
// Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
|
||||
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
|
||||
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
final name = (profile?['n'] as String?)?.trim();
|
||||
final identity = PublicIdentity(
|
||||
@@ -2753,12 +2805,62 @@ class MessagingService {
|
||||
accountId: opened.senderName,
|
||||
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
||||
);
|
||||
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar;
|
||||
final contact = Contact.fromPublicIdentity(identity, accepted: false)
|
||||
..avatar = avatar;
|
||||
await storage.saveContact(contact);
|
||||
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
||||
|
||||
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
|
||||
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
|
||||
// dosavadní kontakt ztrácí ověření a dostane varování.
|
||||
final previous = contacts
|
||||
.where((c) => c.publicIdentity.accountId == opened.senderName)
|
||||
.firstOrNull;
|
||||
if (previous != null) await _noteIdentityChanged(previous);
|
||||
|
||||
final lookalike =
|
||||
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
|
||||
if (lookalike != null && lookalike.id != previous?.id) {
|
||||
await _noteLookalike(lookalike, identity.displayName);
|
||||
}
|
||||
return contact;
|
||||
}
|
||||
|
||||
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
|
||||
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
|
||||
Future<void> _noteIdentityChanged(Contact contact) async {
|
||||
if (contact.verified) {
|
||||
contact.verified = false;
|
||||
await storage.saveContact(contact);
|
||||
}
|
||||
await _addNotice(contact, MessageNotice.identityChanged);
|
||||
}
|
||||
|
||||
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
|
||||
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
|
||||
Future<void> _noteLookalike(Contact contact, String claimedName) async {
|
||||
if (await storage.loadConversation(contact.id) == null) return;
|
||||
await _addNotice(contact, MessageNotice.lookalikeRequest,
|
||||
content: claimedName);
|
||||
}
|
||||
|
||||
Future<void> _addNotice(Contact contact, MessageNotice notice,
|
||||
{String? content}) async {
|
||||
final me = _identity;
|
||||
if (me == null) return;
|
||||
final last = (await storage.loadConversation(contact.id))?.lastMessage;
|
||||
if (last?.notice == notice && last?.content == content) return;
|
||||
await storage.addMessage(
|
||||
contact.id,
|
||||
Message.notice(
|
||||
contactId: contact.id,
|
||||
myId: me.id,
|
||||
notice: notice,
|
||||
content: content),
|
||||
);
|
||||
onUpdate?.call();
|
||||
}
|
||||
|
||||
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
||||
|
||||
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
||||
|
||||
Reference in New Issue
Block a user