mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(mobile): keep cached state with its community (#8349)
## Summary One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single `ProviderScope` and doesn't remount on a community switch, so one community's data showed up in another, and some of it was published to the other community's relay. - **Channel groups**: the second community showed the first community's groups. - **Read marks** (contexts, publishable set, NIP-RS client and slot IDs): the first community's context IDs were merged into, and published to, the second community's read-state slot. - **Inbox done/unread rows and thread follows**: one shared 500-entry cap, so activity in one community evicted another's entries. - **Channel stars and mutes**: union-merged across communities and published to every relay. Every one of these is now keyed by pubkey + normalized community relay, the same way channel sort already is. **Switching communities.** The relay session rebuilds for the new community before the previous community's managers are retired. Each manager (groups, read state, stars, mutes) now checks that the session still serves its own community before it publishes, merges a fetch, or handles a live event. Previously, a pending publish flushed on dispose, or an in-flight fetch, could cross into the next community. **Old pubkey-only entries.** Channel IDs can repeat across communities, so nothing tells us which community an old channel-keyed entry belongs to: - **Groups, stars, mutes and read marks** are dropped on upgrade, and each community restores its own from its relay. Any change that never reached a relay is lost, and a read mark the relay doesn't hold shows as unread once. That's an accepted tradeoff: such changes would be very old by now. - **Inbox, thread follows, and read-state slot and client IDs** seed every community, and the old entry is kept. The inbox and follows entries are event IDs, which only ever match their own community. The slot ID keeps each community publishing to the slot it already has on its relay. This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr filters only it used). Nothing in the app navigates to it anymore, and its providers were among the community-unscoped caches. ### Related issue None found. ### Testing - Each affected store has a regression test through the real provider that switches A → B → A. For groups, read marks, stars and mutes, the test sets a value in A, switches to B while A's change is still pending, and checks two cases: B's data arriving live and B answering A's in-flight fetch. It asserts that nothing sent to B contains A's IDs, B shows only its own value, and A comes back unchanged. Each guard was removed in turn to confirm a test fails without it, except two that the current switch order never reaches: the groups merge guard and the read-state live-event guard. - Upgrade tests cover each kind of old entry: dropped for groups, stars, mutes and read marks, seeded for inbox, follows and slot IDs. - `flutter analyze` is clean, and the full mobile suite passes in the pre-push hook at `89ebfcc12`. - Not yet exercised in the app. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<InboxLocalState> {
|
||||
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<InboxLocalState> {
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> _applyEvents(List<NostrEvent> 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<void> _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<bool> _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');
|
||||
|
||||
@@ -63,6 +63,7 @@ class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
|
||||
late final ChannelMutesManager manager;
|
||||
manager = ChannelMutesManager(
|
||||
pubkey: pubkey,
|
||||
relayUrl: relayConfig.baseUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
|
||||
@@ -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<bool> write(String pubkey, ChannelMuteStore store) =>
|
||||
_prefs.setString(channelMutesKey(pubkey), jsonEncode(store.toJson()));
|
||||
Future<bool> write(String pubkey, String relayUrl, ChannelMuteStore store) =>
|
||||
_prefs.setString(
|
||||
channelMutesKey(pubkey, relayUrl),
|
||||
jsonEncode(store.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> _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<ChannelSection> _sortedSections() {
|
||||
|
||||
@@ -63,6 +63,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
|
||||
late final ChannelSectionsManager manager;
|
||||
manager = ChannelSectionsManager(
|
||||
pubkey: pubkey,
|
||||
relayUrl: relayConfig.baseUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> _applyEvents(List<NostrEvent> 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<void> _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<bool> _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');
|
||||
|
||||
@@ -63,6 +63,7 @@ class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
|
||||
late final ChannelStarsManager manager;
|
||||
manager = ChannelStarsManager(
|
||||
pubkey: pubkey,
|
||||
relayUrl: relayConfig.baseUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
|
||||
@@ -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<bool> write(String pubkey, ChannelStarStore store) =>
|
||||
_prefs.setString(channelStarsKey(pubkey), jsonEncode(store.toJson()));
|
||||
Future<bool> write(String pubkey, String relayUrl, ChannelStarStore store) =>
|
||||
_prefs.setString(
|
||||
channelStarsKey(pubkey, relayUrl),
|
||||
jsonEncode(store.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ class ThreadFollowsState {
|
||||
class ThreadFollowsNotifier extends Notifier<ThreadFollowsState> {
|
||||
List<ThreadFollowEntry> _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<ThreadFollowsState> {
|
||||
try {
|
||||
_entries = ThreadFollowsStorage(
|
||||
ref.read(savedPrefsProvider),
|
||||
).read(pubkey);
|
||||
).read(pubkey, _relayUrl);
|
||||
} catch (_) {
|
||||
_entries = const [];
|
||||
}
|
||||
@@ -66,7 +68,7 @@ class ThreadFollowsNotifier extends Notifier<ThreadFollowsState> {
|
||||
try {
|
||||
ThreadFollowsStorage(
|
||||
ref.read(savedPrefsProvider),
|
||||
).write(pubkey, _entries);
|
||||
).write(pubkey, _relayUrl, _entries);
|
||||
} catch (_) {
|
||||
// In-memory state still works this session.
|
||||
}
|
||||
|
||||
@@ -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<ThreadFollowEntry> read(String pubkey) {
|
||||
final raw = _prefs.getString(threadFollowsKey(pubkey));
|
||||
List<ThreadFollowEntry> 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<ThreadFollowEntry> entries) {
|
||||
void write(String pubkey, String relayUrl, List<ThreadFollowEntry> entries) {
|
||||
_prefs.setString(
|
||||
threadFollowsKey(pubkey),
|
||||
threadFollowsKey(pubkey, relayUrl),
|
||||
jsonEncode([for (final entry in entries) entry.toJson()]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<String, PulseReactionState> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String>? 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<bool?>(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<void>(
|
||||
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<void>(
|
||||
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<String> pulseNamedIdentities(Iterable<UserNote> 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}';
|
||||
}
|
||||
@@ -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<void> publishNote(
|
||||
WidgetRef ref, {
|
||||
required String content,
|
||||
UserNote? replyTo,
|
||||
List<String> mentionPubkeys = const [],
|
||||
List<List<String>> 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 = <List<String>>[];
|
||||
final seen = <String>{};
|
||||
|
||||
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<void> 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<void> setContactList(WidgetRef ref, List<ContactEntry> 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<void> 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<void> 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<List<ContactEntry>> _fetchFreshContacts(
|
||||
WidgetRef ref,
|
||||
String currentPubkey,
|
||||
) async {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(
|
||||
NostrFilters.contactList(currentPubkey),
|
||||
);
|
||||
return contactsFromEvents(events);
|
||||
}
|
||||
@@ -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<List<String>> 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<String> 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<String> pubkeys;
|
||||
final Map<String, String> 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<UserNote> notes;
|
||||
final int latestAt;
|
||||
final int earliestAt;
|
||||
|
||||
const AgentNoteGroup({
|
||||
required this.pubkey,
|
||||
required this.notes,
|
||||
required this.latestAt,
|
||||
required this.earliestAt,
|
||||
});
|
||||
}
|
||||
|
||||
List<AgentNoteGroup> groupAgentNotes(
|
||||
List<UserNote> notes, {
|
||||
int windowSeconds = 300,
|
||||
}) {
|
||||
if (notes.isEmpty) return const [];
|
||||
|
||||
final groups = <AgentNoteGroup>[];
|
||||
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<String> toTag() => ['p', pubkey.toLowerCase(), ?relayUrl, ?petname];
|
||||
}
|
||||
@@ -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<List<ContactEntry>>.data([])
|
||||
: ref.watch(contactListProvider(currentPubkey));
|
||||
final contacts = contactsAsync.asData?.value ?? const <ContactEntry>[];
|
||||
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<List<UserNote>>.data([])
|
||||
: ref.watch(notesTimelineProvider(pulseKeyFor([currentPubkey]))),
|
||||
};
|
||||
|
||||
final visibleNotes = notesAsync.asData?.value ?? const <UserNote>[];
|
||||
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 <String, PulseReactionState>{};
|
||||
|
||||
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<void>(builder: (_) => const ComposeNotePage()),
|
||||
),
|
||||
tooltip: 'New note',
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(BuzzIcons.plus),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(height: frostedAppBarHeight(context)),
|
||||
FilterChipBar<PulseTab>(
|
||||
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<void> _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<List<UserNote>> notesAsync;
|
||||
final Map<String, PulseReactionState> reactions;
|
||||
final Set<String> agentPubkeys;
|
||||
final Set<String> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<UserNote>>((ref) async {
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final events = await session.fetchHistory(NostrFilters.globalNotes());
|
||||
return _notesFromEvents(events);
|
||||
});
|
||||
|
||||
final notesTimelineProvider = FutureProvider.family<List<UserNote>, 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<List<UserNote>>((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 = <String>[];
|
||||
final seen = <String>{};
|
||||
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<Map<String, PulseReactionState>, 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 = <String, Set<String>>{};
|
||||
final currentReactionIds = <String, String>{};
|
||||
|
||||
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, () => <String>{}).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<List<ContactEntry>, 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<List<String>>((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 = <String>{};
|
||||
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<List<UserNote>>((ref) async {
|
||||
final pubkeys = await ref.watch(agentPubkeysProvider.future);
|
||||
if (pubkeys.isEmpty) return const [];
|
||||
return ref.watch(notesTimelineProvider(pulseKeyFor(pubkeys)).future);
|
||||
});
|
||||
|
||||
List<UserNote> _notesFromEvents(List<NostrEvent> 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<ContactEntry> _contactsFromTags(List<List<String>> 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<List<String>> tags) {
|
||||
for (final tag in tags.reversed) {
|
||||
if (tag.length >= 2 && tag[0] == 'e') return tag[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String pulseKeyFor(Iterable<String> values) {
|
||||
final normalized =
|
||||
values
|
||||
.map((value) => value.toLowerCase().trim())
|
||||
.where((value) => value.isNotEmpty)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return normalized.join(',');
|
||||
}
|
||||
|
||||
List<String> parsePulseKey(String key) {
|
||||
if (key.isEmpty) return const [];
|
||||
return key.split(',').where((value) => value.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
Future<List<NostrEvent>> _filterDeletedReactions(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrEvent> reactions, {
|
||||
List<String>? 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 = <String>{};
|
||||
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<ContactEntry> contactsFromEvents(List<NostrEvent> events) {
|
||||
if (events.isEmpty) return const [];
|
||||
return _contactsFromTags(events.first.tags);
|
||||
}
|
||||
|
||||
void preloadPulseProfiles(WidgetRef ref, List<UserNote> notes) {
|
||||
final pubkeys = <String>{};
|
||||
for (final note in notes) {
|
||||
pubkeys.add(note.pubkey);
|
||||
pubkeys.addAll(note.mentionPubkeys);
|
||||
}
|
||||
ref.read(userCacheProvider.notifier).preload(pubkeys.toList());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<NostrEvent> 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<void> _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<String, int>? 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) =>
|
||||
|
||||
@@ -103,6 +103,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
|
||||
late final ReadStateManager manager;
|
||||
manager = ReadStateManager(
|
||||
pubkey: pubkey,
|
||||
relayUrl: relayConfig.baseUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
|
||||
@@ -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<String, int> 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<String, int> contexts,
|
||||
Set<String> publishableContextIds,
|
||||
Map<String, int> 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<String, int> _readContexts(String pubkey) {
|
||||
final raw = _prefs.getString(localReadStateKey(pubkey));
|
||||
Map<String, int> _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<String> _readPublishableContextIds(String pubkey) {
|
||||
final raw = _prefs.getString(localPublishableContextKey(pubkey));
|
||||
Set<String> _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<String, int> _readSourceCreatedAt(String pubkey) {
|
||||
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey));
|
||||
Map<String, int> _readSourceCreatedAt(String pubkey, String relayUrl) {
|
||||
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey, relayUrl));
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
|
||||
final Object? parsed;
|
||||
|
||||
@@ -164,61 +164,6 @@ abstract final class NostrFilters {
|
||||
extensions: const {'search_mode': 'prefix'},
|
||||
);
|
||||
|
||||
/// Deletions (kind:5) targeting event IDs.
|
||||
static NostrFilter deletionsByTargetIds(
|
||||
List<String> ids, {
|
||||
List<String>? 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<String> 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<String> noteIds) => NostrFilter(
|
||||
kinds: [EventKind.reaction],
|
||||
tags: {'#e': noteIds},
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
/// Fetch notes by ids.
|
||||
static NostrFilter notesByIds(List<String> 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);
|
||||
|
||||
@@ -133,10 +133,20 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
final Map<Object, Future<void> 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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,9 +28,9 @@ class FilterChipItem<T> {
|
||||
|
||||
/// 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<T> extends StatelessWidget {
|
||||
final List<FilterChipItem<T>> items;
|
||||
final T selected;
|
||||
|
||||
@@ -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<String, Object> 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'});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> 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': <String, String>{},
|
||||
}, 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<void>();
|
||||
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<String> 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': <String, String>{},
|
||||
},
|
||||
),
|
||||
),
|
||||
'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<void>();
|
||||
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<String, Object> blob(Map<String, (bool, int)> 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,
|
||||
|
||||
@@ -50,6 +50,7 @@ void main() {
|
||||
}) {
|
||||
return ChannelSectionsManager(
|
||||
pubkey: keychain.public,
|
||||
relayUrl: 'https://relay.example',
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relaySession,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReadStateNotifier> pumpNotifier() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
Future<ReadStateNotifier> pumpNotifier({
|
||||
Map<String, Object> 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<void> settle() async {
|
||||
for (var i = 0; i < 10 && !state().isReady; i++) {
|
||||
await Future<void>.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
|
||||
|
||||
@@ -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 = <NostrEvent>[];
|
||||
final _storedByRelay = <String?, List<NostrEvent>>{};
|
||||
|
||||
/// Heads retained by the relay [session] currently serves, so a community
|
||||
/// switch reaches a different relay's store.
|
||||
List<NostrEvent> get stored =>
|
||||
_storedByRelay.putIfAbsent(session.relayUrl, () => <NostrEvent>[]);
|
||||
final published = <NostrEvent>[];
|
||||
|
||||
/// The relay [session] served when each [published] event was sent.
|
||||
final publishedTo = <String?>[];
|
||||
final reqs = <List<dynamic>>[];
|
||||
final _liveSubIds = <String, String>{};
|
||||
final _liveSubIds = <String?, String>{};
|
||||
int historyFailures = 0;
|
||||
Completer<void>? holdHistory;
|
||||
Completer<void>? 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<List<String>> 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<String, dynamic>);
|
||||
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<void> _answerHistory(String subId, String dTag) async {
|
||||
Future<void> _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<dynamic> message) => session.debugHandleMessage(message);
|
||||
|
||||
static String _dTagOf(List<dynamic> req) =>
|
||||
((req[2] as Map<String, dynamic>)['#d'] as List).single as String;
|
||||
/// Null for a filter by `#t` alone, such as read state's slot discovery.
|
||||
static String? _dTagOf(List<dynamic> req) =>
|
||||
((req[2] as Map<String, dynamic>)['#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<bool> remove(String key) => _inner.remove(key);
|
||||
|
||||
@override
|
||||
Future<bool> setString(String key, String value) async {
|
||||
if (failures == 0) return _inner.setString(key, value);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,38 +11,54 @@ Future<SharedPreferences> _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');
|
||||
});
|
||||
|
||||
@@ -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<String, UserProfile> _users;
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
|
||||
@override
|
||||
Future<bool> preload(List<String> pubkeys) async => true;
|
||||
}
|
||||
|
||||
class _MutableUserCache extends UserCacheNotifier {
|
||||
_MutableUserCache(this._initial);
|
||||
|
||||
final Map<String, UserProfile> _initial;
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _initial;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => state[pubkey.toLowerCase()];
|
||||
|
||||
@override
|
||||
Future<bool> preload(List<String> 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<String, UserProfile>? 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<Text>(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',
|
||||
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 "" 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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
@@ -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<String, UserProfile> _users;
|
||||
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> 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<Text>(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<List<String>> 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<List<String>> tags,
|
||||
Map<String, UserProfile> 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<Text>(
|
||||
find.descendant(of: avatar, matching: find.byType(Text)),
|
||||
);
|
||||
return initial.data!;
|
||||
}
|
||||
@@ -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<String, UserProfile> _users;
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
|
||||
@override
|
||||
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
||||
|
||||
@override
|
||||
Future<bool> preload(List<String> 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 = <MethodCall>[];
|
||||
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<UiKitView>(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<Text>(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')));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user