Files
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

110 lines
3.4 KiB
Dart

/// Anketa uvnitř zprávy.
///
/// Na drátě jede jako běžná textová zpráva s polem `poll` — starší verze
/// appky pole neznají a ukážou záložní text (otázku a možnosti), nové
/// z něj vykreslí anketu. Hlasy jsou samostatné řídicí zprávy `vote`;
/// klíč hlasu je otisk odesílatele ověřený sealed senderem, nikdy údaj
/// z obsahu.
class Poll {
const Poll({
required this.question,
required this.options,
this.multi = false,
this.votes = const {},
});
static const minOptions = 2;
static const maxOptions = 12;
static const maxQuestionLength = 200;
static const maxOptionLength = 100;
final String question;
final List<String> options;
/// Smí jeden člověk zvolit víc možností.
final bool multi;
/// Otisk hlasujícího → zvolené indexy možností.
final Map<String, List<int>> votes;
/// Kolik lidí zvolilo možnost [index].
int countFor(int index) =>
votes.values.where((choice) => choice.contains(index)).length;
/// Otisky lidí, kteří zvolili možnost [index].
List<String> votersFor(int index) => [
for (final e in votes.entries)
if (e.value.contains(index)) e.key,
];
int get voterCount => votes.length;
/// Nový hlas [voter]. Neplatné indexy se zahodí, duplicity sloučí;
/// u ankety s jednou volbou platí jen první. Prázdná volba = hlas zrušen.
Poll withVote(String voter, List<int> choice) {
final clean = <int>[
for (final i in choice.toSet())
if (i >= 0 && i < options.length) i,
]..sort();
final picked = multi ? clean : clean.take(1).toList();
final next = Map<String, List<int>>.from(votes);
if (picked.isEmpty) {
next.remove(voter);
} else {
next[voter] = picked;
}
return Poll(question: question, options: options, multi: multi, votes: next);
}
/// Co jde na drát (bez hlasů — ty cestují zvlášť).
Map<String, dynamic> toWire() => {
'q': question,
'o': options,
if (multi) 'm': true,
};
/// Anketa od protistrany. Cokoli mimo limity = null (zpráva se pak
/// ukáže jako obyčejný text), aby cizí data nemohla rozbít vykreslení.
static Poll? fromWire(Object? raw) {
if (raw is! Map) return null;
final q = raw['q'];
final o = raw['o'];
if (q is! String || o is! List) return null;
final question = q.trim();
if (question.isEmpty || question.length > maxQuestionLength) return null;
final options = [
for (final item in o)
if (item is String) item.trim(),
];
if (options.length != o.length ||
options.length < minOptions ||
options.length > maxOptions ||
options.any((x) => x.isEmpty || x.length > maxOptionLength)) {
return null;
}
return Poll(question: question, options: options, multi: raw['m'] == true);
}
Map<String, dynamic> toJson() => {
...toWire(),
'votes': votes,
};
static Poll? fromJson(Object? raw) {
final poll = fromWire(raw);
if (poll == null || raw is! Map) return poll;
final stored = raw['votes'];
var result = poll;
if (stored is Map) {
for (final e in stored.entries) {
final choice = e.value;
if (e.key is String && choice is List) {
result = result.withVote(
e.key as String, choice.whereType<int>().toList());
}
}
}
return result;
}
}