fix(mobile): show ephemeral (TTL) channels in the channel list (#571)

Signed-off-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-13 15:15:18 -07:00
committed by GitHub
co-authored by Dawn
parent efbdc7bfd5
commit b176b52b70
5 changed files with 306 additions and 19 deletions
@@ -108,6 +108,40 @@ final currentPubkeyProvider = Provider<String?>((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<ChannelDetails, String>((
ref,
@@ -126,22 +160,7 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
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.
@@ -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<List<Channel>> {
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<List<Channel>> {
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 = <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) {
ref.invalidate(channelDetailsProvider(channel.id));
}
}
if (subscribeLive) {
await _subscribeLive(channels);
}
@@ -182,6 +205,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
),
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,
@@ -248,6 +248,7 @@ class ChannelData {
final List<String> 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,
);
}
}
@@ -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);
});
}
@@ -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',