Files
sepot/app/lib/providers/messaging_provider.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

98 lines
4.3 KiB
Dart

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';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Jediná instance messaging služby (krypto + relay + storage).
final messagingServiceProvider = Provider<MessagingService>((ref) {
final service = MessagingService(storage: ref.read(storageProvider));
ref.onDispose(service.stop);
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.
final messagingStartProvider = FutureProvider<void>((ref) async {
final identity = await ref.watch(identityProvider.future);
if (identity == null) return;
// Po auto-locku se tenhle provider eager-rebuildne (drží ho pushInitProvider)
// ještě NAD zamčeným storage → service.start() by spadl na „Storage is
// locked" a provider by uvázl v AsyncError (žádný příjem/odeslání až do
// killu procesu). Nad zamčeným storage tiše nic nestartuj; po odemčení
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
final storage = ref.read(storageProvider);
if (!storage.isUnlocked) return;
final service = ref.read(messagingServiceProvider);
if (service.isStarted) return;
service.onUpdate = () {
ref.invalidate(conversationsProvider);
// celá family — jinak se otevřená konverzace nedozví o nové zprávě
// ani o změně stavu přílohy
ref.invalidate(conversationProvider);
ref.invalidate(contactsProvider);
// příchozí ginv/gleave/gmeta mění skupiny
ref.invalidate(groupsProvider);
};
// 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),
// 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(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);
});
/// Stav připojení k relayi pro UI indikátor.
final relayConnectionProvider = StreamProvider<RelayConnectionState>((ref) {
ref.watch(messagingStartProvider);
final service = ref.read(messagingServiceProvider);
return service.connectionStream ?? const Stream.empty();
});