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.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
@@ -20,13 +23,23 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
/// Odešli zprávu — E2E šifrovaně přes relay. `replyToId` = odpověď.
|
||||
/// `contactId` může být i gid skupiny (fanout členům).
|
||||
Future<void> sendMessage(String contactId, String text,
|
||||
{String? replyToId}) async {
|
||||
{String? replyToId, List<String> mentions = const []}) async {
|
||||
if (isNotes(contactId)) {
|
||||
return _addNote((me) => Message.text(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
content: text,
|
||||
replyToId: replyToId,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupText(group, text, replyToId: replyToId);
|
||||
await service.sendGroupText(group, text,
|
||||
replyToId: replyToId, mentions: mentions);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
// sendText zprávu po odeslání sám uloží (s reálným delivery stavem)
|
||||
@@ -37,6 +50,51 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(conversations);
|
||||
}
|
||||
|
||||
/// Pošle anketu. [fallback] je text, který uvidí starší verze appky
|
||||
/// (skládá ho UI, protože zná jazyk uživatele).
|
||||
Future<void> sendPoll(String convId, Poll poll, String fallback) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(convId);
|
||||
if (group != null) {
|
||||
await service.sendGroupText(group, fallback, poll: poll);
|
||||
} else {
|
||||
await service.sendText(await _contact(convId), fallback, poll: poll);
|
||||
}
|
||||
state = AsyncData(await ref.read(storageProvider).loadConversations());
|
||||
}
|
||||
|
||||
/// Hlas v anketě (prázdná volba = zrušit).
|
||||
Future<void> votePoll(String convId, String pollId, List<int> choice) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(convId);
|
||||
await service.votePoll(
|
||||
convId,
|
||||
pollId,
|
||||
choice,
|
||||
group: group,
|
||||
contact: group == null ? await _contact(convId) : null,
|
||||
);
|
||||
}
|
||||
|
||||
static bool isNotes(String id) => id == AppConstants.notesConversationId;
|
||||
|
||||
/// Poznámka pro sebe: jen šifrované úložiště v telefonu, nic do sítě.
|
||||
Future<void> _addNote(Message Function(String myId) build) async {
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.addMessage(
|
||||
AppConstants.notesConversationId, build(identity.id));
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Otevře poznámky; prázdné je založí, aby byly vidět i v seznamu chatů.
|
||||
Future<void> ensureNotes() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.ensureConversation(AppConstants.notesConversationId);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
Future<Contact> _contact(String contactId) async {
|
||||
final contacts = await ref.read(storageProvider).loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
@@ -67,6 +125,17 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
/// Úprava textu odeslané zprávy.
|
||||
Future<void> editMessage(
|
||||
String contactId, String messageId, String newText) async {
|
||||
if (isNotes(contactId)) {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
final old = conv?.messages.where((m) => m.id == messageId).firstOrNull;
|
||||
if (conv == null || old == null) return;
|
||||
conv.updateMessage(
|
||||
old.copyWith(content: newText, editedAt: DateTime.now()));
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
return;
|
||||
}
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
@@ -112,6 +181,18 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Připnout / odepnout konverzaci (lokální). Archivovaná se připnutím
|
||||
/// vrací mezi aktivní — připnutý chat v archivu by nikdo neviděl.
|
||||
Future<void> setPinned(String contactId, bool pinned) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.pinned = pinned;
|
||||
if (pinned) conv.archived = false;
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Smazání zprávy jen u mě (lokálně) — protistraně se nic neposílá.
|
||||
Future<void> deleteLocalMessage(String contactId, String messageId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
@@ -134,6 +215,18 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
if (isNotes(contactId)) {
|
||||
final meta = await service.prepareNoteAttachment(
|
||||
bytes: bytes, fileName: fileName, mime: mime);
|
||||
return _addNote((me) => Message.attachment(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
attachment: meta,
|
||||
caption: caption,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
@@ -167,6 +260,19 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (isNotes(contactId)) {
|
||||
final meta = await service.prepareNoteAttachment(
|
||||
bytes: bytes,
|
||||
fileName: 'voice-message.m4a',
|
||||
mime: mime,
|
||||
durationMs: durationMs);
|
||||
return _addNote((me) => Message.attachment(
|
||||
senderId: me,
|
||||
recipientId: me,
|
||||
attachment: meta,
|
||||
status: MessageStatus.sent,
|
||||
));
|
||||
}
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
@@ -188,6 +294,33 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Přepošle zprávu do jiné konverzace (kontakt i skupina). Příloha se
|
||||
/// dešifruje a odešle znovu s novým klíčem: odkaz na původní blob by
|
||||
/// vypršel s ním (relay drží bloby 30 dní) a cíl by ho neměl čím otevřít.
|
||||
Future<void> forwardMessage(Message message, String targetId) async {
|
||||
final meta = message.attachment;
|
||||
if (meta == null) {
|
||||
await sendMessage(targetId, message.content ?? '');
|
||||
return;
|
||||
}
|
||||
final bytes = await ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(message);
|
||||
if (bytes == null) throw const AttachmentUnavailableError();
|
||||
if (meta.mime.startsWith('audio/') && meta.durationMs != null) {
|
||||
await sendVoiceMessage(targetId,
|
||||
bytes: bytes, durationMs: meta.durationMs!, mime: meta.mime);
|
||||
return;
|
||||
}
|
||||
await sendAttachment(
|
||||
targetId,
|
||||
bytes: bytes,
|
||||
fileName: meta.name ?? 'attachment',
|
||||
mime: meta.mime,
|
||||
caption: message.content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ruční opakování downloadu blobu (stav failed).
|
||||
Future<void> retryAttachmentDownload(
|
||||
String contactId, Message message) async {
|
||||
@@ -210,23 +343,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
||||
final justRead = conv.messages
|
||||
.where((m) => !m.isMine(identity.id) && !m.isRead)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
|
||||
conv.markAllRead(identity.id);
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
|
||||
// Potvrzení přečtení ve skupině v1 neposíláme (fanout N receipts
|
||||
// za každé otevření chatu; per-member matice až v2).
|
||||
if (justRead.isNotEmpty && await _groupOrNull(contactId) == null) {
|
||||
final contacts = await storage.loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
if (contact != null) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendReadReceipts(contact, justRead);
|
||||
}
|
||||
if (justRead.isEmpty) return;
|
||||
// Ve skupině dostane potvrzení jen autor dané zprávy — ne celý roster.
|
||||
// Otevření chatu tak stojí tolik obálek, kolik lidí mezitím psalo.
|
||||
final group = await _groupOrNull(contactId);
|
||||
final byAuthor = <String, List<String>>{};
|
||||
for (final m in justRead) {
|
||||
byAuthor.putIfAbsent(m.senderId, () => []).add(m.id);
|
||||
}
|
||||
final contacts = await storage.loadContacts();
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
for (final MapEntry(key: author, value: ids) in byAuthor.entries) {
|
||||
final contact = contacts.where((c) => c.id == author).firstOrNull;
|
||||
if (contact == null) continue;
|
||||
await service.sendReadReceipts(contact, ids, gid: group?.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Rozepsané zprávy (contactId → text). Jeden záznam v šifrovaném nastavení,
|
||||
/// ne pole konverzace: konverzace se ukládá celá jako load → změna → save
|
||||
/// a zápis konceptu při psaní by mohl přepsat zprávu, která mezitím dorazila.
|
||||
class DraftsNotifier extends AsyncNotifier<Map<String, String>> {
|
||||
static const key = 'drafts';
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> build() async {
|
||||
final raw =
|
||||
await ref.read(storageProvider).getSetting<Map<String, dynamic>>(key);
|
||||
return {
|
||||
for (final e in (raw ?? const {}).entries)
|
||||
if (e.value is String) e.key: e.value as String,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> set(String contactId, String text) async {
|
||||
// Počkat na načtení — jinak by zápis před dokončením build() smazal
|
||||
// koncepty ostatních chatů.
|
||||
final current = {...await future};
|
||||
final trimmed = text.trim();
|
||||
if ((current[contactId] ?? '') == trimmed) return;
|
||||
if (trimmed.isEmpty) {
|
||||
current.remove(contactId);
|
||||
} else {
|
||||
current[contactId] = trimmed;
|
||||
}
|
||||
state = AsyncData(current);
|
||||
await ref.read(storageProvider).setSetting(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
final draftsProvider =
|
||||
AsyncNotifierProvider<DraftsNotifier, Map<String, String>>(
|
||||
DraftsNotifier.new);
|
||||
@@ -30,6 +30,20 @@ class GroupsNotifier extends AsyncNotifier<List<Group>> {
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> removeMember(Group group, String fingerprint) async {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.removeGroupMember(group, fingerprint);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> setAdmin(Group group, String fingerprint, bool admin) async {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.setGroupAdmin(group, fingerprint, admin);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> leave(Group group) async {
|
||||
await ref.read(messagingServiceProvider).leaveGroup(group);
|
||||
await _refresh();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/calls/call_controller.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
@@ -16,6 +19,28 @@ final messagingServiceProvider = Provider<MessagingService>((ref) {
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Kdo právě píše: konverzace → otisky pisatelů. Prázdné, když je
|
||||
/// „píše…“ vypnuté (služba pak nic nepřijímá).
|
||||
final typingProvider = StreamProvider<Map<String, Set<String>>>((ref) async* {
|
||||
final tracker = ref.watch(messagingServiceProvider).typing;
|
||||
yield tracker.current;
|
||||
yield* tracker.changes;
|
||||
});
|
||||
|
||||
/// Profilová fotka člověka podle otisku (null = iniciály). Přepočítá se,
|
||||
/// když se změní kontakty — tam dorazí nový odkaz i stažení fotky.
|
||||
final avatarProvider =
|
||||
FutureProvider.family<Uint8List?, String>((ref, fingerprint) async {
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final me = ref.watch(identityProvider).valueOrNull;
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
if (!service.isStarted) return null;
|
||||
final avatar = fingerprint == me?.id
|
||||
? service.myAvatar
|
||||
: contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar;
|
||||
return avatar == null ? null : service.loadAvatar(avatar);
|
||||
});
|
||||
|
||||
/// Nastartuje službu, jakmile existuje identita. Watchovat z první
|
||||
/// obrazovky po odemčení (chat list) — do té doby se nic nespojuje.
|
||||
/// Předpokládá odemčený storage — na /chats se jde vždy až po unlocku.
|
||||
@@ -46,16 +71,21 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
// Appka běží, ale není v popředí (jiná appka / zhasnutý displej se
|
||||
// živým procesem) → systémová notifikace. Jen jméno, žádný obsah.
|
||||
// Ztlumená konverzace notifikaci potlačí (unread badge běží dál).
|
||||
service.onMessageReceived = (contact, message) async {
|
||||
// Ztlumená konverzace notifikaci potlačí (unread badge běží dál),
|
||||
// zmínka mě ve skupině projde i tak.
|
||||
service.onMessageReceived = (contact, message, convId) async {
|
||||
final visible = WidgetsBinding.instance.lifecycleState ==
|
||||
AppLifecycleState.resumed;
|
||||
if (visible) return;
|
||||
final conv = await ref.read(storageProvider).loadConversation(contact.id);
|
||||
if (conv?.isMuted == true) return;
|
||||
final conv = await ref.read(storageProvider).loadConversation(convId);
|
||||
final mentionedMe = message.mentions.contains(identity.id);
|
||||
if (conv?.isMuted == true && !mentionedMe) return;
|
||||
PushNotifier.showMessageNotification(contact.displayName);
|
||||
};
|
||||
|
||||
service.onCallSignal = (contact, signal) =>
|
||||
ref.read(callProvider.notifier).onSignal(contact, signal);
|
||||
|
||||
await service.start(identity);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_protector/screen_protector.dart';
|
||||
import 'package:sepot/core/backup/backup_service.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
@@ -32,6 +33,29 @@ class ReadReceiptsNotifier extends AsyncNotifier<bool> {
|
||||
final readReceiptsProvider =
|
||||
AsyncNotifierProvider<ReadReceiptsNotifier, bool>(ReadReceiptsNotifier.new);
|
||||
|
||||
/// „píše…“ — stejná pravidla jako potvrzení přečtení: výchozí vypnuto,
|
||||
/// reciproční (kdo nesdílí, nevidí).
|
||||
class TypingIndicatorsNotifier extends AsyncNotifier<bool> {
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return (await storage.getSetting<bool>(
|
||||
MessagingService.typingSettingKey)) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting(MessagingService.typingSettingKey, value);
|
||||
state = AsyncData(value);
|
||||
}
|
||||
}
|
||||
|
||||
final typingIndicatorsProvider =
|
||||
AsyncNotifierProvider<TypingIndicatorsNotifier, bool>(
|
||||
TypingIndicatorsNotifier.new);
|
||||
|
||||
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
||||
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
|
||||
/// screenshoty, tak ať se uživatel rozhodne sám.
|
||||
|
||||
Reference in New Issue
Block a user