mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user