Skip to content
Merged
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
51 changes: 35 additions & 16 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
39 changes: 36 additions & 3 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions mobile/lib/shared/relay/nostr_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ class ChannelData {
final List<String> participantPubkeys;
final int? ttlSeconds;
final DateTime? ttlDeadline;
final bool isArchived;

const ChannelData({
required this.id,
Expand All @@ -259,6 +260,7 @@ class ChannelData {
this.participantPubkeys = const [],
this.ttlSeconds,
this.ttlDeadline,
this.isArchived = false,
});

factory ChannelData.fromEvent(NostrEvent event) {
Expand Down Expand Up @@ -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,
Expand All @@ -300,6 +308,7 @@ class ChannelData {
participantPubkeys: participants,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isArchived: isArchived,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading
Loading