Files
buzz/mobile/lib/shared/relay/signed_event_relay.dart
3742076434 fix(mobile): mitigate message-post delay with optimistic rendering (#3037)
### What changed?

Adds optimistic rendering for newly locally posted messages in channels
and threads.
- Messages appear immediately
- Relay echoes and history are deduplicated by event ID
- Rejected or timed-out publishes erase the optimistic rendering.

The implementation covers reconnect and hydration races, channel-window
and legacy WebSocket paths, thread-local overlays, and rapid concurrent
sends.

### Why?

This is a valuable partial mitigation for
[BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the):
senders no longer depend on receiving a relay echo before seeing their
own post.

It does not address the likely primary cause of stale channels. Mobile
currently does not recover live subscriptions after a rate-limited relay
`CLOSED`; that recovery is being handled separately.

### How is it tested?

Full mobile suite: 676 passed, 1 skipped.

Added regression coverage for optimistic insertion, authoritative
deduplication, rollback, reconnect and hydration, thread replies, rapid
and equal-time sends, never-echoed successful sends, and legacy
WebSocket retirement.

---------

Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
2026-07-27 07:16:33 -04:00

60 lines
1.7 KiB
Dart

import 'package:nostr/nostr.dart' as nostr;
import 'nostr_models.dart';
import 'relay_session.dart';
/// Signs and submits Nostr events through the relay WebSocket connection.
class SignedEventRelay {
final RelaySessionNotifier _session;
final String? _nsec;
SignedEventRelay({
required RelaySessionNotifier session,
required String? nsec,
}) : _session = session,
_nsec = nsec;
/// The hex pubkey derived from the signing key, or null if no key.
String? get pubkey {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return null;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
}
/// Sign and submit an event. Returns the relay's OK response as a [NostrEvent]
/// whose `content` field contains the OK message (e.g. `"response:{...}"`
/// for command kinds).
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot submit event: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final event = nostr.Event.from(
kind: kind,
content: content,
tags: tags,
secretKey: privkeyHex,
createdAt: createdAt,
verify: false,
);
final nostrEvent = NostrEvent.fromJson(event.toMap());
onSigned?.call(nostrEvent);
return _session.publish(nostrEvent);
}
}