From 3ec632761880b3d8ab0be573869e0366edfe2db8 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 19 Oct 2026 15:59:38 -0700 Subject: [PATCH] fix(mobile): keep cached state with its community (#8349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single `ProviderScope` and doesn't remount on a community switch, so one community's data showed up in another, and some of it was published to the other community's relay. - **Channel groups**: the second community showed the first community's groups. - **Read marks** (contexts, publishable set, NIP-RS client and slot IDs): the first community's context IDs were merged into, and published to, the second community's read-state slot. - **Inbox done/unread rows and thread follows**: one shared 500-entry cap, so activity in one community evicted another's entries. - **Channel stars and mutes**: union-merged across communities and published to every relay. Every one of these is now keyed by pubkey + normalized community relay, the same way channel sort already is. **Switching communities.** The relay session rebuilds for the new community before the previous community's managers are retired. Each manager (groups, read state, stars, mutes) now checks that the session still serves its own community before it publishes, merges a fetch, or handles a live event. Previously, a pending publish flushed on dispose, or an in-flight fetch, could cross into the next community. **Old pubkey-only entries.** Channel IDs can repeat across communities, so nothing tells us which community an old channel-keyed entry belongs to: - **Groups, stars, mutes and read marks** are dropped on upgrade, and each community restores its own from its relay. Any change that never reached a relay is lost, and a read mark the relay doesn't hold shows as unread once. That's an accepted tradeoff: such changes would be very old by now. - **Inbox, thread follows, and read-state slot and client IDs** seed every community, and the old entry is kept. The inbox and follows entries are event IDs, which only ever match their own community. The slot ID keeps each community publishing to the slot it already has on its relay. This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr filters only it used). Nothing in the app navigates to it anymore, and its providers were among the community-unscoped caches. ### Related issue None found. ### Testing - Each affected store has a regression test through the real provider that switches A β†’ B β†’ A. For groups, read marks, stars and mutes, the test sets a value in A, switches to B while A's change is still pending, and checks two cases: B's data arriving live and B answering A's in-flight fetch. It asserts that nothing sent to B contains A's IDs, B shows only its own value, and A comes back unchanged. Each guard was removed in turn to confirm a test fails without it, except two that the current switch order never reaches: the groups merge guard and the read-state live-event guard. - Upgrade tests cover each kind of old entry: dropped for groups, stars, mutes and read marks, seeded for inbox, follows and slot IDs. - `flutter analyze` is clean, and the full mobile suite passes in the pre-push hook at `89ebfcc12`. - Not yet exercised in the app. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- .../activity/inbox_local_state_provider.dart | 39 +- .../channel_mutes/channel_mutes_manager.dart | 20 +- .../channel_mutes/channel_mutes_provider.dart | 1 + .../channel_mutes/channel_mutes_storage.dart | 24 +- .../channel_sections_manager.dart | 20 +- .../channel_sections_provider.dart | 1 + .../channel_sections_storage.dart | 25 +- .../channel_stars/channel_stars_manager.dart | 20 +- .../channel_stars/channel_stars_provider.dart | 1 + .../channel_stars/channel_stars_storage.dart | 24 +- .../thread_follows_provider.dart | 6 +- .../thread_follows_storage.dart | 25 +- .../features/profile/user_profile_sheet.dart | 4 +- .../features/pulse/agent_activity_card.dart | 181 --------- .../lib/features/pulse/compose_note_page.dart | 296 -------------- mobile/lib/features/pulse/note_card.dart | 384 ------------------ mobile/lib/features/pulse/pulse_actions.dart | 105 ----- mobile/lib/features/pulse/pulse_models.dart | 139 ------- mobile/lib/features/pulse/pulse_page.dart | 343 ---------------- mobile/lib/features/pulse/pulse_provider.dart | 217 ---------- .../identity_names_provider.dart | 4 +- .../shared/read_state/read_state_manager.dart | 24 +- .../read_state/read_state_provider.dart | 1 + .../shared/read_state/read_state_storage.dart | 126 +++--- mobile/lib/shared/relay/nostr_filters.dart | 55 --- mobile/lib/shared/relay/relay_session.dart | 10 + mobile/lib/shared/theme/app_theme.dart | 2 +- .../lib/shared/widgets/filter_chip_bar.dart | 6 +- .../inbox_local_state_provider_test.dart | 68 ++++ .../channel_preferences_manager_test.dart | 2 + .../channel_reader_recovery_test.dart | 205 ++++++++++ .../channel_sections_manager_test.dart | 1 + .../channel_sections_storage_test.dart | 17 + .../channel_stars_storage_test.dart | 23 ++ .../read_state/read_state_manager_test.dart | 58 ++- .../read_state/read_state_provider_test.dart | 59 ++- .../channels/sidebar_sync_fixture.dart | 70 +++- .../thread_follows_provider_test.dart | 49 +++ .../thread_follows_storage_test.dart | 30 +- .../pulse/compose_note_page_test.dart | 288 ------------- .../test/features/pulse/note_card_test.dart | 348 ---------------- .../test/features/pulse/pulse_page_test.dart | 179 -------- 42 files changed, 825 insertions(+), 2675 deletions(-) delete mode 100644 mobile/lib/features/pulse/agent_activity_card.dart delete mode 100644 mobile/lib/features/pulse/compose_note_page.dart delete mode 100644 mobile/lib/features/pulse/note_card.dart delete mode 100644 mobile/lib/features/pulse/pulse_actions.dart delete mode 100644 mobile/lib/features/pulse/pulse_models.dart delete mode 100644 mobile/lib/features/pulse/pulse_page.dart delete mode 100644 mobile/lib/features/pulse/pulse_provider.dart create mode 100644 mobile/test/features/activity/inbox_local_state_provider_test.dart create mode 100644 mobile/test/features/channels/thread_follows/thread_follows_provider_test.dart delete mode 100644 mobile/test/features/pulse/compose_note_page_test.dart delete mode 100644 mobile/test/features/pulse/note_card_test.dart delete mode 100644 mobile/test/features/pulse/pulse_page_test.dart diff --git a/mobile/lib/features/activity/inbox_local_state_provider.dart b/mobile/lib/features/activity/inbox_local_state_provider.dart index ace1e9cb84..67bfc4d3cf 100644 --- a/mobile/lib/features/activity/inbox_local_state_provider.dart +++ b/mobile/lib/features/activity/inbox_local_state_provider.dart @@ -3,9 +3,11 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/community_theme_preference.dart'; import '../../shared/theme/theme_provider.dart'; -const _localStateKey = 'activity_inbox_local_state_v1'; +const _localStateKeyPrefix = 'activity_inbox_local_state_v1'; const _maxTrackedIds = 500; /// Local, per-device inbox row overrides β€” mirrors desktop's @@ -25,10 +27,24 @@ class InboxLocalState { } class InboxLocalStateNotifier extends Notifier { + String? _key; + @override InboxLocalState build() { + // Rows belong to one identity in one community, and each keeps its own cap. + final relayUrl = ref.watch(relayConfigProvider).baseUrl; + final pubkey = ref.watch(myPubkeyProvider); + if (pubkey == null) { + _key = null; + return const InboxLocalState.empty(); + } + final key = _key = + '$_localStateKeyPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; final prefs = ref.read(savedPrefsProvider); - final raw = prefs.getString(_localStateKey); + // Each community starts from the earlier device-wide entry, which stays + // for the others. Its ids are event ids, so they only ever match rows in + // their own community. + final raw = prefs.getString(key) ?? prefs.getString(_localStateKeyPrefix); if (raw == null) return const InboxLocalState.empty(); try { final decoded = jsonDecode(raw); @@ -76,14 +92,17 @@ class InboxLocalStateNotifier extends Notifier { doneIds: capped(doneIds), unreadIds: capped(unreadIds), ); - final prefs = ref.read(savedPrefsProvider); - prefs.setString( - _localStateKey, - jsonEncode({ - 'done': state.doneIds.toList(), - 'unread': state.unreadIds.toList(), - }), - ); + final key = _key; + if (key == null) return; + ref + .read(savedPrefsProvider) + .setString( + key, + jsonEncode({ + 'done': state.doneIds.toList(), + 'unread': state.unreadIds.toList(), + }), + ); } } diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart index 683b1b5d41..78750fc6f9 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_manager.dart @@ -30,6 +30,7 @@ class ChannelMutesCrypto { class ChannelMutesManager { final String pubkey; + final String relayUrl; final ChannelMutesStorage _storage; final ChannelMutesCrypto _crypto; final RelaySessionNotifier? _relaySession; @@ -58,6 +59,7 @@ class ChannelMutesManager { ChannelMutesManager({ required this.pubkey, + required this.relayUrl, required SharedPreferences prefs, required ChannelMutesCrypto crypto, required RelaySessionNotifier? relaySession, @@ -73,7 +75,7 @@ class ChannelMutesManager { _remoteEnabled = remoteEnabled, _onChanged = onChanged, _startupRetryBaseDelay = startupRetryBaseDelay, - _store = ChannelMutesStorage(prefs).read(pubkey); + _store = ChannelMutesStorage(prefs).read(pubkey, relayUrl); ChannelMuteStore get store => _store; @@ -283,6 +285,8 @@ class ChannelMutesManager { /// then stays put so the same head is retried. An undecodable head is /// skipped without holding startup open. Future _applyEvents(List events) async { + // Another community's head must never reach this community's store. + if (!_servesThisCommunity) return true; var applied = true; for (final event in events) { if (event.pubkey != pubkey || event.getTagValue('d') != 'channel-mutes') { @@ -336,7 +340,8 @@ class ChannelMutesManager { Future _publish({bool allowDisposed = false}) async { if ((!allowDisposed && _disposed) || !_remoteEnabled || - _signedEventRelay == null) { + _signedEventRelay == null || + !_servesThisCommunity) { return; } @@ -346,7 +351,7 @@ class ChannelMutesManager { if (!_disposed) _onChanged(); // No-op suppression: skip if nothing changed - if (_isIdenticalToLastPublished()) return; + if (_isIdenticalToLastPublished() || !_servesThisCommunity) return; try { final payload = jsonEncode(_store.toJson()); @@ -378,9 +383,16 @@ class ChannelMutesManager { } } + /// Switching community rebuilds the session in place before this manager + /// is retired, so in between it must neither take in nor publish data. + bool get _servesThisCommunity { + final served = _relaySession?.relayUrl; + return served == null || served == relayUrl; + } + Future _persist() async { try { - if (await _storage.write(pubkey, _store)) return true; + if (await _storage.write(pubkey, relayUrl, _store)) return true; debugPrint('[ChannelMutesManager] persist returned false'); } catch (error) { debugPrint('[ChannelMutesManager] persist failed: $error'); diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index 6d52c95f84..4e2d965fba 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -63,6 +63,7 @@ class ChannelMutesNotifier extends Notifier { late final ChannelMutesManager manager; manager = ChannelMutesManager( pubkey: pubkey, + relayUrl: relayConfig.baseUrl, prefs: prefs, crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart index c37669c3eb..da34beb4ba 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_storage.dart @@ -2,7 +2,15 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -String channelMutesKey(String pubkey) => 'buzz.channel-mutes.v1:$pubkey'; +import '../../../shared/theme/community_theme_preference.dart'; + +/// Channel ids belong to one community, so the cache is keyed by relay as +/// well as identity. One identity can be paired with several communities. +String channelMutesKey(String pubkey, String relayUrl) => + 'buzz.channel-mutes.v1:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + +/// The earlier key, shared by every community of the identity. +String legacyChannelMutesKey(String pubkey) => 'buzz.channel-mutes.v1:$pubkey'; class ChannelMuteEntry { final bool muted; @@ -65,8 +73,11 @@ class ChannelMutesStorage { ChannelMutesStorage(this._prefs); - ChannelMuteStore read(String pubkey) { - final raw = _prefs.getString(channelMutesKey(pubkey)); + ChannelMuteStore read(String pubkey, String relayUrl) { + // The shared entry may hold another community's channels, and the relay + // copy is authoritative, so drop it rather than let one community claim it. + _prefs.remove(legacyChannelMutesKey(pubkey)); + final raw = _prefs.getString(channelMutesKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) { return const ChannelMuteStore(); } @@ -85,6 +96,9 @@ class ChannelMutesStorage { } } - Future write(String pubkey, ChannelMuteStore store) => - _prefs.setString(channelMutesKey(pubkey), jsonEncode(store.toJson())); + Future write(String pubkey, String relayUrl, ChannelMuteStore store) => + _prefs.setString( + channelMutesKey(pubkey, relayUrl), + jsonEncode(store.toJson()), + ); } diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart index 5856e45a24..4e6e13915b 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -33,6 +33,7 @@ class ChannelSectionsCrypto { class ChannelSectionsManager { final String pubkey; + final String relayUrl; final ChannelSectionsStorage _storage; final ChannelSectionsCrypto _crypto; final RelaySessionNotifier? _relaySession; @@ -61,6 +62,7 @@ class ChannelSectionsManager { ChannelSectionsManager({ required this.pubkey, + required this.relayUrl, required SharedPreferences prefs, required ChannelSectionsCrypto crypto, required RelaySessionNotifier? relaySession, @@ -76,7 +78,7 @@ class ChannelSectionsManager { _remoteEnabled = remoteEnabled, _onChanged = onChanged, _startupRetryBaseDelay = startupRetryBaseDelay, - _store = ChannelSectionsStorage(prefs).read(pubkey); + _store = ChannelSectionsStorage(prefs).read(pubkey, relayUrl); ChannelSectionStore get store => _store; @@ -400,6 +402,8 @@ class ChannelSectionsManager { } void _mergeEvent(NostrEvent event) { + // Another community's groups must never reach this community's store. + if (!_servesThisCommunity) return; // Only process channel-sections d-tag events. final dTag = event.getTagValue('d'); if (dTag != 'channel-sections') return; @@ -461,7 +465,8 @@ class ChannelSectionsManager { Future _publish({bool allowDisposed = false}) async { if ((!allowDisposed && _disposed) || !_remoteEnabled || - _signedEventRelay == null) { + _signedEventRelay == null || + !_servesThisCommunity) { return; } @@ -469,7 +474,7 @@ class ChannelSectionsManager { await _fetchAndMerge(allowDisposed: allowDisposed); // No-op suppression: skip if nothing changed - if (_isIdenticalToLastPublished()) return; + if (_isIdenticalToLastPublished() || !_servesThisCommunity) return; final submitted = _store; final revision = _localRevision; @@ -513,10 +518,17 @@ class ChannelSectionsManager { } } + /// Switching community rebuilds the session in place before this manager + /// is retired, so in between it must neither take in nor publish data. + bool get _servesThisCommunity { + final served = _relaySession?.relayUrl; + return served == null || served == relayUrl; + } + /// A retired manager never writes the prefs key its successor now owns. void _persist() { if (_disposed) return; - _storage.write(pubkey, _store); + _storage.write(pubkey, relayUrl, _store); } List _sortedSections() { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 31d424cc95..f10180757d 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -63,6 +63,7 @@ class ChannelSectionsNotifier extends Notifier { late final ChannelSectionsManager manager; manager = ChannelSectionsManager( pubkey: pubkey, + relayUrl: relayConfig.baseUrl, prefs: prefs, crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart index 01643c6d54..ac531167c0 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart @@ -2,7 +2,16 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey'; +import '../../../shared/theme/community_theme_preference.dart'; + +/// Groups belong to one community, so the cache is keyed by relay as well as +/// identity. One identity can be paired with several communities. +String channelSectionsKey(String pubkey, String relayUrl) => + 'buzz.channel-sections.v1:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + +/// The earlier key, shared by every community of the identity. +String legacyChannelSectionsKey(String pubkey) => + 'buzz.channel-sections.v1:$pubkey'; class ChannelSection { final String id; @@ -90,8 +99,11 @@ class ChannelSectionsStorage { ChannelSectionsStorage(this._prefs); - ChannelSectionStore read(String pubkey) { - final raw = _prefs.getString(channelSectionsKey(pubkey)); + ChannelSectionStore read(String pubkey, String relayUrl) { + // The shared entry may hold another community's groups, and the relay + // copy is authoritative, so drop it rather than let one community claim it. + _prefs.remove(legacyChannelSectionsKey(pubkey)); + final raw = _prefs.getString(channelSectionsKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) { return const ChannelSectionStore(); } @@ -110,7 +122,10 @@ class ChannelSectionsStorage { } } - void write(String pubkey, ChannelSectionStore store) { - _prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson())); + void write(String pubkey, String relayUrl, ChannelSectionStore store) { + _prefs.setString( + channelSectionsKey(pubkey, relayUrl), + jsonEncode(store.toJson()), + ); } } diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart index c98c7da2d6..c9fcd6aea5 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart @@ -30,6 +30,7 @@ class ChannelStarsCrypto { class ChannelStarsManager { final String pubkey; + final String relayUrl; final ChannelStarsStorage _storage; final ChannelStarsCrypto _crypto; final RelaySessionNotifier? _relaySession; @@ -58,6 +59,7 @@ class ChannelStarsManager { ChannelStarsManager({ required this.pubkey, + required this.relayUrl, required SharedPreferences prefs, required ChannelStarsCrypto crypto, required RelaySessionNotifier? relaySession, @@ -73,7 +75,7 @@ class ChannelStarsManager { _remoteEnabled = remoteEnabled, _onChanged = onChanged, _startupRetryBaseDelay = startupRetryBaseDelay, - _store = ChannelStarsStorage(prefs).read(pubkey); + _store = ChannelStarsStorage(prefs).read(pubkey, relayUrl); ChannelStarStore get store => _store; @@ -283,6 +285,8 @@ class ChannelStarsManager { /// then stays put so the same head is retried. An undecodable head is /// skipped without holding startup open. Future _applyEvents(List events) async { + // Another community's head must never reach this community's store. + if (!_servesThisCommunity) return true; var applied = true; for (final event in events) { if (event.pubkey != pubkey || event.getTagValue('d') != 'channel-stars') { @@ -336,7 +340,8 @@ class ChannelStarsManager { Future _publish({bool allowDisposed = false}) async { if ((!allowDisposed && _disposed) || !_remoteEnabled || - _signedEventRelay == null) { + _signedEventRelay == null || + !_servesThisCommunity) { return; } @@ -346,7 +351,7 @@ class ChannelStarsManager { if (!_disposed) _onChanged(); // No-op suppression: skip if nothing changed - if (_isIdenticalToLastPublished()) return; + if (_isIdenticalToLastPublished() || !_servesThisCommunity) return; try { final payload = jsonEncode(_store.toJson()); @@ -378,9 +383,16 @@ class ChannelStarsManager { } } + /// Switching community rebuilds the session in place before this manager + /// is retired, so in between it must neither take in nor publish data. + bool get _servesThisCommunity { + final served = _relaySession?.relayUrl; + return served == null || served == relayUrl; + } + Future _persist() async { try { - if (await _storage.write(pubkey, _store)) return true; + if (await _storage.write(pubkey, relayUrl, _store)) return true; debugPrint('[ChannelStarsManager] persist returned false'); } catch (error) { debugPrint('[ChannelStarsManager] persist failed: $error'); diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index 9bac36d7b3..2ecd1f94f5 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -63,6 +63,7 @@ class ChannelStarsNotifier extends Notifier { late final ChannelStarsManager manager; manager = ChannelStarsManager( pubkey: pubkey, + relayUrl: relayConfig.baseUrl, prefs: prefs, crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart b/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart index cf6809cbd0..fda4e77b4c 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart @@ -2,7 +2,15 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -String channelStarsKey(String pubkey) => 'buzz.channel-stars.v1:$pubkey'; +import '../../../shared/theme/community_theme_preference.dart'; + +/// Channel ids belong to one community, so the cache is keyed by relay as +/// well as identity. One identity can be paired with several communities. +String channelStarsKey(String pubkey, String relayUrl) => + 'buzz.channel-stars.v1:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + +/// The earlier key, shared by every community of the identity. +String legacyChannelStarsKey(String pubkey) => 'buzz.channel-stars.v1:$pubkey'; class ChannelStarEntry { final bool starred; @@ -65,8 +73,11 @@ class ChannelStarsStorage { ChannelStarsStorage(this._prefs); - ChannelStarStore read(String pubkey) { - final raw = _prefs.getString(channelStarsKey(pubkey)); + ChannelStarStore read(String pubkey, String relayUrl) { + // The shared entry may hold another community's channels, and the relay + // copy is authoritative, so drop it rather than let one community claim it. + _prefs.remove(legacyChannelStarsKey(pubkey)); + final raw = _prefs.getString(channelStarsKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) { return const ChannelStarStore(); } @@ -85,6 +96,9 @@ class ChannelStarsStorage { } } - Future write(String pubkey, ChannelStarStore store) => - _prefs.setString(channelStarsKey(pubkey), jsonEncode(store.toJson())); + Future write(String pubkey, String relayUrl, ChannelStarStore store) => + _prefs.setString( + channelStarsKey(pubkey, relayUrl), + jsonEncode(store.toJson()), + ); } diff --git a/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart b/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart index 13210ca8e9..cb26d296fb 100644 --- a/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart +++ b/mobile/lib/features/channels/thread_follows/thread_follows_provider.dart @@ -19,11 +19,13 @@ class ThreadFollowsState { class ThreadFollowsNotifier extends Notifier { List _entries = const []; String? _pubkey; + late String _relayUrl; @override ThreadFollowsState build() { final pubkey = ref.watch(myPubkeyProvider)?.trim().toLowerCase(); _pubkey = pubkey; + _relayUrl = ref.watch(relayConfigProvider).baseUrl; if (pubkey == null || pubkey.isEmpty) { _entries = const []; return const ThreadFollowsState(); @@ -32,7 +34,7 @@ class ThreadFollowsNotifier extends Notifier { try { _entries = ThreadFollowsStorage( ref.read(savedPrefsProvider), - ).read(pubkey); + ).read(pubkey, _relayUrl); } catch (_) { _entries = const []; } @@ -66,7 +68,7 @@ class ThreadFollowsNotifier extends Notifier { try { ThreadFollowsStorage( ref.read(savedPrefsProvider), - ).write(pubkey, _entries); + ).write(pubkey, _relayUrl, _entries); } catch (_) { // In-memory state still works this session. } diff --git a/mobile/lib/features/channels/thread_follows/thread_follows_storage.dart b/mobile/lib/features/channels/thread_follows/thread_follows_storage.dart index 6538af3e77..319ac1ef63 100644 --- a/mobile/lib/features/channels/thread_follows/thread_follows_storage.dart +++ b/mobile/lib/features/channels/thread_follows/thread_follows_storage.dart @@ -2,15 +2,24 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../shared/theme/community_theme_preference.dart'; + /// Persistence for followed thread root IDs. /// /// Mirrors the desktop store (`useThreadFollows.ts`): per-pubkey entries of /// `{rootId, followedAt}`, capped at the oldest-out limit. Desktop keeps this /// client-local (localStorage), so mobile does the same with /// SharedPreferences β€” following on one device intentionally does not sync. +/// Threads belong to one community, so entries and their cap are kept per +/// relay as well as per identity. const threadFollowsMaxEntries = 500; -String threadFollowsKey(String pubkey) => 'buzz-thread-follows.v1:$pubkey'; +String threadFollowsKey(String pubkey, String relayUrl) => + 'buzz-thread-follows.v1:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + +/// The earlier key, shared by every community of the identity. +String legacyThreadFollowsKey(String pubkey) => + 'buzz-thread-follows.v1:$pubkey'; class ThreadFollowEntry { final String rootId; @@ -26,8 +35,14 @@ class ThreadFollowsStorage { ThreadFollowsStorage(this._prefs); - List read(String pubkey) { - final raw = _prefs.getString(threadFollowsKey(pubkey)); + List read(String pubkey, String relayUrl) { + final key = threadFollowsKey(pubkey, relayUrl); + // Each community starts from the shared entry, which stays for the + // others. Its root ids are event ids, so they only ever match threads in + // their own community, and as the oldest follows they are evicted first. + final raw = + _prefs.getString(key) ?? + _prefs.getString(legacyThreadFollowsKey(pubkey)); if (raw == null || raw.isEmpty) return const []; try { @@ -48,9 +63,9 @@ class ThreadFollowsStorage { } } - void write(String pubkey, List entries) { + void write(String pubkey, String relayUrl, List entries) { _prefs.setString( - threadFollowsKey(pubkey), + threadFollowsKey(pubkey, relayUrl), jsonEncode([for (final entry in entries) entry.toJson()]), ); } diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 1899129880..6577074890 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -28,8 +28,8 @@ import 'user_status_cache_provider.dart'; /// Show a user profile bottom sheet for the given [pubkey]. /// /// The sheet names [pubkey] as the opening surface did: within the live -/// comparison context [names] (for example a channel's members or a Pulse -/// timeline). Without it, the identity is compared only with itself. +/// comparison context [names] (for example a channel's members). Without it, +/// the identity is compared only with itself. void showUserProfileSheet( BuildContext context, String pubkey, { diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart deleted file mode 100644 index 89a2c64290..0000000000 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/theme/buzz_icons.dart'; - -import '../../shared/identity_names/identity_names.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/avatar_image.dart'; -import '../../shared/profile/user_cache_provider.dart'; -import 'note_card.dart'; -import 'pulse_models.dart'; - -class AgentActivityCard extends HookConsumerWidget { - final AgentNoteGroup group; - final Map reactions; - final VoidCallback? onReactionChanged; - - const AgentActivityCard({ - super.key, - required this.group, - required this.reactions, - required this.names, - this.onReactionChanged, - }); - - /// The Pulse timeline's identity labels. - final IdentityNames names; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final expanded = useState(group.notes.length == 1); - final profile = - ref.watch(userCacheProvider.select((cache) => cache[group.pubkey])) ?? - ref.read(userCacheProvider.notifier).get(group.pubkey); - final name = names.labelFor(group.pubkey); - - return Column( - children: [ - InkWell( - onTap: group.notes.length > 1 - ? () => expanded.value = !expanded.value - : null, - borderRadius: BorderRadius.circular(Radii.md), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.twelve), - child: Row( - children: [ - Stack( - children: [ - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 18, - backgroundColor: context.controlSurface, - fallback: const Icon(BuzzIcons.bot, size: 18), - isAgent: true, - ), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: context.appColors.success, - shape: BoxShape.circle, - border: Border.all( - color: context.colors.surface, - width: 1.5, - ), - ), - ), - ), - ], - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: Grid.half), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: 2, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues( - alpha: 0.12, - ), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - 'BOT', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w800, - fontSize: 10, - ), - ), - ), - ], - ), - Text( - '${group.notes.length} update${group.notes.length == 1 ? '' : 's'} Β· ${formatPulseRelativeTime(group.latestAt)}', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - if (group.notes.length > 1) - Icon( - expanded.value - ? BuzzIcons.chevronUp - : BuzzIcons.chevronDown, - size: 18, - color: context.colors.onSurfaceVariant, - ), - ], - ), - ), - ), - if (expanded.value) - Padding( - padding: const EdgeInsets.only(left: Grid.xl), - child: Column( - children: [ - for (final note in group.notes) ...[ - NoteCard( - note: note, - reaction: - reactions[note.id] ?? - const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - isAgent: true, - names: names, - onReactionChanged: onReactionChanged, - ), - if (note != group.notes.last) - Divider( - height: 1, - thickness: 1, - color: context.colors.outlineVariant.withValues( - alpha: 0.4, - ), - ), - ], - ], - ), - ) - else - Padding( - padding: const EdgeInsets.only(left: Grid.xl, bottom: Grid.xxs), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - group.notes.first.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, - ), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart deleted file mode 100644 index 1e270445ae..0000000000 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/identity_names/identity_names.dart'; -import '../../shared/identity_names/identity_names_provider.dart'; -import '../../shared/mentions/mention_tags.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/avatar_image.dart'; -import '../../shared/widgets/buzz_loading_indicator.dart'; -import '../../shared/widgets/frosted_app_bar.dart'; -import '../../shared/widgets/frosted_scaffold.dart'; -import '../channels/message_content.dart'; -import '../profile/profile_provider.dart'; -import '../../shared/profile/user_cache_provider.dart'; -import 'note_card.dart'; -import 'pulse_actions.dart'; -import 'pulse_models.dart'; - -/// Full-screen page for composing a new note or replying to one. -/// -/// When [replyTo] is null this composes a new top-level note; when set the -/// page shows the note being replied to as context and the action button -/// reads "Reply". The text field auto-focuses on open so the keyboard is -/// ready immediately. -class ComposeNotePage extends HookConsumerWidget { - final UserNote? replyTo; - - /// The comparison context of the surface that opened the reply, so the - /// reply context names people as they were shown there. Its labels are - /// resolved against live naming facts while this page is open. - final IdentityNames? names; - - const ComposeNotePage({super.key, this.replyTo, this.names}); - - bool get _isReply => replyTo != null; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final focusNode = useFocusNode(); - final isSending = useState(false); - final hasText = useListenableSelector( - controller, - () => controller.text.trim().isNotEmpty, - ); - final profile = ref.watch(profileProvider).asData?.value; - - // Auto-focus the field once the page has mounted. - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); - return null; - }, const []); - - Future submit() async { - if (!hasText || isSending.value) return; - isSending.value = true; - try { - await publishNote(ref, content: controller.text, replyTo: replyTo); - if (context.mounted) Navigator.of(context).pop(true); - } catch (_) { - // Keep the page open so the draft isn't lost; re-enable the button. - isSending.value = false; - rethrow; - } - } - - return FrostedScaffold( - resizeToAvoidBottomInset: true, - appBar: FrostedAppBar( - nativeTitle: _isReply ? 'Reply' : 'New note', - nativeActions: [ - IosNavigationAction( - label: isSending.value - ? 'Sending…' - : _isReply - ? 'Reply' - : 'Post', - onPressed: hasText && !isSending.value ? submit : null, - ), - ], - leading: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: Grid.xxs), - child: FilledButton( - onPressed: hasText && !isSending.value ? submit : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - minimumSize: const Size(0, 36), - shape: const StadiumBorder(), - ), - child: isSending.value - ? SizedBox( - width: 16, - height: 16, - child: BuzzLoadingIndicator( - size: 16, - semanticLabel: _isReply - ? 'Sending reply' - : 'Publishing post', - ), - ) - : Text(_isReply ? 'Reply' : 'Post'), - ), - ), - ], - ), - body: SafeArea( - top: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: frostedAppBarHeight(context)), - if (_isReply) _ReplyContext(note: replyTo!, names: names), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Grid.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 18, - backgroundColor: context.controlSurface, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - controller: controller, - focusNode: focusNode, - minLines: 3, - maxLines: null, - autofocus: true, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: context.textTheme.bodyLarge, - decoration: InputDecoration( - hintText: _isReply - ? 'Post your reply' - : 'What’s on your mind?', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: Grid.half, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -/// A read-only preview of the note being replied to, laid out like a list -/// row (avatar + name + time + content) but non-interactive. Capped in -/// height so a long note doesn't push the editor off-screen. -class _ReplyContext extends ConsumerWidget { - final UserNote note; - final IdentityNames? names; - - const _ReplyContext({required this.note, this.names}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pubkey = note.pubkey.toLowerCase(); - final profile = - ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ?? - ref.read(userCacheProvider.notifier).get(pubkey); - final mentionPubkeys = mentionedPubkeysFromTags(note.tags); - final labels = - names?.withSources(ref.watch(identityNameSourcesProvider)) ?? - watchIdentityNames(ref, {pubkey, ...mentionPubkeys}); - final displayName = labels.labelFor(pubkey); - - return Padding( - padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Replying to $displayName', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.xs), - // Mirror the NoteCard list-row layout, read-only. Shrink-wraps to - // the content; long notes are capped to ~3 lines via the parent - // page scroll + ellipsis on the content. - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 18, - backgroundColor: context.controlSurface, - fallback: Text( - // Name-derived when the profile is cached; keyed to the - // hex public key when it isn't, so the compact-npub - // fallback label doesn't render `N` for every unnamed - // author. - profile?.initial ?? - (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - displayName, - maxLines: 1, - style: messageUsernameTextStyle, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: Grid.half), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: Grid.xxl), - child: Text( - formatPulseRelativeTime(note.createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: messageTimestampTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ), - const SizedBox(height: Grid.half), - // Rich content (images, links, mentions) like the list - // row, but clipped to a compact max height so a tall - // image or long note can't blow up the page. - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 132), - child: ClipRect( - child: Align( - alignment: Alignment.topLeft, - heightFactor: 1, - child: MessageContent( - content: note.content, - mentionLabels: { - for (final key in mentionPubkeys) - key: labels.labelFor(key), - }, - tags: note.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: Grid.xs), - Divider( - height: 1, - color: context.colors.outlineVariant.withValues(alpha: 0.5), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart deleted file mode 100644 index b7588cdc70..0000000000 --- a/mobile/lib/features/pulse/note_card.dart +++ /dev/null @@ -1,384 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/theme/buzz_icons.dart'; -import 'package:nostr/nostr.dart' as nostr; - -import '../../shared/clipboard_utils.dart'; -import '../../shared/identity_names/identity_names.dart'; -import '../../shared/identity_names/identity_names_provider.dart'; -import '../../shared/mentions/mention_tags.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/avatar_image.dart'; -import '../channels/channel_detail_page.dart'; -import '../channels/channel_management_provider.dart'; -import '../channels/message_content.dart'; -import '../../shared/profile/user_cache_provider.dart'; -import '../profile/user_profile_sheet.dart'; -import 'compose_note_page.dart'; -import 'pulse_actions.dart'; -import 'pulse_models.dart'; - -class NoteCard extends HookConsumerWidget { - final UserNote note; - final PulseReactionState reaction; - final bool isAgent; - final bool isFollowing; - final bool canFollow; - final VoidCallback? onReactionChanged; - final ValueChanged? onFollowChanged; - - /// The surrounding collection's identity labels (for example the Pulse - /// timeline's authors). When absent, the note alone is the context. - final IdentityNames? names; - - const NoteCard({ - super.key, - required this.note, - required this.reaction, - this.isAgent = false, - this.isFollowing = false, - this.canFollow = false, - this.onReactionChanged, - this.onFollowChanged, - this.names, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pendingUpvote = useState(null); - final pubkey = note.pubkey.toLowerCase(); - final profile = - ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ?? - ref.read(userCacheProvider.notifier).get(pubkey); - final mentionPubkeys = mentionedPubkeysFromTags(note.tags); - final labels = - names ?? watchIdentityNames(ref, pulseNamedIdentities([note])); - final displayName = labels.labelFor(pubkey); - final mentionLabels = { - for (final key in mentionPubkeys) key: labels.labelFor(key), - }; - final effectiveUpvoted = - pendingUpvote.value ?? reaction.reactedByCurrentUser; - final effectiveCount = _effectiveCount(reaction, pendingUpvote.value); - - // Clear the optimistic flag only once the refetched server state matches - // it, so the count never flickers back through the stale value mid-refetch. - useEffect(() { - if (pendingUpvote.value != null && - reaction.reactedByCurrentUser == pendingUpvote.value) { - pendingUpvote.value = null; - } - return null; - }, [reaction.reactedByCurrentUser]); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.twelve), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () => showUserProfileSheet( - context, - note.pubkey, - names: liveIdentityNamesProvider(labels), - ), - child: AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 18, - backgroundColor: context.controlSurface, - fallback: Text( - // Name-derived when the profile is cached; keyed to the hex - // public key when it isn't, so the compact-npub fallback - // label doesn't render `N` for every unnamed author. - profile?.initial ?? - (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - isAgent: profile?.ownerPubkey != null, - ), - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => showUserProfileSheet( - context, - note.pubkey, - names: liveIdentityNamesProvider(labels), - ), - child: Row( - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - displayName, - maxLines: 1, - style: messageUsernameTextStyle, - overflow: TextOverflow.ellipsis, - ), - ), - if (isAgent) ...[ - const SizedBox(width: Grid.half), - Icon( - BuzzIcons.bot, - size: 13, - color: context.colors.primary, - ), - ], - ], - ), - ), - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: Grid.xl, - ), - child: Text( - formatPulseRelativeTime(note.createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: messageTimestampTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ), - if (canFollow) ...[ - const SizedBox(width: Grid.half), - _FollowButton( - isFollowing: isFollowing, - onPressed: () async { - if (isFollowing) { - await unfollowUser(ref, note.pubkey); - } else { - await followUser(ref, note.pubkey); - } - onFollowChanged?.call(note.pubkey); - }, - ), - ], - ], - ), - if (note.replyParentId != null) ...[ - const SizedBox(height: 2), - Text( - 'Replying to ${_replyTargetLabel(note, labels)}', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: Grid.half), - MessageContent( - content: note.content, - mentionLabels: mentionLabels, - tags: note.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - ), - const SizedBox(height: Grid.xxs), - Row( - children: [ - _ActionButton( - icon: BuzzIcons.heart, - label: effectiveCount > 0 ? '$effectiveCount' : null, - color: effectiveUpvoted ? Colors.redAccent : null, - onTap: () async { - final next = !effectiveUpvoted; - pendingUpvote.value = next; - try { - await toggleNoteUpvote( - ref, - noteId: note.id, - isUpvoted: reaction.reactedByCurrentUser, - reactionEventId: reaction.currentUserReactionId, - ); - onReactionChanged?.call(); - } catch (_) { - // Revert the optimistic state on failure. - pendingUpvote.value = null; - } - }, - ), - _ActionButton( - icon: BuzzIcons.messageCircle, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ComposeNotePage(replyTo: note, names: labels), - ), - ), - ), - _ActionButton( - icon: BuzzIcons.share, - onTap: () async { - await copyToClipboard( - context, - _shareUri(note), - message: 'Copied note URI', - ); - }, - ), - _ActionButton( - icon: BuzzIcons.mail, - onTap: () async { - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [note.pubkey]); - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); - }, - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - int _effectiveCount(PulseReactionState reaction, bool? pending) { - if (pending == null || pending == reaction.reactedByCurrentUser) { - return reaction.count; - } - return (reaction.count + (pending ? 1 : -1)).clamp(0, 1 << 31); - } -} - -class _FollowButton extends StatelessWidget { - final bool isFollowing; - final VoidCallback onPressed; - - const _FollowButton({required this.isFollowing, required this.onPressed}); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(Radii.md), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.half, vertical: 2), - child: Text( - isFollowing ? 'Following' : 'Follow', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } -} - -class _ActionButton extends StatelessWidget { - final IconData icon; - final String? label; - final VoidCallback onTap; - final Color? color; - - const _ActionButton({ - required this.icon, - required this.onTap, - this.label, - this.color, - }); - - @override - Widget build(BuildContext context) { - final effectiveColor = color ?? context.colors.onSurfaceVariant; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(Radii.md), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.half, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: effectiveColor), - if (label != null) ...[ - const SizedBox(width: 3), - Text( - label!, - style: context.textTheme.labelSmall?.copyWith( - color: effectiveColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - ), - ); - } -} - -/// Every identity [notes] name on screen: authors, reply-parent authors and -/// mentions. A Pulse collection compares all of them together. -Set pulseNamedIdentities(Iterable notes) => { - for (final note in notes) ...{ - note.pubkey.toLowerCase(), - ?note.replyParentAuthor?.toLowerCase(), - ...mentionedPubkeysFromTags(note.tags), - }, -}; - -String _shareUri(UserNote note) => - 'nostr:${nostr.Nip19.encodeShareableIdentifiers(prefix: nostr.Nip19Prefix.nevent, data: note.id, author: note.pubkey, kind: 1)}'; - -/// Compact label for a note's reply target: the parent author's contextual -/// label when known, otherwise the parent event id. Event ids are not public -/// keys β€” they stay hex-truncated and out of the npub contract. -String _replyTargetLabel(UserNote note, IdentityNames names) { - final author = note.replyParentAuthor; - if (author != null) return names.labelFor(author); - final eventId = note.replyParentId!; - return eventId.length <= 8 ? eventId : '${eventId.substring(0, 8)}…'; -} - -String formatPulseRelativeTime(int createdAt) { - final date = DateTime.fromMillisecondsSinceEpoch(createdAt * 1000); - final diff = DateTime.now().difference(date); - if (diff.inMinutes < 1) return 'just now'; - if (diff.inHours < 1) return '${diff.inMinutes}m'; - if (diff.inDays < 1) return '${diff.inHours}h'; - if (diff.inDays < 7) return '${diff.inDays}d'; - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${months[date.month - 1]} ${date.day}'; -} diff --git a/mobile/lib/features/pulse/pulse_actions.dart b/mobile/lib/features/pulse/pulse_actions.dart deleted file mode 100644 index 13b5dee667..0000000000 --- a/mobile/lib/features/pulse/pulse_actions.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/relay/relay.dart'; -import '../channels/channel_management_provider.dart'; -import '../../shared/custom_emoji/custom_emoji.dart'; -import '../../shared/custom_emoji/custom_emoji_provider.dart'; -import 'pulse_models.dart'; -import 'pulse_provider.dart'; - -Future publishNote( - WidgetRef ref, { - required String content, - UserNote? replyTo, - List mentionPubkeys = const [], - List> mediaTags = const [], -}) async { - final text = content.trim(); - if (text.isEmpty) return; - - final config = ref.read(relayConfigProvider); - final session = ref.read(relaySessionProvider.notifier); - final relay = SignedEventRelay(session: session, nsec: config.nsec); - final tags = >[]; - final seen = {}; - - if (replyTo != null) { - tags.add(['e', replyTo.id, '', 'reply']); - seen.add(replyTo.pubkey.toLowerCase()); - tags.add(['p', replyTo.pubkey.toLowerCase()]); - } - - for (final pubkey in mentionPubkeys) { - final normalized = pubkey.toLowerCase(); - if (seen.add(normalized)) tags.add(['p', normalized]); - } - tags.addAll(mediaTags); - tags.addAll(buildCustomEmojiTags(text, ref.read(customEmojiListProvider))); - - await relay.submit(kind: EventKind.note, content: text, tags: tags); - ref.invalidate(globalNotesProvider); - ref.invalidate(likedNotesProvider); -} - -Future toggleNoteUpvote( - WidgetRef ref, { - required String noteId, - required bool isUpvoted, - String? reactionEventId, -}) async { - final actions = ref.read(channelActionsProvider); - if (isUpvoted) { - if (reactionEventId != null) { - await actions.removeReaction(reactionEventId, '+'); - } - } else { - await actions.addReaction(noteId, '+'); - } - ref.invalidate(likedNotesProvider); -} - -Future setContactList(WidgetRef ref, List contacts) async { - final currentPubkey = ref.read(myPubkeyProvider); - if (currentPubkey == null) return; - final config = ref.read(relayConfigProvider); - final session = ref.read(relaySessionProvider.notifier); - final relay = SignedEventRelay(session: session, nsec: config.nsec); - - await relay.submit( - kind: EventKind.contactList, - content: '', - tags: contacts.map((entry) => entry.toTag()).toList(), - ); - ref.invalidate(contactListProvider(currentPubkey)); -} - -Future followUser(WidgetRef ref, String pubkey) async { - final currentPubkey = ref.read(myPubkeyProvider); - if (currentPubkey == null) return; - final contacts = await _fetchFreshContacts(ref, currentPubkey); - final normalized = pubkey.toLowerCase(); - if (contacts.any((entry) => entry.pubkey == normalized)) return; - await setContactList(ref, [...contacts, ContactEntry(pubkey: normalized)]); -} - -Future unfollowUser(WidgetRef ref, String pubkey) async { - final currentPubkey = ref.read(myPubkeyProvider); - if (currentPubkey == null) return; - final normalized = pubkey.toLowerCase(); - final contacts = await _fetchFreshContacts(ref, currentPubkey); - await setContactList( - ref, - contacts.where((entry) => entry.pubkey != normalized).toList(), - ); -} - -Future> _fetchFreshContacts( - WidgetRef ref, - String currentPubkey, -) async { - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.contactList(currentPubkey), - ); - return contactsFromEvents(events); -} diff --git a/mobile/lib/features/pulse/pulse_models.dart b/mobile/lib/features/pulse/pulse_models.dart deleted file mode 100644 index 1a3ddbc6f0..0000000000 --- a/mobile/lib/features/pulse/pulse_models.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import '../../shared/relay/nostr_models.dart'; - -@immutable -class UserNote { - final String id; - final String pubkey; - final int createdAt; - final String content; - final List> tags; - - const UserNote({ - required this.id, - required this.pubkey, - required this.createdAt, - required this.content, - required this.tags, - }); - - factory UserNote.fromEvent(NostrEvent event) => UserNote( - id: event.id, - pubkey: event.pubkey.toLowerCase(), - createdAt: event.createdAt, - content: event.content, - tags: event.tags, - ); - - String? get replyParentId { - for (final tag in tags) { - if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') return tag[1]; - } - return null; - } - - String? get replyParentAuthor { - for (final tag in tags) { - if (tag.length >= 2 && tag[0] == 'p') return tag[1].toLowerCase(); - } - return null; - } - - List get mentionPubkeys => [ - for (final tag in tags) - if (tag.length >= 2 && tag[0] == 'p') tag[1].toLowerCase(), - ]; -} - -@immutable -class NoteReactionSummary { - final String noteId; - final String emoji; - final int count; - final List pubkeys; - final Map reactionIdsByPubkey; - - const NoteReactionSummary({ - required this.noteId, - required this.emoji, - required this.count, - required this.pubkeys, - this.reactionIdsByPubkey = const {}, - }); -} - -@immutable -class PulseReactionState { - final int count; - final bool reactedByCurrentUser; - final String? currentUserReactionId; - - const PulseReactionState({ - required this.count, - required this.reactedByCurrentUser, - this.currentUserReactionId, - }); -} - -@immutable -class AgentNoteGroup { - final String pubkey; - final List notes; - final int latestAt; - final int earliestAt; - - const AgentNoteGroup({ - required this.pubkey, - required this.notes, - required this.latestAt, - required this.earliestAt, - }); -} - -List groupAgentNotes( - List notes, { - int windowSeconds = 300, -}) { - if (notes.isEmpty) return const []; - - final groups = []; - AgentNoteGroup? current; - - for (final note in notes) { - final lastNote = current?.notes.last; - if (current != null && - lastNote != null && - current.pubkey == note.pubkey && - lastNote.createdAt - note.createdAt <= windowSeconds) { - current = AgentNoteGroup( - pubkey: current.pubkey, - notes: [...current.notes, note], - latestAt: current.latestAt, - earliestAt: note.createdAt, - ); - } else { - if (current != null) groups.add(current); - current = AgentNoteGroup( - pubkey: note.pubkey, - notes: [note], - latestAt: note.createdAt, - earliestAt: note.createdAt, - ); - } - } - - if (current != null) groups.add(current); - return groups; -} - -@immutable -class ContactEntry { - final String pubkey; - final String? relayUrl; - final String? petname; - - const ContactEntry({required this.pubkey, this.relayUrl, this.petname}); - - List toTag() => ['p', pubkey.toLowerCase(), ?relayUrl, ?petname]; -} diff --git a/mobile/lib/features/pulse/pulse_page.dart b/mobile/lib/features/pulse/pulse_page.dart deleted file mode 100644 index d9e8ef4c7f..0000000000 --- a/mobile/lib/features/pulse/pulse_page.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/theme/buzz_icons.dart'; - -import '../../shared/identity_names/identity_names_provider.dart'; -import '../../shared/relay/relay.dart'; -import '../../shared/theme/theme.dart'; -import '../../shared/widgets/filter_chip_bar.dart'; -import '../../shared/widgets/bee_refresh_indicator.dart'; -import '../../shared/widgets/frosted_app_bar.dart'; -import '../../shared/widgets/frosted_scaffold.dart'; -import 'agent_activity_card.dart'; -import 'compose_note_page.dart'; -import 'note_card.dart'; -import 'pulse_models.dart'; -import 'pulse_provider.dart'; - -enum PulseTab { everyone, following, liked, agents, mine } - -class PulsePage extends HookConsumerWidget { - const PulsePage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final active = useState(PulseTab.everyone); - final isIos = defaultTargetPlatform == TargetPlatform.iOS; - final currentPubkey = ref.watch(myPubkeyProvider); - final contactsAsync = currentPubkey == null - ? const AsyncValue>.data([]) - : ref.watch(contactListProvider(currentPubkey)); - final contacts = contactsAsync.asData?.value ?? const []; - final contactPubkeys = contacts.map((c) => c.pubkey).toList(); - final contactSet = contactPubkeys.toSet(); - final agentPubkeys = - ref.watch(agentPubkeysProvider).asData?.value.toSet() ?? {}; - - final notesAsync = switch (active.value) { - PulseTab.everyone => ref.watch(globalNotesProvider), - PulseTab.following => ref.watch( - notesTimelineProvider(pulseKeyFor(contactPubkeys)), - ), - PulseTab.liked => ref.watch(likedNotesProvider), - PulseTab.agents => ref.watch(agentNotesProvider), - PulseTab.mine => - currentPubkey == null - ? const AsyncValue>.data([]) - : ref.watch(notesTimelineProvider(pulseKeyFor([currentPubkey]))), - }; - - final visibleNotes = notesAsync.asData?.value ?? const []; - if (visibleNotes.isNotEmpty) preloadPulseProfiles(ref, visibleNotes); - final notesKey = pulseKeyFor(visibleNotes.map((note) => note.id)); - final reactions = ref.watch(noteReactionsProvider(notesKey)); - final reactionMap = - reactions.asData?.value ?? const {}; - - return FrostedScaffold( - // Reset the native collapse state together with the new timeline. - key: isIos ? ValueKey(active.value) : null, - nativePinnedBody: true, - resizeToAvoidBottomInset: true, - appBar: const FrostedAppBar(nativeLargeTitle: true, title: Text('Pulse')), - floatingActionButton: FloatingActionButton( - heroTag: 'pulse-compose-fab', - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ComposeNotePage()), - ), - tooltip: 'New note', - shape: const CircleBorder(), - child: const Icon(BuzzIcons.plus), - ), - body: Column( - children: [ - SizedBox(height: frostedAppBarHeight(context)), - FilterChipBar( - selected: active.value, - onSelected: (tab) => active.value = tab, - items: const [ - FilterChipItem( - id: PulseTab.everyone, - label: 'Everyone', - icon: BuzzIcons.radio, - ), - FilterChipItem( - id: PulseTab.following, - label: 'Following', - icon: BuzzIcons.users, - ), - FilterChipItem( - id: PulseTab.liked, - label: 'Liked', - icon: BuzzIcons.heart, - ), - FilterChipItem( - id: PulseTab.agents, - label: 'Agents', - icon: BuzzIcons.bot, - ), - FilterChipItem( - id: PulseTab.mine, - label: 'Mine', - icon: BuzzIcons.user, - ), - ], - ), - Expanded( - child: BeeRefreshIndicator( - onRefresh: () async => _refresh(ref, active.value, currentPubkey), - child: _PulseBody( - tab: active.value, - notesAsync: notesAsync, - reactions: reactionMap, - agentPubkeys: agentPubkeys, - contactPubkeys: contactSet, - currentPubkey: currentPubkey, - onReactionChanged: () => - ref.invalidate(noteReactionsProvider(notesKey)), - ), - ), - ), - ], - ), - ); - } - - Future _refresh( - WidgetRef ref, - PulseTab tab, - String? currentPubkey, - ) async { - ref.invalidate(globalNotesProvider); - ref.invalidate(likedNotesProvider); - ref.invalidate(agentPubkeysProvider); - ref.invalidate(agentNotesProvider); - if (currentPubkey != null) { - ref.invalidate(contactListProvider(currentPubkey)); - } - } -} - -class _PulseBody extends ConsumerWidget { - final PulseTab tab; - final AsyncValue> notesAsync; - final Map reactions; - final Set agentPubkeys; - final Set contactPubkeys; - final String? currentPubkey; - final VoidCallback onReactionChanged; - - const _PulseBody({ - required this.tab, - required this.notesAsync, - required this.reactions, - required this.agentPubkeys, - required this.contactPubkeys, - required this.currentPubkey, - required this.onReactionChanged, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return notesAsync.when( - loading: () => const _TimelineSkeleton(), - error: (_, _) => _MessageListShell( - child: _EmptyState( - icon: BuzzIcons.circleAlert, - message: 'Could not load Pulse. Pull to try again.', - ), - ), - data: (notes) { - if (notes.isEmpty) { - return _MessageListShell( - child: _EmptyState(message: _emptyMessage(tab)), - ); - } - // Everyone the timeline names β€” authors, reply targets and mentions - // β€” is one comparison context, so two same-name targets on - // different notes are still told apart. - final names = watchIdentityNames( - ref, - pulseNamedIdentities(notes), - agentPubkeys: tab == PulseTab.agents - ? {for (final note in notes) note.pubkey.toLowerCase()} - : agentPubkeys, - ); - if (tab == PulseTab.agents) { - final groups = groupAgentNotes(notes); - return ListView.separated( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.xs, - ), - itemCount: groups.length, - separatorBuilder: (_, _) => const _NoteDivider(), - itemBuilder: (context, index) => AgentActivityCard( - group: groups[index], - reactions: reactions, - names: names, - onReactionChanged: onReactionChanged, - ), - ); - } - return ListView.separated( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.xs, - ), - itemCount: notes.length, - separatorBuilder: (_, _) => const _NoteDivider(), - itemBuilder: (context, index) { - final note = notes[index]; - return NoteCard( - note: note, - reaction: - reactions[note.id] ?? - const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - isAgent: agentPubkeys.contains(note.pubkey), - names: names, - isFollowing: contactPubkeys.contains(note.pubkey), - canFollow: - currentPubkey != null && - currentPubkey!.toLowerCase() != note.pubkey.toLowerCase(), - onReactionChanged: onReactionChanged, - onFollowChanged: (_) { - if (currentPubkey != null) { - ref.invalidate(contactListProvider(currentPubkey!)); - } - }, - ); - }, - ); - }, - ); - } - - String _emptyMessage(PulseTab tab) => switch (tab) { - PulseTab.everyone => 'No notes yet. Be the first pulse.', - PulseTab.following => 'Follow people to build your Pulse timeline.', - PulseTab.liked => 'Heart notes to save them here.', - PulseTab.agents => - 'No agent notes yet. Agents post here when they publish.', - PulseTab.mine => 'Your notes will show up here.', - }; -} - -class _MessageListShell extends StatelessWidget { - final Widget child; - - const _MessageListShell({required this.child}); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(Grid.xs), - children: [SizedBox(height: 260, child: Center(child: child))], - ); - } -} - -class _EmptyState extends StatelessWidget { - final IconData icon; - final String message; - - const _EmptyState({this.icon = BuzzIcons.radio, required this.message}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.lg), - decoration: BoxDecoration( - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(Radii.lg), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: context.colors.onSurfaceVariant), - const SizedBox(height: Grid.xs), - Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - -class _TimelineSkeleton extends StatelessWidget { - const _TimelineSkeleton(); - - @override - Widget build(BuildContext context) { - return ListView.separated( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.xs, - ), - itemCount: 5, - separatorBuilder: (_, _) => const _NoteDivider(), - itemBuilder: (_, _) => Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.twelve), - child: Container( - height: 64, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues( - alpha: 0.55, - ), - borderRadius: BorderRadius.circular(Radii.md), - ), - ), - ), - ); - } -} - -class _NoteDivider extends StatelessWidget { - const _NoteDivider(); - - @override - Widget build(BuildContext context) { - return Divider( - height: 1, - thickness: 1, - color: context.colors.outlineVariant.withValues(alpha: 0.5), - ); - } -} diff --git a/mobile/lib/features/pulse/pulse_provider.dart b/mobile/lib/features/pulse/pulse_provider.dart deleted file mode 100644 index f09edf680c..0000000000 --- a/mobile/lib/features/pulse/pulse_provider.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../shared/relay/relay.dart'; -import '../../shared/profile/user_cache_provider.dart'; -import 'pulse_models.dart'; - -final globalNotesProvider = FutureProvider>((ref) async { - final session = ref.watch(relaySessionProvider.notifier); - final events = await session.fetchHistory(NostrFilters.globalNotes()); - return _notesFromEvents(events); -}); - -final notesTimelineProvider = FutureProvider.family, String>(( - ref, - pubkeysKey, -) async { - final pubkeys = parsePulseKey(pubkeysKey); - if (pubkeys.isEmpty) return const []; - final session = ref.watch(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.notesTimeline(pubkeys), - ); - return _notesFromEvents(events); -}); - -final likedNotesProvider = FutureProvider>((ref) async { - final pubkey = ref.watch(myPubkeyProvider); - if (pubkey == null) return const []; - final session = ref.watch(relaySessionProvider.notifier); - final reactions = await session.fetchHistory( - NostrFilters.userReactions(pubkey), - ); - final liveReactions = await _filterDeletedReactions( - session, - reactions, - deletionAuthors: [pubkey], - ); - final ids = []; - final seen = {}; - for (final reaction in liveReactions) { - if (reaction.content != '+') continue; - final noteId = _lastETag(reaction.tags); - if (noteId != null && seen.add(noteId)) ids.add(noteId); - } - if (ids.isEmpty) return const []; - final notes = await session.fetchHistory(NostrFilters.notesByIds(ids)); - return _notesFromEvents(notes); -}); - -final noteReactionsProvider = - FutureProvider.family, String>(( - ref, - noteIdsKey, - ) async { - final noteIds = parsePulseKey(noteIdsKey); - if (noteIds.isEmpty) return const {}; - final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); - final session = ref.watch(relaySessionProvider.notifier); - final reactions = await session.fetchHistory( - NostrFilters.noteReactions(noteIds), - ); - final events = await _filterDeletedReactions(session, reactions); - final pubkeysByNote = >{}; - final currentReactionIds = {}; - - for (final event in events) { - if (event.content != '+') continue; - final noteId = _lastETag(event.tags); - if (noteId == null) continue; - final pubkey = event.pubkey.toLowerCase(); - pubkeysByNote.putIfAbsent(noteId, () => {}).add(pubkey); - if (currentPubkey != null && pubkey == currentPubkey) { - currentReactionIds[noteId] = event.id; - } - } - - return { - for (final noteId in noteIds) - noteId: PulseReactionState( - count: pubkeysByNote[noteId]?.length ?? 0, - reactedByCurrentUser: currentReactionIds.containsKey(noteId), - currentUserReactionId: currentReactionIds[noteId], - ), - }; - }); - -final contactListProvider = FutureProvider.family, String>(( - ref, - pubkey, -) async { - if (pubkey.isEmpty) return const []; - final session = ref.watch(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.contactList(pubkey.toLowerCase()), - ); - return contactsFromEvents(events); -}); - -final agentPubkeysProvider = FutureProvider>((ref) async { - final session = ref.watch(relaySessionProvider.notifier); - - // Primary source: kind:10100 agent profile events (each agent signs its own). - final profileEvents = await session.fetchHistory( - NostrFilters.agentProfiles(), - ); - final pubkeys = {}; - for (final event in profileEvents) { - pubkeys.add(event.pubkey.toLowerCase()); - final p = event.getTagValue('p'); - if (p != null) pubkeys.add(p.toLowerCase()); - } - - // Fallback: relay membership list (kind:13534) β€” extract members with - // role "bot". This catches managed agents that may not have published - // a kind:10100 profile event yet. - final memberEvents = await session.fetchHistory(NostrFilters.relayMembers()); - if (memberEvents.isNotEmpty) { - final event = memberEvents.first; - for (final tag in event.tags) { - if (tag.length >= 3 && tag[0] == 'member' && tag[2] == 'bot') { - pubkeys.add(tag[1].toLowerCase()); - } - // NIP-29 fallback: ["p", pubkey, relay_url?, role?] - if (tag.length >= 4 && tag[0] == 'p' && tag[3] == 'bot') { - pubkeys.add(tag[1].toLowerCase()); - } - } - } - - return pubkeys.toList(); -}); - -final agentNotesProvider = FutureProvider>((ref) async { - final pubkeys = await ref.watch(agentPubkeysProvider.future); - if (pubkeys.isEmpty) return const []; - return ref.watch(notesTimelineProvider(pulseKeyFor(pubkeys)).future); -}); - -List _notesFromEvents(List events) { - final notes = - events - .where((event) => event.kind == EventKind.note) - .map(UserNote.fromEvent) - .toList() - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return notes; -} - -List _contactsFromTags(List> tags) => [ - for (final tag in tags) - if (tag.length >= 2 && tag[0] == 'p') - ContactEntry( - pubkey: tag[1].toLowerCase(), - relayUrl: tag.length >= 3 && tag[2].isNotEmpty ? tag[2] : null, - petname: tag.length >= 4 && tag[3].isNotEmpty ? tag[3] : null, - ), -]; - -String? _lastETag(List> tags) { - for (final tag in tags.reversed) { - if (tag.length >= 2 && tag[0] == 'e') return tag[1]; - } - return null; -} - -String pulseKeyFor(Iterable values) { - final normalized = - values - .map((value) => value.toLowerCase().trim()) - .where((value) => value.isNotEmpty) - .toSet() - .toList() - ..sort(); - return normalized.join(','); -} - -List parsePulseKey(String key) { - if (key.isEmpty) return const []; - return key.split(',').where((value) => value.isNotEmpty).toList(); -} - -Future> _filterDeletedReactions( - RelaySessionNotifier session, - List reactions, { - List? deletionAuthors, -}) async { - if (reactions.isEmpty) return const []; - final reactionIds = reactions.map((event) => event.id).toList(); - final deletions = await session.fetchHistory( - NostrFilters.deletionsByTargetIds(reactionIds, authors: deletionAuthors), - ); - final deletedIds = {}; - for (final deletion in deletions) { - for (final tag in deletion.tags) { - if (tag.length >= 2 && tag[0] == 'e') deletedIds.add(tag[1]); - } - } - if (deletedIds.isEmpty) return reactions; - return [ - for (final reaction in reactions) - if (!deletedIds.contains(reaction.id)) reaction, - ]; -} - -List contactsFromEvents(List events) { - if (events.isEmpty) return const []; - return _contactsFromTags(events.first.tags); -} - -void preloadPulseProfiles(WidgetRef ref, List notes) { - final pubkeys = {}; - for (final note in notes) { - pubkeys.add(note.pubkey); - pubkeys.addAll(note.mentionPubkeys); - } - ref.read(userCacheProvider.notifier).preload(pubkeys.toList()); -} diff --git a/mobile/lib/shared/identity_names/identity_names_provider.dart b/mobile/lib/shared/identity_names/identity_names_provider.dart index a43b32e645..ba2d5236f5 100644 --- a/mobile/lib/shared/identity_names/identity_names_provider.dart +++ b/mobile/lib/shared/identity_names/identity_names_provider.dart @@ -37,8 +37,8 @@ void loadIdentityNameOwners(Ref ref, IdentityNames names) { } /// Labels for a displayed collection with no channel context, such as search -/// results, a Pulse timeline, or a picker's choices. [candidates] is that -/// collection; keys outside it resolve against it plus themselves. +/// results or a picker's choices. [candidates] is that collection; keys +/// outside it resolve against it plus themselves. /// /// Uncached profiles are loaded for the identities in [shown] (default: all /// [candidates]). Pass [shown] when the context also holds identities that are diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 7f730caa9f..53cf19e136 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -43,6 +43,7 @@ enum _ApplyRemoteContextResult { unchanged, advanced } class ReadStateManager { final String pubkey; + final String relayUrl; final ReadStateCrypto _crypto; final ReadStateStorage _storage; final RelaySessionNotifier? _relaySession; @@ -76,6 +77,7 @@ class ReadStateManager { ReadStateManager({ required this.pubkey, + required this.relayUrl, required SharedPreferences prefs, required ReadStateCrypto crypto, required RelaySessionNotifier? relaySession, @@ -88,8 +90,8 @@ class ReadStateManager { _signedEventRelay = signedEventRelay, _remoteEnabled = remoteEnabled, _onChanged = onChanged { - _clientId = _storage.getOrCreateClientId(pubkey); - _slotId = _storage.getOrCreateSlotId(pubkey); + _clientId = _storage.getOrCreateClientId(pubkey, relayUrl); + _slotId = _storage.getOrCreateSlotId(pubkey, relayUrl); _hydrateFromLocalStorage(); } @@ -231,6 +233,8 @@ class ReadStateManager { } void _mergeEvents(List events) { + // Another community's marks must never reach this community's state. + if (!_servesThisCommunity) return; ReadStateBlob? ownBlob; var ownBlobCreatedAt = 0; @@ -321,7 +325,7 @@ class ReadStateManager { } void _handleIncomingEvent(NostrEvent event) { - if (_disposed) return; + if (_disposed || !_servesThisCommunity) return; debugPrint( '[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}', ); @@ -446,9 +450,11 @@ class ReadStateManager { } Future _publishOnce(SignedEventRelay signedEventRelay) async { + if (!_servesThisCommunity) return; debugPrint('[ReadStateManager] publish starting slotId=$_slotId'); try { await _fetchOwnBlobBeforePublish(); + if (!_servesThisCommunity) return; final contexts = _currentContexts(); if (contexts == null) { @@ -534,6 +540,13 @@ class ReadStateManager { } } + /// Switching community rebuilds the session in place before this manager + /// is retired, so in between it must neither take in nor publish data. + bool get _servesThisCommunity { + final served = _relaySession?.relayUrl; + return served == null || served == relayUrl; + } + bool _isIdenticalToLastPublished(Map? contexts) { // Null: the slot cannot be published, so there is nothing to send. if (contexts == null) return true; @@ -577,7 +590,7 @@ class ReadStateManager { } void _hydrateFromLocalStorage() { - final stored = _storage.read(pubkey); + final stored = _storage.read(pubkey, relayUrl); // Earlier versions merged the web app's override keys and published // them in this device's slot, so publishable ones are carried. _carriedOverrides @@ -617,6 +630,7 @@ class ReadStateManager { } _storage.write( pubkey, + relayUrl, saved, { ..._publishableContextIds.where(saved.containsKey), @@ -631,7 +645,7 @@ class ReadStateManager { void _rotateSlotId() { _slotId = generateReadStateSlotId(); - _storage.writeSlotId(pubkey, _slotId); + _storage.writeSlotId(pubkey, relayUrl, _slotId); } bool _isPlausibleCreatedAt(int createdAt) => diff --git a/mobile/lib/shared/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart index b7e2ee645e..a1965e1b4b 100644 --- a/mobile/lib/shared/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -103,6 +103,7 @@ class ReadStateNotifier extends Notifier { late final ReadStateManager manager; manager = ReadStateManager( pubkey: pubkey, + relayUrl: relayConfig.baseUrl, prefs: prefs, crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), diff --git a/mobile/lib/shared/read_state/read_state_storage.dart b/mobile/lib/shared/read_state/read_state_storage.dart index 6be7dd438d..d46ce4e80c 100644 --- a/mobile/lib/shared/read_state/read_state_storage.dart +++ b/mobile/lib/shared/read_state/read_state_storage.dart @@ -5,24 +5,40 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; +import '../theme/community_theme_preference.dart'; import 'read_state_format.dart'; import 'read_state_time.dart'; +const _contextsKeyPrefix = 'buzz.channel-read-state.v2'; +const _publishableKeyPrefix = 'buzz.channel-read-state.publishable.v1'; +const _sourceCreatedAtKeyPrefix = + 'buzz.channel-read-state.source-created-at.v1'; const _clientIdKeyPrefix = 'buzz.nip-rs.client-id'; const _slotIdKeyPrefix = 'buzz.nip-rs.slot-id'; const _uuid = Uuid(); -String localReadStateKey(String pubkey) => 'buzz.channel-read-state.v2:$pubkey'; +/// Read marks belong to one community, so every key is scoped by relay as +/// well as identity. One identity can be paired with several communities. +String _scope(String pubkey, String relayUrl) => + '$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; -String localPublishableContextKey(String pubkey) => - 'buzz.channel-read-state.publishable.v1:$pubkey'; +String localReadStateKey(String pubkey, String relayUrl) => + '$_contextsKeyPrefix:${_scope(pubkey, relayUrl)}'; -String localSourceCreatedAtKey(String pubkey) => - 'buzz.channel-read-state.source-created-at.v1:$pubkey'; +String localPublishableContextKey(String pubkey, String relayUrl) => + '$_publishableKeyPrefix:${_scope(pubkey, relayUrl)}'; -String clientIdKey(String pubkey) => '$_clientIdKeyPrefix:$pubkey'; +String localSourceCreatedAtKey(String pubkey, String relayUrl) => + '$_sourceCreatedAtKeyPrefix:${_scope(pubkey, relayUrl)}'; -String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey'; +String clientIdKey(String pubkey, String relayUrl) => + '$_clientIdKeyPrefix:${_scope(pubkey, relayUrl)}'; + +String slotIdKey(String pubkey, String relayUrl) => + '$_slotIdKeyPrefix:${_scope(pubkey, relayUrl)}'; + +/// The earlier keys, shared by every community of the identity. +String _legacyKey(String prefix, String pubkey) => '$prefix:$pubkey'; class StoredReadState { final Map contexts; @@ -43,44 +59,62 @@ class ReadStateStorage { ReadStateStorage(this._prefs); - String getOrCreateClientId(String pubkey) { - final key = clientIdKey(pubkey); + String getOrCreateClientId(String pubkey, String relayUrl) => _getOrCreateId( + clientIdKey(pubkey, relayUrl), + _legacyKey(_clientIdKeyPrefix, pubkey), + isValid: _isValidClientId, + generate: _uuid.v4, + ); + + String getOrCreateSlotId(String pubkey, String relayUrl) => _getOrCreateId( + slotIdKey(pubkey, relayUrl), + _legacyKey(_slotIdKeyPrefix, pubkey), + isValid: _isValidSlotId, + generate: generateReadStateSlotId, + ); + + /// Each community seeds its id from the identity-wide one when it has none, + /// so it keeps publishing to the slot that already exists on its relay. The + /// shared id stays for the other communities to seed from. + String _getOrCreateId( + String key, + String legacyKey, { + required bool Function(String?) isValid, + required String Function() generate, + }) { final stored = _prefs.getString(key); - if (_isValidClientId(stored)) { - return stored!; + if (isValid(stored)) return stored!; + + final legacy = _prefs.getString(legacyKey); + final id = isValid(legacy) ? legacy! : generate(); + _prefs.setString(key, id); + return id; + } + + void writeSlotId(String pubkey, String relayUrl, String slotId) { + _prefs.setString(slotIdKey(pubkey, relayUrl), slotId); + } + + StoredReadState read(String pubkey, String relayUrl) { + // The shared marks may be another community's, and the relay keeps the + // published ones, so drop them rather than let one community claim them. + for (final prefix in const [ + _contextsKeyPrefix, + _publishableKeyPrefix, + _sourceCreatedAtKeyPrefix, + ]) { + _prefs.remove(_legacyKey(prefix, pubkey)); } - - final generated = _uuid.v4(); - _prefs.setString(key, generated); - return generated; - } - - String getOrCreateSlotId(String pubkey) { - final key = slotIdKey(pubkey); - final stored = _prefs.getString(key); - if (_isValidSlotId(stored)) { - return stored!; - } - - final generated = generateReadStateSlotId(); - _prefs.setString(key, generated); - return generated; - } - - void writeSlotId(String pubkey, String slotId) { - _prefs.setString(slotIdKey(pubkey), slotId); - } - - StoredReadState read(String pubkey) { return StoredReadState( - contexts: _readContexts(pubkey), - publishableContextIds: _readPublishableContextIds(pubkey), - sourceCreatedAt: _readSourceCreatedAt(pubkey), + contexts: _readContexts(pubkey, relayUrl), + publishableContextIds: _readPublishableContextIds(pubkey, relayUrl), + sourceCreatedAt: _readSourceCreatedAt(pubkey, relayUrl), ); } void write( String pubkey, + String relayUrl, Map contexts, Set publishableContextIds, Map sourceCreatedAt, @@ -90,19 +124,19 @@ class ReadStateStorage { state[entry.key] = unixSecondsToDateTime(entry.value).toIso8601String(); } - _prefs.setString(localReadStateKey(pubkey), jsonEncode(state)); + _prefs.setString(localReadStateKey(pubkey, relayUrl), jsonEncode(state)); _prefs.setString( - localPublishableContextKey(pubkey), + localPublishableContextKey(pubkey, relayUrl), jsonEncode(publishableContextIds.toList()), ); _prefs.setString( - localSourceCreatedAtKey(pubkey), + localSourceCreatedAtKey(pubkey, relayUrl), jsonEncode(sourceCreatedAt.map((k, v) => MapEntry(k, v.toString()))), ); } - Map _readContexts(String pubkey) { - final raw = _prefs.getString(localReadStateKey(pubkey)); + Map _readContexts(String pubkey, String relayUrl) { + final raw = _prefs.getString(localReadStateKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) { return {}; } @@ -133,8 +167,8 @@ class ReadStateStorage { return contexts; } - Set _readPublishableContextIds(String pubkey) { - final raw = _prefs.getString(localPublishableContextKey(pubkey)); + Set _readPublishableContextIds(String pubkey, String relayUrl) { + final raw = _prefs.getString(localPublishableContextKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) { return {}; } @@ -159,8 +193,8 @@ class ReadStateStorage { }; } - Map _readSourceCreatedAt(String pubkey) { - final raw = _prefs.getString(localSourceCreatedAtKey(pubkey)); + Map _readSourceCreatedAt(String pubkey, String relayUrl) { + final raw = _prefs.getString(localSourceCreatedAtKey(pubkey, relayUrl)); if (raw == null || raw.isEmpty) return {}; final Object? parsed; diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index a636977c57..e65e54bba7 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -164,61 +164,6 @@ abstract final class NostrFilters { extensions: const {'search_mode': 'prefix'}, ); - /// Deletions (kind:5) targeting event IDs. - static NostrFilter deletionsByTargetIds( - List ids, { - List? authors, - }) => NostrFilter( - kinds: [EventKind.deletion], - authors: authors, - tags: {'#e': ids}, - limit: ids.length, - ); - - /// User notes (kind:1) for the global Pulse timeline. - static NostrFilter globalNotes({int limit = 50, int? until}) => - NostrFilter(kinds: [EventKind.note], limit: limit, until: until); - - /// Notes by a set of authors for Pulse timelines. - static NostrFilter notesTimeline( - List pubkeys, { - int limit = 200, - int? until, - }) => NostrFilter( - kinds: [EventKind.note], - authors: pubkeys, - limit: limit, - until: until, - ); - - /// Reactions authored by a user. - static NostrFilter userReactions(String pubkey, {int limit = 200}) => - NostrFilter(kinds: [EventKind.reaction], authors: [pubkey], limit: limit); - - /// Reactions targeting notes. - static NostrFilter noteReactions(List noteIds) => NostrFilter( - kinds: [EventKind.reaction], - tags: {'#e': noteIds}, - limit: 500, - ); - - /// Fetch notes by ids. - static NostrFilter notesByIds(List ids) => - NostrFilter(kinds: [EventKind.note], ids: ids, limit: ids.length); - - /// User notes (kind:1) for a single author. - static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) => - NostrFilter( - kinds: [EventKind.note], - authors: [pubkey], - limit: limit, - until: until, - ); - - /// Contact list (kind:3) for a user. - static NostrFilter contactList(String pubkey) => - NostrFilter(kinds: [EventKind.contactList], authors: [pubkey], limit: 1); - /// Relay membership list (kind:13534). static NostrFilter relayMembers() => const NostrFilter(kinds: [EventKind.relayMembership], limit: 1); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 3173afc85f..f955640a3e 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -133,10 +133,20 @@ class RelaySessionNotifier extends Notifier { final Map Function()> _beforePauseCallbacks = {}; bool _socketConnected = false; bool _closedRetryReplayScheduled = false; + String? _relayUrl; + + /// The relay origin this session serves. Switching community rebuilds the + /// session in place, so work begun for another community compares this + /// before reading from or publishing through it. + String? get relayUrl => _relayUrl; + + @visibleForTesting + set debugRelayUrl(String? value) => _relayUrl = value; @override SessionState build() { final config = ref.watch(relayConfigProvider); + _relayUrl = config.baseUrl; final authState = ref.watch(authProvider); _ageRestricted = ref.watch(ageSignalProvider) == AgeSignalState.restricted; diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index b109a11766..bc15451f3f 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -276,7 +276,7 @@ class AppTheme { labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary), // M3 resolves the chip container via `color` (WidgetStateProperty); // `selectedColor` is the legacy M2 path and is ignored here. Selected - // filter chips (Pulse/Search/Activity tabs) use the accent. + // filter chips (Search/Activity tabs) use the accent. color: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) return scheme.primary; return scheme.surfaceContainerHighest; diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 0b4f622c59..25bd351846 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -28,9 +28,9 @@ class FilterChipItem { /// A horizontally-scrolling row of Material [FilterChip]s. /// -/// This is the single shared filter/segment selector used across Pulse, -/// Search, and Activity. It relies on the app's `chipTheme` for styling so -/// every screen looks identical β€” do not hand-roll chip rows elsewhere. +/// This is the single shared filter/segment selector used across Search and +/// Activity. It relies on the app's `chipTheme` for styling so every screen +/// looks identical β€” do not hand-roll chip rows elsewhere. class FilterChipBar extends StatelessWidget { final List> items; final T selected; diff --git a/mobile/test/features/activity/inbox_local_state_provider_test.dart b/mobile/test/features/activity/inbox_local_state_provider_test.dart new file mode 100644 index 0000000000..0b4a18a9d0 --- /dev/null +++ b/mobile/test/features/activity/inbox_local_state_provider_test.dart @@ -0,0 +1,68 @@ +import 'package:buzz/features/activity/inbox_local_state_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _SwitchableRelayConfig extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'https://relay-a.example'); + + void switchTo(String baseUrl) => state = RelayConfig(baseUrl: baseUrl); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future<(ProviderContainer, _SwitchableRelayConfig)> start( + Map stored, + ) async { + SharedPreferences.setMockInitialValues(stored); + final prefs = await SharedPreferences.getInstance(); + final config = _SwitchableRelayConfig(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => config), + myPubkeyProvider.overrideWithValue('pk_a'), + ], + ); + addTearDown(container.dispose); + return (container, config); + } + + test('each community keeps its own done and unread rows', () async { + final (container, config) = await start({}); + InboxLocalState state() => container.read(inboxLocalStateProvider); + + container.read(inboxLocalStateProvider.notifier).markDone('approval-a'); + expect(state().doneIds, {'approval-a'}); + + // Filling the second community's cap must not evict the first's rows. + config.switchTo('https://relay-b.example'); + expect(state().doneIds, isEmpty); + final notifier = container.read(inboxLocalStateProvider.notifier); + for (var i = 0; i < 600; i++) { + notifier.markDone('approval-b-$i'); + } + + config.switchTo('https://relay-a.example'); + expect(state().doneIds, {'approval-a'}); + }); + + test('every community starts from the device-wide rows', () async { + final (container, config) = await start({ + 'activity_inbox_local_state_v1': + '{"done":["approval-a","approval-b"],"unread":[]}', + }); + InboxLocalState state() => container.read(inboxLocalStateProvider); + + expect(state().doneIds, {'approval-a', 'approval-b'}); + container.read(inboxLocalStateProvider.notifier).markUnread(['approval-a']); + config.switchTo('https://relay-b.example'); + expect(state().doneIds, {'approval-a', 'approval-b'}); + config.switchTo('https://relay-a.example'); + expect(state().doneIds, {'approval-b'}); + }); +} diff --git a/mobile/test/features/channels/channel_preferences_manager_test.dart b/mobile/test/features/channels/channel_preferences_manager_test.dart index 776221ae31..6a91522cd3 100644 --- a/mobile/test/features/channels/channel_preferences_manager_test.dart +++ b/mobile/test/features/channels/channel_preferences_manager_test.dart @@ -13,6 +13,7 @@ void main() { var changes = 0; final manager = ChannelStarsManager( pubkey: keys.public, + relayUrl: 'https://relay.example', prefs: prefs, crypto: ChannelStarsCrypto(keys.nsec, keys.public), relaySession: null, @@ -37,6 +38,7 @@ void main() { var changes = 0; final manager = ChannelMutesManager( pubkey: keys.public, + relayUrl: 'https://relay.example', prefs: prefs, crypto: ChannelMutesCrypto(keys.nsec, keys.public), relaySession: null, diff --git a/mobile/test/features/channels/channel_reader_recovery_test.dart b/mobile/test/features/channels/channel_reader_recovery_test.dart index 0c48d97f9f..3efa44789e 100644 --- a/mobile/test/features/channels/channel_reader_recovery_test.dart +++ b/mobile/test/features/channels/channel_reader_recovery_test.dart @@ -8,6 +8,8 @@ import 'package:buzz/features/channels/channel_sort/channel_sort_provider.dart'; import 'package:buzz/features/channels/channel_stars/channel_stars_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -51,6 +53,7 @@ final _lanes = [ _Lane('channel-stars', 'starred', (prefs, relay) { final m = ChannelStarsManager( pubkey: relay.pubkey, + relayUrl: 'https://relay.example', prefs: prefs, crypto: ChannelStarsCrypto(relay.keys.nsec, relay.pubkey), relaySession: relay.session, @@ -71,6 +74,7 @@ final _lanes = [ _Lane('channel-mutes', 'muted', (prefs, relay) { final m = ChannelMutesManager( pubkey: relay.pubkey, + relayUrl: 'https://relay.example', prefs: prefs, crypto: ChannelMutesCrypto(relay.keys.nsec, relay.pubkey), relaySession: relay.session, @@ -104,6 +108,16 @@ class _Config extends RelayConfigNotifier { RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); } +class _SwitchableConfig extends RelayConfigNotifier { + _SwitchableConfig(this.nsec); + final String nsec; + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); + void switchTo(String baseUrl) => + state = RelayConfig(baseUrl: baseUrl, nsec: nsec); +} + class _InitializingSortSession extends ConnectedRelaySession { _InitializingSortSession(this.firstSnapshot); final NostrEvent firstSnapshot; @@ -236,6 +250,194 @@ void main() { c.dispose(); }); + fakeAsyncTest('groups stay with the community that owns them', (clock) { + final relay = SidebarRelay(); + final config = _SwitchableConfig(relay.keys.nsec); + final c = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relay.session), + activeCommunityProvider.overrideWith( + (ref) async => Community( + id: 'c', + name: 'c', + relayUrl: 'wss://relay.example', + addedAt: DateTime(2026), + ), + ), + appLifecycleProvider.overrideWith(_Lifecycle.new), + ], + ); + List names() => [ + for (final s in c.read(channelSectionsProvider).store.sections) s.name, + ]; + c.read(relaySessionProvider); + relay.stored.add( + relay.event('channel-sections', { + 'version': 1, + 'sections': [ + {'id': 'studio', 'name': 'Studio', 'order': 0}, + ], + 'assignments': {}, + }, nowSeconds()), + ); + c.listen(channelSectionsProvider, (_, _) {}); + clock.elapse(const Duration(seconds: 1)); + expect(names(), ['Studio']); + + // The same identity in a second community that has no groups. + config.switchTo('https://second.example'); + clock.elapse(const Duration(seconds: 1)); + expect(names(), isEmpty); + + // Returning shows the first community's cached groups before its relay + // answers. + final held = relay.holdHistory = Completer(); + config.switchTo('https://relay.example'); + clock.flushMicrotasks(); + expect(names(), ['Studio']); + held.complete(); + c.dispose(); + }); + + // Each lane: what it shows, one local change to it, and the second + // community's own value as its relay retains it. + final scopedLanes = + < + String, + ( + Set Function(ProviderContainer), + void Function(ProviderContainer, String), + (String, Object) Function(String), + ) + >{ + 'channel-stars': ( + (c) => c.read(channelStarsProvider).store.channels.keys.toSet(), + (c, id) => c.read(channelStarsProvider.notifier).starChannel(id), + (id) => ( + 'channel-stars', + { + 'version': 1, + 'channels': { + id: {'starred': true, 'updatedAt': nowSeconds()}, + }, + }, + ), + ), + 'channel-mutes': ( + (c) => c.read(channelMutesProvider).store.channels.keys.toSet(), + (c, id) => c.read(channelMutesProvider.notifier).muteChannel(id), + (id) => ( + 'channel-mutes', + { + 'version': 1, + 'channels': { + id: {'muted': true, 'updatedAt': nowSeconds()}, + }, + }, + ), + ), + 'channel-sections': ( + (c) => { + for (final s in c.read(channelSectionsProvider).store.sections) + s.name, + }, + (c, name) => + c.read(channelSectionsProvider.notifier).createSection(name), + (name) => ( + 'channel-sections', + { + 'version': 1, + 'sections': [ + {'id': name, 'name': name, 'order': 0}, + ], + 'assignments': {}, + }, + ), + ), + 'read-state': ( + (c) => c.read(readStateProvider).contexts.keys.toSet(), + (c, id) => c + .read(readStateProvider.notifier) + .markContextRead(id, nowSeconds()), + (id) => ( + '$readStateDTagPrefix${'b' * 32}', + ReadStateBlob( + clientId: 'second-device', + contexts: {id: nowSeconds()}, + ).toJson(), + ), + ), + }; + for (final MapEntry(key: dTag, value: (values, set, secondHead)) + in scopedLanes.entries) { + for (final reaches in ['live', 'in-flight fetch']) { + fakeAsyncTest('$dTag stay with the community they were set in ' + '(other community reached by $reaches)', (clock) { + final relay = SidebarRelay(); + final config = _SwitchableConfig(relay.keys.nsec); + final c = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relay.session), + activeCommunityProvider.overrideWith((ref) async => null), + appLifecycleProvider.overrideWith(_Lifecycle.new), + ], + ); + // Either the first community's subscription is live at the switch, or + // its startup fetch is still in flight. + final history = reaches == 'live' ? null : Completer(); + relay.holdHistory = history; + for (final p in [ + channelStarsProvider, + channelMutesProvider, + channelSectionsProvider, + readStateProvider, + ]) { + c.listen(p, (_, _) {}); + } + clock.elapse(const Duration(seconds: 1)); + set(c, 'chan-a'); + + // The session rebuilds for the second community before the first + // community's manager is retired. Until then that manager must neither + // flush its pending change through it nor take in what it delivers. + config.switchTo('https://second.example'); + c.read(relaySessionProvider); + final (headTag, payload) = secondHead('chan-b'); + final head = relay.event( + headTag, + payload, + nowSeconds() + 1, + tags: [ + if (dTag == 'read-state') ['t', 'read-state'], + ], + ); + relay.stored.add(head); + if (history == null) { + relay.emit(head); + } else { + history.complete(); + } + clock.elapse(const Duration(seconds: 10)); + final sentToSecond = [ + for (var i = 0; i < relay.published.length; i++) + if (relay.publishedTo[i] == 'https://second.example') + jsonEncode(relay.decrypt(relay.published[i])), + ]; + expect(sentToSecond, everyElement(isNot(contains('chan-a')))); + expect(values(c), {'chan-b'}); + + config.switchTo('https://relay.example'); + clock.elapse(const Duration(seconds: 1)); + expect(values(c), {'chan-a'}); + c.dispose(); + }); + } + } + for (final lane in _lanes) { Map blob(Map entries) => { 'version': 1, @@ -425,6 +627,7 @@ void wholeBlobLanes(SharedPreferences Function() prefs) { ChannelSectionsManager start(FakeAsync clock) { m = ChannelSectionsManager( pubkey: relay.pubkey, + relayUrl: 'https://relay.example', prefs: prefs(), crypto: ChannelSectionsCrypto(relay.keys.nsec, relay.pubkey), relaySession: relay.session, @@ -554,6 +757,7 @@ void wholeBlobLanes(SharedPreferences Function() prefs) { clock.flushMicrotasks(); m = ChannelSectionsManager( pubkey: relay.pubkey, + relayUrl: 'https://relay.example', prefs: prefs(), crypto: ChannelSectionsCrypto(relay.keys.nsec, relay.pubkey), relaySession: relay.session, @@ -611,6 +815,7 @@ void wholeBlobLanes(SharedPreferences Function() prefs) { m.dispose(flushPending: false); m = ChannelSectionsManager( pubkey: relay.pubkey, + relayUrl: 'https://relay.example', prefs: prefs(), crypto: ChannelSectionsCrypto(relay.keys.nsec, relay.pubkey), relaySession: relay.session, diff --git a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart index a8520d6107..e7471fb5cf 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart @@ -50,6 +50,7 @@ void main() { }) { return ChannelSectionsManager( pubkey: keychain.public, + relayUrl: 'https://relay.example', prefs: prefs, crypto: crypto, relaySession: relaySession, diff --git a/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart index 09b84161c5..f65b44b022 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart @@ -1,5 +1,6 @@ import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { group('ChannelSection icon', () { @@ -40,4 +41,20 @@ void main() { expect(section.toJson()['icon'], ''); }); }); + + test('identity-wide groups from before relay scoping are dropped', () async { + SharedPreferences.setMockInitialValues({ + legacyChannelSectionsKey('pk'): + '{"version":1,"sections":[{"id":"s","name":"Other","order":0}],' + '"assignments":{}}', + }); + final prefs = await SharedPreferences.getInstance(); + + final store = ChannelSectionsStorage( + prefs, + ).read('pk', 'https://relay.example'); + + expect(store.sections, isEmpty); + expect(prefs.containsKey(legacyChannelSectionsKey('pk')), isFalse); + }); } diff --git a/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart b/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart index 2dc683f787..cb71c9c1f4 100644 --- a/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart +++ b/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart @@ -1,8 +1,31 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/channel_mutes/channel_mutes_storage.dart' + show ChannelMutesStorage, legacyChannelMutesKey; import 'package:buzz/features/channels/channel_stars/channel_stars_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Tests for [ChannelStarStore] parsing and [mergeStores] last-writer-wins. void main() { + test( + 'identity-wide stars and mutes from before relay scoping are dropped', + () async { + SharedPreferences.setMockInitialValues({ + legacyChannelStarsKey( + 'pk', + ): '{"version":1,"channels":{"chan-a":{"starred":true,"updatedAt":1}}}', + legacyChannelMutesKey('pk'): + '{"version":1,"channels":{"chan-a":{"muted":true,"updatedAt":1}}}', + }); + final prefs = await SharedPreferences.getInstance(); + const relayUrl = 'https://relay.example'; + + expect(ChannelStarsStorage(prefs).read('pk', relayUrl).channels, isEmpty); + expect(ChannelMutesStorage(prefs).read('pk', relayUrl).channels, isEmpty); + expect(prefs.containsKey(legacyChannelStarsKey('pk')), isFalse); + expect(prefs.containsKey(legacyChannelMutesKey('pk')), isFalse); + }, + ); + group('ChannelStarStore.fromJson', () { test('parses a valid payload', () { final store = ChannelStarStore.fromJson({ diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index ee6eb9cc37..c021bd9ab0 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -11,6 +11,8 @@ import 'package:buzz/shared/read_state/read_state_storage.dart'; import 'package:buzz/shared/read_state/read_state_time.dart'; import 'package:buzz/shared/relay/relay.dart'; +const _relayUrl = 'https://relay.example'; + void main() { test('dispose flushes a pending publish after marking disposed', () async { SharedPreferences.setMockInitialValues({}); @@ -24,6 +26,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto!, relaySession: null, @@ -59,6 +62,7 @@ void main() { final relay = _UnsupportedKindSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto!, relaySession: null, @@ -91,6 +95,7 @@ void main() { final relay = _MissingScopeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto!, relaySession: null, @@ -121,6 +126,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: null, @@ -166,6 +172,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: session, @@ -215,6 +222,7 @@ void main() { final relay = _ParkedSignedEventRelay(failFirst: failFirst); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: null, @@ -256,6 +264,7 @@ void main() { )!; ReadStateManager create() => ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: null, @@ -275,7 +284,7 @@ void main() { expect(first.getEffectiveTimestamp('msg:stale'), isNotNull); first.dispose(); - final stored = ReadStateStorage(prefs).read(keychain.public); + final stored = ReadStateStorage(prefs).read(keychain.public, _relayUrl); final messages = stored.contexts.keys.where((k) => k.startsWith('msg:')); expect(messages.length, localMaxPrunableContexts); expect(stored.contexts, isNot(contains('msg:stale'))); @@ -296,6 +305,36 @@ void main() { ); }); + test('each community keeps its own slot, seeded from the shared one, ' + 'and none takes the shared marks', () async { + SharedPreferences.setMockInitialValues({ + 'buzz.nip-rs.slot-id:pk': 'a' * 32, + 'buzz.channel-read-state.v2:pk': + '{"channel-1":"2026-01-01T00:00:00.000",' + '"thread-activity:root-1":"2026-01-01T00:00:00.000"}', + 'buzz.channel-read-state.publishable.v1:pk': + '["channel-1","thread-activity:root-1"]', + }); + final prefs = await SharedPreferences.getInstance(); + final storage = ReadStateStorage(prefs); + + // Both communities keep publishing to the slot they already have. + expect(storage.getOrCreateSlotId('pk', _relayUrl), 'a' * 32); + expect(storage.getOrCreateSlotId('pk', 'https://other.example'), 'a' * 32); + storage.writeSlotId('pk', 'https://other.example', 'b' * 32); + expect(storage.getOrCreateSlotId('pk', _relayUrl), 'a' * 32); + + // The shared marks may be another community's, and the relay keeps + // the published ones, so neither community takes them. + expect(storage.read('pk', _relayUrl).contexts, isEmpty); + expect(storage.read('pk', 'https://other.example').contexts, isEmpty); + expect(prefs.containsKey('buzz.channel-read-state.v2:pk'), isFalse); + expect( + prefs.containsKey('buzz.channel-read-state.publishable.v1:pk'), + isFalse, + ); + }); + test('carries its own override keys and takes none from others', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -305,8 +344,8 @@ void main() { pubkey: keychain.public, )!; final storage = ReadStateStorage(prefs); - final clientId = storage.getOrCreateClientId(keychain.public); - final slotId = storage.getOrCreateSlotId(keychain.public); + final clientId = storage.getOrCreateClientId(keychain.public, _relayUrl); + final slotId = storage.getOrCreateSlotId(keychain.public, _relayUrl); const ownGroup = { 'ov_s:channel-1': 2, 'ov_c:channel-1': 1, @@ -345,6 +384,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: session, @@ -378,6 +418,7 @@ void main() { final restartedRelay = _FakeSignedEventRelay(); final restarted = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: null, @@ -410,8 +451,8 @@ void main() { pubkey: keychain.public, )!; final storage = ReadStateStorage(prefs); - final clientId = storage.getOrCreateClientId(keychain.public); - final slotId = storage.getOrCreateSlotId(keychain.public); + final clientId = storage.getOrCreateClientId(keychain.public, _relayUrl); + final slotId = storage.getOrCreateSlotId(keychain.public, _relayUrl); final session = _FakeRelaySession() ..historyEvents = [ _readStateEvent( @@ -439,6 +480,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: session, @@ -475,8 +517,8 @@ void main() { pubkey: keychain.public, )!; final storage = ReadStateStorage(prefs); - final clientId = storage.getOrCreateClientId(keychain.public); - final slotId = storage.getOrCreateSlotId(keychain.public); + final clientId = storage.getOrCreateClientId(keychain.public, _relayUrl); + final slotId = storage.getOrCreateSlotId(keychain.public, _relayUrl); final session = _FakeRelaySession() ..historyEvents = [ _readStateEvent( @@ -498,6 +540,7 @@ void main() { final relay = _FakeSignedEventRelay(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto, relaySession: session, @@ -525,6 +568,7 @@ void main() { final relay = _FakeRelaySession(); final manager = ReadStateManager( pubkey: keychain.public, + relayUrl: _relayUrl, prefs: prefs, crypto: crypto!, relaySession: relay, diff --git a/mobile/test/features/channels/read_state/read_state_provider_test.dart b/mobile/test/features/channels/read_state/read_state_provider_test.dart index 8a5500ca36..57430ffa77 100644 --- a/mobile/test/features/channels/read_state/read_state_provider_test.dart +++ b/mobile/test/features/channels/read_state/read_state_provider_test.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:buzz/shared/read_state/read_state_format.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:buzz/shared/read_state/read_state_time.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; @@ -20,16 +23,21 @@ void main() { final otherMsgKey = msgContextKey('msg-2'); late ProviderContainer container; + late _FakeRelayConfig config; - Future pumpNotifier() async { - SharedPreferences.setMockInitialValues({}); + Future pumpNotifier({ + Map Function(String pubkey)? stored, + }) async { + final keys = nostr.Keys.generate(); + SharedPreferences.setMockInitialValues(stored?.call(keys.public) ?? {}); final prefs = await SharedPreferences.getInstance(); - final nsec = nostr.Keys.generate().nsec; + final nsec = keys.nsec; + config = _FakeRelayConfig(nsec); container = ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), - relayConfigProvider.overrideWith(() => _FakeRelayConfig(nsec)), + relayConfigProvider.overrideWith(() => config), relaySessionProvider.overrideWith(_FakeRelaySession.new), activeCommunityProvider.overrideWith((ref) async => null), appLifecycleProvider.overrideWith(_FakeAppLifecycle.new), @@ -50,6 +58,46 @@ void main() { ReadStateState state() => container.read(readStateProvider); + Future settle() async { + for (var i = 0; i < 10 && !state().isReady; i++) { + await Future.delayed(Duration.zero); + } + expect(state().isReady, isTrue); + } + + test('read marks stay with the community they were made in', () async { + // Recent, so the saved copy keeps message marks. + final readAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final at = unixSecondsToDateTime(readAt).toIso8601String(); + // Upgrading from the identity-wide marks: they may be another + // community's, and the relay keeps the published ones, so none is taken. + final notifier = await pumpNotifier( + stored: (pubkey) => { + 'buzz.channel-read-state.v2:$pubkey': jsonEncode({ + channelId: at, + otherMsgKey: at, + }), + }, + ); + expect(state().contexts, isEmpty); + notifier.markContextRead(msgKey, readAt); + + // Each community keeps its own marks, even for a channel with the same id. + config.switchTo('http://localhost:2'); + container.read(readStateProvider); + await settle(); + expect(state().contexts, isEmpty); + container + .read(readStateProvider.notifier) + .markContextRead(channelId, readAt + 5); + + config.switchTo('http://localhost:1'); + container.read(readStateProvider); + await settle(); + expect(state().effectiveTimestamp(msgKey), readAt); + expect(state().effectiveTimestamp(channelId), isNull); + }); + test('message unread β†’ read β†’ unread round-trips through the real ' 'notifier', () async { final notifier = await pumpNotifier(); @@ -147,6 +195,9 @@ class _FakeRelayConfig extends RelayConfigNotifier { @override RelayConfig build() => RelayConfig(baseUrl: 'http://localhost:1', nsec: nsec); + + void switchTo(String baseUrl) => + state = RelayConfig(baseUrl: baseUrl, nsec: nsec); } /// Relay session that never connects and returns no history, keeping the diff --git a/mobile/test/features/channels/sidebar_sync_fixture.dart b/mobile/test/features/channels/sidebar_sync_fixture.dart index 99ca53cc24..03191e463c 100644 --- a/mobile/test/features/channels/sidebar_sync_fixture.dart +++ b/mobile/test/features/channels/sidebar_sync_fixture.dart @@ -19,10 +19,18 @@ class SidebarRelay { final keys = nostr.Keys.generate(); final session = ConnectedRelaySession(); late final signer = SignedEventRelay(session: session, nsec: keys.nsec); - final stored = []; + final _storedByRelay = >{}; + + /// Heads retained by the relay [session] currently serves, so a community + /// switch reaches a different relay's store. + List get stored => + _storedByRelay.putIfAbsent(session.relayUrl, () => []); final published = []; + + /// The relay [session] served when each [published] event was sent. + final publishedTo = []; final reqs = >[]; - final _liveSubIds = {}; + final _liveSubIds = {}; int historyFailures = 0; Completer? holdHistory; Completer? holdOk; @@ -31,18 +39,24 @@ class SidebarRelay { String get pubkey => keys.public; - NostrEvent event(String dTag, Object payload, int createdAt, {String? id}) => - NostrEvent( - id: id ?? 'e$createdAt'.padRight(64, 'f'), - pubkey: pubkey, - createdAt: createdAt, - kind: EventKind.readState, - tags: [ - ['d', dTag], - ], - content: nip44Encrypt(_key, jsonEncode(payload)), - sig: 'sig', - ); + NostrEvent event( + String dTag, + Object payload, + int createdAt, { + String? id, + List> tags = const [], + }) => NostrEvent( + id: id ?? 'e$createdAt'.padRight(64, 'f'), + pubkey: pubkey, + createdAt: createdAt, + kind: EventKind.readState, + tags: [ + ['d', dTag], + ...tags, + ], + content: nip44Encrypt(_key, jsonEncode(payload)), + sig: 'sig', + ); Object? decrypt(NostrEvent event) => jsonDecode(nip44Decrypt(_key, event.content)); @@ -57,9 +71,11 @@ class SidebarRelay { if ((req[1] as String).startsWith(prefix) && _dTagOf(req) == dTag) req, ]; + /// Delivers [event] on the live subscription for its `d` tag, or else on + /// the one filtered by `#t` alone. void emit(NostrEvent event) => session.debugHandleMessage([ 'EVENT', - _liveSubIds[event.getTagValue('d')], + _liveSubIds[event.getTagValue('d')] ?? _liveSubIds[null], event.toJson(), ]); @@ -70,6 +86,7 @@ class SidebarRelay { if (message.first == 'EVENT') { final event = NostrEvent.fromJson(message[1] as Map); published.add(event); + publishedTo.add(session.relayUrl); stored.add(event); final ok = ['OK', event.id, true, '']; final hold = holdOk; @@ -94,12 +111,19 @@ class SidebarRelay { ); } - Future _answerHistory(String subId, String dTag) async { + Future _answerHistory(String subId, String? dTag) async { await holdHistory?.future; if (historyFailures > 0) { historyFailures--; return _reply(['CLOSED', subId, 'error: overloaded']); } + // A `#t` filter reaches every slot it tags; a `#d` filter, one head. + if (dTag == null) { + for (final e in stored.where((e) => e.getTagValue('t') != null)) { + _reply(['EVENT', subId, e.toJson()]); + } + return _reply(['EOSE', subId]); + } final heads = stored.where((e) => e.getTagValue('d') == dTag).toList() ..sort( (a, b) => a.createdAt != b.createdAt @@ -112,13 +136,18 @@ class SidebarRelay { void _reply(List message) => session.debugHandleMessage(message); - static String _dTagOf(List req) => - ((req[2] as Map)['#d'] as List).single as String; + /// Null for a filter by `#t` alone, such as read state's slot discovery. + static String? _dTagOf(List req) => + ((req[2] as Map)['#d'] as List?)?.single as String?; } class ConnectedRelaySession extends RelaySessionNotifier { @override - SessionState build() => const SessionState(status: SessionStatus.connected); + SessionState build() { + // Follows the active community, as the production session does. + debugRelayUrl = ref.watch(relayConfigProvider).baseUrl; + return const SessionState(status: SessionStatus.connected); + } } class _ScriptedSocket extends RelaySocket { @@ -151,6 +180,9 @@ class FlakyPrefs extends Fake implements SharedPreferences { @override String? getString(String key) => _inner.getString(key); + @override + Future remove(String key) => _inner.remove(key); + @override Future setString(String key, String value) async { if (failures == 0) return _inner.setString(key, value); diff --git a/mobile/test/features/channels/thread_follows/thread_follows_provider_test.dart b/mobile/test/features/channels/thread_follows/thread_follows_provider_test.dart new file mode 100644 index 0000000000..8413649d3f --- /dev/null +++ b/mobile/test/features/channels/thread_follows/thread_follows_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; +import 'package:buzz/features/channels/thread_follows/thread_follows_storage.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _SwitchableRelayConfig extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'https://relay-a.example'); + + void switchTo(String baseUrl) => state = RelayConfig(baseUrl: baseUrl); +} + +void main() { + test( + 'following threads in one community keeps the other\'s follows', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = _SwitchableRelayConfig(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => config), + myPubkeyProvider.overrideWithValue('pk_a'), + ], + ); + addTearDown(container.dispose); + ThreadFollowsNotifier notifier() => + container.read(threadFollowsProvider.notifier); + bool following(String rootId) => + container.read(threadFollowsProvider).isFollowing(rootId); + + notifier().followThread('root-a'); + + // A full cap in the second community must not evict the first's follows. + config.switchTo('https://relay-b.example'); + expect(following('root-a'), isFalse); + for (var i = 0; i < threadFollowsMaxEntries + 1; i++) { + notifier().followThread('root-b-$i'); + } + + config.switchTo('https://relay-a.example'); + expect(following('root-a'), isTrue); + }, + ); +} diff --git a/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart b/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart index e74357ca1d..de107e4db5 100644 --- a/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart +++ b/mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart @@ -11,38 +11,54 @@ Future _prefs() async { void main() { const pubkey = 'abc123'; + const relayUrl = 'https://relay-a.example'; test('round-trips entries per pubkey', () async { final storage = ThreadFollowsStorage(await _prefs()); - storage.write(pubkey, const [ + storage.write(pubkey, relayUrl, const [ ThreadFollowEntry(rootId: threadIdRootDash1, followedAt: 100), ThreadFollowEntry(rootId: threadIdRootDash2, followedAt: 200), ]); - final entries = storage.read(pubkey); + final entries = storage.read(pubkey, relayUrl); expect(entries.map((e) => e.rootId), [ threadIdRootDash1, threadIdRootDash2, ]); expect(entries.map((e) => e.followedAt), [100, 200]); - expect(storage.read('other-pubkey'), isEmpty); + expect(storage.read('other-pubkey', relayUrl), isEmpty); + }); + + test('every community starts from the shared entry', () async { + SharedPreferences.setMockInitialValues({ + legacyThreadFollowsKey(pubkey): + '[{"rootId":"root-a","followedAt":5},' + '{"rootId":"root-b","followedAt":6}]', + }); + final storage = ThreadFollowsStorage(await SharedPreferences.getInstance()); + storage.write(pubkey, relayUrl, const []); + expect(storage.read(pubkey, relayUrl), isEmpty); + expect( + storage.read(pubkey, 'https://relay-b.example').map((e) => e.rootId), + ['root-a', 'root-b'], + ); }); test('ignores malformed stored payloads', () async { SharedPreferences.setMockInitialValues({ - threadFollowsKey(pubkey): '{"not":"a list"}', + threadFollowsKey(pubkey, relayUrl): '{"not":"a list"}', }); final storage = ThreadFollowsStorage(await SharedPreferences.getInstance()); - expect(storage.read(pubkey), isEmpty); + expect(storage.read(pubkey, relayUrl), isEmpty); }); test('drops malformed entries but keeps valid ones', () async { SharedPreferences.setMockInitialValues({ - threadFollowsKey(pubkey): + threadFollowsKey(pubkey, relayUrl): '[{"rootId":"good","followedAt":5},{"rootId":42},"junk"]', }); final storage = ThreadFollowsStorage(await SharedPreferences.getInstance()); - final entries = storage.read(pubkey); + final entries = storage.read(pubkey, relayUrl); expect(entries, hasLength(1)); expect(entries.single.rootId, 'good'); }); diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart deleted file mode 100644 index 3370dd5457..0000000000 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/identity_names/identity_names.dart'; -import 'package:buzz/shared/identity_names/identity_names_provider.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; -import 'package:buzz/shared/profile/user_profile.dart'; -import 'package:buzz/features/pulse/compose_note_page.dart'; -import 'package:buzz/features/pulse/pulse_models.dart'; -import 'package:buzz/shared/theme/theme.dart'; -import 'package:buzz/shared/widgets/avatar_image.dart'; - -class _FakeUserCacheNotifier extends UserCacheNotifier { - final Map _users; - _FakeUserCacheNotifier(this._users); - - @override - Map build() => _users; - - @override - UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; - - @override - Future preload(List pubkeys) async => true; -} - -class _MutableUserCache extends UserCacheNotifier { - _MutableUserCache(this._initial); - - final Map _initial; - - @override - Map build() => _initial; - - @override - UserProfile? get(String pubkey) => state[pubkey.toLowerCase()]; - - @override - Future preload(List pubkeys) async => true; - - void replace(UserProfile profile) => - state = {...state, profile.pubkey: profile}; -} - -void main() { - final replyNote = UserNote( - id: 'note1', - pubkey: 'alice_pk', - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, - content: 'The original note being replied to', - tags: const [], - ); - - Widget buildTestable( - Widget home, { - TextScaler textScaler = TextScaler.noScaling, - String displayName = 'Alice', - Map? users, - }) { - return ProviderScope( - overrides: [ - userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier( - users ?? - { - 'alice_pk': UserProfile( - pubkey: 'alice_pk', - displayName: displayName, - ), - }, - ), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: home, - ), - ), - ), - ); - } - - testWidgets('reply mode shows a rich preview of the replied-to note', ( - tester, - ) async { - await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote))); - await tester.pump(); - - expect(find.text('Replying to Alice'), findsOneWidget); - expect(find.text('Alice'), findsOneWidget); // author name in the row - expect(find.textContaining('original note being replied to'), findsWidgets); - expect(find.text('Reply'), findsOneWidget); // action button label - // Named parent: the preview avatar initial comes from the authored name. - expect(_replyPreviewAvatarInitial(tester, 'Replying to Alice'), 'A'); - }); - - testWidgets('a supplied context follows profile changes while open', ( - tester, - ) async { - final author = 'a' * 64; - final cache = _MutableUserCache({ - author: UserProfile(pubkey: author, displayName: 'Scout'), - }); - final note = UserNote( - id: 'note-live', - pubkey: author, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, - content: 'Original', - tags: const [], - ); - await tester.pumpWidget( - ProviderScope( - overrides: [userCacheProvider.overrideWith(() => cache)], - child: MaterialApp( - theme: AppTheme.light(), - home: Consumer( - builder: (context, ref, _) { - // The opener's context, as NoteCard passes it to the route. - final IdentityNames opened = ref - .read(identityNameSourcesProvider) - .scope([author]); - return ComposeNotePage(replyTo: note, names: opened); - }, - ), - ), - ), - ); - await tester.pump(); - expect(find.text('Replying to Scout'), findsOneWidget); - - cache.replace(UserProfile(pubkey: author, displayName: 'Renamed')); - await tester.pump(); - expect(find.text('Replying to Renamed'), findsOneWidget); - }); - - testWidgets('reply preview constrains its timestamp at large text sizes', ( - tester, - ) async { - final oldReplyNote = UserNote( - id: 'old-note', - pubkey: 'alice_pk', - createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, - content: 'An older note', - tags: const [], - ); - - await tester.pumpWidget( - buildTestable( - ComposeNotePage(replyTo: oldReplyNote), - textScaler: const TextScaler.linear(2), - ), - ); - await tester.pump(); - - final timestamp = tester.widget(find.text('Sep 30')); - expect(timestamp.maxLines, 1); - expect(timestamp.overflow, TextOverflow.ellipsis); - expect(tester.takeException(), isNull); - }); - - testWidgets('gives the reply author unused timestamp width', (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 600); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - const displayName = 'A moderately long Pulse reply author'; - - await tester.pumpWidget( - buildTestable( - ComposeNotePage(replyTo: replyNote), - displayName: displayName, - ), - ); - await tester.pump(); - - expect(tester.getSize(find.text(displayName)).width, greaterThan(150)); - expect(find.text('2m'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('new-note mode shows no reply preview', (tester) async { - await tester.pumpWidget(buildTestable(const ComposeNotePage())); - await tester.pump(); - - expect(find.textContaining('Replying to'), findsNothing); - expect(find.text('Post'), findsOneWidget); - }); - - testWidgets('reply preview stays compact for a short note', (tester) async { - await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote))); - await tester.pump(); - - // The preview content sits just above the divider; for a one-line note - // the gap between the content text and the divider must be small (this - // guards the earlier "preview row way too tall" regression). - final contentBottom = tester - .getRect(find.text('The original note being replied to')) - .bottom; - final divider = find.byType(Divider); - expect(divider, findsOneWidget); - final dividerTop = tester.getRect(divider).top; - expect( - dividerTop - contentBottom, - lessThan(24), - reason: 'reply preview should hug its content, not reserve tall space', - ); - }); - - testWidgets('reply preview with an image note clips to a bounded height', ( - tester, - ) async { - final imageNote = UserNote( - id: 'note2', - pubkey: 'alice_pk', - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60, - content: '#buzz\n![image](https://example.com/big.png)', - tags: const [ - [ - 'imeta', - 'url https://example.com/big.png', - 'm image/png', - 'dim 800x1600', - ], - ], - ); - await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: imageNote))); - await tester.pump(); - - // Renders the rich content (not the raw "![image](...)" markdown). - expect(find.textContaining('![image]'), findsNothing); - // The whole reply context stays bounded: divider sits within a screen of - // the "Replying to" label (guards a tall image blowing up the page). - final labelTop = tester.getRect(find.text('Replying to Alice')).top; - final dividerTop = tester.getRect(find.byType(Divider)).top; - expect(dividerTop - labelTop, lessThan(220)); - }); - - testWidgets('reply preview keys unnamed parent authors to their hex key', ( - tester, - ) async { - const a11ce = - 'a11ce00000000000000000000000000000000000000000000000000000000000'; - - final unnamedParentNote = UserNote( - id: 'note-unnamed-parent', - pubkey: a11ce, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, - content: 'The original note being replied to', - tags: const [], - ); - - await tester.pumpWidget( - buildTestable( - ComposeNotePage(replyTo: unnamedParentNote), - users: const {}, - ), - ); - await tester.pump(); - - expect(find.text('Replying to npub15yw\u2026ccpw'), findsOneWidget); - expect( - _replyPreviewAvatarInitial(tester, 'Replying to npub15yw\u2026ccpw'), - 'A', - ); - }); -} - -/// Avatar fallback initial in the reply-context preview, located by the -/// `Replying to` label it renders beside β€” the parent author's avatar, not -/// the composer's. -String _replyPreviewAvatarInitial(WidgetTester tester, String replyingLabel) { - final preview = find - .ancestor(of: find.text(replyingLabel), matching: find.byType(Column)) - .first; - final avatar = find.descendant( - of: preview, - matching: find.byType(AvatarImage), - ); - final initial = tester.widget( - find.descendant(of: avatar, matching: find.byType(Text)), - ); - return initial.data!; -} diff --git a/mobile/test/features/pulse/note_card_test.dart b/mobile/test/features/pulse/note_card_test.dart deleted file mode 100644 index 0f660ea851..0000000000 --- a/mobile/test/features/pulse/note_card_test.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'package:buzz/shared/identity_names/identity_names.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; -import 'package:buzz/shared/profile/user_profile.dart'; -import 'package:buzz/features/pulse/note_card.dart'; -import 'package:buzz/features/pulse/pulse_models.dart'; -import 'package:buzz/shared/theme/theme.dart'; -import 'package:buzz/shared/widgets/avatar_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../helpers/thread_ids.dart'; - -class _FakeUserCacheNotifier extends UserCacheNotifier { - final Map _users; - - _FakeUserCacheNotifier(this._users); - - @override - Map build() => _users; -} - -void main() { - testWidgets('names the author and reply target within the timeline', ( - tester, - ) async { - final first = 'a' * 64, second = 'b' * 64; - final users = { - first: UserProfile(pubkey: first, displayName: 'Scout'), - second: UserProfile(pubkey: second, displayName: 'Scout'), - }; - final names = IdentityNameSources(profiles: users).scope([first, second]); - expect(names.labelFor(first), isNot('Scout')); - final note = UserNote( - id: 'note-1', - pubkey: first, - createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, - content: 'A reply', - tags: [ - ['e', threadIdParentDashNote, '', 'reply'], - ['p', second], - ], - ); - expect(note.replyParentAuthor, second); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: NoteCard( - note: note, - reaction: const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - names: names, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text(names.labelFor(first)), findsOneWidget); - expect(find.text('Replying to ${names.labelFor(second)}'), findsOneWidget); - }); - - testWidgets('constrains timestamp with agent and follow metadata', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(280, 600); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - - final note = UserNote( - id: 'note-1', - pubkey: 'alice', - createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, - content: 'A note', - tags: const [], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier({ - 'alice': const UserProfile( - pubkey: 'alice', - displayName: 'A very long display name', - ), - }), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: Scaffold( - body: NoteCard( - note: note, - reaction: const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - isAgent: true, - canFollow: true, - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final timestamp = tester.widget(find.text('Sep 30')); - expect(timestamp.maxLines, 1); - expect(timestamp.overflow, TextOverflow.ellipsis); - expect(tester.takeException(), isNull); - }); - - testWidgets('gives the author unused timestamp width', (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 600); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - const displayName = 'A moderately long Pulse author'; - final note = UserNote( - id: 'note-2', - pubkey: 'alice', - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, - content: 'A note', - tags: const [], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier({ - 'alice': const UserProfile( - pubkey: 'alice', - displayName: displayName, - ), - }), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: NoteCard( - note: note, - reaction: const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - canFollow: true, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(tester.getSize(find.text(displayName)).width, greaterThan(145)); - expect(find.text('2m'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets( - 'reply targets and author identities render npub keys, hex event ids, ' - 'and keyed avatar initials', - (tester) async { - const b0b = - 'b0b0000000000000000000000000000000000000000000000000000000000000'; - const a11ce = - 'a11ce00000000000000000000000000000000000000000000000000000000000'; - const parentEventId = - 'feedbeef00000000000000000000000000000000000000000000000000000000'; - - UserNote noteOf(String id, List> tags) => UserNote( - id: id, - pubkey: b0b, - createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000, - content: 'A reply', - tags: tags, - ); - - final scenarios = - < - ({ - String id, - List> tags, - Map users, - String authorLabel, - String? replyLabel, - int npubLabelCount, - String avatarInitial, - }) - >[ - // `p`-tagged parent: the reply target is a public key, so it renders - // the compact npub β€” a second npub beside the unnamed author's own. - ( - id: 'reply-parent-author', - tags: [ - ['e', parentEventId, '', 'reply'], - ['p', a11ce], - ], - users: const {}, - authorLabel: 'npub1kzc\u2026uyv8', - replyLabel: 'Replying to npub15yw\u2026ccpw', - npubLabelCount: 2, - avatarInitial: 'B', - ), - // No `p` tag β€” the parent author is unknown, so the reply target - // falls back to the parent event id, which is not a public key: it - // keeps its hex truncation and stays out of the npub contract. - ( - id: 'reply-parent-event', - tags: [ - ['e', parentEventId, '', 'reply'], - ], - users: const {}, - authorLabel: 'npub1kzc\u2026uyv8', - replyLabel: 'Replying to feedbeef\u2026', - npubLabelCount: 1, - avatarInitial: 'B', - ), - // Cached author: the authored name wins for both the label and the - // avatar initial. - ( - id: 'cached-named-author', - tags: const [], - users: { - b0b: const UserProfile(pubkey: b0b, displayName: 'Carol'), - }, - authorLabel: 'Carol', - replyLabel: null, - npubLabelCount: 0, - avatarInitial: 'C', - ), - // Cached blank display names (empty or whitespace-only β€” relay - // profiles can carry both) must fall back to the compact npub - // for the label instead of rendering an empty author row. - ( - id: 'cached-blank-name-author', - tags: const [], - users: {b0b: const UserProfile(pubkey: b0b, displayName: '')}, - authorLabel: 'npub1kzc\u2026uyv8', - replyLabel: null, - npubLabelCount: 1, - avatarInitial: 'B', - ), - ( - id: 'cached-whitespace-name-author', - tags: const [], - users: {b0b: const UserProfile(pubkey: b0b, displayName: ' ')}, - authorLabel: 'npub1kzc\u2026uyv8', - replyLabel: null, - npubLabelCount: 1, - avatarInitial: 'B', - ), - ]; - - for (final scenario in scenarios) { - // Each scenario remounts its own ProviderScope: Riverpod keeps an - // updated scope's container, so an already-initialized user cache - // would otherwise serve the first scenario's overrides. - await tester.pumpWidget( - KeyedSubtree( - key: ValueKey(scenario.id), - child: ProviderScope( - overrides: [ - userCacheProvider.overrideWith( - () => _FakeUserCacheNotifier(scenario.users), - ), - ], - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: NoteCard( - note: noteOf(scenario.id, scenario.tags), - reaction: const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect( - find.text(scenario.authorLabel), - findsOneWidget, - reason: scenario.id, - ); - expect( - find.textContaining('npub'), - findsNWidgets(scenario.npubLabelCount), - reason: scenario.id, - ); - if (scenario.replyLabel != null) { - expect( - find.text(scenario.replyLabel!), - findsOneWidget, - reason: scenario.id, - ); - } - // Unnamed authors key the avatar initial to the hex public key - // (never the `N` every npub label starts with); a cached profile - // switches it to the authored-name initial. - expect( - _noteCardAvatarInitial(tester), - scenario.avatarInitial, - reason: scenario.id, - ); - expect(tester.takeException(), isNull, reason: scenario.id); - } - }, - ); -} - -/// Avatar fallback initial of the single rendered [NoteCard] β€” asserts at -/// the production seam (the rendered card), not the model getter. -String _noteCardAvatarInitial(WidgetTester tester) { - final card = find.byType(NoteCard); - final avatar = find.descendant(of: card, matching: find.byType(AvatarImage)); - final initial = tester.widget( - find.descendant(of: avatar, matching: find.byType(Text)), - ); - return initial.data!; -} diff --git a/mobile/test/features/pulse/pulse_page_test.dart b/mobile/test/features/pulse/pulse_page_test.dart deleted file mode 100644 index af6fd88c19..0000000000 --- a/mobile/test/features/pulse/pulse_page_test.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:buzz/features/pulse/pulse_models.dart'; -import 'package:buzz/features/pulse/pulse_page.dart'; -import 'package:buzz/features/pulse/pulse_provider.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; -import 'package:buzz/shared/profile/user_profile.dart'; -import 'package:buzz/shared/relay/relay.dart'; -import 'package:buzz/shared/theme/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class _UserCache extends UserCacheNotifier { - _UserCache(this._users); - - final Map _users; - - @override - Map build() => _users; - - @override - UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; - - @override - Future preload(List pubkeys) async => true; -} - -void main() { - testWidgets('iOS tab changes reset collapsed title and timeline together', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - const channel = MethodChannel('buzz/ios_navigation_bar/987'); - final calls = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - calls.add(call); - return null; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); - final author = '1' * 64; - await tester.pumpWidget( - ProviderScope( - overrides: [ - userCacheProvider.overrideWith( - () => _UserCache({ - author: UserProfile(pubkey: author, displayName: 'Alice'), - }), - ), - myPubkeyProvider.overrideWithValue(null), - agentPubkeysProvider.overrideWith((ref) async => const []), - globalNotesProvider.overrideWith( - (ref) async => List.generate( - 30, - (i) => UserNote( - id: 'note-$i', - pubkey: author, - createdAt: 1759233600, - content: 'Timeline note $i', - tags: const [], - ), - ), - ), - likedNotesProvider.overrideWith((ref) async => const []), - noteReactionsProvider.overrideWith((ref, key) async => const {}), - ], - child: MaterialApp(theme: AppTheme.light(), home: const PulsePage()), - ), - ); - await tester.pumpAndSettle(); - void bindNative() => tester - .widget(find.byType(UiKitView)) - .onPlatformViewCreated!(987); - bindNative(); - await tester.pump(); - final expandedHeight = tester.getSize(find.byType(UiKitView)).height; - final expandedTabsTop = tester.getTopLeft(find.text('Everyone')).dy; - final row = find.text('Timeline note 1'); - final gesture = await tester.startGesture( - tester.getCenter(find.byType(ListView).last), - ); - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - for (var i = 0; i < 5; i++) { - final before = tester.getTopLeft(row).dy; - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - expect(before - tester.getTopLeft(row).dy, closeTo(20, 1)); - } - await gesture.up(); - await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView).last, const Offset(0, -400)); - await tester.pumpAndSettle(); - expect(calls.where((c) => c.method == 'scroll').last.arguments, 520); - expect( - tester.getSize(find.byType(UiKitView)).height, - lessThan(expandedHeight), - ); - expect(tester.getTopLeft(find.text('Everyone')).dy, expandedTabsTop - 52); - await tester.tap(find.text('Liked')); - await tester.pumpAndSettle(); - bindNative(); - await tester.pump(); - expect(calls.where((c) => c.method == 'scroll').last.arguments, 0); - expect(tester.getSize(find.byType(UiKitView)).height, expandedHeight); - expect(tester.getTopLeft(find.text('Everyone')).dy, expandedTabsTop); - await tester.tap(find.text('Everyone')); - await tester.pumpAndSettle(); - bindNative(); - await tester.pump(); - expect(calls.where((c) => c.method == 'scroll').last.arguments, 0); - expect(find.text('Timeline note 0'), findsOneWidget); - expect(tester.getTopLeft(find.text('Everyone')).dy, expandedTabsTop); - await tester.pumpWidget(const SizedBox()); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('same-name reply targets on different notes are told apart', ( - tester, - ) async { - // Two authors reply to two different people who are not authors on the - // timeline but share the name Scout. - final alice = '1' * 64, bob = '2' * 64; - final scoutA = 'a' * 64, scoutB = 'b' * 64; - final users = { - alice: UserProfile(pubkey: alice, displayName: 'Alice'), - bob: UserProfile(pubkey: bob, displayName: 'Bob'), - scoutA: UserProfile(pubkey: scoutA, displayName: 'Scout'), - scoutB: UserProfile(pubkey: scoutB, displayName: 'Scout'), - }; - final createdAt = - DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000; - UserNote reply(String id, String author, String target) => UserNote( - id: id, - pubkey: author, - createdAt: createdAt, - content: 'A reply', - tags: [ - ['e', 'parent-$id', '', 'reply'], - ['p', target], - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - userCacheProvider.overrideWith(() => _UserCache(users)), - myPubkeyProvider.overrideWithValue(null), - agentPubkeysProvider.overrideWith((ref) async => const []), - globalNotesProvider.overrideWith( - (ref) async => [ - reply('note-1', alice, scoutA), - reply('note-2', bob, scoutB), - ], - ), - noteReactionsProvider.overrideWith((ref, key) async => const {}), - ], - child: MaterialApp(theme: AppTheme.light(), home: const PulsePage()), - ), - ); - await tester.pumpAndSettle(); - - final replyLabels = tester - .widgetList(find.textContaining('Replying to ')) - .map((text) => text.data) - .toList(); - expect(replyLabels, hasLength(2)); - expect(replyLabels.toSet(), hasLength(2)); - expect(replyLabels, isNot(contains('Replying to Scout'))); - }); -}