diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index daec4f944..55dcd0a6e 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -108,6 +108,40 @@ final currentPubkeyProvider = Provider((ref) { return null; }); +/// Build [ChannelDetails] from a kind:39000 metadata event. +/// +/// Exposed as a pure function so the mapping can be unit-tested without +/// Riverpod / WebSocket scaffolding. Make sure all fields parsed by +/// [ChannelData.fromEvent] that exist on [ChannelDetails] are propagated — +/// any omission silently drops state when [Channel.mergeDetails] is called. +@visibleForTesting +ChannelDetails channelDetailsFromEvent(NostrEvent event) { + final data = ChannelData.fromEvent(event); + final eventTime = DateTime.fromMillisecondsSinceEpoch( + event.createdAt * 1000, + isUtc: true, + ); + return ChannelDetails( + id: data.id, + name: data.name, + channelType: data.channelType, + visibility: data.visibility, + description: data.description, + topic: data.topic, + createdBy: event.pubkey, + createdAt: eventTime, + memberCount: 0, + // Same archival-timestamp convention as `_channelFromMeta` — the event's + // `createdAt` is when the relay republished the metadata. Without this, + // `Channel.mergeDetails(details)` would clobber the archived state set + // on the base channel and the detail view would show compose/manage + // actions for expired/archived channels. + archivedAt: data.isArchived ? eventTime : null, + ttlSeconds: data.ttlSeconds, + ttlDeadline: data.ttlDeadline, + ); +} + /// Single channel's metadata via kind:39000. final channelDetailsProvider = FutureProvider.family(( ref, @@ -126,22 +160,7 @@ final channelDetailsProvider = FutureProvider.family(( if (events.isEmpty) { throw Exception('Channel not found: $channelId'); } - final event = events.first; - final data = ChannelData.fromEvent(event); - return ChannelDetails( - id: data.id, - name: data.name, - channelType: data.channelType, - visibility: data.visibility, - description: data.description, - topic: data.topic, - createdBy: event.pubkey, - createdAt: DateTime.fromMillisecondsSinceEpoch( - event.createdAt * 1000, - isUtc: true, - ), - memberCount: 0, - ); + return channelDetailsFromEvent(events.first); }); /// Channel members from kind:39002 NIP-29 members event. diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index e6c892f16..27fd4c1a1 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; +import 'channel_management_provider.dart' show channelDetailsProvider; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; @@ -131,9 +132,11 @@ class ChannelsNotifier extends AsyncNotifier> { isMember: true, displayNames: displayNames, ); - if (!channel.isEphemeral) { - channels.add(channel); - } + // 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); } channels.sort((left, right) { @@ -145,6 +148,26 @@ class ChannelsNotifier extends AsyncNotifier> { return left.name.toLowerCase().compareTo(right.name.toLowerCase()); }); + // Invalidate `channelDetailsProvider` entries whose archived state flipped + // since the last fetch. Required because `channelDetailsProvider` is a + // separate Riverpod cache and `Channel.mergeDetails(details)` overwrites + // archivedAt from the cached details — so an active-then-archived channel + // (e.g. TTL auto-archive by the relay reaper) could keep showing compose + // and manage actions in the detail view until the cache expired naturally. + // + // Scoped narrowly to the archived flip — broader metadata staleness + // (renames, topic changes, etc.) is a separate, pre-existing concern that + // already affects this provider for other reasons. + final prevById = { + for (final c in state.value ?? const []) c.id: c, + }; + for (final channel in channels) { + final prev = prevById[channel.id]; + if (prev != null && prev.isArchived != channel.isArchived) { + ref.invalidate(channelDetailsProvider(channel.id)); + } + } + if (subscribeLive) { await _subscribeLive(channels); } @@ -182,6 +205,16 @@ class ChannelsNotifier extends AsyncNotifier> { ), 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, diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index f0a78906d..a36aeb0b3 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -248,6 +248,7 @@ class ChannelData { final List participantPubkeys; final int? ttlSeconds; final DateTime? ttlDeadline; + final bool isArchived; const ChannelData({ required this.id, @@ -259,6 +260,7 @@ class ChannelData { this.participantPubkeys = const [], this.ttlSeconds, this.ttlDeadline, + this.isArchived = false, }); factory ChannelData.fromEvent(NostrEvent event) { @@ -290,6 +292,12 @@ class ChannelData { final ttlDeadline = ttlDeadlineRaw != null ? DateTime.tryParse(ttlDeadlineRaw) : null; + // Relay republishes kind:39000 with `["archived", "true"]` when a channel + // is archived (including the auto-archive emitted by the TTL reaper). The + // tag value "false" is also accepted server-side, so only treat "true" as + // archived — anything else (missing tag, "false", unexpected value) means + // active. + final isArchived = event.getTagValue('archived') == 'true'; return ChannelData( id: id, name: name, @@ -300,6 +308,7 @@ class ChannelData { participantPubkeys: participants, ttlSeconds: ttlSeconds, ttlDeadline: ttlDeadline, + isArchived: isArchived, ); } } diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart new file mode 100644 index 000000000..3abef763c --- /dev/null +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/channel_management_provider.dart'; +import 'package:sprout_mobile/shared/relay/relay.dart'; + +/// Tests for [channelDetailsFromEvent]. +/// +/// The function maps a kind:39000 metadata event to [ChannelDetails], and is +/// the source of truth for the merge that [Channel.mergeDetails] performs in +/// the channel detail view. Anything `ChannelData.fromEvent` parses that's +/// also exposed on `ChannelDetails` MUST be propagated here — otherwise +/// `mergeDetails` silently clears that state on the merged Channel. +void main() { + test('propagates archived state from kind:39000 archived tag', () { + // Regression: previously this mapping ignored the `archived` tag, so + // `Channel.mergeDetails` would clear the archived flag the list provider + // had set, and the detail screen would show compose/manage actions for + // expired/archived TTL channels. + final details = channelDetailsFromEvent( + NostrEvent( + id: 'meta-1', + pubkey: 'creator', + createdAt: 1700000000, + kind: 39000, + tags: const [ + ['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'], + ['name', 'expired-ttl'], + ['t', 'stream'], + ['public'], + ['ttl', '86400'], + ['archived', 'true'], + ], + content: '', + sig: 'sig', + ), + ); + + expect(details.archivedAt, isNotNull); + expect(details.ttlSeconds, 86400); + }); + + test('omits archivedAt when no archived tag is present', () { + final details = channelDetailsFromEvent( + NostrEvent( + id: 'meta-1', + pubkey: 'creator', + createdAt: 1700000000, + kind: 39000, + tags: const [ + ['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'], + ['name', 'active'], + ['t', 'stream'], + ['public'], + ], + content: '', + sig: 'sig', + ), + ); + + expect(details.archivedAt, isNull); + expect(details.ttlSeconds, isNull); + }); + + test('propagates ttl_deadline tag', () { + final details = channelDetailsFromEvent( + NostrEvent( + id: 'meta-1', + pubkey: 'creator', + createdAt: 1700000000, + kind: 39000, + tags: const [ + ['d', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'], + ['name', 'with-deadline'], + ['t', 'stream'], + ['public'], + ['ttl', '86400'], + ['ttl_deadline', '2026-05-14T19:54:06.989151+00:00'], + ], + content: '', + sig: 'sig', + ), + ); + + expect(details.ttlSeconds, 86400); + expect(details.ttlDeadline, isNotNull); + expect(details.ttlDeadline!.isUtc, isTrue); + }); +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 315936c74..e4a158d17 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/features/channels/channel_management_provider.dart'; import 'package:sprout_mobile/features/channels/channels_provider.dart'; import 'package:sprout_mobile/shared/relay/relay.dart'; @@ -80,6 +81,140 @@ void main() { expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000); }); + test('ephemeral (TTL) channels appear in the list', () async { + // Regression: previously the provider unconditionally dropped any channel + // with a `ttl` tag, which made TTL channels invisible on iOS even when the + // user was a member. They should be included so the existing + // `_EphemeralBadge` UI in `channels_page.dart` can render them. + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta( + id: _channelB, + name: 'agent-creation-deep-dive', + ttlSeconds: 86400, + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect( + channels.map((c) => c.name), + containsAll(['general', 'agent-creation-deep-dive']), + ); + final ephemeral = channels.firstWhere( + (c) => c.name == 'agent-creation-deep-dive', + ); + expect(ephemeral.isEphemeral, isTrue); + expect(ephemeral.ttlSeconds, 86400); + }); + + test( + 'archived kind:39000 metadata sets Channel.isArchived (covers TTL auto-archive)', + () async { + // The relay's TTL reaper auto-archives expired ephemeral channels and + // republishes kind:39000 with `["archived", "true"]`. The Channel needs + // `archivedAt != null` so the `_SliverChannelsList` filter + // (`!channel.isArchived`) hides it from the sidebar after expiry. + // Previously the mobile parser ignored the `archived` tag, so expired + // TTL channels would have stayed visible after the `!isEphemeral` guard + // was removed. + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'active'), + _meta( + id: _channelB, + name: 'expired-ttl', + ttlSeconds: 86400, + archived: true, + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + final expired = channels.firstWhere((c) => c.name == 'expired-ttl'); + expect(expired.isArchived, isTrue); + expect(expired.isEphemeral, isTrue); + // The active channel must not be flagged archived. + final active = channels.firstWhere((c) => c.name == 'active'); + expect(active.isArchived, isFalse); + }, + ); + + test( + 'archive transition invalidates cached channelDetailsProvider', + () async { + // Codex review v2 caught: if a TTL channel is opened (caching its + // ChannelDetails) and then the reaper archives it, the cached details + // — built from the pre-archive kind:39000 — would clobber the newer + // archivedAt set on the base Channel during `mergeDetails`. We invalidate + // the details provider when the archived state flips so the next + // mergeDetails sees fresh data. + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'active')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + // Initial load. + final initial = await container.read(channelsProvider.future); + expect(initial.single.isArchived, isFalse); + + // Prime the detail cache. + final detailsFiltersBefore = session.historyFilters + .where((f) => f.kinds.contains(39000) && f.tags['#d'] != null) + .length; + await container.read(channelDetailsProvider(_channelA).future); + final detailsFetchesAfterPrime = + session.historyFilters + .where((f) => f.kinds.contains(39000) && f.tags['#d'] != null) + .length - + detailsFiltersBefore; + expect(detailsFetchesAfterPrime, 1); + + // Simulate the reaper auto-archiving the channel by swapping the + // metadata the fake returns, then refreshing the channels provider. + session.metadata + ..clear() + ..add(_meta(id: _channelA, name: 'active', archived: true)); + await container.read(channelsProvider.notifier).refresh(); + final refreshed = container.read(channelsProvider).value!; + expect(refreshed.single.isArchived, isTrue); + + // Take a fresh baseline AFTER the refresh — the refresh itself issues a + // `kinds:[39000], #d:[id]` query as part of channel metadata refetch and + // we must not count that toward our invalidation assertion. Only the + // fetch triggered by the second `channelDetailsProvider` read should be + // attributed to invalidation. + final detailsFiltersAfterRefresh = session.historyFilters + .where((f) => f.kinds.contains(39000) && f.tags['#d'] != null) + .length; + + // Reading the details provider again must trigger a fresh fetch — proving + // the prior cache was invalidated by the archive transition. Without + // invalidation, Riverpod would return the cached pre-archive details and + // no new `kinds:[39000], #d:[id]` filter would be sent. + await container.read(channelDetailsProvider(_channelA).future); + final detailsFetchesFromInvalidation = + session.historyFilters + .where((f) => f.kinds.contains(39000) && f.tags['#d'] != null) + .length - + detailsFiltersAfterRefresh; + expect(detailsFetchesFromInvalidation, greaterThan(0)); + }, + ); + test('initial fetch issues membership + metadata queries', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -126,6 +261,8 @@ NostrEvent _meta({ required String name, String channelType = 'stream', int createdAt = 1, + int? ttlSeconds, + bool archived = false, }) => NostrEvent( id: 'meta-$id', pubkey: 'creator', @@ -136,6 +273,8 @@ NostrEvent _meta({ ['name', name], ['t', channelType], ['public'], + if (ttlSeconds != null) ['ttl', '$ttlSeconds'], + if (archived) ['archived', 'true'], ], content: '', sig: 'sig',