Fix mobile unread badges and read state sync (#422)

This commit is contained in:
Wes
2026-04-29 08:44:44 -06:00
committed by GitHub
parent 9ab4799766
commit ddb242749f
28 changed files with 2399 additions and 72 deletions

View File

@@ -26,6 +26,9 @@ import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';
import 'reaction_row.dart';
import 'send_message_provider.dart';
import 'small_avatar.dart';
@@ -53,6 +56,30 @@ Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
}
}
int? _channelReadTimestamp({
required Channel channel,
required AsyncValue<List<NostrEvent>> messagesState,
}) {
if (channel.isForum) {
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
final events = messagesState.value;
if (events != null && events.isNotEmpty) {
var latest = 0;
for (final event in events) {
if (event.createdAt > latest) {
latest = event.createdAt;
}
}
if (latest > 0) {
return latest;
}
}
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
@@ -63,6 +90,7 @@ class ChannelDetailPage extends HookConsumerWidget {
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
final messagesState = ref.watch(channelMessagesProvider(channel.id));
final readState = ref.watch(readStateProvider);
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
@@ -89,6 +117,10 @@ class ChannelDetailPage extends HookConsumerWidget {
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final readTimestamp = _channelReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
);
// Preload channel member profiles so @mentions resolve correctly.
useEffect(() {
@@ -96,6 +128,17 @@ class ChannelDetailPage extends HookConsumerWidget {
return null;
}, [channel.id]);
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
return null;
}
return deferReadStateUpdate(context, () {
ref
.read(readStateProvider.notifier)
.markContextRead(channel.id, readTimestamp);
});
}, [channel.id, readState.isReady, readTimestamp]);
return FrostedScaffold(
appBar: FrostedAppBar(
title: resolvedChannel.isDm

View File

@@ -1,15 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'channel_management_provider.dart';
/// Provides the message list for a specific channel. Fetches history on init,
/// then subscribes to live events via the websocket session.
/// Provides the message list for a specific channel. Registers a live
/// subscription first, then syncs history via the websocket session.
class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final String channelId;
void Function()? _unsubscribe;
bool _reachedOldest = false;
bool _initInFlight = false;
int _initVersion = 0;
ChannelMessagesNotifier(this.channelId);
@@ -21,11 +23,13 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider);
ref.onDispose(() {
_unsubscribe?.call();
_unsubscribe = null;
_initVersion++;
_clearSubscription();
});
if (sessionState.status != SessionStatus.connected) {
_initVersion++;
_initInFlight = false;
// Return cached messages if available so the UI remains usable while
// disconnected/reconnecting, instead of showing an empty screen.
return AsyncData(_lastKnownMessages ?? const []);
@@ -42,11 +46,41 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
}
Future<void> _init() async {
final initVersion = ++_initVersion;
_initInFlight = true;
_clearSubscription();
try {
final session = ref.read(relaySessionProvider.notifier);
// 1. Fetch recent history via REQ/EOSE.
// Register live first, then sync history. This matches desktop and closes
// the race where an event can arrive after history EOSE but before live
// subscription registration.
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 50,
),
_handleLiveEvent,
);
if (!_isCurrentInit(initVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
} catch (error) {
if (!_isCurrentInit(initVersion)) {
return;
}
debugPrint(
'[ChannelMessagesNotifier] live subscription failed for $channelId: $error',
);
}
// Fetch recent history via REQ/EOSE after the subscription is active.
final history = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelEventKinds,
@@ -56,18 +90,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
limit: 50,
),
);
// 2. Subscribe to live events for this channel.
_unsubscribe = await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 0,
),
_handleLiveEvent,
);
if (!_isCurrentInit(initVersion)) {
return;
}
// Merge fresh history with any events already in state (e.g. from
// fetchOlder() or live events that arrived while _init was in flight)
@@ -82,18 +107,30 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_lastKnownMessages = merged;
state = AsyncData(merged);
} catch (e, st) {
if (!_isCurrentInit(initVersion)) {
return;
}
final fallbackMessages = state.value ?? _lastKnownMessages;
if (fallbackMessages != null) {
debugPrint(
'[ChannelMessagesNotifier] history sync failed for $channelId: $e',
);
state = AsyncData(fallbackMessages);
return;
}
state = AsyncError(e, st);
} finally {
_initInFlight = false;
if (_isCurrentInit(initVersion)) {
_initInFlight = false;
}
}
}
void _handleLiveEvent(NostrEvent event) {
state = state.whenData((events) {
final merged = _mergeEvent(events, event);
_lastKnownMessages = merged;
return merged;
});
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final merged = _mergeEvent(current, event);
_lastKnownMessages = merged;
state = AsyncData(merged);
// When a membership system event arrives, refresh the channel member list
// so the @mention autocomplete picks up new members without a restart.
@@ -120,6 +157,13 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
return updated;
}
bool _isCurrentInit(int initVersion) => initVersion == _initVersion;
void _clearSubscription() {
_unsubscribe?.call();
_unsubscribe = null;
}
/// Whether all history has been loaded (no more older messages).
bool get reachedOldest => _reachedOldest;

View File

@@ -22,6 +22,9 @@ import 'channel.dart';
import 'channel_detail_page.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';
enum _QuickAction { createChannel, createForum, newDm }
@@ -29,6 +32,16 @@ enum _QuickAction { createChannel, createForum, newDm }
/// plus the ~16px row content (12px spinner / labelSmall text).
const double _kBannerHeight = 24.0;
bool _isUnread(Channel channel, ReadStateState readState) {
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null) {
return false;
}
final readAt = readState.effectiveTimestamp(channel.id);
return readAt == null || lastMessageAt > readAt;
}
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({super.key});
@@ -227,6 +240,7 @@ class _SliverChannelsList extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final readState = ref.watch(readStateProvider);
final visibleChannels = channels
.where((channel) => channel.isMember && !channel.isArchived)
.toList();
@@ -243,6 +257,50 @@ class _SliverChannelsList extends HookConsumerWidget {
final channelsExpanded = useState(true);
final forumsExpanded = useState(true);
final dmsExpanded = useState(true);
final initialSeedComplete = useState(false);
final seededPubkey = useRef<String?>(null);
final seedCompleteForPubkey =
seededPubkey.value == readState.pubkey && initialSeedComplete.value;
useEffect(() {
if (!readState.isReady) {
return null;
}
return deferReadStateUpdate(context, () {
if (seededPubkey.value != readState.pubkey) {
seededPubkey.value = readState.pubkey;
initialSeedComplete.value = false;
}
if (initialSeedComplete.value) {
return;
}
final notifier = ref.read(readStateProvider.notifier);
for (final channel in visibleChannels) {
if (readState.effectiveTimestamp(channel.id) != null) {
continue;
}
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt != null) {
notifier.seedContextRead(channel.id, lastMessageAt);
}
}
initialSeedComplete.value = true;
});
}, [readState.isReady, readState.pubkey, visibleChannels]);
final unreadChannelIds = readState.isReady
? {
for (final channel in visibleChannels)
if ((seedCompleteForPubkey ||
readState.effectiveTimestamp(channel.id) != null) &&
_isUnread(channel, readState))
channel.id,
}
: const <String>{};
return SliverPadding(
padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80),
@@ -257,6 +315,7 @@ class _SliverChannelsList extends HookConsumerWidget {
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: streamChannels,
unreadChannelIds: unreadChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No stream channels yet',
onSelectChannel: onSelectChannel,
@@ -267,6 +326,7 @@ class _SliverChannelsList extends HookConsumerWidget {
expanded: forumsExpanded.value,
onToggle: () => forumsExpanded.value = !forumsExpanded.value,
channels: forumChannels,
unreadChannelIds: unreadChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No forums yet',
onSelectChannel: onSelectChannel,
@@ -277,6 +337,7 @@ class _SliverChannelsList extends HookConsumerWidget {
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: dmChannels,
unreadChannelIds: unreadChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No direct messages yet',
onSelectChannel: onSelectChannel,
@@ -294,6 +355,7 @@ class _ChannelSection extends StatelessWidget {
final bool expanded;
final VoidCallback onToggle;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final String? currentPubkey;
final String emptyLabel;
final Future<void> Function(Channel channel) onSelectChannel;
@@ -304,6 +366,7 @@ class _ChannelSection extends StatelessWidget {
required this.expanded,
required this.onToggle,
required this.channels,
required this.unreadChannelIds,
required this.currentPubkey,
required this.emptyLabel,
required this.onSelectChannel,
@@ -340,6 +403,7 @@ class _ChannelSection extends StatelessWidget {
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
),
@@ -431,11 +495,13 @@ class _SectionHeader extends StatelessWidget {
class _ChannelTile extends ConsumerWidget {
final Channel channel;
final bool isUnread;
final String? currentPubkey;
final VoidCallback onTap;
const _ChannelTile({
required this.channel,
required this.isUnread,
required this.currentPubkey,
required this.onTap,
});
@@ -476,14 +542,29 @@ class _ChannelTile extends ConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: hasActivity
color: isUnread
? context.colors.onSurface
: hasActivity
? context.colors.onSurface
: context.colors.onSurfaceVariant,
fontWeight: isUnread ? FontWeight.w700 : null,
),
),
],
),
),
if (isUnread) ...[
const SizedBox(width: Grid.xxs),
Container(
key: Key('channel-unread-${channel.id}'),
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
),
),
],
if (!channel.isMember && !channel.isDm)
Padding(
padding: const EdgeInsets.only(right: Grid.xxs),

View File

@@ -7,7 +7,8 @@ import 'channel.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
void Function()? _unsubscribe;
final List<void Function()> _unsubscribers = [];
int _subscriptionVersion = 0;
@override
Future<List<Channel>> build() {
@@ -23,23 +24,20 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});
ref.onDispose(() {
_unsubscribe?.call();
_unsubscribe = null;
_clearLiveSubscriptions();
});
// Initial fetch via HTTP (reliable, paginated).
final fetchFuture = _fetch();
// If websocket is connected, subscribe to live channel events to keep
// the list up to date without polling.
if (sessionState.status == SessionStatus.connected) {
_subscribeLive();
if (sessionState.status != SessionStatus.connected) {
_clearLiveSubscriptions();
}
return fetchFuture;
// Initial fetch via HTTP (reliable, paginated).
return _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
);
}
Future<List<Channel>> _fetch() async {
Future<List<Channel>> _fetch({bool subscribeLive = false}) async {
final client = ref.read(relayClientProvider);
final json = await client.get('/api/channels') as List<dynamic>;
final channels = json
@@ -55,15 +53,56 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
return left.name.compareTo(right.name);
});
if (subscribeLive) {
await _subscribeLive(channels);
}
return channels;
}
void _subscribeLive() async {
Future<void> _subscribeLive(List<Channel> channels) async {
_clearLiveSubscriptions();
final subscriptionVersion = _subscriptionVersion;
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
return;
}
final session = ref.read(relaySessionProvider.notifier);
_unsubscribe = await session.subscribe(
NostrFilter(kinds: EventKind.channelEventKinds, limit: 0),
_handleLiveEvent,
final channelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final subscriptions = await Future.wait(
channelIds.map((channelId) async {
try {
return await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 0,
),
_handleLiveEvent,
);
} catch (error) {
debugPrint(
'[ChannelsNotifier] live subscription failed for $channelId: $error',
);
return null;
}
}),
);
if (subscriptionVersion != _subscriptionVersion ||
ref.read(relaySessionProvider).status != SessionStatus.connected) {
for (final unsubscribe in subscriptions.whereType<void Function()>()) {
unsubscribe();
}
return;
}
_unsubscribers.addAll(subscriptions.whereType<void Function()>());
}
void _handleLiveEvent(NostrEvent event) {
@@ -93,7 +132,19 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}
Future<void> refresh() async {
state = await AsyncValue.guard(_fetch);
final sessionState = ref.read(relaySessionProvider);
state = await AsyncValue.guard(
() =>
_fetch(subscribeLive: sessionState.status == SessionStatus.connected),
);
}
void _clearLiveSubscriptions() {
_subscriptionVersion++;
for (final unsubscribe in _unsubscribers) {
unsubscribe();
}
_unsubscribers.clear();
}
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
VoidCallback deferReadStateUpdate(BuildContext context, VoidCallback update) {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!cancelled && context.mounted) {
update();
}
});
return () {
cancelled = true;
};
}

View File

@@ -0,0 +1,169 @@
import 'dart:convert';
import '../../../shared/relay/nostr_models.dart';
const readStateDTagPrefix = 'read-state:';
const readStateFetchLimit = 500;
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
const _maxContexts = 10000;
typedef ReadStateDecrypt = String Function(String ciphertext);
class ReadStateBlob {
final String clientId;
final Map<String, int> contexts;
ReadStateBlob({required this.clientId, required Map<String, int> contexts})
: contexts = Map.unmodifiable(contexts);
Map<String, dynamic> toJson() => {
'v': 1,
'client_id': clientId,
'contexts': contexts,
};
}
class DecodedReadStateEvent {
final NostrEvent event;
final String dTag;
final ReadStateBlob blob;
const DecodedReadStateEvent({
required this.event,
required this.dTag,
required this.blob,
});
}
bool isPlainJsonObject(Object? value) {
if (value is! Map) return false;
return value.keys.every((key) => key is String);
}
Map<String, Object?>? asStringObjectMap(Object? value) {
if (!isPlainJsonObject(value)) return null;
return (value as Map).cast<String, Object?>();
}
bool isValidReadStateDTag(String? value) {
if (value == null || !value.startsWith(readStateDTagPrefix)) {
return false;
}
final slotId = value.substring(readStateDTagPrefix.length);
if (slotId.isEmpty || slotId.length > 64) {
return false;
}
for (var index = 0; index < slotId.length; index++) {
if (slotId.codeUnitAt(index) > 0x7f) {
return false;
}
}
return true;
}
bool hasValidReadStateTags(NostrEvent event) {
final dTags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd');
if (dTags.length != 1) {
return false;
}
final dTag = dTags.single;
if (dTag.length < 2 || !isValidReadStateDTag(dTag[1])) {
return false;
}
final tTags = event.tags.where(
(tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == 'read-state',
);
return tTags.length == 1;
}
ReadStateBlob? decodeReadStateBlob(String plaintext) {
final Object? parsed;
try {
parsed = jsonDecode(plaintext);
} catch (_) {
return null;
}
final record = asStringObjectMap(parsed);
if (record == null) return null;
if (record['v'] != 1) return null;
final clientId = record['client_id'];
if (clientId is! String || clientId.isEmpty || clientId.runes.length > 64) {
return null;
}
final contexts = asStringObjectMap(record['contexts']);
if (contexts == null || contexts.length > _maxContexts) {
return null;
}
return ReadStateBlob(
clientId: clientId,
contexts: sanitizeReadStateContexts(contexts),
);
}
Map<String, int> sanitizeReadStateContexts(Map<String, Object?> contexts) {
final sanitized = <String, int>{};
for (final entry in contexts.entries) {
if (utf8.encode(entry.key).length > 256) continue;
final value = entry.value;
if (value is! int) continue;
if (value < 0 || value > 4294967295) continue;
sanitized[entry.key] = value;
}
return sanitized;
}
DecodedReadStateEvent? decodeReadStateEvent(
NostrEvent event, {
required String pubkey,
required ReadStateDecrypt decrypt,
}) {
if (event.pubkey.toLowerCase() != pubkey.toLowerCase()) {
return null;
}
if (!hasValidReadStateTags(event)) {
return null;
}
final dTag = event.tags.firstWhere(
(tag) => tag.isNotEmpty && tag[0] == 'd',
)[1];
final String plaintext;
try {
plaintext = decrypt(event.content);
} catch (_) {
return null;
}
final blob = decodeReadStateBlob(plaintext);
if (blob == null) {
return null;
}
return DecodedReadStateEvent(event: event, dTag: dTag, blob: blob);
}
Map<String, int> mergeReadStateContexts(
Iterable<Map<String, int>> contextSets,
) {
final merged = <String, int>{};
for (final contexts in contextSets) {
for (final entry in contexts.entries) {
final current = merged[entry.key] ?? 0;
if (entry.value > current) {
merged[entry.key] = entry.value;
}
}
}
return merged;
}

View File

@@ -0,0 +1,446 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import 'read_state_format.dart';
import 'read_state_storage.dart';
import 'read_state_time.dart';
class ReadStateCrypto {
final Uint8List conversationKey;
const ReadStateCrypto._(this.conversationKey);
static ReadStateCrypto? tryCreate({
required String nsec,
required String pubkey,
}) {
try {
final privkeyHex = nostr.Nip19.decodePrivkey(nsec);
if (privkeyHex.isEmpty || pubkey.isEmpty) {
return null;
}
return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey));
} catch (_) {
return null;
}
}
String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(conversationKey, ciphertext);
}
class ReadStateManager {
final String pubkey;
final ReadStateCrypto _crypto;
final ReadStateStorage _storage;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
late final String _clientId;
late String _slotId;
final Map<String, int> _effectiveState = {};
final Set<String> _publishableContextIds = {};
Map<String, int> _lastPublishedContexts = {};
Timer? _debounceTimer;
void Function()? _unsubscribeLive;
bool _initialized = false;
bool _disposed = false;
bool _isPublishing = false;
bool _remoteUnsupported = false;
int _maxFetchedCreatedAt = 0;
ReadStateManager({
required this.pubkey,
required SharedPreferences prefs,
required ReadStateCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
}) : _crypto = crypto,
_storage = ReadStateStorage(prefs),
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged {
_clientId = _storage.getOrCreateClientId(pubkey);
_slotId = _storage.getOrCreateSlotId(pubkey);
_hydrateFromLocalStorage();
}
Map<String, int> get effectiveContexts => Map.unmodifiable(_effectiveState);
int? getEffectiveTimestamp(String contextId) => _effectiveState[contextId];
Future<void> initialize() async {
if (_initialized || _disposed) return;
_initialized = true;
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
if (!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
_onChanged();
}
void markContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: true);
}
void seedContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: false);
}
Future<void> flush() async {
_debounceTimer?.cancel();
_debounceTimer = null;
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
await _publish();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
final hadPendingPublish = _debounceTimer != null;
_debounceTimer?.cancel();
_debounceTimer = null;
if (flushPending &&
hadPendingPublish &&
_remoteEnabled &&
!_remoteUnsupported) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribeLive?.call();
_unsubscribeLive = null;
}
void _advanceContext(
String contextId,
int unixTimestamp, {
required bool publishable,
}) {
if (_disposed || unixTimestamp < 0) return;
final current = _effectiveState[contextId] ?? 0;
if (unixTimestamp <= current) {
if (!publishable || _publishableContextIds.contains(contextId)) {
return;
}
_publishableContextIds.add(contextId);
_persistLocalState();
_onChanged();
_schedulePublish();
return;
}
_effectiveState[contextId] = unixTimestamp;
if (publishable) {
_publishableContextIds.add(contextId);
}
_persistLocalState();
_onChanged();
if (publishable) {
_schedulePublish();
}
}
Future<void> _fetchAndMerge() async {
try {
final events = await _relaySession!.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#t': ['read-state'],
},
since: currentUnixSeconds() - readStateHorizonSeconds,
limit: readStateFetchLimit,
),
);
_mergeEvents(events);
_persistLocalState();
_onChanged();
} catch (_) {
// Local state remains usable when relay history is unavailable.
}
}
void _mergeEvents(List<NostrEvent> events) {
ReadStateBlob? ownBlob;
var ownBlobCreatedAt = 0;
for (final event in events) {
final decoded = decodeReadStateEvent(
event,
pubkey: pubkey,
decrypt: _crypto.decrypt,
);
if (decoded == null) {
continue;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
_rotateSlotId();
}
for (final entry in decoded.blob.contexts.entries) {
final current = _effectiveState[entry.key] ?? 0;
if (entry.value > current) {
_effectiveState[entry.key] = entry.value;
}
_publishableContextIds.add(entry.key);
}
if (decoded.blob.clientId == _clientId &&
event.createdAt > ownBlobCreatedAt) {
ownBlob = decoded.blob;
ownBlobCreatedAt = event.createdAt;
}
}
if (ownBlob != null) {
_lastPublishedContexts = Map<String, int>.from(ownBlob.contexts);
_publishableContextIds.addAll(ownBlob.contexts.keys);
}
}
Future<void> _startLiveSubscription() async {
try {
_unsubscribeLive = await _relaySession!.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#t': ['read-state'],
},
limit: readStateFetchLimit,
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal; history and local writes still work.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
final decoded = decodeReadStateEvent(
event,
pubkey: pubkey,
decrypt: _crypto.decrypt,
);
if (decoded == null) {
return;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
_rotateSlotId();
}
var changed = false;
for (final entry in decoded.blob.contexts.entries) {
final current = _effectiveState[entry.key] ?? 0;
if (entry.value > current) {
_effectiveState[entry.key] = entry.value;
changed = true;
}
if (_publishableContextIds.add(entry.key)) {
changed = true;
}
}
if (decoded.blob.clientId == _clientId) {
_lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts);
}
if (changed) {
_persistLocalState();
_onChanged();
}
if (decoded.blob.clientId != _clientId &&
_contextsExceedLastPublished(decoded.blob.contexts)) {
_schedulePublish();
}
}
void _schedulePublish() {
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(seconds: 5), () {
_debounceTimer = null;
unawaited(_publish());
});
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_remoteUnsupported ||
_signedEventRelay == null) {
return;
}
if (_isPublishing) return;
_isPublishing = true;
try {
await _fetchOwnBlobBeforePublish();
final contexts = _currentContexts();
if (_isIdenticalToLastPublished(contexts)) {
return;
}
final blob = ReadStateBlob(clientId: _clientId, contexts: contexts);
final ciphertext = _crypto.encrypt(jsonEncode(blob.toJson()));
final createdAt = max(currentUnixSeconds(), _maxFetchedCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', '$readStateDTagPrefix$_slotId'],
['t', 'read-state'],
],
createdAt: createdAt,
);
_lastPublishedContexts = contexts;
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
} catch (error) {
if (_isPermanentReadStateRemoteError(error)) {
_remoteUnsupported = true;
_debounceTimer?.cancel();
_debounceTimer = null;
debugPrint(
'[ReadStateManager] remote read-state sync is unavailable; '
'using local read state.',
);
return;
}
debugPrint('[ReadStateManager] publish failed: $error');
} finally {
_isPublishing = false;
}
}
Future<void> _fetchOwnBlobBeforePublish() async {
if (_relaySession == null) return;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: {
'#d': ['$readStateDTagPrefix$_slotId'],
},
limit: readStateFetchLimit,
),
);
_mergeEvents(events);
_persistLocalState();
if (!_disposed) {
_onChanged();
}
} catch (_) {
// Per NIP-RS, proceed with reachable data and merge later.
}
}
bool _contextsExceedLastPublished(Map<String, int> contexts) {
for (final entry in contexts.entries) {
final last = _lastPublishedContexts[entry.key];
if (last == null || entry.value > last) {
return true;
}
}
return false;
}
bool _isIdenticalToLastPublished(Map<String, int> contexts) {
if (_lastPublishedContexts.length != contexts.length) {
return false;
}
for (final entry in contexts.entries) {
if (_lastPublishedContexts[entry.key] != entry.value) {
return false;
}
}
return true;
}
Map<String, int> _currentContexts() {
final contexts = <String, int>{};
for (final entry in _effectiveState.entries) {
if (_publishableContextIds.contains(entry.key)) {
contexts[entry.key] = entry.value;
}
}
return contexts;
}
void _hydrateFromLocalStorage() {
final stored = _storage.read(pubkey);
_effectiveState
..clear()
..addAll(stored.contexts);
_publishableContextIds
..clear()
..addAll(stored.publishableContextIds);
_persistLocalState();
}
void _persistLocalState() {
_storage.write(pubkey, _effectiveState, _publishableContextIds);
}
void _rotateSlotId() {
_slotId = generateReadStateSlotId();
_storage.writeSlotId(pubkey, _slotId);
}
bool _isPermanentReadStateRemoteError(Object error) {
if (error is! RelayException) {
return false;
}
final body = error.body.toLowerCase();
return (error.statusCode == 400 && body.contains('unknown event kind')) ||
(error.statusCode == 403 &&
(body.contains('missing users:write') ||
body.contains('insufficient scope')));
}
}

View File

@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/workspace/workspace_provider.dart';
import 'read_state_manager.dart';
class ReadStateState {
final bool isReady;
final String? pubkey;
final Map<String, int> contexts;
final int version;
const ReadStateState({
required this.isReady,
required this.pubkey,
required this.contexts,
required this.version,
});
const ReadStateState.inert()
: isReady = false,
pubkey = null,
contexts = const {},
version = 0;
int? effectiveTimestamp(String contextId) => contexts[contextId];
ReadStateState copyWithContext(String contextId, int timestamp) {
final current = contexts[contextId] ?? 0;
if (timestamp <= current) {
return this;
}
return ReadStateState(
isReady: isReady,
pubkey: pubkey,
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
version: version + 1,
);
}
}
class ReadStateNotifier extends Notifier<ReadStateState> {
ReadStateManager? _manager;
bool _isInitialized = false;
@override
ReadStateState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final relayClient = ref.watch(relayClientProvider);
final sessionState = ref.watch(relaySessionProvider);
final activeWorkspace = ref.watch(activeWorkspaceProvider).value;
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ReadStateState.inert();
}
final signedRelay = SignedEventRelay(client: relayClient, nsec: nsec);
final pubkey =
_normalizePubkey(activeWorkspace?.pubkey) ??
_safeDerivedPubkey(signedRelay);
if (pubkey == null) {
return const ReadStateState.inert();
}
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
if (crypto == null) {
return const ReadStateState.inert();
}
final prefs = ref.read(savedPrefsProvider);
late final ReadStateManager manager;
manager = ReadStateManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
ref.listen(appLifecycleProvider, (_, next) {
if (next == AppLifecycleState.paused ||
next == AppLifecycleState.detached ||
next == AppLifecycleState.hidden) {
unawaited(manager.flush());
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
return _stateFromManager(manager, isReady: false);
}
void markContextRead(String contextId, int unixTimestamp) {
_manager?.markContextRead(contextId, unixTimestamp);
}
void seedContextRead(String contextId, int unixTimestamp) {
_manager?.seedContextRead(contextId, unixTimestamp);
}
void _emitManagerState(ReadStateManager manager) {
if (_manager != manager) return;
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
ReadStateState _stateFromManager(
ReadStateManager manager, {
required bool isReady,
int? previousVersion,
}) {
return ReadStateState(
isReady: isReady,
pubkey: manager.pubkey,
contexts: manager.effectiveContexts,
version: (previousVersion ?? 0) + 1,
);
}
}
final readStateProvider = NotifierProvider<ReadStateNotifier, ReadStateState>(
ReadStateNotifier.new,
);
String? _normalizePubkey(String? value) {
final normalized = value?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
String? _safeDerivedPubkey(SignedEventRelay relay) {
try {
return _normalizePubkey(relay.pubkey);
} catch (_) {
return null;
}
}

View File

@@ -0,0 +1,162 @@
import 'dart:convert';
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'read_state_format.dart';
import 'read_state_time.dart';
const _clientIdKeyPrefix = 'sprout.nip-rs.client-id';
const _slotIdKeyPrefix = 'sprout.nip-rs.slot-id';
const _uuid = Uuid();
String localReadStateKey(String pubkey) =>
'sprout.channel-read-state.v2:$pubkey';
String localPublishableContextKey(String pubkey) =>
'sprout.channel-read-state.publishable.v1:$pubkey';
String clientIdKey(String pubkey) => '$_clientIdKeyPrefix:$pubkey';
String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey';
class StoredReadState {
final Map<String, int> contexts;
final Set<String> publishableContextIds;
StoredReadState({
required Map<String, int> contexts,
required Set<String> publishableContextIds,
}) : contexts = Map.unmodifiable(contexts),
publishableContextIds = Set.unmodifiable(publishableContextIds);
}
class ReadStateStorage {
final SharedPreferences _prefs;
ReadStateStorage(this._prefs);
String getOrCreateClientId(String pubkey) {
final key = clientIdKey(pubkey);
final stored = _prefs.getString(key);
if (_isValidClientId(stored)) {
return stored!;
}
final generated = _uuid.v4();
_prefs.setString(key, generated);
return generated;
}
String getOrCreateSlotId(String pubkey) {
final key = slotIdKey(pubkey);
final stored = _prefs.getString(key);
if (_isValidSlotId(stored)) {
return stored!;
}
final generated = generateReadStateSlotId();
_prefs.setString(key, generated);
return generated;
}
void writeSlotId(String pubkey, String slotId) {
_prefs.setString(slotIdKey(pubkey), slotId);
}
StoredReadState read(String pubkey) {
return StoredReadState(
contexts: _readContexts(pubkey),
publishableContextIds: _readPublishableContextIds(pubkey),
);
}
void write(
String pubkey,
Map<String, int> contexts,
Set<String> publishableContextIds,
) {
final state = <String, String>{};
for (final entry in contexts.entries) {
state[entry.key] = unixSecondsToDateTime(entry.value).toIso8601String();
}
_prefs.setString(localReadStateKey(pubkey), jsonEncode(state));
_prefs.setString(
localPublishableContextKey(pubkey),
jsonEncode(publishableContextIds.toList()),
);
}
Map<String, int> _readContexts(String pubkey) {
final raw = _prefs.getString(localReadStateKey(pubkey));
if (raw == null || raw.isEmpty) {
return {};
}
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (_) {
return {};
}
final record = asStringObjectMap(parsed);
if (record == null) {
return {};
}
final contexts = <String, int>{};
for (final entry in record.entries) {
final timestamp = isoToUnixSeconds(entry.value);
if (timestamp == null) continue;
final current = contexts[entry.key] ?? 0;
if (timestamp > current) {
contexts[entry.key] = timestamp;
}
}
return contexts;
}
Set<String> _readPublishableContextIds(String pubkey) {
final raw = _prefs.getString(localPublishableContextKey(pubkey));
if (raw == null || raw.isEmpty) {
return {};
}
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (_) {
return {};
}
if (parsed is! List) {
return {};
}
return {
for (final value in parsed)
if (value is String) value,
};
}
}
String generateReadStateSlotId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
bool _isValidClientId(String? value) {
return value != null && value.isNotEmpty && value.runes.length <= 64;
}
bool _isValidSlotId(String? value) {
if (value == null || value.isEmpty || value.length > 64) {
return false;
}
return isValidReadStateDTag('$readStateDTagPrefix$value');
}

View File

@@ -0,0 +1,23 @@
const _millisecondsPerSecond = 1000;
int? dateTimeToUnixSeconds(DateTime? value) {
if (value == null) return null;
return value.millisecondsSinceEpoch ~/ _millisecondsPerSecond;
}
int currentUnixSeconds() => dateTimeToUnixSeconds(DateTime.now())!;
DateTime unixSecondsToDateTime(int value) {
return DateTime.fromMillisecondsSinceEpoch(
value * _millisecondsPerSecond,
isUtc: true,
);
}
int? isoToUnixSeconds(Object? value) {
if (value is! String || value.isEmpty) {
return null;
}
return dateTimeToUnixSeconds(DateTime.tryParse(value));
}

View File

@@ -430,16 +430,7 @@ class PairingNotifier extends Notifier<PairingState> {
_validateRelayUrl(relayUrl);
// Validate credentials against the relay.
final client = RelayClient(
baseUrl: relayUrl,
apiToken: token,
httpClient: ref.read(pairingHttpClientProvider),
);
try {
await client.get('/api/users/me/profile');
} finally {
client.dispose();
}
await _validateCredentials(relayUrl: relayUrl, token: token);
// Send complete only after credentials are validated.
_sendComplete(true);
@@ -545,16 +536,10 @@ class PairingNotifier extends Notifier<PairingState> {
try {
final workspace = _parseLegacyInput(rawInput);
final client = RelayClient(
baseUrl: workspace.relayUrl,
apiToken: workspace.token,
httpClient: ref.read(pairingHttpClientProvider),
await _validateCredentials(
relayUrl: workspace.relayUrl,
token: workspace.token,
);
try {
await client.get('/api/users/me/profile');
} finally {
client.dispose();
}
await ref
.read(authProvider.notifier)
@@ -582,6 +567,20 @@ class PairingNotifier extends Notifier<PairingState> {
}
}
Future<void> _validateCredentials({
required String relayUrl,
required String token,
}) async {
final client = RelayClient(
baseUrl: relayUrl,
apiToken: token,
httpClient: ref.read(pairingHttpClientProvider),
);
// The HTTP client is owned by pairingHttpClientProvider. Closing this
// short-lived wrapper would close the provider client for future attempts.
await client.get('/api/users/me/profile');
}
Workspace _parseLegacyInput(String raw) {
var payload = raw.trim();

View File

@@ -9,6 +9,7 @@ abstract final class EventKind {
static const streamMessage = 9;
static const typingIndicator = 20002;
static const auth = 22242;
static const readState = 30078;
static const streamMessageV2 = 40002;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
@@ -16,12 +17,20 @@ abstract final class EventKind {
static const forumPost = 45001;
static const forumComment = 45003;
/// Event kinds that represent user-visible channel messages.
static const channelMessageEventKinds = [
streamMessage, // 9
streamMessageV2, // 40002
forumPost, // 45001
forumComment, // 45003
];
/// Event kinds that represent channel activity (messages, edits, reactions,
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
static const channelEventKinds = [
deletion, // 5
reaction, // 7
streamMessage, // 9
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
streamMessageDiff, // 40008
@@ -133,6 +142,7 @@ class NostrEvent {
@immutable
class NostrFilter {
final List<int> kinds;
final List<String>? authors;
final int limit;
final int? since;
final int? until;
@@ -142,6 +152,7 @@ class NostrFilter {
const NostrFilter({
required this.kinds,
this.authors,
this.limit = 100,
this.since,
this.until,
@@ -151,6 +162,7 @@ class NostrFilter {
/// Return a copy with an updated `since` value.
NostrFilter copyWithSince(int since) => NostrFilter(
kinds: kinds,
authors: authors,
limit: limit,
since: since,
until: until,
@@ -159,6 +171,7 @@ class NostrFilter {
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
if (authors != null) json['authors'] = authors;
if (since != null) json['since'] = since;
if (until != null) json['until'] = until;
for (final entry in tags.entries) {

View File

@@ -86,5 +86,11 @@ class RelayException implements Exception {
RelayException(this.statusCode, this.body);
@override
String toString() => 'RelayException($statusCode)';
String toString() {
final trimmedBody = body.trim();
if (trimmedBody.isEmpty) {
return 'RelayException($statusCode)';
}
return 'RelayException($statusCode): $trimmedBody';
}
}

View File

@@ -73,13 +73,14 @@ class RelaySessionNotifier extends Notifier<SessionState> {
static const _maxReconnectDelayMs = 30000;
static const _eventBatchMs = 16;
static const _reconnectReplaySkewSeconds = 5;
static const _maxRecentDeliveryKeys = 5000;
RelaySocket? _socket;
final Map<String, _HistorySubscription> _historySubscriptions = {};
final Map<String, _LiveSubscription> _liveSubscriptions = {};
final Map<String, _PendingEvent> _pendingEvents = {};
final List<_BufferedEvent> _eventBuffer = [];
final Set<String> _recentEventIds = {};
final Set<String> _recentDeliveryKeys = {};
Timer? _reconnectTimer;
Timer? _flushTimer;
Timer? _backgroundGraceTimer;
@@ -199,6 +200,12 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_socket?.send(payload);
}
@visibleForTesting
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();
/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
await _socket?.disconnect();
@@ -430,19 +437,22 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_eventBuffer.clear();
for (final buffered in batch) {
// Deduplication: skip events we've already seen recently.
if (_recentEventIds.contains(buffered.event.id)) continue;
_recentEventIds.add(buffered.event.id);
final sub = _liveSubscriptions[buffered.subId];
if (sub == null) continue;
// Deduplicate per subscription. The same relay event can legitimately
// match multiple live subscriptions, e.g. the channel list unread listener
// and the open channel message listener.
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
// Cap the dedup set to prevent unbounded memory growth.
if (_recentEventIds.length > 5000) {
_recentEventIds.clear();
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
_recentDeliveryKeys.clear();
}
_recentDeliveryKeys.add(deliveryKey);
final sub = _liveSubscriptions[buffered.subId];
if (sub != null) {
sub.onEvent(buffered.event);
}
sub.onEvent(buffered.event);
}
}
@@ -465,6 +475,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
void _unsubscribe(String subId) {
_liveSubscriptions.remove(subId);
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
_sendClose(subId);
}
@@ -495,6 +506,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_backgroundGraceTimer?.cancel();
_cancelAllHistory(null);
_rejectAllPending(null);
_recentDeliveryKeys.clear();
_socket?.dispose();
_socket = null;
}

View File

@@ -26,6 +26,7 @@ class SignedEventRelay {
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
}) async {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
@@ -42,6 +43,7 @@ class SignedEventRelay {
content: content,
tags: tags,
privkey: privkeyHex,
createdAt: createdAt,
verify: false,
);

View File

@@ -11,6 +11,7 @@ import 'package:sprout_mobile/features/channels/channel_management_provider.dart
import 'package:sprout_mobile/features/channels/channel_messages_provider.dart';
import 'package:sprout_mobile/features/channels/channel_typing_provider.dart';
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_provider.dart';
import 'package:sprout_mobile/features/profile/profile_provider.dart';
import 'package:sprout_mobile/features/profile/user_cache_provider.dart';
import 'package:sprout_mobile/features/profile/user_profile.dart';
@@ -120,6 +121,7 @@ Widget _buildTestable({
List<NavigatorObserver> navigatorObservers = const [],
Future<List<ChannelMember>> Function()? loadMembers,
ChannelActions Function(Ref ref)? createChannelActions,
ReadStateNotifier? readStateNotifier,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -150,6 +152,8 @@ Widget _buildTestable({
),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)
readStateProvider.overrideWith(() => readStateNotifier),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
@@ -180,6 +184,43 @@ Finder findRichText(String text) {
void main() {
group('ChannelDetailPage', () {
testWidgets('defers read-state mark until after build', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'First',
createdAt: 1100,
),
_textMsg(
id: 'msg2',
pubkey: 'alice',
content: 'Latest',
createdAt: 1200,
),
],
readStateNotifier: readState,
),
);
expect(tester.takeException(), isNull);
await tester.pump();
expect(readState.markedContexts, {_channelId: 1200});
expect(tester.takeException(), isNull);
});
testWidgets('shows forum posts view for forum channels', (tester) async {
final forumChannel = Channel(
id: _channelId,
@@ -1079,6 +1120,22 @@ class _FakeTypingNotifier extends ChannelTypingNotifier {
List<TypingEntry> build() => _entries;
}
class _SynchronousReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> markedContexts = {};
_SynchronousReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
@override
void markContextRead(String contextId, int unixTimestamp) {
markedContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>

View File

@@ -0,0 +1,180 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sprout_mobile/features/channels/channel_messages_provider.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
void main() {
test(
'keeps live events that arrive while initial history is loading',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.emit(_event(id: 'live', createdAt: 20));
await _pumpEventQueue();
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((event) => event.id),
['live'],
);
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
final messages = container
.read(channelMessagesProvider(_channelId))
.value!;
expect(messages.map((event) => event.id), ['history', 'live']);
expect(relaySession.operations, ['subscribe', 'fetch']);
expect(
relaySession.liveFilters.single.kinds,
EventKind.channelEventKinds,
);
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
expect(relaySession.liveFilters.single.limit, 50);
expect(
relaySession.historyFilters.single.kinds,
EventKind.channelEventKinds,
);
expect(relaySession.historyFilters.single.tags['#h'], [_channelId]);
},
);
test('still loads history when live subscription fails', () async {
final relaySession = _RecordingRelaySessionNotifier(failSubscribe: true);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
await _pumpEventQueue();
final messages = container.read(channelMessagesProvider(_channelId)).value!;
expect(messages.map((event) => event.id), ['history']);
expect(relaySession.operations, ['subscribe', 'fetch']);
});
test(
'keeps live messages when history sync fails after subscribing',
() async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
relaySession.emit(_event(id: 'live', createdAt: 20));
await _pumpEventQueue();
relaySession.failHistory(Exception('history failed'));
await _pumpEventQueue();
final state = container.read(channelMessagesProvider(_channelId));
expect(state.hasError, isFalse);
expect(state.value?.map((event) => event.id), ['live']);
},
);
}
const _channelId = '11111111-1111-4111-8111-111111111111';
ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) {
return ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relaySession)],
);
}
NostrEvent _event({required String id, required int createdAt}) {
return NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelId],
],
content: id,
sig: 'sig',
);
}
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final bool failSubscribe;
final List<String> operations = [];
final List<NostrFilter> liveFilters = [];
final List<NostrFilter> historyFilters = [];
final List<void Function(NostrEvent)> _listeners = [];
final Completer<void> _subscribed = Completer<void>();
final Completer<List<NostrEvent>> _history = Completer<List<NostrEvent>>();
_RecordingRelaySessionNotifier({this.failSubscribe = false});
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) {
operations.add('fetch');
historyFilters.add(filter);
return _history.future;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent,
) async {
operations.add('subscribe');
liveFilters.add(filter);
if (!_subscribed.isCompleted) {
_subscribed.complete();
}
if (failSubscribe) {
throw Exception('subscribe failed');
}
_listeners.add(onEvent);
return () {
_listeners.remove(onEvent);
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
void completeHistory(List<NostrEvent> events) {
if (!_history.isCompleted) {
_history.complete(events);
}
}
void failHistory(Object error) {
if (!_history.isCompleted) {
_history.completeError(error);
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/misc.dart';
import 'package:sprout_mobile/features/channels/channel.dart';
import 'package:sprout_mobile/features/channels/channels_page.dart';
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_provider.dart';
import 'package:sprout_mobile/features/profile/profile_provider.dart';
import 'package:sprout_mobile/features/profile/user_profile.dart';
import 'package:sprout_mobile/shared/theme/theme.dart';
@@ -147,6 +148,142 @@ void main() {
expect(find.text('Could not load channels'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('renders and clears unread indicator', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('channel-unread-1')), findsOneWidget);
readState.markContextRead('1', 20);
await tester.pump();
expect(find.byKey(const Key('channel-unread-1')), findsNothing);
});
testWidgets('seeds first loaded channels as read', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
expect(find.byKey(const Key('channel-unread-1')), findsNothing);
});
testWidgets('waits for read-state readiness before initial seeding', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: false,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, isEmpty);
expect(readState.markedContexts, isEmpty);
readState.setReady();
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
});
}
class _FakeNotifier extends ChannelsNotifier {
@@ -172,3 +309,35 @@ class _FakePresenceNotifier extends PresenceNotifier {
@override
Future<String> build() async => 'online';
}
class _FakeReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> seededContexts = {};
final Map<String, int> markedContexts = {};
_FakeReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
void setReady() {
state = ReadStateState(
isReady: true,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
);
}
@override
void seedContextRead(String contextId, int unixTimestamp) {
seededContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
@override
void markContextRead(String contextId, int unixTimestamp) {
markedContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
}

View File

@@ -0,0 +1,156 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
void main() {
test('subscribes to live channel events per loaded member channel', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(
relaySession: relaySession,
channelsJson: [
_channelJson(id: _channelA, name: 'general'),
_channelJson(id: _channelB, name: 'random'),
_channelJson(id: _channelC, name: 'archived', archived: true),
_channelJson(id: _channelD, name: 'unjoined', member: false),
],
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
expect(relaySession.filters, hasLength(2));
expect(
relaySession.filters.map((filter) => filter.tags['#h']?.single).toSet(),
{_channelA, _channelB},
);
expect(
relaySession.filters.every(
(filter) => filter.kinds == EventKind.channelEventKinds,
),
isTrue,
);
});
test('live channel events update channel lastMessageAt', () async {
final relaySession = _RecordingRelaySessionNotifier();
final container = _buildContainer(
relaySession: relaySession,
channelsJson: [
_channelJson(
id: _channelA,
name: 'general',
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
10 * 1000,
isUtc: true,
),
),
],
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
relaySession.emit(
NostrEvent(
id: 'event-1',
pubkey: 'alice',
createdAt: 20,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelA],
],
content: 'new message',
sig: 'sig',
),
);
final channels = container.read(channelsProvider).value!;
expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000);
});
}
const _channelA = '11111111-1111-4111-8111-111111111111';
const _channelB = '22222222-2222-4222-8222-222222222222';
const _channelC = '33333333-3333-4333-8333-333333333333';
const _channelD = '44444444-4444-4444-8444-444444444444';
ProviderContainer _buildContainer({
required _RecordingRelaySessionNotifier relaySession,
required List<Map<String, dynamic>> channelsJson,
}) {
final client = RelayClient(
baseUrl: 'http://localhost:3000',
httpClient: http_testing.MockClient((request) async {
expect(request.url.path, '/api/channels');
return http.Response(jsonEncode(channelsJson), 200);
}),
);
return ProviderContainer(
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relayClientProvider.overrideWithValue(client),
relaySessionProvider.overrideWith(() => relaySession),
],
);
}
Map<String, dynamic> _channelJson({
required String id,
required String name,
bool member = true,
bool archived = false,
DateTime? lastMessageAt,
}) {
return {
'id': id,
'name': name,
'channel_type': 'stream',
'visibility': 'open',
'description': '',
'created_by': 'creator',
'created_at': DateTime(2025).toIso8601String(),
'member_count': 1,
'is_member': member,
'last_message_at': lastMessageAt?.toIso8601String(),
'archived_at': archived ? DateTime(2025, 1, 2).toIso8601String() : null,
};
}
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final List<NostrFilter> filters = [];
final List<void Function(NostrEvent)> _listeners = [];
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent,
) async {
filters.add(filter);
_listeners.add(onEvent);
return () {
filters.remove(filter);
_listeners.remove(onEvent);
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
}
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_format.dart';
import 'package:sprout_mobile/shared/relay/nostr_models.dart';
void main() {
group('read state event validation', () {
test('requires exactly one valid d tag and one read-state t tag', () {
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {'channel-a': 1},
});
expect(
decodeReadStateEvent(
_event(tags: const []),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['d', 'read-state:slot-b'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slót'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
expect(
decodeReadStateEvent(
_event(
tags: const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
['t', 'read-state'],
],
),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
),
isNull,
);
});
test('decrypts and sanitizes a valid read-state blob', () {
final longContextId = 'x' * 257;
final plaintext = jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': {
'channel-a': 10,
'string-value': '10',
'double-value': 10.5,
'negative': -1,
'too-large': 4294967296,
longContextId: 20,
},
});
final decoded = decodeReadStateEvent(
_event(),
pubkey: 'user-pubkey',
decrypt: (_) => plaintext,
);
expect(decoded, isNotNull);
expect(decoded!.dTag, 'read-state:slot-a');
expect(decoded.blob.clientId, 'client-a');
expect(decoded.blob.contexts, {'channel-a': 10});
});
test('rejects malformed blobs', () {
expect(decodeReadStateBlob('not json'), isNull);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 2,
'client_id': 'client-a',
'contexts': <String, int>{},
}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({'v': 1, 'client_id': '', 'contexts': <String, int>{}}),
),
isNull,
);
expect(
decodeReadStateBlob(
jsonEncode({
'v': 1,
'client_id': 'client-a',
'contexts': List.filled(1, 'not-a-map'),
}),
),
isNull,
);
});
});
test('mergeReadStateContexts keeps the maximum timestamp per context', () {
expect(
mergeReadStateContexts([
{'channel-a': 10, 'channel-b': 5},
{'channel-a': 7, 'channel-c': 1},
{'channel-b': 12},
]),
{'channel-a': 10, 'channel-b': 12, 'channel-c': 1},
);
});
}
NostrEvent _event({List<List<String>>? tags}) {
return NostrEvent(
id: 'event-id',
pubkey: 'user-pubkey',
createdAt: 100,
kind: EventKind.readState,
tags:
tags ??
const [
['d', 'read-state:slot-a'],
['t', 'read-state'],
],
content: 'ciphertext',
sig: 'sig',
);
}

View File

@@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_manager.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
void main() {
test('dispose flushes a pending publish after marking disposed', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _FakeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
manager.dispose();
final submitted = await relay.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(submitted.kind, EventKind.readState);
expect(
submitted.tags.any(
(tag) => tag.length == 2 && tag[0] == 't' && tag[1] == 'read-state',
),
isTrue,
);
});
test('disables remote sync after relay rejects read-state kind', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _UnsupportedKindSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
});
test(
'disables remote sync after token permanently lacks write scope',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keychain = nostr.Keychain.generate();
final nsec = nostr.Nip19.encodePrivkey(keychain.private);
final crypto = ReadStateCrypto.tryCreate(
nsec: nsec,
pubkey: keychain.public,
);
final relay = _MissingScopeSignedEventRelay();
final manager = ReadStateManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto!,
relaySession: null,
signedEventRelay: relay,
remoteEnabled: true,
onChanged: () {},
);
manager.markContextRead('channel-1', 42);
await manager.flush();
manager.markContextRead('channel-2', 43);
await manager.flush();
expect(relay.submitCount, 1);
expect(manager.getEffectiveTimestamp('channel-2'), 43);
},
);
}
class _SubmittedEvent {
final int kind;
final List<List<String>> tags;
const _SubmittedEvent({required this.kind, required this.tags});
}
class _FakeSignedEventRelay extends SignedEventRelay {
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
_FakeSignedEventRelay()
: super(client: RelayClient(baseUrl: 'http://localhost:3000'), nsec: null);
@override
Future<void> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
}) async {
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
}
}
class _UnsupportedKindSignedEventRelay extends SignedEventRelay {
int submitCount = 0;
_UnsupportedKindSignedEventRelay()
: super(client: RelayClient(baseUrl: 'http://localhost:3000'), nsec: null);
@override
Future<void> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
}) async {
submitCount++;
throw RelayException(400, '{"error":"restricted: unknown event kind"}');
}
}
class _MissingScopeSignedEventRelay extends SignedEventRelay {
int submitCount = 0;
_MissingScopeSignedEventRelay()
: super(client: RelayClient(baseUrl: 'http://localhost:3000'), nsec: null);
@override
Future<void> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
}) async {
submitCount++;
throw RelayException(403, '{"message":"missing users:write"}');
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_time.dart';
void main() {
test('dateTimeToUnixSeconds converts DateTime values and nulls', () {
final value = DateTime.utc(2026, 4, 29, 4, 30, 45, 900);
expect(dateTimeToUnixSeconds(value), 1777437045);
expect(dateTimeToUnixSeconds(null), isNull);
});
test('isoToUnixSeconds parses stored ISO timestamps defensively', () {
expect(isoToUnixSeconds('1970-01-01T00:00:42.000Z'), 42);
expect(isoToUnixSeconds(''), isNull);
expect(isoToUnixSeconds('not-a-date'), isNull);
expect(isoToUnixSeconds(42), isNull);
});
test('unixSecondsToDateTime writes UTC ISO-compatible values', () {
expect(
unixSecondsToDateTime(1700000000).toIso8601String(),
'2023-11-14T22:13:20.000Z',
);
});
}

View File

@@ -119,6 +119,24 @@ void main() {
expect(fakeAuth.lastWorkspace?.pubkey, 'abc123');
});
test('reuses provider HTTP client across pairing attempts', () async {
var requestCount = 0;
final mock = http_testing.MockClient((_) async {
requestCount++;
return http.Response(jsonEncode({'id': '$requestCount'}), 200);
});
container = createContainer(mock);
await container.read(pairingProvider.notifier).pair(_encodePairingCode());
expect(container.read(pairingProvider).status, PairingStatus.success);
container.read(pairingProvider.notifier).reset();
await container.read(pairingProvider.notifier).pair(_encodePairingCode());
expect(container.read(pairingProvider).status, PairingStatus.success);
expect(requestCount, 2);
});
test('relay 401 sets error state', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{"error": "unauthorized"}', 401),

View File

@@ -0,0 +1,75 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/shared/relay/nostr_models.dart';
void main() {
test('NostrFilter serializes and preserves authors', () {
const filter = NostrFilter(
kinds: [EventKind.readState],
authors: ['pubkey-a'],
tags: {
'#t': ['read-state'],
},
since: 10,
limit: 5,
);
expect(filter.toJson(), {
'kinds': [EventKind.readState],
'limit': 5,
'authors': ['pubkey-a'],
'since': 10,
'#t': ['read-state'],
});
final copied = filter.copyWithSince(20);
expect(copied.toJson(), {
'kinds': [EventKind.readState],
'limit': 5,
'authors': ['pubkey-a'],
'since': 20,
'#t': ['read-state'],
});
});
test('NostrFilter can serialize broad deletion subscriptions', () {
const filter = NostrFilter(kinds: [EventKind.deletion], limit: 0);
expect(filter.toJson(), {
'kinds': [EventKind.deletion],
'limit': 0,
});
});
test('channel unread activity kinds exclude non-message updates', () {
expect(
EventKind.channelMessageEventKinds,
contains(EventKind.streamMessage),
);
expect(EventKind.channelMessageEventKinds, contains(EventKind.forumPost));
expect(
EventKind.channelMessageEventKinds,
contains(EventKind.forumComment),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.reaction)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.streamMessageEdit)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.streamMessageDiff)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.deletion)),
);
expect(
EventKind.channelMessageEventKinds,
isNot(contains(EventKind.systemMessage)),
);
});
}

View File

@@ -63,6 +63,24 @@ void main() {
);
});
test('RelayException string includes non-empty response body', () {
final exception = RelayException(
403,
'{"message":"missing users:write"}',
);
expect(
exception.toString(),
'RelayException(403): {"message":"missing users:write"}',
);
});
test('RelayException string omits empty response body', () {
final exception = RelayException(403, ' ');
expect(exception.toString(), 'RelayException(403)');
});
test('omits Authorization header when no token', () async {
final mockClient = http_testing.MockClient((request) async {
expect(request.headers.containsKey('Authorization'), isFalse);

View File

@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
void main() {
test('delivers the same live event to each matching subscription', () async {
final session = RelaySessionNotifier();
final firstEvents = <NostrEvent>[];
final secondEvents = <NostrEvent>[];
const filter = NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [_channelId],
},
limit: 50,
);
final firstSubscribe = session.subscribe(filter, firstEvents.add);
session.debugHandleMessage(['EOSE', 'l-1']);
final unsubscribeFirst = await firstSubscribe;
final secondSubscribe = session.subscribe(filter, secondEvents.add);
session.debugHandleMessage(['EOSE', 'l-2']);
final unsubscribeSecond = await secondSubscribe;
final event = _event();
session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]);
session.debugHandleMessage(['EVENT', 'l-2', event.toJson()]);
session.debugFlushEventBuffer();
expect(firstEvents.map((event) => event.id), [event.id]);
expect(secondEvents.map((event) => event.id), [event.id]);
session.debugHandleMessage(['EVENT', 'l-1', event.toJson()]);
session.debugFlushEventBuffer();
expect(firstEvents.map((event) => event.id), [event.id]);
expect(secondEvents.map((event) => event.id), [event.id]);
unsubscribeFirst();
unsubscribeSecond();
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
NostrEvent _event() {
return const NostrEvent(
id: 'event-1',
pubkey: 'alice',
createdAt: 20,
kind: EventKind.streamMessageV2,
tags: [
['h', _channelId],
],
content: 'hello',
sig: 'sig',
);
}

View File

@@ -65,6 +65,7 @@ void main() {
await relay.submit(
kind: 9007,
content: 'test message',
createdAt: 1234567890,
tags: [
['h', 'channel-1'],
],
@@ -73,6 +74,7 @@ void main() {
expect(postedBody, isNotNull);
expect(postedBody!['kind'], 9007);
expect(postedBody!['content'], 'test message');
expect(postedBody!['created_at'], 1234567890);
expect(postedBody!['sig'], isNotEmpty);
expect(postedBody!['pubkey'], keychain.public);
});