Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
82 changes: 76 additions & 6 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
/// Membership loading resolves kind:39002 events tagged `#p:<my-pubkey>`,
/// 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
Expand All @@ -48,6 +55,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
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<String, Set<String>> _pendingOwnedChannelIdsByScope = {};

static const _backstopInterval = Duration(seconds: 60);

final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {};
Expand Down Expand Up @@ -111,6 +126,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_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();
Expand Down Expand Up @@ -200,16 +218,34 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
.map((e) => e.getTagValue('d'))
.whereType<String>()
.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 <String>{};
if (pendingOwnedIds.isNotEmpty) {
_clearPendingOwnedChannels(scope, memberChannelIds);
}
final effectiveMemberIds = <String>{
...memberChannelIds,
...(_pendingOwnedChannelIdsByScope[scope] ?? const <String>{}),
};
_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 <NostrEvent>[]
: await _fenced(
fence,
session.fetchHistory(
NostrFilters.channelMetadata(memberChannelIds.toList()),
NostrFilters.channelMetadata(effectiveMemberIds.toList()),
),
);

Expand Down Expand Up @@ -255,7 +291,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// 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 <NostrEvent>[]
: await _fenced(
Expand Down Expand Up @@ -283,7 +319,12 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
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,
Expand Down Expand Up @@ -419,6 +460,35 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
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<String> 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
Expand Down
102 changes: 102 additions & 0 deletions mobile/test/features/channels/channels_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<NostrFilter>(
(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)],
Expand Down
Loading