Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'dart:typed_data';
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
enum AttachmentTransferState {
/// Přijatá zpráva, blob se ještě nezačal stahovat.
pending,
downloading,
/// Blob leží (šifrovaný) v lokálním AttachmentStore.
ready,
/// Download/ověření selhalo — UI nabídne opakování.
failed,
}
/// Metadata přílohy. Klíč/nonce/digest dorazily E2E kanálem (relay je
/// nevidí); v lokální DB žijí uvnitř SQLCipher, takže at-rest jsou
/// chráněné stejně jako zbytek zpráv.
///
/// Wire tvar (pole `att` v content JSON, krátké klíče kvůli obálce):
/// { bid, k, n, d, s, mime, name?, w?, h?, th? }
class AttachmentMeta {
const AttachmentMeta({
required this.blobId,
required this.keyB64,
required this.nonceB64,
required this.digestB64,
required this.size,
required this.mime,
this.name,
this.width,
this.height,
this.thumbB64,
this.durationMs,
this.state = AttachmentTransferState.pending,
});
final String blobId; // 32 hex znaků (id na relayi i lokálně)
final String keyB64; // 32 B per-file AES klíč
final String nonceB64; // 12 B GCM nonce
final String digestB64; // SHA-256 šifrovaného blobu
final int size; // velikost PLAINTEXTU v bajtech
final String mime;
final String? name; // původní název souboru (dokumenty)
final int? width; // rozměry obrázku
final int? height;
final String? thumbB64; // JPEG náhled — jede UVNITŘ E2E plaintextu
final int? durationMs; // délka hlasové zprávy / audia
final AttachmentTransferState state;
bool get isImage => mime.startsWith('image/');
bool get isAudio => mime.startsWith('audio/');
Uint8List get key => base64Decode(keyB64);
Uint8List get nonce => base64Decode(nonceB64);
Uint8List get digest => base64Decode(digestB64);
Uint8List? get thumbnail =>
thumbB64 == null ? null : base64Decode(thumbB64!);
AttachmentMeta copyWith({AttachmentTransferState? state}) {
return AttachmentMeta(
blobId: blobId,
keyB64: keyB64,
nonceB64: nonceB64,
digestB64: digestB64,
size: size,
mime: mime,
name: name,
width: width,
height: height,
thumbB64: thumbB64,
durationMs: durationMs,
state: state ?? this.state,
);
}
/// Do content JSON zprávy (bez lokálního stavu).
Map<String, dynamic> toWire() => {
'bid': blobId,
'k': keyB64,
'n': nonceB64,
'd': digestB64,
's': size,
'mime': mime,
if (name != null) 'name': name,
if (width != null) 'w': width,
if (height != null) 'h': height,
if (thumbB64 != null) 'th': thumbB64,
if (durationMs != null) 'dur': durationMs,
};
/// Z content JSON příchozí zprávy. Remote input — tvary se validují
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
final size = wire['s'] as int;
if (size <= 0 || size > 512 * 1024 * 1024) {
throw const FormatException('implausible attachment size');
}
return AttachmentMeta(
blobId: wire['bid'] as String,
keyB64: wire['k'] as String,
nonceB64: wire['n'] as String,
digestB64: wire['d'] as String,
size: size,
mime: wire['mime'] as String,
name: wire['name'] as String?,
width: wire['w'] as int?,
height: wire['h'] as int?,
thumbB64: wire['th'] as String?,
durationMs: wire['dur'] as int?,
state: AttachmentTransferState.pending,
);
}
/// Lokální perzistence (SQLCipher) — wire tvar + stav.
Map<String, dynamic> toJson() => {
...toWire(),
'state': state.name,
};
factory AttachmentMeta.fromJson(Map<String, dynamic> json) {
final base = AttachmentMeta.fromWire(json);
final stateName = json['state'] as String?;
return base.copyWith(
state: AttachmentTransferState.values.asNameMap()[stateName] ??
AttachmentTransferState.pending,
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:sepot/models/identity.dart';
/// Kontakt — osoba, se kterou můžeme chatovat
///
/// Odpovídá Rust: Contact
class Contact {
String displayName;
final PublicIdentity publicIdentity;
final DateTime addedAt;
bool verified;
String? notes;
/// Blokovaný kontakt: příchozí zprávy se zahodí (ACKnou, ale neuloží ani
/// nezobrazí) a odesílání je v UI zablokované.
bool blocked;
Contact({
required this.displayName,
required this.publicIdentity,
required this.addedAt,
this.verified = false,
this.notes,
this.blocked = false,
});
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
///
/// Dřív náhodné UUID — vznikly tím DVĚ klíčové soustavy (konverzace se
/// ukládaly pod UUID, UI ale routovalo fingerprintem), takže konverzace
/// otevřená z kontaktů neuměla odeslat zprávu a chat list nenašel jméno.
/// Bez datové migrace (žádná produkční data; testovací zařízení se
/// re-párují — stejný precedens jako S9).
String get id => publicIdentity.id;
/// Vytvoř nový kontakt z veřejné identity
factory Contact.fromPublicIdentity(
PublicIdentity identity, {
bool verified = false,
}) {
return Contact(
displayName: identity.displayName,
publicIdentity: identity,
addedAt: DateTime.now(),
verified: verified,
);
}
/// Šepot ID kontaktu
String get sepotId => publicIdentity.sepotId;
/// Otisk kontaktu (plné Crockford base32 zobrazení)
String get fingerprint => publicIdentity.fingerprint.display;
Map<String, dynamic> toJson() => {
'displayName': displayName,
'publicIdentity': publicIdentity.toJson(),
'addedAt': addedAt.toIso8601String(),
'verified': verified,
'notes': notes,
'blocked': blocked,
};
factory Contact.fromJson(Map<String, dynamic> json) {
return Contact(
displayName: json['displayName'] as String,
publicIdentity: PublicIdentity.fromJson(
json['publicIdentity'] as Map<String, dynamic>,
),
addedAt: DateTime.parse(json['addedAt'] as String),
verified: json['verified'] as bool? ?? false,
notes: json['notes'] as String?,
blocked: json['blocked'] as bool? ?? false,
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:sepot/models/message.dart';
/// Konverzace s kontaktem
///
/// Odpovídá Rust: Conversation
class Conversation {
final String contactId;
final List<Message> messages;
DateTime lastActivity;
int? disappearAfterSeconds; // Globální nastavení mizících zpráv
/// Ztlumení a archivace — čistě LOKÁLNÍ (neopouští zařízení).
bool muted;
DateTime? mutedUntil; // null + muted = navždy; jinak dočasně
bool archived;
Conversation({
required this.contactId,
List<Message>? messages,
DateTime? lastActivity,
this.disappearAfterSeconds,
this.muted = false,
this.mutedUntil,
this.archived = false,
}) : messages = messages ?? [],
lastActivity = lastActivity ?? DateTime.now();
/// Je konverzace teď ztlumená? (dočasné ztlumení sama vyprší)
bool get isMuted =>
muted && (mutedUntil == null || mutedUntil!.isAfter(DateTime.now()));
/// Poslední zpráva
Message? get lastMessage => messages.isEmpty ? null : messages.last;
/// Počet nepřečtených zpráv
int unreadCount(String myId) {
return messages
.where((m) => !m.isRead && m.senderId != myId)
.length;
}
/// Přidej zprávu
void addMessage(Message message) {
messages.add(message);
lastActivity = DateTime.now();
}
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
/// Neexistující id se tiše ignoruje (zpráva mohla být smazána).
void updateMessage(Message message) {
final index = messages.indexWhere((m) => m.id == message.id);
if (index >= 0) messages[index] = message;
}
/// Označ všechny zprávy jako přečtené
void markAllRead(String myId) {
for (var i = 0; i < messages.length; i++) {
if (!messages[i].isRead && messages[i].senderId != myId) {
messages[i] = messages[i].copyWith(isRead: true);
}
}
}
Map<String, dynamic> toJson() => {
'contactId': contactId,
'messages': messages.map((m) => m.toJson()).toList(),
'lastActivity': lastActivity.toIso8601String(),
'disappearAfterSeconds': disappearAfterSeconds,
'muted': muted,
'mutedUntil': mutedUntil?.toIso8601String(),
'archived': archived,
};
factory Conversation.fromJson(Map<String, dynamic> json) {
return Conversation(
contactId: json['contactId'] as String,
messages: (json['messages'] as List<dynamic>?)
?.map((m) => Message.fromJson(m as Map<String, dynamic>))
.toList() ??
[],
lastActivity: DateTime.parse(json['lastActivity'] as String),
disappearAfterSeconds: json['disappearAfterSeconds'] as int?,
// Defaulty — legacy JSON bez těchto klíčů musí projít
muted: json['muted'] as bool? ?? false,
mutedUntil: json['mutedUntil'] == null
? null
: DateTime.parse(json['mutedUntil'] as String),
archived: json['archived'] as bool? ?? false,
);
}
}
+56
View File
@@ -0,0 +1,56 @@
/// Skupina — párový fanout MVP.
///
/// Drží jen jméno a fingerprinty členů; routing a identity klíče členů
/// žijí v tabulce contacts (člen = běžný kontakt založený z rosteru).
/// Konverzace skupiny je řádek v conversations s contact_id = [id].
class Group {
Group({
required this.id,
required this.name,
required this.memberIds,
required this.createdBy,
DateTime? createdAt,
this.left = false,
}) : createdAt = createdAt ?? DateTime.now();
/// UUID v4 skupiny (= `gid` na drátu, = contact_id konverzace).
final String id;
String name;
/// Fingerprinty členů VČETNĚ mě (kompletní roster).
List<String> memberIds;
/// Fingerprint zakladatele.
final String createdBy;
final DateTime createdAt;
/// Opustil jsem skupinu — historie zůstává, vstup je mrtvý a příchozí
/// zprávy skupiny se zahazují.
bool left;
/// Strop členů (doporučení: 128).
static const maxMembers = 128;
bool isMember(String fingerprint) => memberIds.contains(fingerprint);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'memberIds': memberIds,
'createdBy': createdBy,
'createdAt': createdAt.toIso8601String(),
'left': left,
};
factory Group.fromJson(Map<String, dynamic> json) => Group(
id: json['id'] as String,
name: json['name'] as String,
memberIds:
(json['memberIds'] as List<dynamic>).cast<String>().toList(),
createdBy: json['createdBy'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
left: json['left'] as bool? ?? false,
);
}
+261
View File
@@ -0,0 +1,261 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:base32/base32.dart';
import 'package:base32/encodings.dart';
import 'package:pointycastle/digests/blake2b.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
/// Fingerprint identity — jediné user-facing ID Šepotu.
///
/// fingerprint = BLAKE2b-256( serializovaný identity pubkey, 33 B )
///
/// Zobrazuje se jako Crockford base32 (52 znaků), zkráceně „SEP-XXXXXXXXXX".
/// POZOR: NEPLÉST s `account_id` — to je náhodný 16B routing handle, který
/// přiděluje relay při registraci a nemá k identitě kryptografický vztah.
class SepotFingerprint {
SepotFingerprint(this.bytes)
: assert(bytes.length == 32, 'BLAKE2b-256 = 32 bytes');
/// Spočítá fingerprint ze serializovaného veřejného identity klíče.
factory SepotFingerprint.ofPublicKey(Uint8List identityPublicKey) {
final digest = Blake2bDigest(digestSize: 32);
return SepotFingerprint(digest.process(identityPublicKey));
}
final Uint8List bytes;
/// Plné zobrazení — 52 znaků Crockford base32, bez paddingu.
String get display =>
base32.encode(bytes, encoding: Encoding.crockford).toUpperCase();
/// UX zkratka do titulků, QR popisků a seznamů.
String get short => 'SEP-${display.substring(0, 10)}';
@override
String toString() => display;
@override
bool operator ==(Object other) =>
other is SepotFingerprint && display == other.display;
@override
int get hashCode => display.hashCode;
}
/// Lokální identita uživatele — identity keypair + zobrazované jméno.
///
/// Identita = klíč. Žádné UUID, žádný telefon, žádný e-mail. Obnova
/// přes 24slovný mnemonic (reverzibilní enkódování privátního klíče).
class LocalIdentity {
LocalIdentity({
required this.displayName,
required this.identityPrivateKey,
required this.identityPublicKey,
required this.createdAt,
}) : fingerprint = SepotFingerprint.ofPublicKey(identityPublicKey);
final String displayName;
/// 32 B, serializovaný (clampnutý). Nikdy neopouští zařízení jinak než
/// jako mnemonic záloha uživatele. ZÁKAZ kopírování do schránky.
final Uint8List identityPrivateKey;
/// 33 B, serializovaný (libsignal type byte + 32 B).
final Uint8List identityPublicKey;
final DateTime createdAt;
final SepotFingerprint fingerprint;
/// Vytvoř novou identitu (onboarding).
static Future<LocalIdentity> create(String displayName) async {
final keys = SepotCrypto.generateIdentityKeys();
return LocalIdentity(
displayName: displayName,
identityPrivateKey: keys.privateKey,
identityPublicKey: keys.publicKey,
createdAt: DateTime.now(),
);
}
/// Obnov identitu z 24slovného mnemonic (recovery flow).
///
/// Obnoví IDENTITU (stejný klíč → stejný fingerprint → „verified"
/// kontakty drží), ne historii zpráv — ta je díky forward secrecy pryč
/// a UI to musí říct.
static Future<LocalIdentity> recover({
required List<String> mnemonicWords,
required String displayName,
}) async {
final privateKey = SepotMnemonic.decode(mnemonicWords);
return LocalIdentity(
displayName: displayName,
identityPrivateKey: privateKey,
identityPublicKey: SepotCrypto.derivePublicKey(privateKey),
createdAt: DateTime.now(),
);
}
/// 24 slov zálohy identity. Zobrazit jen v chráněném recovery UI.
List<String> get mnemonic => SepotMnemonic.encode(identityPrivateKey);
/// Kompatibilní alias — dřív tu bylo UUID, teď je ID totožné
/// s fingerprintem (deterministicky z klíče).
String get id => fingerprint.display;
/// Šepot ID pro zobrazení v UI.
String get sepotId => fingerprint.short;
/// Veřejná identita (bezpečné ke sdílení).
PublicIdentity get publicIdentity => PublicIdentity(
displayName: displayName,
identityKey: identityPublicKey,
createdAt: createdAt,
);
Map<String, dynamic> toJson() => {
'v': 2,
'displayName': displayName,
'identityPrivateKey': base64Encode(identityPrivateKey),
'identityPublicKey': base64Encode(identityPublicKey),
'createdAt': createdAt.toIso8601String(),
};
factory LocalIdentity.fromJson(Map<String, dynamic> json) {
if (json['v'] != 2) {
throw const FormatException(
'Unsupported identity format (pre-libsignal era) — new onboarding required',
);
}
return LocalIdentity(
displayName: json['displayName'] as String,
identityPrivateKey: base64Decode(json['identityPrivateKey'] as String),
identityPublicKey: base64Decode(json['identityPublicKey'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
/// Veřejná identita — bezpečné ke sdílení, pro QR kódy a `sepot://` odkazy.
///
/// Vedle krypto identity (identity klíč → fingerprint) nese i ROUTING:
/// `accountId` (náhodný server handle — pojmenovává libsignal session
/// odesílatele, musí sedět se SenderCertificate) a `deliveryAddress`
/// (32B blind token, podle kterého relay směruje obálky). Bez nich nejde
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
class PublicIdentity {
PublicIdentity({
required this.displayName,
required this.identityKey,
required this.createdAt,
this.accountId,
this.deliveryAddress,
}) : fingerprint = SepotFingerprint.ofPublicKey(identityKey);
final String displayName;
/// 33 B, serializovaný veřejný identity klíč.
final Uint8List identityKey;
final DateTime createdAt;
final SepotFingerprint fingerprint;
/// Hex accountId na relayi (null = ještě neregistrováno / neznámo).
final String? accountId;
/// 32 B routing token na relayi (null = neznámo).
final Uint8List? deliveryAddress;
static const _linkPrefix = 'sepot://add/';
/// Kompatibilní alias na fingerprint (dřív UUID).
String get id => fingerprint.display;
/// Šepot ID pro zobrazení.
String get sepotId => fingerprint.short;
PublicIdentity withRouting({
required String accountId,
required Uint8List deliveryAddress,
}) =>
PublicIdentity(
displayName: displayName,
identityKey: identityKey,
createdAt: createdAt,
accountId: accountId,
deliveryAddress: deliveryAddress,
);
/// Export jako `sepot://add/...` odkaz (obsah QR kódu i sdílený link).
String toShareString() {
final payload = base64UrlEncode(
utf8.encode(jsonEncode({
'v': 2,
'n': displayName,
'ik': base64Encode(identityKey),
'ts': createdAt.toIso8601String(),
if (accountId != null) 'aid': accountId,
if (deliveryAddress != null) 'da': base64Encode(deliveryAddress!),
})),
);
return '$_linkPrefix$payload';
}
/// Import z odkazu / QR. Vrací null, pokud vstup není platný.
static PublicIdentity? fromShareString(String s) {
try {
var payload = s.trim();
if (payload.startsWith(_linkPrefix)) {
payload = payload.substring(_linkPrefix.length);
}
final map = jsonDecode(utf8.decode(base64Url.decode(payload)))
as Map<String, dynamic>;
if (map['v'] != 2) return null;
final identityKey = base64Decode(map['ik'] as String);
if (identityKey.length != 33) return null;
final da = map['da'] as String?;
Uint8List? deliveryAddress;
if (da != null) {
deliveryAddress = base64Decode(da);
// deliveryAddress je 32B routing token — vadný odkaz odmítni
// (stejně jako u identityKey), ať se do kontaktů nedostane nesmysl.
if (deliveryAddress.length != 32) return null;
}
return PublicIdentity(
displayName: map['n'] as String,
identityKey: identityKey,
createdAt: DateTime.parse(map['ts'] as String),
accountId: map['aid'] as String?,
deliveryAddress: deliveryAddress,
);
} catch (_) {
return null;
}
}
Map<String, dynamic> toJson() => {
'v': 2,
'displayName': displayName,
'identityKey': base64Encode(identityKey),
'createdAt': createdAt.toIso8601String(),
'accountId': accountId,
'deliveryAddress':
deliveryAddress == null ? null : base64Encode(deliveryAddress!),
};
factory PublicIdentity.fromJson(Map<String, dynamic> json) {
if (json['v'] != 2) {
throw const FormatException('Unsupported contact format');
}
final da = json['deliveryAddress'] as String?;
return PublicIdentity(
displayName: json['displayName'] as String,
identityKey: base64Decode(json['identityKey'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
accountId: json['accountId'] as String?,
deliveryAddress: da == null ? null : base64Decode(da),
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'package:sepot/models/attachment.dart';
import 'package:uuid/uuid.dart';
/// Typy zpráv — odpovídá Rust: MessageType
enum MessageType {
text,
image,
file,
readReceipt,
typing,
keyExchange,
}
/// Stav odeslané (mojí) zprávy — poctivá hranice: read receipts zatím
/// NEjsou (design: default OFF), takže „přečteno" se nezobrazuje.
/// sending — lokálně zařazeno, čeká na potvrzení relaye (⏱)
/// sent — relay přijal, příjemce byl offline → v jeho frontě (✓)
/// delivered — doručeno na zařízení příjemce (✓✓)
/// read — příjemce zprávu přečetl (✓✓ barevně; jen když obě strany
/// mají zapnuté potvrzení přečtení)
/// failed — odeslání selhalo (✕, klepnout pro opakování)
enum MessageStatus { sending, sent, delivered, read, failed }
/// Zpráva v konverzaci
///
/// Odpovídá Rust: PlaintextMessage + StoredMessage
class Message {
final String id;
final String senderId;
final String recipientId;
final MessageType type;
final String? content; // Text zprávy (u příloh volitelný popisek)
final String? fileName; // Pro typ file
final int? fileSize;
final String? replyToId;
final DateTime timestamp;
final bool isRead;
final bool isDelivered;
final MessageStatus status; // stav odeslání (jen u mých zpráv)
final int? disappearAfterSeconds; // Mizící zprávy
final AttachmentMeta? attachment; // image/file — klíč+blob_id+stav
final DateTime? editedAt; // zpráva byla upravena
final bool isDeleted; // smazáno pro všechny (tombstone)
/// Reakce: fingerprint odesílatele reakce → emoji. Prázdné = žádné.
final Map<String, String> reactions;
const Message({
required this.id,
required this.senderId,
required this.recipientId,
required this.type,
this.content,
this.fileName,
this.fileSize,
this.replyToId,
required this.timestamp,
this.isRead = false,
this.isDelivered = false,
this.status = MessageStatus.sent,
this.disappearAfterSeconds,
this.attachment,
this.editedAt,
this.isDeleted = false,
this.reactions = const {},
});
/// Vytvoř textovou zprávu
factory Message.text({
required String senderId,
required String recipientId,
required String content,
String? replyToId,
int? disappearAfterSeconds,
MessageStatus status = MessageStatus.sending,
}) {
return Message(
id: const Uuid().v4(),
senderId: senderId,
recipientId: recipientId,
type: MessageType.text,
content: content,
replyToId: replyToId,
timestamp: DateTime.now(),
status: status,
disappearAfterSeconds: disappearAfterSeconds,
);
}
/// Vytvoř zprávu s přílohou (obrázek/dokument dle meta.isImage)
factory Message.attachment({
required String senderId,
required String recipientId,
required AttachmentMeta attachment,
String? caption,
String? id,
int? disappearAfterSeconds,
MessageStatus status = MessageStatus.sending,
}) {
return Message(
id: id ?? const Uuid().v4(),
senderId: senderId,
recipientId: recipientId,
type: attachment.isImage ? MessageType.image : MessageType.file,
content: caption,
fileName: attachment.name,
fileSize: attachment.size,
timestamp: DateTime.now(),
attachment: attachment,
status: status,
disappearAfterSeconds: disappearAfterSeconds,
);
}
/// Vytvoř read receipt
factory Message.readReceipt({
required String senderId,
required String recipientId,
required String messageId,
}) {
return Message(
id: const Uuid().v4(),
senderId: senderId,
recipientId: recipientId,
type: MessageType.readReceipt,
content: messageId,
timestamp: DateTime.now(),
);
}
/// Kopie s aktualizovanými hodnotami
Message copyWith({
String? content,
bool? isRead,
bool? isDelivered,
MessageStatus? status,
AttachmentMeta? attachment,
DateTime? editedAt,
bool? isDeleted,
Map<String, String>? reactions,
}) {
return Message(
id: id,
senderId: senderId,
recipientId: recipientId,
type: type,
content: content ?? this.content,
fileName: fileName,
fileSize: fileSize,
replyToId: replyToId,
timestamp: timestamp,
isRead: isRead ?? this.isRead,
isDelivered: isDelivered ?? this.isDelivered,
status: status ?? this.status,
disappearAfterSeconds: disappearAfterSeconds,
attachment: attachment ?? this.attachment,
editedAt: editedAt ?? this.editedAt,
isDeleted: isDeleted ?? this.isDeleted,
reactions: reactions ?? this.reactions,
);
}
/// Má zpráva mizet?
bool get isDisappearing => disappearAfterSeconds != null;
/// Je zpráva odeslána mnou?
bool isMine(String myId) => senderId == myId;
Map<String, dynamic> toJson() => {
'id': id,
'senderId': senderId,
'recipientId': recipientId,
'type': type.name,
'content': content,
'fileName': fileName,
'fileSize': fileSize,
'replyToId': replyToId,
'timestamp': timestamp.toIso8601String(),
'isRead': isRead,
'isDelivered': isDelivered,
'status': status.name,
'disappearAfterSeconds': disappearAfterSeconds,
'attachment': attachment?.toJson(),
'editedAt': editedAt?.toIso8601String(),
'isDeleted': isDeleted,
'reactions': reactions,
};
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
id: json['id'] as String,
senderId: json['senderId'] as String,
recipientId: json['recipientId'] as String,
type: MessageType.values.byName(json['type'] as String),
content: json['content'] as String?,
fileName: json['fileName'] as String?,
fileSize: json['fileSize'] as int?,
replyToId: json['replyToId'] as String?,
timestamp: DateTime.parse(json['timestamp'] as String),
isRead: json['isRead'] as bool? ?? false,
isDelivered: json['isDelivered'] as bool? ?? false,
// Legacy záznamy bez 'status' → odvoď z isDelivered
status: MessageStatus.values.asNameMap()[json['status'] as String?] ??
((json['isDelivered'] as bool? ?? false)
? MessageStatus.delivered
: MessageStatus.sent),
disappearAfterSeconds: json['disappearAfterSeconds'] as int?,
attachment: json['attachment'] == null
? null
: AttachmentMeta.fromJson(
json['attachment'] as Map<String, dynamic>),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
isDeleted: json['isDeleted'] as bool? ?? false,
reactions: (json['reactions'] as Map<String, dynamic>?)
?.map((k, v) => MapEntry(k, v as String)) ??
const {},
);
}
}