Files
buzz/mobile/lib/features/channels/channels_provider.dart
Wes d090ac24bb perf(mobile): cut idle and resume sync churn (#8490)
## Summary

Mobile does background work nobody sees, and it waits on round trips it
doesn't need. This PR fixes four things:

- **Chats you've left let go.** A chat with no listener for 30 s drops
its live and typing subscriptions. Coming back shows the cached timeline
right away, then resubscribes and catches up.
- **No requests before AUTH.** The relay rejects any REQ that arrives
before NIP-42 AUTH completes. History requests are now held until the
socket authenticates, and they flush after the rate-limit gate. Live
subscriptions skip their REQ, and the replay on connect sends it.
`auth-required` is terminal for a live subscription, so a pre-AUTH live
REQ could be dropped for good.
- **Resume reconciles the channel list once.** `resumed` is published
before the session hears about it. So the channel list now decides one
microtask later: if the session is reconnecting, the connect does the
reconcile.
- **The 60 s backstop only runs in the foreground on a connected
session.** After an OS suspension, the missed tick fires at unfreeze,
about 450 ms before the app hears `resumed`, while the session still
looks connected. Without this, that tick triggered a second full sync.

Scope note: while the app is in the foreground, the backstop still
re-downloads membership every 60 s. That matches desktop, which
refetches every 60 s while focused. To drop it, mobile would need to
subscribe to the live membership notices (44100/44101), as desktop does.
That would be a follow-up.

### Related issue

N/A. This is follow-up work from the mobile perf dive. None found.

## Before and after

Simulator setup: iPhone 18 Pro simulator, debug build, a local relay
behind a proxy that adds ~120 ms RTT, and a seeded account with 120
channels. I scripted one identical run against each build:
1. Open 5 channels.
2. Visit 20 more channels.
3. Wait 40 s.
4. Stay idle in the foreground for 3 min.
5. Go to the background for 3 min.
6. Resume.

`main` is `ca3be5501`; the branch is `d77c4e86f`. The last amend changed
only test names. Since then, chat history waits for the live EOSE again
(see the note below), so channel open is back to `main`'s order.

| | `main` | this PR |
|---|---|---|
| Live subscriptions after visiting 20 chats | 61 | 11 |
| Resume: membership reads | 2 (244 kB) | 1 (122 kB) |
| Resume: websocket REQs | 36 | 31 |
| Resume: REQs sent before AUTH | 9 | 0 |
| Resume: REQs rejected as `auth-required` | 2 | 0 |
| Idle foreground, 3 min | 3 membership reads, 12 REQs | same |

Notes:
- **Channel open:** an earlier revision requested history without
waiting for EOSE, which cut the median open from 297 ms to 197 ms.
Review showed it can lose a message. The relay registers the live REQ
only after its access checks, so a message committed in between reached
neither the window read nor the subscription. A timestamp overlap only
covers messages signed within it. Keeping that speedup safely would mean
a second window read on every open, so it's left out.
- **Idle foreground is unchanged by design.** See the scope note above.
- **Background traffic:** the simulator suspends the app shortly after
it leaves the foreground, so both builds sent almost nothing during the
3 minutes in the background. The backstop fix shows up at unfreeze
instead, as the missing second membership read.
- **Resume inbound events:** in this one run, events received over the
websocket on resume were 247 kB on `main` and 338 kB on the branch. I
haven't attributed the difference.

## Test

These extend the existing journeys, plus one new journey:

- **`channel_messages_journey_test`:**
- The window read waits for the live REQ's admission, and a message
signed a minute earlier but committed before admission shows up.
- After the listener leaves, the chat keeps following for 29 s and stops
at 31 s.
- Coming back shows the cached rows immediately, then catches up on a
message sent while away.
- **`relay_session_test`:** a stale-connected resume sends nothing
before AUTH. Then exactly one history REQ and one live REQ go out after
AUTH.
- **New `channels_sync_journey_test`:** drives the real lifecycle,
session and channel notifier against `FakeRelay`:
  - A backgrounding just before a backstop tick sends nothing.
  - A resume after the session paused syncs once.
- An OS-suspension resume with AUTH held syncs once, after AUTH, and
sends no REQ before it.
- **`FakeRelay`:** gained `holdReq`, `holdAuth`, `before_id` pagination
and an injectable clock.

Each guard was mutation-checked, and every mutation failed its test:
- live REQ sent before AUTH
- history sent before AUTH
- resume refresh not deferred
- backstop without the lifecycle check
- history read alongside the live REQ
- a 30-day detach delay

Follow-up, not in this PR: a history REQ already in flight on a socket
that gets replaced still waits for its 8 s timeout. Princess Donut found
this in review; it predates this change.

## Validation

- `flutter analyze`: clean
- Full mobile suite at `678617b`: 3,696 passed
- Court review (Princess Donut on the relay session, Mongo on chat
subscriptions, Mordecai on the channel list plus a challenge pass): no
blocking findings. The rate-limit flush ordering came from that review.

**Human test:** Wes ran `da5077d` on the simulator with his real account
and confirmed it works. `460dc35` then fixed a review blocker: a chat
rebuilt while detached stayed detached. A retest of that path is
pending: leave a chat for more than 30 s, background the app for more
than 5 s, resume, then reopen the chat.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:24:43 -06:00

1010 lines
37 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart'
show ChannelMember, channelDetailsProvider;
import 'channel_mutes/channel_mutes_provider.dart';
import 'huddle_channel_filter.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/observed_unread_event.dart';
import 'unread_badge/should_notify_for_event.dart';
part 'channel_directory.dart';
part 'channel_member_snapshots.dart';
part 'channels_provider_lifecycle.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
const _unreadCatchUpLimit = 1000;
/// Relay requests one channel-history batch keeps in flight at most.
const _historyRequestConcurrency = 4;
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
/// Loads the user's channel list from the relay over WebSocket.
///
/// Membership loading resolves kind:39002 events tagged `#p:<my-pubkey>`,
/// then fetches kind:39000 metadata for those channel ids.
///
/// The paginated kind:39000 directory is fetched separately when Browse
/// channels opens, so discovery never delays the main Conversations screen.
/// Live updates are layered on top via chunked subscriptions on the `#h` tag
/// for any visible channel event kind. Chunks stay within the relay's explicit
/// channel cap and incoming events bump `lastMessageAt` for their channel.
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
static const _backstopInterval = Duration(seconds: 60);
final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {};
Future<void> _liveSubscriptionQueue = Future.value();
Future<void> _unreadCatchUp = Future.value();
int? _settledUnreadGeneration;
Completer<void> _unreadReadinessChanged = Completer<void>();
Set<String> _desiredLiveChannelIds = const {};
int _subscriptionVersion = 0;
int _nextLiveChunkGeneration = 0;
final Set<String> _terminallyClosedLiveChunks = {};
String? _subscriptionRelayBaseUrl;
Timer? _backstopTimer;
final Map<String, int> _latestObservedByChannel = {};
final Map<String, Map<String, ObservedUnreadEvent>>
_observedUnreadEventsByChannel = {};
Set<String> _participatedRootIds = {};
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;
String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
List<NostrEvent> _directoryMetas = const [];
Set<String> _hiddenDmIds = const {};
/// Fences directory responses to the relay and identity that requested them.
late final _ChannelRefreshCoordinator _refreshCoordinator =
_ChannelRefreshCoordinator.forRef(ref);
// Expose the notifier's protected Ref only to same-library extensions used to
// keep lifecycle code below the file-size ratchet.
Ref get _lifecycleRef => ref;
/// The member snapshot already returned while loading the channel list.
///
/// Mention autocomplete can use this synchronously while its independent
/// channel-member refresh is still in flight.
List<ChannelMember> cachedMembersForChannel(String channelId) =>
_memberSnapshotsByChannelId[channelId] ?? const [];
Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
Set<String> get hiddenDmIds => Set.unmodifiable(_hiddenDmIds);
bool get hasLoaded => _hasLoaded;
/// Waits for the current list's initial unread history to settle.
///
/// Presentation transitions can await this without blocking ordinary cached
/// channel rendering. Failure keeps observed unread state and permits landing;
/// follow a newer refresh if it replaces work we awaited.
Future<void> waitForUnreadCatchUp() async {
while (ref.mounted) {
final changed = _unreadReadinessChanged.future;
final subscriptions = _liveSubscriptionQueue;
await subscriptions;
if (!ref.mounted) return;
final unread = _unreadCatchUp;
await unread;
if (identical(subscriptions, _liveSubscriptionQueue) &&
identical(unread, _unreadCatchUp) &&
_settledUnreadGeneration == _subscriptionVersion) {
return;
}
// A disconnected generation has no settled destination history.
// Wake on reconciliation rather than accepting an older completed future.
await changed;
}
}
void _notifyUnreadReadinessChanged() {
final previous = _unreadReadinessChanged;
_unreadReadinessChanged = Completer<void>();
previous.complete();
}
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel =>
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
for (final entry in _observedUnreadEventsByChannel.entries)
entry.key: Map<String, ObservedUnreadEvent>.unmodifiable(entry.value),
});
@override
Future<List<Channel>> build() async {
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
_memberSnapshotPubkey != pubkey) {
// A cached snapshot is usable only within its relay and identity.
// A new scope must await its own connection and first channel fetch.
_hasLoaded = false;
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
_directoryMetas = const [];
_hiddenDmIds = const {};
// Retire any in-flight directory request: its response describes the
// previous relay or identity and must not reach this scope's state.
_refreshCoordinator.retireInFlight();
}
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
ref.listen(relaySessionProvider, (previous, next) {
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
} else if (previous?.status != SessionStatus.connected) {
// A new authenticated connection can change relay admission policy.
// Ordinary refreshes must not retry an unchanged terminal rejection.
_terminallyClosedLiveChunks.clear();
unawaited(_backstopRefresh());
}
});
// Re-fetch when the app returns to foreground so channels created on
// another device while mobile was backgrounded appear immediately.
ref.listen(appLifecycleProvider, (prev, next) {
if (next != AppLifecycleState.resumed) return;
// Resume is published before the session hears it. Let the session
// decide first: one that reconnects reconciles when it connects, so
// refreshing here too would fetch the same list twice.
scheduleMicrotask(() {
if (!ref.mounted) return;
if (ref.read(relaySessionProvider).status == SessionStatus.connected) {
refresh();
}
});
});
ref.onDispose(() {
_clearLiveSubscriptions();
_latestObservedByChannel.clear();
_observedUnreadEventsByChannel.clear();
_backstopTimer?.cancel();
_backstopTimer = null;
});
if (sessionState.status != SessionStatus.connected) {
// Preserve cached channels only for a reconnect within the same scope.
if (_hasLoaded) return state.value ?? const [];
await connected.future;
}
return _fetch(subscribeLive: true);
}
Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
bool fetchDirectory = false,
}) async {
final channels = await _fetchChannels(
subscribeLive: subscribeLive,
fetchLastMessage: fetchLastMessage,
fetchDirectory: fetchDirectory,
);
_hasLoaded = true;
return channels;
}
Future<List<Channel>> _fetchChannels({
bool subscribeLive = false,
bool fetchLastMessage = true,
bool fetchDirectory = false,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
_loadThreadInterestStores(myPk);
final session = ref.read(relaySessionProvider.notifier);
// Acquire request ownership before the first relay await. Every channel-list
// path uses this fence so completion order cannot let an older ordinary,
// directory, or reconnect refresh replace a newer membership list.
final fence = await _refreshCoordinator.beginRefresh(
fetchesDirectory: fetchDirectory,
);
// Step 1: find the channels I'm a member of via kind:39002.
final memberships = await _fenced(
fence,
_fetchChannelMemberships(session, myPk),
);
final memberChannelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
.toSet();
_cacheMemberSnapshots(memberships, replaceAll: true);
// Step 2: everything below the membership list depends only on it, so
// send those reads together instead of one round trip after another. A
// user with no memberships must still continue to directory discovery.
final memberChannelIdList = memberChannelIds.toList();
final memberMetasRequest = memberChannelIds.isEmpty
? Future.value(const <NostrEvent>[])
: session.fetchHistory(
NostrFilters.channelMetadata(memberChannelIdList),
);
final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk);
// The authoritative membership snapshots are needed to filter Huddle
// backing channels. The relay-signed kind:39000 metadata identifies the
// relay, not the channel creator; the owner role in kind:39002 is the
// canonical creator identity used to reject forged Huddle links.
final memberEventsRequest = memberChannelIds.isEmpty
? Future.value(const <NostrEvent>[])
: session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': memberChannelIdList},
limit: memberChannelIdList.length,
),
);
final huddleStartsRequest = _fetchHuddleStarts(
session,
memberChannelIdList,
);
final (
memberMetas,
hiddenDmIds,
memberEvents,
huddleStarts,
) = await _fenced(fence, () async {
// Future.wait handles every request's failure and rethrows the first
// one unchanged, as soon as it happens.
await Future.wait(eagerError: true, [
memberMetasRequest,
hiddenDmIdsRequest,
memberEventsRequest,
huddleStartsRequest,
]);
return (
await memberMetasRequest,
await hiddenDmIdsRequest,
await memberEventsRequest,
await huddleStartsRequest,
);
}());
// Step 3: fetch the open-channel directory. The relay filters this global
// kind:39000 query by the caller's access, but the client still rejects
// private channels and DMs below so discovery fails closed if that contract
// ever regresses. The composite cursor preserves tied-timestamp rows.
if (fetchDirectory) {
final metas = await _refreshCoordinator.loadDirectory(session, fence);
if (metas != null) _directoryMetas = metas;
}
// Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable,
// but stale revisions from before the relay's d_tag backfill can linger.
final latestMetaPerId = <String, NostrEvent>{};
for (final event in [...memberMetas, ..._directoryMetas]) {
if (event.kind != 39000) continue;
final id = event.getTagValue('d');
if (id == null) continue;
final existing = latestMetaPerId[id];
if (existing == null ||
event.createdAt > existing.createdAt ||
(event.createdAt == existing.createdAt &&
event.id.compareTo(existing.id) < 0)) {
latestMetaPerId[id] = event;
}
}
final dedupedMetas = latestMetaPerId.values.toList();
// Resolve DM participant display names. Extracted into the part file so
// `channels_provider.dart` stays under the 1200-line ceiling enforced by
// `just file-size-check`.
final displayNames = await _resolveDmDisplayNames(
session,
fence,
dedupedMetas,
myPk,
);
// Name resolution may return without a fenced read; check before writing.
fence.ensureCurrent();
_hiddenDmIds = Set.unmodifiable(hiddenDmIds);
final huddleBackingIds = huddleBackingChannelIds(
huddleStarts,
memberEvents,
);
final channels = <Channel>[];
for (final event in dedupedMetas) {
final id = event.getTagValue('d');
if (id == null) continue;
final isMember = memberChannelIds.contains(id);
final channel = _channelFromMeta(
event,
isMember: isMember,
displayNames: displayNames,
);
if (!isMember && (channel.isPrivate || channel.isDm)) continue;
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
if (huddleBackingIds.contains(channel.id) &&
channel.isStream &&
channel.isPrivate) {
continue;
}
// Ephemeral (TTL) channels are surfaced in the list with an
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
// hidden. Desktop shows them too. Previously dropped here unconditionally,
// which made TTL channels invisible on iOS even when the user was a member.
channels.add(channel);
}
// Use the membership snapshots already fetched above for both Huddle
// linkage validation and member-count hydration.
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
final memberCounts = _memberCountsByChannelId(memberEvents);
for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id];
if (count != null) {
channels[i] = channels[i].copyWith(memberCount: count);
}
}
// 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 backstop refreshes 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 = await _fenced(
fence,
_fetchLastMessageEvents(session, activeChannels),
);
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;
}
}
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,
),
);
}
}
}
channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
(_channelTypeOrder[right.channelType] ?? 99);
if (typeOrder != 0) return typeOrder;
// Case-insensitive to match desktop's `localeCompare` ordering.
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
});
// Details are a separate cache. Invalidate changes that would otherwise
// overwrite fresh Solo identity or archive state when opening a channel.
// Re-check before the first write that other providers can observe. Every
// await above is fenced, but the switch can also land in the synchronous
// gap, so the guard sits immediately before the write rather than only
// after the await.
fence.ensureCurrent();
final prevById = <String, Channel>{
for (final c in state.value ?? const <Channel>[]) c.id: c,
};
for (final channel in channels) {
final prev = prevById[channel.id];
if (prev == null ||
prev.isArchived != channel.isArchived ||
prev.channelType != channel.channelType ||
prev.visibility != channel.visibility ||
prev.createdBy != channel.createdBy ||
!listEquals(prev.labels, channel.labels)) {
ref.invalidate(channelDetailsProvider(channel.id));
}
}
// Layer live delivery onto the already-built snapshot without making EOSE
// part of the provider's readiness path. Every refresh queues a generation;
// a later refresh or scope switch retires older queued/in-flight work.
if (subscribeLive) {
unawaited(_subscribeLive(channels, fence));
}
// Guard the provider-state write in `retryDirectory` and `build`: the
// caller assigns whatever this returns, so the last check belongs here.
fence.ensureCurrent();
return channels;
}
/// Fetches each channel's independent latest-message window in one HTTP
/// bridge request. The relay preserves NIP-01 per-filter limits while
/// executing the filters with bounded concurrency, avoiding an unbounded
/// burst of websocket REQs on communities with many channels.
Future<List<NostrEvent>?> _fetchLastMessageEvents(
RelaySessionNotifier session,
List<Channel> channels,
) async {
if (channels.isEmpty) return const [];
final filters = [
for (final channel in channels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: channel.isDm ? 1 : 20,
),
];
return _fetchChannelHistoryBatch(
session,
filters,
operation: 'latest-message query',
);
}
/// Returns null when the batch is unavailable: it hit a relay deadline.
///
/// The relay rejects a request whose filters name more than
/// [kMaxExplicitChannelValues] channels in aggregate, so larger batches are
/// split into requests within that cap.
Future<List<NostrEvent>?> _fetchChannelHistoryBatch(
RelaySessionNotifier session,
List<NostrFilter> filters, {
required String operation,
}) async {
final requests = <List<NostrFilter>>[];
var channelValues = 0;
for (final filter in filters) {
final values = filter.tags['#h']?.length ?? 0;
if (requests.isEmpty ||
channelValues + values > kMaxExplicitChannelValues) {
requests.add([]);
channelValues = 0;
}
requests.last.add(filter);
channelValues += values;
}
final events = <NostrEvent>[];
final fallback = <NostrFilter>[];
var deadline = false;
for (
var start = 0;
start < requests.length;
start += _historyRequestConcurrency
) {
final wave = requests.sublist(
start,
min(start + _historyRequestConcurrency, requests.length),
);
final results = await Future.wait(
wave.map((request) async {
try {
return await session.queryRelay(request);
} catch (error) {
// Per-filter fallback would re-run the timed-out work another way.
if (isRelayDeadlineError(error)) {
deadline = true;
} else {
debugPrint(
'[ChannelsNotifier] batched $operation failed; '
'using bounded websocket fallback: $error',
);
}
return null;
}
}),
);
// A request that hit the deadline makes the whole batch unavailable.
if (deadline) {
debugPrint('[ChannelsNotifier] batched $operation hit the deadline');
return null;
}
for (final (index, result) in results.indexed) {
result == null ? fallback.addAll(wave[index]) : events.addAll(result);
}
}
if (fallback.isEmpty) return events;
// Fallback starts once every request has settled, so the batch never has
// more than the request concurrency in flight.
final fallbackEvents = await _fetchChannelHistoryFallback(
session,
fallback,
);
return fallbackEvents == null ? null : [...events, ...fallbackEvents];
}
Future<List<NostrEvent>?> _fetchChannelHistoryFallback(
RelaySessionNotifier session,
List<NostrFilter> filters,
) async {
final events = <NostrEvent>[];
var deadline = false;
for (
var start = 0;
start < filters.length;
start += _historyRequestConcurrency
) {
// Stop sending once a filter hit the deadline; partial events are not
// a result.
if (deadline) return null;
final end = min(start + _historyRequestConcurrency, filters.length);
final results = await Future.wait(
filters.sublist(start, end).map((filter) async {
try {
return await session.fetchHistory(filter);
} catch (error) {
// A timed-out filter makes the batch unavailable, not empty.
if (isRelayDeadlineError(error)) deadline = true;
return const <NostrEvent>[];
}
}),
);
for (final result in results) {
events.addAll(result);
}
}
if (deadline) return null;
return events;
}
/// Build a [Channel] from a kind:39000 metadata event.
///
/// [displayNames] maps lowercase participant pubkey → resolved label and is
/// used to populate [Channel.participants] for DMs so [Channel.displayLabel]
/// can render real names instead of the relay-canonical "DM" name.
Channel _channelFromMeta(
NostrEvent event, {
required bool isMember,
Map<String, String> displayNames = const {},
}) {
final data = ChannelData.fromEvent(event);
final participants = data.channelType == 'dm'
? [
for (final pk in data.participantPubkeys)
displayNames[pk.toLowerCase()] ?? shortPubkey(pk),
]
: const <String>[];
return Channel(
id: data.id,
name: data.name,
channelType: data.channelType,
labels: data.labels,
visibility: data.visibility,
description: data.description,
topic: data.topic,
createdBy: data.creator ?? event.pubkey,
createdAt: DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
),
memberCount: 0,
lastMessageAt: null,
// `archivedAt` doubles as both the archived-state flag and the timestamp.
// The kind:39000 metadata only carries `["archived", "true"]`, not the
// moment of archival, so we stamp the event's `createdAt` — that's when
// the relay republished the metadata, which is the closest signal we have.
archivedAt: data.isArchived
? DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
)
: null,
participants: participants,
participantPubkeys: data.participantPubkeys,
isMember: isMember,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
);
}
/// Backfills unread badges for the channels this refresh just installed.
///
/// Runs detached from the refresh that starts it, so the lifecycle token
/// captured below is what keeps a response that outlived its refresh from
/// writing unread state into whatever the user is looking at now. Every
/// refresh path starts a catch-up, including the initial load, the ordinary
/// membership refresh a join performs and the reconnect backstop, so the
/// token is unconditional rather than tied to discovery. A retired refresh
/// returns instead of throwing: nothing awaits this future, so a thrown
/// [_StaleChannelRefresh] would only surface as an unhandled error.
///
/// The request fence and subscription generation are passed from the refresh
/// that installed [channels], preserving request ownership after detachment.
Future<void> _catchUpUnreadEvents(
List<Channel> channels,
_ChannelRefreshFence fence,
int subscriptionGeneration,
) async {
if (!ref.mounted) return;
try {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final mutedChannelIds = _mutedChannelIds();
final readState = ref.read(readStateProvider);
final activeChannels = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final channelById = {
for (final channel in activeChannels) channel.id: channel,
};
final readAtByChannel = {
for (final channel in activeChannels)
channel.id: readState.effectiveTimestamp(channel.id),
};
final filters = [
for (final channel in activeChannels)
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
since: (readAtByChannel[channel.id] ?? -1) + 1,
limit: _unreadCatchUpLimit,
),
];
final events = await _fetchChannelHistoryBatch(
session,
filters,
operation: 'unread catch-up',
);
// Unavailable: keep the unread state already observed.
if (events == null) return;
// The relay round-trip above is the window Jed's probes park in: a newer
// refresh, a community switch or an identity switch here means every
// write below belongs to a channel list the user has left.
if (!ref.mounted || _isCatchUpRetired(fence, subscriptionGeneration)) {
return;
}
for (final event in events) {
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
_recordSelfThreadInterest(event, myPk);
}
}
final recordedAtByChannel = <String, int>{};
for (final event in events) {
final channelId = event.channelId;
if (channelId == null) continue;
final channel = channelById[channelId];
if (channel == null) continue;
final readAt = readAtByChannel[channelId];
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue;
if (readAt != null && event.createdAt <= readAt) continue;
if (!shouldNotifyForEvent(
event,
myPk,
participatedRootIds: _participatedRootIds,
followedRootIds: _followedRootIds(),
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
continue;
}
_recordUnreadEvent(channel, event, myPk);
recordedAtByChannel[channelId] = max(
recordedAtByChannel[channelId] ?? 0,
event.createdAt,
);
}
// Republish only when this catch-up actually changed unread state. A
// batch that recorded nothing has nothing to show, and a failed or
// superseded batch must not repaint another refresh's list: the retired
// check above already returned in that case, and no await separates it
// from here, so a second check would be dead code.
// Recorded events also advance `lastMessageAt`, as live events do: a
// deadline-unavailable latest-message batch leaves it unset.
if (recordedAtByChannel.isNotEmpty) {
state = state.whenData(
(channels) => [
for (final channel in channels)
_advanceLastMessageAt(channel, recordedAtByChannel[channel.id]),
],
);
}
} catch (error) {
if (!ref.mounted) return;
debugPrint('[ChannelsNotifier] unread catch-up failed: $error');
} finally {
// Optional history can terminate without data. Release this destination
// with its retained unread state, but never settle a retired request.
if (ref.mounted && !_isCatchUpRetired(fence, subscriptionGeneration)) {
_settledUnreadGeneration = subscriptionGeneration;
_notifyUnreadReadinessChanged();
}
}
}
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) {
final channelId = event.channelId;
if (channelId == null) return;
final myPk = ref.read(myPubkeyProvider);
final mutedChannelIds = _mutedChannelIds();
state = state.whenData((channels) {
final idx = channels.indexWhere((c) => c.id == channelId);
if (idx == -1) {
refresh();
return channels;
}
final updated = List<Channel>.of(channels);
final channel = updated[idx];
if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) {
_recordSelfThreadInterest(event, myPk);
}
if (myPk != null &&
shouldNotifyForEvent(
event,
myPk,
participatedRootIds: _participatedRootIds,
followedRootIds: _followedRootIds(),
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
_recordUnreadEvent(channel, event, myPk);
final eventTime = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
if (channel.lastMessageAt == null ||
eventTime.isAfter(channel.lastMessageAt!)) {
updated[idx] = channel.copyWith(lastMessageAt: eventTime);
}
}
return updated;
});
}
Set<String> _mutedChannelIds() => {
for (final entry in ref.read(channelMutesProvider).store.channels.entries)
if (entry.value.muted) entry.key,
};
Set<String> _followedRootIds() =>
ref.read(threadFollowsProvider).followedRootIds;
void _loadThreadInterestStores(String pubkey) {
final normalizedPubkey = pubkey.toLowerCase();
if (_threadInterestPubkey == normalizedPubkey) return;
_threadInterestPubkey = normalizedPubkey;
try {
final prefs = ref.read(savedPrefsProvider);
_participatedRootIds = _readRootIdSet(
prefs.getString('$_participatedRootIdsPrefix:$normalizedPubkey'),
);
_authoredRootIds = _readRootIdSet(
prefs.getString('$_authoredRootIdsPrefix:$normalizedPubkey'),
);
} catch (_) {
_participatedRootIds = {};
_authoredRootIds = {};
}
}
void _recordSelfThreadInterest(NostrEvent event, String pubkey) {
final ref = event.threadReference;
final target = ref.rootId != null ? _participatedRootIds : _authoredRootIds;
final id = ref.rootId ?? event.id;
if (!target.add(id)) return;
_writeThreadInterestStores(pubkey);
}
void _writeThreadInterestStores(String pubkey) {
final normalizedPubkey = pubkey.toLowerCase();
try {
final prefs = ref.read(savedPrefsProvider);
prefs.setString(
'$_participatedRootIdsPrefix:$normalizedPubkey',
_encodeRootIdSet(_participatedRootIds),
);
prefs.setString(
'$_authoredRootIdsPrefix:$normalizedPubkey',
_encodeRootIdSet(_authoredRootIds),
);
} catch (_) {
// Ignore storage failures; in-memory interest still works this session.
}
}
void clearObservedUnreadForChannel(String channelId) {
_latestObservedByChannel.remove(channelId);
_observedUnreadEventsByChannel.remove(channelId);
state = state.whenData((channels) => List<Channel>.of(channels));
}
void clearObservedUnreadCoveredByRead(String channelId, int readAt) {
final latest = _latestObservedByChannel[channelId];
if (latest != null && latest <= readAt) {
clearObservedUnreadForChannel(channelId);
}
}
/// Backstop refresh that preserves existing state on transient failure.
Future<void> _backstopRefresh() async {
try {
final sessionState = ref.read(relaySessionProvider);
final prevChannels = state.value ?? const [];
final prevLastMessage = {
for (final c in prevChannels)
if (c.lastMessageAt != null) c.id: c.lastMessageAt,
};
final channels = await _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
fetchLastMessage: false,
fetchDirectory: false,
);
for (var i = 0; i < channels.length; i++) {
final prev = prevLastMessage[channels[i].id];
if (channels[i].lastMessageAt == null && prev != null) {
channels[i] = channels[i].copyWith(lastMessageAt: prev);
}
}
state = AsyncData(channels);
} on _StaleChannelRefresh {
return;
} catch (error) {
debugPrint('[ChannelsNotifier] backstop refresh failed: $error');
}
}
/// Refreshes memberships and, when [fetchDirectory], the open directory.
/// Invite starter recovery opts in to converge on another identity's
/// starters instead of attempting duplicate creation from memberships.
Future<void> refresh({bool fetchDirectory = false}) async {
final sessionState = ref.read(relaySessionProvider);
// Don't attempt to fetch when the session isn't connected — fetchHistory
// would send REQs over an unauthenticated socket that either time out
// (returning empty results) or get cancelled on disconnect, replacing the
// cached channel list with [] or an error. Wait for `build()` to re-run
// when the session transitions to connected.
if (sessionState.status != SessionStatus.connected) return;
try {
final channels = await _fetch(
subscribeLive: true,
fetchDirectory: fetchDirectory,
);
state = AsyncData(channels);
} on _StaleChannelRefresh {
return;
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
}
}
/// Loads the directory when Browse channels opens after startup or an error.
Future<void> ensureDirectoryLoaded() async {
final directoryState = ref.read(channelDirectoryLoadStatusProvider);
final scope = channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
if (directoryState.scope == scope &&
(directoryState.status == ChannelDirectoryLoadStatus.loading ||
directoryState.status == ChannelDirectoryLoadStatus.loaded)) {
return;
}
await retryDirectory();
}
/// Retries channel discovery while retaining the current channel list.
Future<void> retryDirectory() async {
final previousChannels = state.value;
final directoryStatus = ref.read(
channelDirectoryLoadStatusProvider.notifier,
);
final scope = channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
final directoryState = ref.read(channelDirectoryLoadStatusProvider);
if (directoryState.scope == scope &&
directoryState.status == ChannelDirectoryLoadStatus.loading) {
return;
}
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
directoryStatus.markError(scope);
return;
}
try {
state = AsyncData(
await _fetch(subscribeLive: true, fetchDirectory: true),
);
} on _StaleChannelRefresh {
// A community or identity switch retired this request. Its response
// describes a scope the user has left, so write neither the channel list
// nor the load status; the new scope owns both now.
return;
} catch (error, stackTrace) {
directoryStatus.markError(scope);
state = previousChannels == null
? AsyncError(error, stackTrace)
: AsyncData(previousChannels);
}
}
}
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
ChannelsNotifier.new,
);