diff --git a/mobile/lib/features/channels/channel_unread_catch_up.dart b/mobile/lib/features/channels/channel_unread_catch_up.dart index 2e33c569ee..15ea7009fe 100644 --- a/mobile/lib/features/channels/channel_unread_catch_up.dart +++ b/mobile/lib/features/channels/channel_unread_catch_up.dart @@ -268,3 +268,11 @@ extension _UnreadCatchUp on ChannelsNotifier { } } } + +Channel _advanceLastMessageAt(Channel channel, int? createdAt) { + if (createdAt == null) return channel; + final at = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000, isUtc: true); + final current = channel.lastMessageAt; + if (current != null && !at.isAfter(current)) return channel; + return channel.copyWith(lastMessageAt: at); +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 1e1058cfc8..644ba98435 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -471,14 +471,18 @@ class ChannelsNotifier extends AsyncNotifier> { ); // Latest-message filters depend on channel identity/privacy, not names. // Start them alongside profiles, and observe both errors immediately. - final latestEventsRequest = fetchLastMessage - ? _fetchLastMessageEvents(session, [ - for (final entry in visibleMetas) - if (memberChannelIds.contains(entry.value.id) && - !entry.value.isArchived) - entry.value, - ]) - : Future.value(const []); + final listed = { + if (!fetchLastMessage) + for (final c in state.value ?? const []) + if (c.isMember) c.id, + }; + final latestEventsRequest = _fetchLastMessageEvents(session, [ + for (final entry in visibleMetas) + if (memberChannelIds.contains(entry.value.id) && + !entry.value.isArchived && + !listed.contains(entry.value.id)) + entry.value, + ]); final (displayNames, latestEvents) = await _fenced(fence, () async { await Future.wait(eagerError: true, [ displayNamesRequest, @@ -522,61 +526,57 @@ class ChannelsNotifier extends AsyncNotifier> { // Step 3: fetch the most recent message per channel to populate lastMessageAt. // kind:39000 metadata doesn't carry message timestamps, so channels load with - // lastMessageAt: null. Without this, unread detection and badge computation - // see every channel as having no messages. Skipped on background list - // reads since live subscriptions keep lastMessageAt current after the - // initial load. - if (fetchLastMessage) { - final activeChannels = [ - for (final channel in channels) - if (channel.isMember && !channel.isArchived) channel, - ]; - final channelById = { - for (final channel in activeChannels) channel.id: channel, - }; - final events = latestEvents; - final lastMessageMap = {}; - // An unavailable batch keeps the timestamps already known rather than - // installing channels whose latest message looks absent. - if (events == null) { - for (final channel in state.value ?? const []) { - final at = channel.lastMessageAt; - if (at != null) { - lastMessageMap[channel.id] = at.millisecondsSinceEpoch ~/ 1000; - } + // lastMessageAt: null. Reads that keep the list's times fetch only newly + // listed channels; live delivery keeps the rest current. + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final events = latestEvents; + final lastMessageMap = {}; + // An unavailable batch keeps the timestamps already known rather than + // installing channels whose latest message looks absent. + if (events == null) { + for (final channel in state.value ?? const []) { + final at = channel.lastMessageAt; + if (at != null) { + lastMessageMap[channel.id] = at.millisecondsSinceEpoch ~/ 1000; } } - final mutedChannelIds = _mutedChannelIds(); - for (final event in events ?? const []) { - final channelId = event.channelId; - if (channelId == null) continue; - final channel = channelById[channelId]; - if (channel == null) continue; - if (!channel.isDm && - !shouldNotifyForEvent( - event, - myPk, - mutedChannelIds: mutedChannelIds, - channelId: channelId, - )) { - continue; - } - final current = lastMessageMap[channelId]; - if (current == null || event.createdAt > current) { - lastMessageMap[channelId] = event.createdAt; - } + } + final mutedChannelIds = _mutedChannelIds(); + for (final event in events ?? const []) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + if (!channel.isDm && + !shouldNotifyForEvent( + event, + myPk, + mutedChannelIds: mutedChannelIds, + channelId: channelId, + )) { + continue; } + final current = lastMessageMap[channelId]; + if (current == null || event.createdAt > current) { + lastMessageMap[channelId] = event.createdAt; + } + } - for (var i = 0; i < channels.length; i++) { - final ts = lastMessageMap[channels[i].id]; - if (ts != null) { - channels[i] = channels[i].copyWith( - lastMessageAt: DateTime.fromMillisecondsSinceEpoch( - ts * 1000, - isUtc: true, - ), - ); - } + for (var i = 0; i < channels.length; i++) { + final ts = lastMessageMap[channels[i].id]; + if (ts != null) { + channels[i] = channels[i].copyWith( + lastMessageAt: DateTime.fromMillisecondsSinceEpoch( + ts * 1000, + isUtc: true, + ), + ); } } @@ -626,12 +626,18 @@ class ChannelsNotifier extends AsyncNotifier> { // snapshot as well as the visible list. for (var i = 0; i < channels.length; i++) { final previous = prevById[channels[i].id]?.lastMessageAt; - if (previous != null) { + final fetched = channels[i].lastMessageAt; + if (previous != null && + (fetched == null || previous.isAfter(fetched))) { channels[i] = channels[i].copyWith(lastMessageAt: previous); } } } - if (fetchLastMessage) _needsSnapshotRevalidation = latestEvents == null; + // A failed targeted batch also leaves new channels for a full read to date. + _needsSnapshotRevalidation = + latestEvents == null || + (!fetchLastMessage && _needsSnapshotRevalidation); + if (latestEvents == null) _listReadRetry.schedule(_readListIfChanged); if (hiddenDmIds != null) { _channelListChangesRead = max(_channelListChangesRead, listChanges); } else { @@ -845,17 +851,6 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - static Channel _advanceLastMessageAt(Channel channel, int? createdAt) { - if (createdAt == null) return channel; - final at = DateTime.fromMillisecondsSinceEpoch( - createdAt * 1000, - isUtc: true, - ); - final current = channel.lastMessageAt; - if (current != null && !at.isAfter(current)) return channel; - return channel.copyWith(lastMessageAt: at); - } - void _handleLiveEvent(NostrEvent event) { if (_noteChannelChange(event) && event.kind == EventKind.nip29EditMetadata) { @@ -1123,7 +1118,12 @@ class ChannelsNotifier extends AsyncNotifier> { final previousChannels = state.value; try { state = AsyncData( - await _fetch(subscribeLive: true, fetchDirectory: true), + await _fetch( + subscribeLive: true, + // Live subscriptions keep a loaded list's latest-message times. + fetchLastMessage: previousChannels == null, + fetchDirectory: true, + ), ); } on _StaleChannelRefresh { // A community or identity switch retired this request. Its response diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index ad63325ab3..2dd0595f04 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -143,6 +143,124 @@ void main() { }, ); + test('a directory load keeps latest-message times', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + recentMessages: [ + const NostrEvent( + id: 'm1', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.streamMessageV2, + tags: [ + ['h', _channelA], + ], + content: 'hi', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + bool isLatest(List batch) => + batch.isNotEmpty && + batch.every( + (filter) => + filter.since == null && + filter.tags.containsKey('#h') && + (filter.limit == 1 || filter.limit == 20), + ); + await container.read(channelsProvider.future); + await _settle(); + final latestReads = session.queryBatches.where(isLatest).length; + expect(latestReads, isPositive); + + await container.read(channelsProvider.notifier).retryDirectory(); + await _settle(); + + expect(session.queryBatches.where(isLatest), hasLength(latestReads)); + final channels = container.read(channelsProvider).requireValue; + expect(channels.map((channel) => channel.id), [_channelB, _channelA]); + expect( + channels.firstWhere((channel) => channel.id == _channelA).lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + }); + + for (final deadlineFirst in [false, true]) { + _testWithClock('a directory load dates newly listed DMs' + '${deadlineFirst ? ' after a latest-message deadline' : ''}', (clock) { + NostrEvent message(String channelId, int at, String author) => NostrEvent( + id: '$channelId-$at', + pubkey: author, + createdAt: at, + kind: EventKind.streamMessageV2, + tags: [ + ['h', channelId], + ], + content: 'hello', + sig: 'sig', + ); + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'Alice', channelType: 'dm'), + _meta(id: _channelB, name: 'Bob', channelType: 'dm'), + ], + recentMessages: [message(_channelA, 30, 'alice')], + enforceHistoryLimits: true, + ); + final container = _buildContainer(session: session); + container.listen(channelsProvider, (_, _) {}); + clock.flushMicrotasks(); + DateTime? latest(String id) => container + .read(channelsProvider) + .requireValue + .singleWhere((channel) => channel.id == id) + .lastMessageAt; + + // Another device opened DM B with an outgoing message, which unread + // catch-up skips. Search's directory load discovers B first. + session.memberships = [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ]; + session.recentMessages = [ + message(_channelA, 30, 'alice'), + message(_channelB, 40, myPk), + ]; + if (deadlineFirst) { + session.latestBatchError = RelayException( + 503, + '{"error":"query timed out"}', + ); + } + container.read(channelsProvider.notifier).retryDirectory(); + clock.flushMicrotasks(); + if (deadlineFirst) { + expect(latest(_channelB), isNull); + // The retry after the deadline reads the whole list again. + session.latestBatchError = null; + clock.elapse(const Duration(seconds: 10)); + } + + expect( + latest(_channelB), + DateTime.fromMillisecondsSinceEpoch(40 * 1000, isUtc: true), + ); + expect( + latest(_channelA), + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + container.dispose(); + clock.elapse(const Duration(minutes: 10)); + }); + } + test('a directory load during the first channel load waits for it', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)],