tane/apps/app_seeds/lib/state/messages_cubit.dart
vjrj a461106dbf feat(chat): disallow links in messages
Local-first chat between people whose web of trust is still forming is a
natural phishing/scam vector, so messages may not contain URLs. A
conservative `containsUrl` rule (explicit scheme, www., or a common-TLD
domain — but not "3.5kg" or "12.30") gates it: the composer warns and
keeps the text for editing, and MessagesCubit.send drops any URL as a
backstop. Incoming text was already non-tappable (plain selectable text).
2026-07-11 07:14:58 +02:00

126 lines
4.7 KiB
Dart

import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../domain/message_rules.dart';
import '../services/message_store.dart';
/// A 1:1 conversation with one peer. Holds the running message list plus send
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
class ChatState extends Equatable {
const ChatState({this.messages = const [], this.sending = false, this.error});
/// Messages in this conversation, oldest first. Incoming ones arrive from the
/// inbox; our own are appended optimistically on send (NIP-17 wraps don't come
/// back to the sender).
final List<PrivateMessage> messages;
final bool sending;
final String? error;
ChatState copyWith({
List<PrivateMessage>? messages,
bool? sending,
String? Function()? error,
}) => ChatState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
error: error != null ? error() : this.error,
);
@override
List<Object?> get props => [messages, sending, error];
}
/// Drives a 1:1 chat over a [MessageTransport]. Filters the shared inbox to
/// [peerPubkey], and tags our own sent messages with [selfPubkey] so the UI can
/// tell the two sides apart. Degrades gracefully when offline (null transport).
class MessagesCubit extends Cubit<ChatState> {
MessagesCubit(
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
MessageStore? store,
Future<void> Function()? onDispose,
}) : _store = store,
_onDispose = onDispose,
super(const ChatState());
final MessageTransport? _transport;
final MessageStore? _store;
final String peerPubkey;
final String selfPubkey;
final Future<void> Function()? _onDispose;
StreamSubscription<PrivateMessage>? _sub;
/// Messages already shown, keyed by sender+timestamp+text. Relays re-deliver
/// stored gift wraps on every (re)subscribe, and the live subscription can
/// hand back the very wrap we just loaded from history — so dedupe on display,
/// not just in the store. (Pre-existing duplicates in old saved history are
/// collapsed here too.)
final _seen = <String>{};
bool get isOnline => _transport != null;
String _key(PrivateMessage m) =>
'${m.fromPubkey}|${m.at.millisecondsSinceEpoch}|${m.text}';
/// Subscribes to incoming messages, then loads any saved history. Subscribing
/// first (before the async history load) avoids dropping an event that arrives
/// during the load; the seen-set keeps either order duplicate-free while
/// preserving arrival order (history first, live appended).
Future<void> start() async {
final transport = _transport;
if (transport != null) {
_sub = transport.inbox().listen((message) async {
if (message.fromPubkey != peerPubkey) return; // another conversation
if (!_seen.add(_key(message))) return; // already shown / re-delivered
await _store?.append(peerPubkey, message);
emit(state.copyWith(messages: [...state.messages, message]));
}, onError: (Object e) => emit(state.copyWith(error: () => '$e')));
}
final history = await _store?.history(peerPubkey);
if (history != null && history.isNotEmpty) {
// Skip any that the live subscription already surfaced during the load,
// and collapse duplicates already sitting in old saved history.
final fresh = history.where((m) => _seen.add(_key(m))).toList();
if (fresh.isNotEmpty) {
emit(state.copyWith(messages: [...fresh, ...state.messages]));
}
}
}
/// Sends [text] to the peer and appends it optimistically.
Future<void> send(String text) async {
final transport = _transport;
final trimmed = text.trim();
// Links aren't allowed in messages (the UI warns; this is the backstop so
// nothing sends a URL programmatically). See message_rules.dart.
if (transport == null || trimmed.isEmpty || containsUrl(trimmed)) return;
emit(state.copyWith(sending: true, error: () => null));
try {
await transport.send(toPubkey: peerPubkey, text: trimmed);
final mine = PrivateMessage(
fromPubkey: selfPubkey,
text: trimmed,
at: DateTime.now(),
);
_seen.add(_key(mine));
await _store?.append(peerPubkey, mine);
emit(state.copyWith(sending: false, messages: [...state.messages, mine]));
} catch (e) {
emit(state.copyWith(sending: false, error: () => '$e'));
}
}
/// Whether [message] was sent by us (for right-aligned bubbles).
bool isMine(PrivateMessage message) => message.fromPubkey == selfPubkey;
@override
Future<void> close() async {
await _sub?.cancel();
await _onDispose?.call();
return super.close();
}
}