Files
sepot/app/lib/models/avatar_ref.dart
T
Julius Joska 5246a97db2 feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
2026-10-03 22:44:53 +02:00

80 lines
2.3 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:sepot/core/crypto/attachment_cipher.dart';
import 'package:sepot/core/storage/attachment_store.dart';
/// Odkaz na profilovou fotku: šifrovaný blob na relayi + klíč k němu.
///
/// Fotka sama nejde v každé zprávě — v profilu `p` cestuje jen tenhle
/// odkaz (pár set bajtů). Příjemce blob stáhne jednou, ověří digest
/// a drží si ho šifrovaný u sebe.
class AvatarRef {
const AvatarRef({
required this.blobId,
required this.key,
required this.nonce,
required this.digest,
this.uploadedAt,
});
/// Profilová fotka je malá (256 px JPEG) — cokoli většího je podvrh.
static const maxBlobBytes = 256 * 1024;
final String blobId;
final Uint8List key;
final Uint8List nonce;
final Uint8List digest;
/// Kdy jsem blob nahrál (jen u vlastní fotky — kvůli obnově před
/// vypršením na relayi).
final DateTime? uploadedAt;
Map<String, dynamic> toWire() => {
'b': blobId,
'k': base64Encode(key),
'n': base64Encode(nonce),
'd': base64Encode(digest),
};
/// Odkaz od protistrany; cokoli podezřelého = null (fotka se neukáže).
static AvatarRef? fromWire(Object? raw) {
if (raw is! Map) return null;
final b = raw['b'];
if (b is! String || !AttachmentStore.isValidBlobId(b)) return null;
try {
final key = base64Decode(raw['k'] as String);
final nonce = base64Decode(raw['n'] as String);
final digest = base64Decode(raw['d'] as String);
if (key.length != AttachmentCipher.keyLength ||
nonce.length != AttachmentCipher.nonceLength ||
digest.length != 32) {
return null;
}
return AvatarRef(blobId: b, key: key, nonce: nonce, digest: digest);
} on Object {
return null;
}
}
Map<String, dynamic> toJson() => {
...toWire(),
if (uploadedAt != null) 'u': uploadedAt!.millisecondsSinceEpoch,
};
static AvatarRef? fromJson(Object? raw) {
final ref = fromWire(raw);
if (ref == null || raw is! Map) return ref;
final u = raw['u'];
return u is int
? AvatarRef(
blobId: ref.blobId,
key: ref.key,
nonce: ref.nonce,
digest: ref.digest,
uploadedAt: DateTime.fromMillisecondsSinceEpoch(u),
)
: ref;
}
}