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
6 changes: 3 additions & 3 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,9 @@ InviteJoinRecovery buildMobileInviteJoinRecovery(
.read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future)
.timeout(const Duration(seconds: 15));
ensureScopeCurrent();
await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true);
ensureScopeCurrent();
final channels = await ref.read(channelsProvider.future);
final channels = await ref
.read(channelsProvider.notifier)
.fetchFresh(fetchDirectory: true);
ensureScopeCurrent();
return channels;
},
Expand Down
22 changes: 22 additions & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ class Channel {
: null,
);

/// Inverse of [Channel.fromJson].
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'channel_type': channelType,
'labels': labels,
'visibility': visibility,
'description': description,
'topic': topic,
'purpose': purpose,
'created_by': createdBy,
'created_at': createdAt.toIso8601String(),
'member_count': memberCount,
'last_message_at': lastMessageAt?.toIso8601String(),
'archived_at': archivedAt?.toIso8601String(),
'participants': participants,
'participant_pubkeys': participantPubkeys,
'is_member': isMember,
'ttl_seconds': ttlSeconds,
'ttl_deadline': ttlDeadline?.toIso8601String(),
};

bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;

/// Solo presentation is carried by NIP-CL metadata, not project filing.
Expand Down
46 changes: 33 additions & 13 deletions mobile/lib/features/channels/channel_directory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,15 @@ Future<T> _fenced<T>(_ChannelRefreshFence fence, Future<T> future) async {
/// Lives in this part file because `channels_provider.dart` sits against the
/// repository-wide 1200-line file ceiling enforced by `just file-size-check`.
Future<Map<String, String>> _resolveDmDisplayNames(
RelaySessionNotifier session,
Ref ref,
_ChannelRefreshFence fence,
Iterable<NostrEvent> dedupedMetas,
String myPk,
) async {
Iterable<ChannelData> channelMetas,
String myPk, {
required bool refreshCached,
}) async {
final dmParticipants = <String>{};
final myPkLower = myPk.toLowerCase();
for (final event in dedupedMetas) {
final data = ChannelData.fromEvent(event);
for (final data in channelMetas) {
if (data.channelType != 'dm') continue;
for (final pk in data.participantPubkeys) {
final lower = pk.toLowerCase();
Expand All @@ -212,24 +212,44 @@ Future<Map<String, String>> _resolveDmDisplayNames(
}
if (dmParticipants.isEmpty) return const {};

final profileEvents = await _fenced(
final profiles = await _fenced(
fence,
session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())),
ref
.read(userCacheProvider.notifier)
.resolve(dmParticipants.toList(), refreshCached: refreshCached),
);
final displayNames = <String, String>{};
for (final event in profileEvents) {
if (event.kind != 0) continue;
final profile = ProfileData.fromEvent(event);
for (final profile in profiles.values) {
final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: profile.nip05?.trim().isNotEmpty == true
? profile.nip05!.trim()
: profile.nip05Handle?.trim().isNotEmpty == true
? profile.nip05Handle!.trim()
: shortPubkey(profile.pubkey);
displayNames[profile.pubkey.toLowerCase()] = label;
}
return displayNames;
}

List<NostrEvent> _latestChannelEvents(
Iterable<NostrEvent> events, {
required int kind,
}) {
final latestById = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != kind) continue;
final id = event.getTagValue('d');
if (id == null) continue;
final current = latestById[id];
if (current == null ||
event.createdAt > current.createdAt ||
(event.createdAt == current.createdAt &&
event.id.compareTo(current.id) < 0)) {
latestById[id] = event;
}
}
return latestById.values.toList();
}

Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
Expand Down
70 changes: 70 additions & 0 deletions mobile/lib/features/channels/channel_snapshot.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
part of 'channels_provider.dart';

extension _ChannelSnapshots on ChannelsNotifier {
ChannelListSnapshotStorage? _snapshotStorage() =>
_lifecycleRef.read(channelListSnapshotStorageProvider);

List<Channel>? _restoreSnapshot(String? pubkey, String relay) {
if (pubkey == null) return null;
final storage = _snapshotStorage();
final snapshot = storage?.read(pubkey, relay);
if (snapshot == null) return null;
try {
final channels = snapshot.channels
.map(Channel.fromJson)
.where(
(channel) =>
channel.isMember && !snapshot.hiddenDmIds.contains(channel.id),
)
.toList();
_hiddenDmIds = Set.unmodifiable(snapshot.hiddenDmIds);
_hasLoaded = true;
_hasCachedSnapshot = true;
_needsSnapshotRevalidation = true;
return channels;
} catch (_) {
unawaited(
storage!.remove(pubkey, relay).catchError((Object error) {
debugPrint(
'[ChannelsNotifier] invalid snapshot removal failed: $error',
);
}),
);
return null;
}
}

void Function(List<Channel>, Set<String>)? _captureSnapshotWriter(
String pubkey,
String relay,
) {
final storage = _snapshotStorage();
if (storage == null) return null;
// Capture removal ownership before network I/O. Community removal retires
// this token even while the active provider is still being torn down.
final token = storage.capture(pubkey, relay);
return (channels, hiddenDmIds) {
final snapshot = ChannelListSnapshot(
channels: [
for (final channel in channels)
if (channel.isMember) channel.toJson(),
],
hiddenDmIds: hiddenDmIds,
);
unawaited(
storage
.write(token, snapshot)
.then(
(saved) {
if (!saved) {
debugPrint('[ChannelsNotifier] snapshot was not saved');
}
},
onError: (Object error, StackTrace stack) {
debugPrint('[ChannelsNotifier] snapshot write failed: $error');
},
),
);
};
}
}
26 changes: 15 additions & 11 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -290,17 +290,21 @@ class ChannelsPage extends HookConsumerWidget {
Future<Rect?> prepareCommunityLanding() async {
// The list snapshot precedes unread history and DM profile hydration.
// Both can still change weight, labels, and sorting behind the picker.
final channels = ref.read(channelsProvider).requireValue;
final dmPubkeys = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived && channel.isDm)
...channel.participantPubkeys,
};
await Future.wait([
ref.read(channelsProvider.notifier).waitForUnreadCatchUp(),
if (dmPubkeys.isNotEmpty)
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()),
]);
// A restored list is already usable offline. Refresh its unread state
// and participant profiles behind the page instead of gating navigation.
if (!ref.read(channelsProvider.notifier).hasCachedSnapshot) {
final channels = ref.read(channelsProvider).requireValue;
final dmPubkeys = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived && channel.isDm)
...channel.participantPubkeys,
};
await Future.wait([
ref.read(channelsProvider.notifier).waitForUnreadCatchUp(),
if (dmPubkeys.isNotEmpty)
ref.read(userCacheProvider.notifier).preload(dmPubkeys.toList()),
]);
}
if (!context.mounted || !communityFlightActive.value) return null;
// Settle Home's scale and scroll while the loading backdrop is opaque.
onSettingsTransitionProgress(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,36 @@ const _communityRevealDuration = Duration(milliseconds: 160);
const _communityCenteredAvatarSize = _communityGridAvatarSize * 1.5;

// Readiness follows the data that determines the landing page's layout. Live
// messages continue to sync normally after this first usable snapshot.
// messages continue to sync normally after this first usable snapshot. The
// header's own profile avatar hydrates independently and never gates landing.
// Cached destinations use local layout/read preferences immediately. Remote
// changes may reorder the list or update badges after reveal; offline landing
// must not wait for those relay reads.
final _communityContentReadyProvider = Provider.autoDispose<AsyncValue<bool>>((
ref,
) {
final channels = ref.watch(channelsProvider);
final profile = ref.watch(profileProvider);
final community = ref.watch(activeCommunityProvider).unwrapPrevious().value;
final sections = ref.watch(channelSectionsProvider).isReady;
final stars = ref.watch(channelStarsProvider).isReady;
final sort = ref.watch(channelSortProvider).isReady;
final mutes = ref.watch(channelMutesProvider).isReady;
final readState = ref.watch(readStateProvider).isReady;
final hasIdentity = ref.watch(myPubkeyProvider) != null;
// A restored snapshot lands with the local section/star/sort/mute/read state
// already applied; their remote catch-up continues after reveal. Watching
// channelsProvider above re-evaluates this when the snapshot is installed.
final cached = ref.read(channelsProvider.notifier).hasCachedSnapshot;
if (!channels.isLoading && channels.hasError) {
return AsyncError(channels.error!, channels.stackTrace!);
}
return AsyncData(
community != null &&
channels.hasValue &&
!channels.isLoading &&
!profile.isLoading &&
(!hasIdentity || (sections && stars && sort && mutes && readState)),
(!hasIdentity ||
cached ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Question. With cached, landing no longer waits for sections, stars, sort, mutes or read state. Those managers show local prefs first, but isReady waits for their relay sync. If a section was changed or channels were read on another device since the last visit, the list can reorder, or unread badges can appear and then clear, shortly after the reveal. Is that the intended tradeoff, and did it show up in your simulator runs? If the jump is noticeable, waiting only for channelSectionsProvider and channelSortProvider (layout) and not read state might be a middle ground.

(sections && stars && sort && mutes && readState)),
);
});

Expand Down Expand Up @@ -168,21 +176,19 @@ class CommunitySwitcherPage extends HookConsumerWidget {
if (!arriving) unawaited(HapticFeedback.selectionClick());
final animate = !reducedMotion && (arriving || destination != null);
try {
// Relay teardown, credential changes and provider hydration must not
// compete with the grid-to-center motion on the UI isolate.
if (animate && !arriving) {
await centering.forward(from: 0).orCancel;
} else {
centering.value = 1;
}
if (!context.mounted) return;
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return;
if (!arriving) {
await ref
.read(communityListProvider.notifier)
.switchCommunity(community.id);
}
// Storage and relay teardown are asynchronous I/O, so they overlap the
// grid-to-center motion. Both settle before any failure is handled, so
// the picker never resets while one of them is still running.
await Future.wait<void>([
if (animate && !arriving)
centering.forward(from: 0).orCancel
else
Future.sync(() => centering.value = 1),
if (!arriving)
ref
.read(communityListProvider.notifier)
.switchCommunity(community.id),
]);
if (!context.mounted) return;
await ref.read(activeCommunityProvider.future);
if (!context.mounted) return;
Expand Down
Loading
Loading