mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Fix mobile unread badges and read state sync (#422)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
169
mobile/lib/features/channels/read_state/read_state_format.dart
Normal file
169
mobile/lib/features/channels/read_state/read_state_format.dart
Normal 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;
|
||||
}
|
||||
446
mobile/lib/features/channels/read_state/read_state_manager.dart
Normal file
446
mobile/lib/features/channels/read_state/read_state_manager.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
167
mobile/lib/features/channels/read_state/read_state_provider.dart
Normal file
167
mobile/lib/features/channels/read_state/read_state_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
162
mobile/lib/features/channels/read_state/read_state_storage.dart
Normal file
162
mobile/lib/features/channels/read_state/read_state_storage.dart
Normal 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');
|
||||
}
|
||||
23
mobile/lib/features/channels/read_state/read_state_time.dart
Normal file
23
mobile/lib/features/channels/read_state/read_state_time.dart
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
156
mobile/test/features/channels/channels_provider_test.dart
Normal file
156
mobile/test/features/channels/channels_provider_test.dart
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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"}');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
75
mobile/test/shared/relay/nostr_models_test.dart
Normal file
75
mobile/test/shared/relay/nostr_models_test.dart
Normal 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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
58
mobile/test/shared/relay/relay_session_test.dart
Normal file
58
mobile/test/shared/relay/relay_session_test.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user