feat(block2): messaging UI — private 1:1 chat (NIP-17)
- MessagesCubit: 1:1 conversation over MessageTransport. Filters the shared inbox to the peer; tags our own sends with our pubkey (NIP-17 wraps don't come back to the sender) so the UI can align bubbles; degrades gracefully offline. - ChatScreen (mockup 10): message bubbles (mine right / peer left) + a composer; opens a session lazily, shows 'set up sharing' when there's no relay. - Entry point: a 'Message' button on each offer card (hidden on your own listings) → /chat/:pubkey. - Routing + i18n en/es/pt. Tests: 5 cubit (send/receive/order/filter/offline) + chat offline + market, green.
This commit is contained in:
parent
cf99b7ff87
commit
4326e79419
13 changed files with 627 additions and 9 deletions
133
apps/app_seeds/lib/state/messages_cubit.dart
Normal file
133
apps/app_seeds/lib/state/messages_cubit.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.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,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose,
|
||||
super(const ChatState());
|
||||
|
||||
final MessageTransport? _transport;
|
||||
final String peerPubkey;
|
||||
final String selfPubkey;
|
||||
final Future<void> Function()? _onDispose;
|
||||
StreamSubscription<PrivateMessage>? _sub;
|
||||
|
||||
bool get isOnline => _transport != null;
|
||||
|
||||
/// Subscribes to incoming messages from [peerPubkey].
|
||||
void start() {
|
||||
final transport = _transport;
|
||||
if (transport == null) return;
|
||||
_sub = transport.inbox().listen(
|
||||
(message) {
|
||||
if (message.fromPubkey != peerPubkey) return; // another conversation
|
||||
emit(state.copyWith(messages: [...state.messages, message]));
|
||||
},
|
||||
onError: (Object e) => emit(state.copyWith(error: () => '$e')),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends [text] to the peer and appends it optimistically.
|
||||
Future<void> send(String text) async {
|
||||
final transport = _transport;
|
||||
final trimmed = text.trim();
|
||||
if (transport == null || trimmed.isEmpty) return;
|
||||
emit(state.copyWith(sending: true, error: () => null));
|
||||
try {
|
||||
await transport.send(toPubkey: peerPubkey, text: trimmed);
|
||||
emit(state.copyWith(
|
||||
sending: false,
|
||||
messages: [
|
||||
...state.messages,
|
||||
PrivateMessage(
|
||||
fromPubkey: selfPubkey,
|
||||
text: trimmed,
|
||||
at: DateTime.now(),
|
||||
),
|
||||
],
|
||||
));
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a [MessagesCubit] wired to the social layer for a chat with
|
||||
/// [peerPubkey], or an offline one that degrades gracefully.
|
||||
Future<MessagesCubit> createMessagesCubit(
|
||||
SocialService social,
|
||||
SocialSettings settings, {
|
||||
required String peerPubkey,
|
||||
}) async {
|
||||
final relays = await settings.relayUrls();
|
||||
if (relays.isEmpty) {
|
||||
return MessagesCubit(null,
|
||||
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
|
||||
}
|
||||
try {
|
||||
final session = await social.openSession(relays);
|
||||
return MessagesCubit(
|
||||
session.messages,
|
||||
peerPubkey: peerPubkey,
|
||||
selfPubkey: social.publicKeyHex,
|
||||
onDispose: session.close,
|
||||
);
|
||||
} catch (_) {
|
||||
return MessagesCubit(null,
|
||||
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue