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'))); - }); -}