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).
This commit is contained in:
vjrj 2026-07-11 07:14:58 +02:00
parent 6a6a81e8a2
commit 1ab243f29e
14 changed files with 107 additions and 34 deletions

View file

@ -4,16 +4,13 @@ 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,
});
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
@ -26,12 +23,11 @@ class ChatState extends Equatable {
List<PrivateMessage>? messages,
bool? sending,
String? Function()? error,
}) =>
ChatState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
error: error != null ? error() : this.error,
);
}) => ChatState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
error: error != null ? error() : this.error,
);
@override
List<Object?> get props => [messages, sending, error];
@ -47,9 +43,9 @@ class MessagesCubit extends Cubit<ChatState> {
required this.selfPubkey,
MessageStore? store,
Future<void> Function()? onDispose,
}) : _store = store,
_onDispose = onDispose,
super(const ChatState());
}) : _store = store,
_onDispose = onDispose,
super(const ChatState());
final MessageTransport? _transport;
final MessageStore? _store;
@ -77,15 +73,12 @@ class MessagesCubit extends Cubit<ChatState> {
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')),
);
_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) {
@ -102,7 +95,9 @@ class MessagesCubit extends Cubit<ChatState> {
Future<void> send(String text) async {
final transport = _transport;
final trimmed = text.trim();
if (transport == null || trimmed.isEmpty) return;
// 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);
@ -113,10 +108,7 @@ class MessagesCubit extends Cubit<ChatState> {
);
_seen.add(_key(mine));
await _store?.append(peerPubkey, mine);
emit(state.copyWith(
sending: false,
messages: [...state.messages, mine],
));
emit(state.copyWith(sending: false, messages: [...state.messages, mine]));
} catch (e) {
emit(state.copyWith(sending: false, error: () => '$e'));
}