perf(mobile): keep latest-message times on a directory load (#9457)

Opening search loads the channel directory when it is older than 30
seconds. That load reruns the whole channel list read, including each
channel's latest messages (up to 20 per channel, 1 per DM), even though
the list is already showing and its live subscriptions keep those times
current. On an iPhone 12 mini signed in to Block's Builderlab, the
latest-message part was about 700 KB of every search open.

A directory load now skips the latest-message read when a channel list
is already loaded and keeps the loaded times, the same as the background
list reads already do. The first load, when no list is showing yet,
still reads them. Both reads still fetch latest messages for member
channels the list did not already show (for example a DM opened on
another device), so those get a time instead of sorting last in Recent;
before, background list reads left them without one. If that read hits
the relay deadline, the list is marked for a full read and retried with
backoff, as a timed-out full read was before.

### Validation

New test: with a loaded list, a directory load sends no latest-message
requests, adds the directory channel and keeps the member channel's
latest-message time. It fails without this change. A second test: a DM
first listed by a directory load, holding only our own message (which
unread catch-up skips), gets its latest-message time. It fails without
the newly-listed read. Its deadline row (the read that first lists the
DM times out) checks that the backoff retry dates the DM; it fails if
either the revalidation flag or the retry is removed.

Full mobile test suite, `flutter analyze` and the file-size check pass.
To stay under the 1200-line limit, `_advanceLastMessageAt` moved
unchanged into `channel_unread_catch_up.dart`.

On the iPhone 12 mini (profile build, signed in to Builderlab), opening
search 90 seconds after a cold start and typing "buzz":

| | Latest messages | All relay HTTP |
| --- | --- | --- |
| Before | 704 KB | 3.56 MB |
| After | 0 | 2.86 MB |

The automated run covered a cold start, the home list, opening search,
channel results for the query, and closing search back to the home list
with the same order.

### Regressions

- A directory load with a loaded list no longer refreshes cached DM
display names; the next full list read (pull to refresh, cold start, or
a channel action) still does, as for background list reads.
- A latest-message time the live subscriptions missed (for example while
the socket was down) is not corrected by opening search; the next full
list read corrects it.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-13 13:39:55 -08:00
committed by GitHub
parent a540e7bd30
commit 237a39fe76
3 changed files with 199 additions and 73 deletions

View File

@@ -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);
}

View File

@@ -471,14 +471,18 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
);
// 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 <NostrEvent>[]);
final listed = {
if (!fetchLastMessage)
for (final c in state.value ?? const <Channel>[])
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<List<Channel>> {
// 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 = <String, int>{};
// 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 <Channel>[]) {
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 = <String, int>{};
// 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 <Channel>[]) {
final at = channel.lastMessageAt;
if (at != null) {
lastMessageMap[channel.id] = at.millisecondsSinceEpoch ~/ 1000;
}
}
final mutedChannelIds = _mutedChannelIds();
for (final event in events ?? const <NostrEvent>[]) {
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 <NostrEvent>[]) {
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<List<Channel>> {
// 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<List<Channel>> {
);
}
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<List<Channel>> {
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

View File

@@ -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<NostrFilter> 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)],