diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index e0879eb0222..8334db56f78 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -53,6 +53,14 @@ class ChannelActions { _ensureCommunityValid(); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); _ensureCommunityValid(); + // The relay provisions the creator's kind:39002 membership asynchronously + // after kind:9007. Overlay this identity's ownership now so the refresh + // below cannot race that write and drop the channel from the list (#7780, + // mirrors Desktop's `mark_pending_owned_channel`). The overlay clears + // itself once real membership is observed. + _ref + .read(channelsProvider.notifier) + .markPendingOwnedChannel(resolvedChannelId); return _refreshChannelsAndRead(resolvedChannelId); } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index c5a77ba930d..d7fea3e3d1b 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -38,6 +38,13 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Membership loading resolves kind:39002 events tagged `#p:`, /// then fetches kind:39000 metadata for those channel ids. /// +/// Channels this identity just created are remembered locally until their +/// relay-side kind:39002 membership entry is observable. The relay provisions +/// that entry asynchronously after kind:9007, so without this overlay a +/// just-created channel can vanish from the list between creation and the +/// membership write landing (#7780, mirrors Desktop's +/// `AppState::pending_owned_channels`). +/// /// 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 @@ -48,6 +55,14 @@ class ChannelsNotifier extends AsyncNotifier> { bool _pushCacheExporting = false; bool _pushCacheDirty = false; + /// Channel ids this identity created whose relay-side kind:39002 membership + /// entry has not been observed yet. + /// + /// Keyed by the relay-and-identity scope that created them so a community or + /// identity switch can never carry a stale overlay into a new scope. Cleared + /// per id as soon as the membership query returns the channel. + final Map> _pendingOwnedChannelIdsByScope = {}; + static const _backstopInterval = Duration(seconds: 60); final Map _liveSubscriptionsByChunk = {}; @@ -111,6 +126,9 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotsByChannelId = const {}; _directoryMetas = const []; _hiddenDmIds = const {}; + // The pending-owner overlay describes channels a previous relay or + // identity created; it must never classify channels in this scope. + _pendingOwnedChannelIdsByScope.clear(); // Retire any in-flight directory request: its response describes the // previous relay or identity and must not reach this scope's state. _refreshCoordinator.retireInFlight(); @@ -200,16 +218,34 @@ class ChannelsNotifier extends AsyncNotifier> { .map((e) => e.getTagValue('d')) .whereType() .toSet(); + + // Pending-owner overlay: channels this identity created whose kind:39002 + // membership entry is not observable yet. The relay provisions it + // asynchronously after kind:9007, so a refresh that races that write would + // otherwise drop the just-created channel entirely (#7780). Treat pending + // ids as members for this refresh; clear each one once real membership + // lands so a later leave still flips `is_member` back to false. + final scope = fence.scope; + final pendingOwnedIds = + _pendingOwnedChannelIdsByScope[scope] ?? const {}; + if (pendingOwnedIds.isNotEmpty) { + _clearPendingOwnedChannels(scope, memberChannelIds); + } + final effectiveMemberIds = { + ...memberChannelIds, + ...(_pendingOwnedChannelIdsByScope[scope] ?? const {}), + }; _cacheMemberSnapshots(memberships, replaceAll: true); - // Step 2: pull metadata for joined channels. A user with no memberships - // must still continue to directory discovery below. - final memberMetas = memberChannelIds.isEmpty + // Step 2: pull metadata for joined channels (and any still-pending + // creations, whose metadata exists but whose membership may not). A user + // with no memberships must still continue to directory discovery below. + final memberMetas = effectiveMemberIds.isEmpty ? const [] : await _fenced( fence, session.fetchHistory( - NostrFilters.channelMetadata(memberChannelIds.toList()), + NostrFilters.channelMetadata(effectiveMemberIds.toList()), ), ); @@ -255,7 +291,7 @@ class ChannelsNotifier extends AsyncNotifier> { // 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 memberCountChannelIds = memberChannelIds.toList(); + final memberCountChannelIds = effectiveMemberIds.toList(); final memberEvents = memberCountChannelIds.isEmpty ? const [] : await _fenced( @@ -283,7 +319,12 @@ class ChannelsNotifier extends AsyncNotifier> { for (final event in dedupedMetas) { final id = event.getTagValue('d'); if (id == null) continue; - final isMember = memberChannelIds.contains(id); + // A pending-owned channel classifies as a member: this identity created + // it, so the missing kind:39002 entry is provisioning lag, not a real + // non-membership. + final isMember = + memberChannelIds.contains(id) || + (_pendingOwnedChannelIdsByScope[scope]?.contains(id) ?? false); final channel = _channelFromMeta( event, isMember: isMember, @@ -419,6 +460,35 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } + /// Returns the current scope key for the pending-owner overlay. + String get _overlayScope => channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + + /// Marks [channelId] pending-owned for the active relay-and-identity scope. + /// + /// Called right after a successful kind:9007 submission, before the refresh + /// that must already show the channel. Mirrors Desktop's + /// `AppState::mark_pending_owned_channel`. + void markPendingOwnedChannel(String channelId) { + final id = channelId.trim(); + if (id.isEmpty) return; + (_pendingOwnedChannelIdsByScope[_overlayScope] ??= {}).add(id); + } + + /// Drops [channelIds] from the pending-owner overlay for [scope]. + /// + /// Real kind:39002 membership has landed for these channels, so the overlay + /// must stop classifying them: otherwise a later leave could be masked and + /// the channel would stay visible with `is_member=true`. + void _clearPendingOwnedChannels(String scope, Iterable channelIds) { + final pending = _pendingOwnedChannelIdsByScope[scope]; + if (pending == null || pending.isEmpty) return; + pending.removeAll(channelIds); + if (pending.isEmpty) _pendingOwnedChannelIdsByScope.remove(scope); + } + /// 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 diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 3bb1be5beb9..3dee148222d 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1518,6 +1518,108 @@ void main() { expect(session.directoryQueryFilters, isNotEmpty); }); + test( + 'pending-owner overlay keeps a just-created channel visible (#7780)', + () async { + final session = _FakeRelaySession( + // The relay's async kind:39002 provisioning has not landed yet: the + // membership query returns nothing for the new channel. + memberships: const [], + metadata: [_meta(id: _channelA, name: 'project-channel')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + container + .read(channelsProvider.notifier) + .markPendingOwnedChannel(_channelA); + await container.read(channelsProvider.notifier).refresh(); + + final channels = container.read(channelsProvider).requireValue; + expect(channels, hasLength(1)); + expect(channels.single.id, _channelA); + expect(channels.single.isMember, isTrue); + // The overlay must extend the membership query so the channel's metadata + // is fetched even though no kind:39002 lists it yet. + expect( + session.historyFilters, + anyElement( + predicate( + (filter) => + filter.kinds.contains(39000) && + (filter.tags['#d'] ?? const []).contains(_channelA), + 'member metadata filter covering the pending channel', + ), + ), + ); + }, + ); + + test( + 'pending-owner overlay clears once real kind:39002 membership lands', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'project-channel')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final notifier = container.read(channelsProvider.notifier); + notifier.markPendingOwnedChannel(_channelA); + await notifier.refresh(); + expect( + container.read(channelsProvider).requireValue.single.isMember, + isTrue, + ); + + // Provisioning completes: the membership query now lists the channel. + session.memberships = [_membership(_channelA, myPk)]; + await notifier.refresh(); + + final channels = container.read(channelsProvider).requireValue; + expect(channels.single.id, _channelA); + expect(channels.single.isMember, isTrue); + + // The membership entry disappeared again (e.g. the user left) — the + // overlay must NOT re-classify the channel as a member. + session.memberships = const []; + await notifier.refresh(); + final afterLeave = container.read(channelsProvider).requireValue; + // The open directory no longer applies (fetchDirectory was never + // fetched), so the channel drops from the member-only list. + expect(afterLeave, isEmpty); + }, + ); + + test( + 'pending-owner overlay does not leak across an identity switch', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'project-channel')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + container + .read(channelsProvider.notifier) + .markPendingOwnedChannel(_channelA); + + container.read(_testPubkeyProvider.notifier).set('someone-else'); + await _waitUntil(() => container.read(channelsProvider).hasValue); + await container.read(channelsProvider.notifier).refresh(); + + // The new identity never created the channel; the overlay scoped to the + // old identity must not classify it as theirs. + expect(container.read(channelsProvider).requireValue, isEmpty); + }, + ); + test('deduplicates joined channels from directory discovery', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)],