mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## 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>
1010 lines
37 KiB
Dart
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,
|
|
);
|