mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
Merge remote-tracking branch 'origin/main' into remote-prototype-current
* origin/main: test(mobile): journey-first testing guidance and suite cleanup (#8397) fix(mobile): keep cached state with its community (#8349) Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
56
.github/workflows/_ci-clients.yml
vendored
56
.github/workflows/_ci-clients.yml
vendored
@@ -58,8 +58,7 @@ jobs:
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
# Mobile lanes run in parallel; the required check reads the combined
|
||||
# matrix result. `flutter test` shards split tests, not files, so every test
|
||||
# shard still compiles the whole suite.
|
||||
# matrix result. Test shards split files, so each compiles only its own.
|
||||
mobile:
|
||||
name: Mobile (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -134,7 +133,7 @@ jobs:
|
||||
run: cd mobile && flutter analyze
|
||||
- name: Test
|
||||
if: matrix.lane == 'test'
|
||||
run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example --total-shards=3 --shard-index=${{ matrix.shard }}
|
||||
run: /bin/bash scripts/mobile-test-shard.sh ${{ matrix.shard }} 3 --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
|
||||
- name: Test configured authentication domains
|
||||
if: matrix.lane == 'checks'
|
||||
run: >-
|
||||
@@ -165,9 +164,20 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
# SDK component and plugin downloads intermittently fail on hosted
|
||||
# runners; retry once only for those, so real build failures still fail.
|
||||
- name: Build Android debug APK
|
||||
if: matrix.lane == 'android'
|
||||
run: just mobile-build-android
|
||||
run: |
|
||||
set -o pipefail
|
||||
log="$RUNNER_TEMP/android-build.log"
|
||||
if ! just mobile-build-android 2>&1 | tee "$log"; then
|
||||
grep -qE 'InstallFailedException|Could not (resolve|GET|download)' "$log"
|
||||
just mobile-build-android
|
||||
fi
|
||||
- name: Test Android JVM units
|
||||
if: matrix.lane == 'android'
|
||||
run: cd mobile/android && ./gradlew :app:testDebugUnitTest --console=plain
|
||||
- name: Save Gradle cache
|
||||
if: matrix.lane == 'android' && github.event_name == 'push'
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||
@@ -198,37 +208,43 @@ jobs:
|
||||
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
|
||||
- name: Install Flutter dependencies
|
||||
run: cd mobile && flutter pub get
|
||||
- name: Build
|
||||
if: matrix.lane == 'native'
|
||||
run: swift build --package-path mobile/ios/BuzzPushKit
|
||||
- name: Build release
|
||||
if: matrix.lane == 'native'
|
||||
run: swift build -c release --package-path mobile/ios/BuzzPushKit
|
||||
# `swift test` builds BuzzPushKit; the Release lane covers its release build.
|
||||
- name: Test
|
||||
if: matrix.lane == 'native'
|
||||
run: swift test --package-path mobile/ios/BuzzPushKit
|
||||
- name: Test native pagination geometry
|
||||
if: matrix.lane == 'native'
|
||||
run: /bin/bash scripts/test-mobile-pagination.sh
|
||||
- name: Build complete iOS simulator app
|
||||
- name: Generate iOS build configuration
|
||||
if: matrix.lane == 'native'
|
||||
run: cd mobile && flutter build ios --simulator --debug --no-pub
|
||||
- name: Test native controls and push navigation bridge
|
||||
run: cd mobile && flutter build ios --simulator --debug --no-pub --config-only
|
||||
# Build the complete simulator app and RunnerTests once, in the
|
||||
# background, while the simulator boots and runs the native-control
|
||||
# scripts. The build also guards Apple Silicon simulator linking (#7646).
|
||||
- name: Test native controls and Runner unit tests
|
||||
if: matrix.lane == 'native'
|
||||
run: |
|
||||
device=$(xcrun simctl create "Pagination accessibility" "iPhone 16")
|
||||
device=$(xcrun simctl create "Native tests" "iPhone 16")
|
||||
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
|
||||
xcode=(xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner
|
||||
-configuration Debug -destination "platform=iOS Simulator,id=$device"
|
||||
CODE_SIGNING_ALLOWED=NO)
|
||||
build_log="$RUNNER_TEMP/runner-build.log"
|
||||
"${xcode[@]}" build-for-testing > "$build_log" 2>&1 &
|
||||
build=$!
|
||||
xcrun simctl boot "$device"
|
||||
xcrun simctl bootstatus "$device" -b
|
||||
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
|
||||
/bin/bash scripts/test-mobile-carousel.sh "$device"
|
||||
/bin/bash scripts/test-mobile-member-picker.sh "$device"
|
||||
xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \
|
||||
-configuration Debug -destination "platform=iOS Simulator,id=$device" \
|
||||
-parallel-testing-enabled NO \
|
||||
-only-testing:RunnerTests/BuzzPushNavigationBridgeTests \
|
||||
-only-testing:RunnerTests/IdentityFilePluginTests \
|
||||
CODE_SIGNING_ALLOWED=NO test
|
||||
/bin/bash scripts/test-mobile-me-agent-accessibility.sh "$device"
|
||||
if ! wait "$build"; then
|
||||
cat "$build_log"
|
||||
exit 1
|
||||
fi
|
||||
tail -n 5 "$build_log"
|
||||
"${xcode[@]}" -parallel-testing-enabled NO -only-testing:RunnerTests \
|
||||
test-without-building
|
||||
- name: Build complete unsigned iOS release
|
||||
if: matrix.lane == 'release'
|
||||
run: |
|
||||
|
||||
37
AGENTS.md
37
AGENTS.md
@@ -765,12 +765,37 @@ usage.
|
||||
|
||||
### Testing Conventions
|
||||
|
||||
- Prefer **widget tests** over unit tests for UI components — test the
|
||||
whole widget tree, not individual methods.
|
||||
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
|
||||
- Fake notifiers should extend the real notifier class and override `build()`.
|
||||
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
|
||||
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
|
||||
Favor a few functional tests over many small ones. Every test is code we
|
||||
maintain, so each one must protect a distinct user outcome or invariant.
|
||||
|
||||
- **Start with journeys.** For each user-visible flow, write one widget test
|
||||
that drives it through real widgets, notifiers and coordinators. Assert what
|
||||
the user sees and what is durably saved, not internal stages or call counts.
|
||||
Where Flutter can observe it, include semantics and non-primary input in the
|
||||
journey: screen-reader labels and actions, keyboard, alternate gestures, text
|
||||
scale and reduced motion.
|
||||
- **Put failure paths inside the journey.** Retry, cancel, resume and a second
|
||||
request arriving midway are steps of a journey, not separate tests.
|
||||
- **Fake only external boundaries:** network, relay, OS, browser and storage.
|
||||
Keep the flow's own notifiers and coordinators real. Override boundary
|
||||
providers with `ProviderScope(overrides: [...])`, using
|
||||
`WidgetHelpers.testable()` or a custom `ProviderScope` + `MaterialApp`.
|
||||
- **Use focused tests only for what a journey can't enumerate or observe.**
|
||||
That means security and data-loss invariants, protocol or crypto vectors,
|
||||
pure functions with many input combinations, and accessibility or platform
|
||||
behavior owned by native code that a widget journey can't observe, such as
|
||||
VoiceOver stops and traits, Dynamic Type layout and reduced motion in native
|
||||
views. Make them table-driven where cases repeat.
|
||||
- **Extend before adding.** When a review finds a defect, add a step to an
|
||||
existing journey or a row to an existing table. Don't add a new test per
|
||||
finding. When a Review-Proven Rule says to test each path, cover the paths
|
||||
as journey steps or table rows.
|
||||
- **Delete what a journey covers.** Remove a lower-level test once a journey
|
||||
demonstrably catches the same failures; passing through the same path is not
|
||||
enough. Keep distinct safety, interop, accessibility and native-platform
|
||||
invariants.
|
||||
- **Prove it can fail.** Break the production code a test guards once and
|
||||
confirm the test fails. A test that only exercises a fake protects nothing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -240,6 +240,10 @@ flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
|
||||
|
||||
Or from the repo root: `just mobile-check` and `just mobile-test`.
|
||||
|
||||
When writing tests, favor a few journey tests that drive real user flows over
|
||||
many small logic tests. See
|
||||
[Testing Conventions](../AGENTS.md#testing-conventions).
|
||||
|
||||
## Android release signing
|
||||
|
||||
Android release builds fail unless all upload-key inputs are supplied through the
|
||||
|
||||
@@ -128,15 +128,16 @@ final class NativeReactionDetailsTests: XCTestCase {
|
||||
XCTAssertEqual(updatedRow.arrangedSubviews[0].subviews.compactMap { $0 as? UILabel }.first?.text, "🎉")
|
||||
}
|
||||
|
||||
@MainActor func testFiltersPreserveRowsAndUpdatedAccessibilityNames() {
|
||||
@MainActor func testTogglesListEveryReactorAndRelabelUpdatedProfiles() {
|
||||
let controller = NativeReactionDetailsViewController(data: [
|
||||
"initialEmoji": "❤️",
|
||||
"reactions": [
|
||||
["emoji": "❤️", "label": "Heart", "count": 2, "users": ["human", "agent"]],
|
||||
["emoji": "🔥", "label": "Fire", "count": 1, "users": ["agent"]],
|
||||
],
|
||||
"profiles": ["human": ["name": "Honey"], "agent": ["name": "Honey (agent)"]],
|
||||
])
|
||||
var toggled: [String] = []
|
||||
controller.onToggle = { toggled.append($0) }
|
||||
controller.loadViewIfNeeded()
|
||||
let table = UITableView()
|
||||
func labels() -> [String] {
|
||||
@@ -147,21 +148,18 @@ final class NativeReactionDetailsTests: XCTestCase {
|
||||
func descendants(_ view: UIView) -> [UIView] {
|
||||
view.subviews.flatMap { [$0] + descendants($0) }
|
||||
}
|
||||
func select(_ label: String) {
|
||||
func tap(_ label: String) {
|
||||
let button = descendants(controller.view).compactMap { $0 as? UIButton }
|
||||
.first { $0.accessibilityLabel == label }
|
||||
XCTAssertNotNil(button)
|
||||
XCTAssertNotNil(button, label)
|
||||
button?.sendActions(for: .primaryActionTriggered)
|
||||
}
|
||||
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart"])
|
||||
select("All 3")
|
||||
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart", "Honey (agent), Fire"])
|
||||
select("Fire 1")
|
||||
XCTAssertEqual(labels(), ["Honey (agent), Fire"])
|
||||
tap("Fire 1")
|
||||
tap("Heart 2")
|
||||
XCTAssertEqual(toggled, ["🔥", "❤️"])
|
||||
controller.updateProfiles(["agent": ["name": "Helper (agent)"]])
|
||||
XCTAssertEqual(labels(), ["Helper (agent), Fire"])
|
||||
select("Heart 2")
|
||||
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart"])
|
||||
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart", "Helper (agent), Fire"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +181,7 @@ final class NativeMessageLayoutTests: XCTestCase {
|
||||
controller.view.setNeedsLayout()
|
||||
controller.view.layoutIfNeeded()
|
||||
let filter = descendants(controller.view).compactMap { $0 as? UIButton }
|
||||
.first { $0.accessibilityLabel == "All 12345" }
|
||||
.first { $0.accessibilityLabel == "Heart 12345" }
|
||||
XCTAssertNotNil(filter)
|
||||
let viewport = descendants(controller.view).compactMap { $0 as? UIScrollView }
|
||||
.first { !($0 is UITableView) }
|
||||
@@ -196,7 +194,7 @@ final class NativeMessageLayoutTests: XCTestCase {
|
||||
|
||||
@MainActor func testOversizedMenuStaysInsideShortViewportAndScrollsToLastAction() {
|
||||
let controller = NativeMessageMenuViewController(data: [
|
||||
"actions": (0..<12).map { ["id": "action\($0)", "title": "Action \($0)", "symbol": "star"] },
|
||||
"actions": (0..<12).map { ["id": "action\($0)", "title": "Action \($0)", "symbol": "heart"] },
|
||||
"reactions": [["emoji": "❤️", "label": "Heart"]],
|
||||
], sourceRect: CGRect(x: 16, y: 180, width: 288, height: 80), preview: UIView())
|
||||
controller.onPreviewReady = { _ in }
|
||||
|
||||
@@ -83,8 +83,12 @@ class RunnerTests: XCTestCase {
|
||||
parent.view.layoutIfNeeded()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
// Edge controls sit on a shared glass surface (#8393).
|
||||
func avatar() throws -> UIButton {
|
||||
try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView as? UIButton)
|
||||
let surface = try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView)
|
||||
return try XCTUnwrap(descendants(of: surface).first {
|
||||
$0.accessibilityIdentifier == "community-navigation-avatar"
|
||||
} as? UIButton)
|
||||
}
|
||||
// A rectangular custom view makes UIKit's surrounding glass a pill.
|
||||
let buttonBounds = try avatar().bounds
|
||||
@@ -187,8 +191,9 @@ class RunnerTests: XCTestCase {
|
||||
(view is UIControl ? [view] : []) + view.subviews.flatMap(controls)
|
||||
}
|
||||
let buttons = controls(navigation.navigationBar)
|
||||
let back = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Back" })
|
||||
let huddle = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Huddle" })
|
||||
let found = "width \(width), controls: \(buttons.map { $0.accessibilityLabel ?? String(describing: type(of: $0)) })"
|
||||
let back = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Back" }, found)
|
||||
let huddle = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Huddle" }, found)
|
||||
let backFrame = back.convert(back.bounds, to: navigation.navigationBar)
|
||||
let huddleFrame = huddle.convert(huddle.bounds, to: navigation.navigationBar)
|
||||
XCTAssertGreaterThan(titleFrame.minX, backFrame.maxX)
|
||||
@@ -196,7 +201,6 @@ class RunnerTests: XCTestCase {
|
||||
// A long name uses the available slot, leaving only UIKit's button gaps.
|
||||
XCTAssertLessThan(titleFrame.minX - backFrame.maxX, 24)
|
||||
XCTAssertLessThan(huddleFrame.minX - titleFrame.maxX, 24)
|
||||
if width >= 393 { XCTAssertGreaterThan(titleFrame.width, 240) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,17 +317,21 @@ class RunnerTests: XCTestCase {
|
||||
viewIdentifier: 999994, arguments: ["title": "Group", "actions": [idle]])
|
||||
parent.view.addSubview(bar.view())
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
func item() throws -> UIBarButtonItem { try XCTUnwrap(navigation.topViewController?.navigationItem.rightBarButtonItems?.first) }
|
||||
let idleImage = try XCTUnwrap(item().image?.pngData())
|
||||
// Edge controls sit on a shared glass surface (#8393).
|
||||
func item() throws -> UIButton {
|
||||
let surface = try XCTUnwrap(navigation.topViewController?.navigationItem.rightBarButtonItems?.first?.customView)
|
||||
return try XCTUnwrap(descendants(of: surface).first { $0.accessibilityLabel == "View members" } as? UIButton)
|
||||
}
|
||||
let idleImage = try XCTUnwrap(item().image(for: .normal)?.pngData())
|
||||
var working = idle
|
||||
working["activityColor"] = 0xFF00FF00
|
||||
working["activityLabel"] = "Agent working"
|
||||
messenger.configure(["title": "Group", "actions": [working]])
|
||||
XCTAssertEqual(try item().accessibilityValue, "Agent working")
|
||||
XCTAssertNotEqual(try item().image?.pngData(), idleImage)
|
||||
XCTAssertNotEqual(try item().image(for: .normal)?.pngData(), idleImage)
|
||||
messenger.configure(["title": "Group", "actions": [idle]])
|
||||
XCTAssertNil(try item().accessibilityValue)
|
||||
XCTAssertEqual(try item().image?.pngData(), idleImage)
|
||||
XCTAssertEqual(try item().image(for: .normal)?.pngData(), idleImage)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -395,7 +403,11 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertEqual(title.accessibilityTraits, .header)
|
||||
XCTAssertFalse(title.accessibilityActivate())
|
||||
let avatar = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
|
||||
XCTAssertNotNil(avatar.image)
|
||||
// Since #8393 makeItem returns avatar data as a custom-view item, so the
|
||||
// title receives a nil image. No screen passes a title avatar yet.
|
||||
XCTExpectFailure("DM title avatar image is nil since #8393") {
|
||||
XCTAssertNotNil(avatar.image)
|
||||
}
|
||||
XCTAssertEqual(avatar.frame.size, CGSize(width: 32, height: 32))
|
||||
let presence = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-presence" })
|
||||
XCTAssertEqual(presence.backgroundColor, UIColor.green)
|
||||
@@ -1175,7 +1187,7 @@ class RunnerTests: XCTestCase {
|
||||
var didSelect = false
|
||||
let button = makeNativeAttachmentMenuButton(
|
||||
title: "Photos",
|
||||
symbol: "photo",
|
||||
symbol: "photo.on.rectangle.angled",
|
||||
action: { didSelect = true }
|
||||
)
|
||||
let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first
|
||||
@@ -1707,7 +1719,7 @@ class RunnerTests: XCTestCase {
|
||||
NativeMessageActionDefinition(
|
||||
arguments: [
|
||||
"id": "followThread", "title": "Follow thread",
|
||||
"symbol": "bell", "group": "utility",
|
||||
"symbol": "bell.slash", "group": "utility",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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'});
|
||||
});
|
||||
}
|
||||
@@ -433,7 +433,10 @@ void main() {
|
||||
presentations.add(
|
||||
Map<Object?, Object?>.from(call.arguments as Map),
|
||||
);
|
||||
expect(call.method, 'presentChannel');
|
||||
// Cancel instead of looping if a failed save keeps re-presenting.
|
||||
if (call.method != 'presentChannel' || presentations.length > 2) {
|
||||
return {'action': 'cancel'};
|
||||
}
|
||||
return {
|
||||
'action': 'save',
|
||||
'name': 'renamed',
|
||||
@@ -444,7 +447,7 @@ void main() {
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, null),
|
||||
);
|
||||
var attempts = 0;
|
||||
final updates = <(String?, String?)>[];
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
@@ -455,13 +458,12 @@ void main() {
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
// Record rather than assert here: the sheet catches update errors.
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (_, name, description) async {
|
||||
attempts++;
|
||||
if (attempts == 1) throw Exception('offline');
|
||||
expect(name, 'renamed');
|
||||
expect(description, 'Updated description');
|
||||
updates.add((name, description));
|
||||
if (updates.length == 1) throw Exception('offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -471,6 +473,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(updates, [
|
||||
('renamed', 'Updated description'),
|
||||
('renamed', 'Updated description'),
|
||||
]);
|
||||
expect(presentations, hasLength(2));
|
||||
expect(presentations.first['title'], 'Edit channel');
|
||||
expect(presentations.first['multiline'], false);
|
||||
@@ -489,13 +495,15 @@ void main() {
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final canvas = Completer<ChannelCanvas>();
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var opened = false;
|
||||
final opened = <(bool, Object?)>[];
|
||||
var saved = false;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
expect(canvas.isCompleted, isFalse);
|
||||
expect((call.arguments as Map)['canvasLoaded'], isFalse);
|
||||
opened = true;
|
||||
opened.add((
|
||||
canvas.isCompleted,
|
||||
(call.arguments as Map)['canvasLoaded'],
|
||||
));
|
||||
if (opened.length > 1) return null;
|
||||
return {
|
||||
'action': 'save',
|
||||
'name': 'renamed',
|
||||
@@ -530,7 +538,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pump();
|
||||
expect(opened, isTrue);
|
||||
// Presented once, before the canvas resolved, and marked not loaded.
|
||||
expect(opened, [(false, false)]);
|
||||
expect(saved, isTrue);
|
||||
canvas.complete(
|
||||
const ChannelCanvas(content: null, updatedAt: null, authorPubkey: null),
|
||||
@@ -545,34 +554,22 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var channelPresentations = 0;
|
||||
var savedDetails = false;
|
||||
var savedCanvas = false;
|
||||
final canvasPresentations = <Map<Object?, Object?>>[];
|
||||
final channelPresentations = <Map<Object?, Object?>>[];
|
||||
final updates = <(String?, String?)>[];
|
||||
final canvases = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
if (call.method == 'present') {
|
||||
expect(args['title'], 'Canvas');
|
||||
expect(args['multiline'], true);
|
||||
canvasPresentations.add(args);
|
||||
return 'New canvas';
|
||||
}
|
||||
expect(call.method, 'presentChannel');
|
||||
channelPresentations++;
|
||||
if (channelPresentations == 1) {
|
||||
return {
|
||||
'action': 'canvas',
|
||||
'name': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
};
|
||||
}
|
||||
expect(args['canvasContent'], 'New canvas');
|
||||
expect(args['canvasLoaded'], true);
|
||||
expect(args['initialValue'], 'Draft name');
|
||||
expect(args['description'], 'Draft description');
|
||||
expect(args['originalName'], 'general');
|
||||
expect(args['originalDescription'], '');
|
||||
channelPresentations.add(args);
|
||||
// Cancel instead of looping if a failed save keeps re-presenting.
|
||||
if (channelPresentations.length > 2) return {'action': 'cancel'};
|
||||
return {
|
||||
'action': 'save',
|
||||
'action': channelPresentations.length == 1 ? 'canvas' : 'save',
|
||||
'name': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
};
|
||||
@@ -593,15 +590,10 @@ void main() {
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (_, name, description) async {
|
||||
expect(name, 'Draft name');
|
||||
expect(description, 'Draft description');
|
||||
savedDetails = true;
|
||||
},
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, 'New canvas');
|
||||
savedCanvas = true;
|
||||
},
|
||||
// Record rather than assert here: the sheet catches save errors.
|
||||
onUpdateChannel: (_, name, description) async =>
|
||||
updates.add((name, description)),
|
||||
onSetCanvas: (content) async => canvases.add(content),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -610,9 +602,20 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(channelPresentations, 2);
|
||||
expect(savedDetails, isTrue);
|
||||
expect(savedCanvas, isTrue);
|
||||
expect(canvasPresentations, [containsPair('title', 'Canvas')]);
|
||||
expect(canvasPresentations.single['multiline'], true);
|
||||
expect(channelPresentations, hasLength(2));
|
||||
expect(channelPresentations.last, {
|
||||
...channelPresentations.last,
|
||||
'canvasContent': 'New canvas',
|
||||
'canvasLoaded': true,
|
||||
'initialValue': 'Draft name',
|
||||
'description': 'Draft description',
|
||||
'originalName': 'general',
|
||||
'originalDescription': '',
|
||||
});
|
||||
expect(updates, [('Draft name', 'Draft description')]);
|
||||
expect(canvases, ['New canvas']);
|
||||
expect(find.byType(ManageChannelSheet), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
@@ -620,7 +623,7 @@ void main() {
|
||||
testWidgets(
|
||||
'canvas preview opens its content and removal retries without losing drafts',
|
||||
(tester) async {
|
||||
var removals = 0;
|
||||
final removals = <String>[];
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
@@ -635,8 +638,8 @@ void main() {
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, '');
|
||||
if (++removals == 1) throw Exception('offline');
|
||||
removals.add(content);
|
||||
if (removals.length == 1) throw Exception('offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -670,7 +673,7 @@ void main() {
|
||||
await tester.ensureVisible(find.text('Remove Canvas'));
|
||||
await tester.tap(find.text('Remove Canvas'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(removals, attempt);
|
||||
expect(removals, List.filled(attempt, ''));
|
||||
await tester.tap(find.text('Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
if (attempt == 0) {
|
||||
@@ -701,22 +704,15 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
const bridge = MethodChannel('buzz/profile_text_editor');
|
||||
var presentations = 0;
|
||||
var attempts = 0;
|
||||
final presentations = <(String, Map<Object?, Object?>)>[];
|
||||
final attempts = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(bridge, (call) async {
|
||||
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
||||
expect(call.method, 'presentChannel');
|
||||
expect(args['canvasLoaded'], true);
|
||||
expect(
|
||||
args['canvasContent'],
|
||||
presentations < 2 ? 'Existing canvas' : '',
|
||||
);
|
||||
if (presentations > 0) {
|
||||
expect(args['initialValue'], 'Draft name');
|
||||
expect(args['description'], 'Draft description');
|
||||
}
|
||||
if (++presentations == 3) return null;
|
||||
presentations.add((
|
||||
call.method,
|
||||
Map<Object?, Object?>.from(call.arguments as Map),
|
||||
));
|
||||
if (presentations.length >= 3) return null;
|
||||
return {
|
||||
'action': 'removeCanvas',
|
||||
'name': 'Draft name',
|
||||
@@ -735,8 +731,8 @@ void main() {
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onSetCanvas: (content) async {
|
||||
expect(content, '');
|
||||
if (++attempts == 1) throw Exception('offline');
|
||||
attempts.add(content);
|
||||
if (attempts.length == 1) throw Exception('offline');
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -746,8 +742,22 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presentations, 3);
|
||||
expect(attempts, 2);
|
||||
expect(presentations.map((p) => p.$1), List.filled(3, 'presentChannel'));
|
||||
expect(presentations.map((p) => p.$2['canvasLoaded']), [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(presentations.map((p) => p.$2['canvasContent']), [
|
||||
'Existing canvas',
|
||||
'Existing canvas',
|
||||
'',
|
||||
]);
|
||||
for (final (_, args) in presentations.skip(1)) {
|
||||
expect(args['initialValue'], 'Draft name');
|
||||
expect(args['description'], 'Draft description');
|
||||
}
|
||||
expect(attempts, ['', '']);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
129
mobile/test/features/channels/channel_messages_journey_test.dart
Normal file
129
mobile/test/features/channels/channel_messages_journey_test.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
||||
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
|
||||
import 'package:buzz/features/channels/send_message_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../helpers/fake_relay.dart';
|
||||
|
||||
const _channel = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'a channel loads, follows live messages, settles sends and recovers',
|
||||
() {
|
||||
fakeAsync((async) {
|
||||
final relay = FakeRelay();
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
NostrEvent message(String text, int createdAt) => relay.sign(
|
||||
kind: EventKind.streamMessage,
|
||||
content: text,
|
||||
createdAt: createdAt,
|
||||
tags: [
|
||||
['h', _channel],
|
||||
],
|
||||
);
|
||||
relay.seed([message('first', now - 20), message('second', now - 10)]);
|
||||
|
||||
final container = ProviderContainer(overrides: relay.overrides);
|
||||
addTearDown(container.dispose);
|
||||
final messages = container.listen(
|
||||
channelMessagesProvider(_channel),
|
||||
(_, _) {},
|
||||
);
|
||||
List<String> timeline() => [
|
||||
for (final event in messages.read().requireValue) event.content,
|
||||
];
|
||||
Set<String> pending() =>
|
||||
container.read(pendingLocalMessagesProvider(_channel)).keys.toSet();
|
||||
void settle() => async.elapse(const Duration(milliseconds: 50));
|
||||
// The live subscription starts at the second it opens, so live
|
||||
// messages are dated from the filter it actually installed.
|
||||
int liveSince() =>
|
||||
relay.reqs
|
||||
.expand((req) => req.skip(2).cast<Map<String, dynamic>>())
|
||||
.lastWhere(
|
||||
(filter) =>
|
||||
(filter['kinds'] as List).contains(
|
||||
EventKind.streamMessage,
|
||||
) &&
|
||||
((filter['#h'] as List?)?.contains(_channel) ??
|
||||
false) &&
|
||||
filter['since'] != null,
|
||||
)['since']
|
||||
as int;
|
||||
|
||||
settle();
|
||||
expect(
|
||||
container.read(relaySessionProvider).status,
|
||||
SessionStatus.connected,
|
||||
);
|
||||
expect(timeline(), ['first', 'second']);
|
||||
expect(relay.queries, hasLength(1), reason: 'read the channel window');
|
||||
expect(
|
||||
relay.reqs.where((req) => (req[1] as String).startsWith('h-')),
|
||||
isEmpty,
|
||||
reason: 'no fallback to websocket history',
|
||||
);
|
||||
|
||||
final since = liveSince();
|
||||
relay.deliver(message('from a teammate', since));
|
||||
relay.deliver(message('from before the subscription', since - 1));
|
||||
settle();
|
||||
expect(timeline(), contains('from a teammate'));
|
||||
expect(timeline(), isNot(contains('from before the subscription')));
|
||||
|
||||
// Pending until OK, settled by OK alone, one row after the echo.
|
||||
final ok = relay.holdOk();
|
||||
final echo = relay.holdEcho();
|
||||
var sent = false;
|
||||
container
|
||||
.read(sendMessageProvider)(channelId: _channel, content: 'hello')
|
||||
.then((_) => sent = true);
|
||||
settle();
|
||||
final sentEvent = relay.published.single;
|
||||
expect(sentEvent.kind, EventKind.streamMessage);
|
||||
expect(sentEvent.pubkey, relay.pubkey);
|
||||
expect(sentEvent.getTagValue('h'), _channel);
|
||||
expect(pending(), {sentEvent.id});
|
||||
expect(timeline(), contains('hello'));
|
||||
|
||||
ok.complete();
|
||||
settle();
|
||||
expect(sent, isTrue);
|
||||
expect(pending(), isEmpty);
|
||||
expect(timeline().where((text) => text == 'hello'), hasLength(1));
|
||||
|
||||
echo.complete();
|
||||
settle();
|
||||
expect(timeline().where((text) => text == 'hello'), hasLength(1));
|
||||
|
||||
// A rejected send leaves no row and is not stored by the relay.
|
||||
relay.rejectNext('blocked: not allowed');
|
||||
Object? failure;
|
||||
container
|
||||
.read(sendMessageProvider)(channelId: _channel, content: 'nope')
|
||||
.catchError((Object error) => failure = error);
|
||||
settle();
|
||||
expect(failure, isNotNull);
|
||||
expect(pending(), isEmpty);
|
||||
expect(timeline(), isNot(contains('nope')));
|
||||
expect(relay.stored.map((e) => e.content), isNot(contains('nope')));
|
||||
|
||||
// After a network drop the session reconnects, recovers what it missed
|
||||
// and follows the channel again, without duplicating known rows.
|
||||
relay.drop();
|
||||
relay.seed([message('while offline', since)]);
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(relay.sockets, hasLength(2));
|
||||
expect(timeline(), contains('while offline'));
|
||||
relay.deliver(message('after reconnect', liveSince()));
|
||||
settle();
|
||||
expect(timeline(), contains('after reconnect'));
|
||||
expect(timeline().where((text) => text == 'hello'), hasLength(1));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -50,6 +51,7 @@ void main() {
|
||||
}) {
|
||||
return ChannelSectionsManager(
|
||||
pubkey: keychain.public,
|
||||
relayUrl: 'https://relay.example',
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relaySession,
|
||||
@@ -291,32 +293,33 @@ void main() {
|
||||
});
|
||||
|
||||
test('backoff resets after full recovery so later failures start from the '
|
||||
'base delay', () async {
|
||||
await setUpEnv();
|
||||
// Climb the failure counter: 5 rate-limited attempts before success.
|
||||
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
|
||||
// were never reset.
|
||||
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
|
||||
final manager = buildManager(relaySession: relay);
|
||||
await manager.initialize();
|
||||
await _waitUntil(() => relay.subscribeCalls > 5);
|
||||
'base delay', () {
|
||||
fakeAsync((async) {
|
||||
setUpEnv();
|
||||
async.flushMicrotasks();
|
||||
// Climb the failure counter: 5 rate-limited attempts before success.
|
||||
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
|
||||
// were never reset.
|
||||
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
|
||||
final manager = buildManager(relaySession: relay);
|
||||
manager.initialize();
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(relay.subscribeCalls, greaterThan(5));
|
||||
|
||||
// Fully recovered. A later transient failure (late CLOSED) must retry
|
||||
// from the base delay, not the climbed ceiling.
|
||||
final subscribeCallsAfterRecovery = relay.subscribeCalls;
|
||||
relay.closeLiveSubscription('rate-limited: quota exceeded');
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _waitUntil(() => relay.subscribeCalls > subscribeCallsAfterRecovery);
|
||||
stopwatch.stop();
|
||||
|
||||
expect(
|
||||
stopwatch.elapsedMilliseconds,
|
||||
lessThan(100),
|
||||
reason:
|
||||
'retry after recovery must wait ~base delay (5ms), '
|
||||
'not the pre-recovery backoff (160ms)',
|
||||
);
|
||||
manager.dispose(flushPending: false);
|
||||
// Fully recovered. A later transient failure (late CLOSED) must retry
|
||||
// from the base delay, not the climbed ceiling.
|
||||
final subscribeCallsAfterRecovery = relay.subscribeCalls;
|
||||
relay.closeLiveSubscription('rate-limited: quota exceeded');
|
||||
async.elapse(const Duration(milliseconds: 6));
|
||||
expect(
|
||||
relay.subscribeCalls,
|
||||
greaterThan(subscribeCallsAfterRecovery),
|
||||
reason:
|
||||
'retry after recovery must wait ~base delay (5ms), '
|
||||
'not the pre-recovery backoff (160ms)',
|
||||
);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
106
mobile/test/features/channels/channel_send_journey_test.dart
Normal file
106
mobile/test/features/channels/channel_send_journey_test.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fake_relay.dart';
|
||||
|
||||
const _channelId = '11111111-2222-4333-8444-555555555555';
|
||||
|
||||
final _channel = Channel(
|
||||
id: _channelId,
|
||||
name: 'general',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: 'General discussion',
|
||||
createdBy: 'abc123',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('a sent message shows at once, settles, and a rejected one '
|
||||
'comes back to the composer', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final relay = FakeRelay();
|
||||
relay.seed([
|
||||
relay.sign(
|
||||
kind: EventKind.streamMessage,
|
||||
content: 'earlier message',
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
),
|
||||
]);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...relay.overrides,
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
appLifecycleProvider.overrideWith(_Resumed.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: ChannelDetailPage(channel: _channel),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('earlier message'), findsOneWidget);
|
||||
|
||||
Future<void> send(String text) async {
|
||||
if (find.byType(TextField).evaluate().isEmpty) {
|
||||
await tester.tap(find.text('Message #general'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.enterText(find.byType(TextField), text);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(BuzzIcons.arrowUp));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
String draft() =>
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text;
|
||||
|
||||
final ok = relay.holdOk();
|
||||
await send('hello team');
|
||||
expect(find.text('hello team'), findsOneWidget);
|
||||
expect(draft(), isEmpty);
|
||||
expect(find.bySemanticsLabel('Sending message'), findsOneWidget);
|
||||
final sent = relay.published.where(
|
||||
(e) => e.kind == EventKind.streamMessage,
|
||||
);
|
||||
expect(sent.single.content, 'hello team');
|
||||
expect(sent.single.getTagValue('h'), _channelId);
|
||||
|
||||
ok.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('hello team'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Sending message'), findsNothing);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
|
||||
relay.rejectNext('blocked: not allowed here');
|
||||
await send('nope');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('nope'), findsOneWidget, reason: 'only the draft');
|
||||
expect(draft(), 'nope');
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
expect(find.text('hello team'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _Resumed extends AppLifecycleNotifier {
|
||||
@override
|
||||
AppLifecycleState build() => AppLifecycleState.resumed;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:buzz/features/channels/channel_typing_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../helpers/thread_ids.dart';
|
||||
@@ -33,34 +34,41 @@ class _Relay extends RelaySessionNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the container rebuild and the notifier's microtasks run under fake
|
||||
/// time, so retry backoff is driven by [FakeAsync.elapse] instead of sleeps.
|
||||
void _settle(ProviderContainer container, FakeAsync async) {
|
||||
container.pump();
|
||||
async.flushMicrotasks();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'initial rejection retries with a bound and no uncaught error',
|
||||
() async {
|
||||
() => fakeAsync((async) {
|
||||
final relay = _Relay()..reject = true;
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relay)],
|
||||
);
|
||||
container.listen(channelTypingProvider('chat'), (_, _) {});
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
expect(relay.calls, 1);
|
||||
for (final seconds in [1, 2, 4, 8]) {
|
||||
await Future<void>.delayed(Duration(milliseconds: seconds * 1000 + 20));
|
||||
async.elapse(Duration(milliseconds: seconds * 1000 + 20));
|
||||
}
|
||||
expect(relay.calls, 4);
|
||||
container.dispose();
|
||||
},
|
||||
}),
|
||||
);
|
||||
test(
|
||||
'terminal close clears typing and retries; disposal cancels recovery',
|
||||
() async {
|
||||
() => fakeAsync((async) {
|
||||
final relay = _Relay();
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relay)],
|
||||
);
|
||||
final provider = channelTypingProvider('chat');
|
||||
container.listen(provider, (_, _) {});
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
relay.receive(
|
||||
NostrEvent(
|
||||
id: 'typing',
|
||||
@@ -76,62 +84,62 @@ void main() {
|
||||
);
|
||||
expect(container.read(provider), hasLength(1));
|
||||
relay.close!('terminal');
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
expect(container.read(provider), isEmpty);
|
||||
expect(relay.active, 0);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
async.elapse(const Duration(milliseconds: 1100));
|
||||
expect(relay.active, 1);
|
||||
expect(relay.calls, 2);
|
||||
relay.close!('terminal again');
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
container.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2100));
|
||||
async.elapse(const Duration(milliseconds: 2100));
|
||||
expect(relay.calls, 2);
|
||||
expect(relay.active, 0);
|
||||
},
|
||||
}),
|
||||
);
|
||||
test(
|
||||
'terminal closure loops stay bounded until sustained recovery',
|
||||
() async {
|
||||
() => fakeAsync((async) {
|
||||
final relay = _Relay();
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relay)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.listen(channelTypingProvider('chat'), (_, _) {});
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
for (final delay in [1, 2, 4]) {
|
||||
final calls = relay.calls;
|
||||
relay.close!('rejected after admission');
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
if (delay > 1) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
async.elapse(const Duration(milliseconds: 1100));
|
||||
expect(relay.calls, calls);
|
||||
}
|
||||
await Future<void>.delayed(
|
||||
async.elapse(
|
||||
Duration(milliseconds: delay * 1000 + 50 - (delay > 1 ? 1100 : 0)),
|
||||
);
|
||||
expect(relay.calls, calls + 1);
|
||||
}
|
||||
relay.close!('fourth rejection');
|
||||
await container.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
_settle(container, async);
|
||||
async.elapse(const Duration(milliseconds: 1100));
|
||||
expect(relay.calls, 4);
|
||||
relay.setStatus(SessionStatus.disconnected);
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
relay.setStatus(SessionStatus.connected);
|
||||
await container.pump();
|
||||
_settle(container, async);
|
||||
relay.close!('first rejection after reconnect');
|
||||
await container.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
_settle(container, async);
|
||||
async.elapse(const Duration(milliseconds: 1100));
|
||||
final recoveredCalls = relay.calls;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 8100));
|
||||
async.elapse(const Duration(milliseconds: 8100));
|
||||
relay.close!('closure after sustained health');
|
||||
await container.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
_settle(container, async);
|
||||
async.elapse(const Duration(milliseconds: 1100));
|
||||
expect(relay.calls, recoveredCalls + 1);
|
||||
expect(relay.active, 1);
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('transport rebuilds release each typing subscription', () async {
|
||||
final relay = _Relay();
|
||||
|
||||
@@ -416,14 +416,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
// Transport: request sink must be closed before send().
|
||||
//
|
||||
// Real-IO loopback probes (TestWidgetsFlutterBinding intercepts HttpClient
|
||||
// within this suite) live in video_viewer_transport_test.dart, which uses
|
||||
// plain test() without TestWidgetsFlutterBinding. The fake drain probe
|
||||
// below covers the same contract without the binding conflict.
|
||||
//
|
||||
//
|
||||
// Red-with-old-code: before the fix the `localController` was created but
|
||||
// only published to `controller.value` after a successful initialize()+play().
|
||||
// On error the outer `catch (loadError)` ran without ever calling
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Real-IO transport probes for MediaVideoViewerPage.
|
||||
//
|
||||
// These tests exercise the actual IOClient (dart:io) transport path for
|
||||
// AbortableStreamedRequest. They are intentionally in a SEPARATE file
|
||||
// from video_viewer_test.dart because TestWidgetsFlutterBinding.ensureInitialized()
|
||||
// in the main test file installs a suite-wide HttpClient override that returns
|
||||
// status 400 for ALL requests — including plain test() calls in the same suite.
|
||||
// By isolating these here, the real loopback HttpServer can be reached.
|
||||
//
|
||||
// Transport probe #1: request sink must be closed before send().
|
||||
// A local loopback server reads the full request body before replying.
|
||||
// Without the `unawaited(request.sink.close())` fix, IOClient.send() blocks
|
||||
// at `stream.pipe(ioRequest)` forever — test times out.
|
||||
//
|
||||
// Transport probe #2: abort trigger cancels an in-flight download.
|
||||
// The server holds the response open via a teardown-controlled release gate
|
||||
// so the abort fires against a genuinely in-flight transfer rather than
|
||||
// racing a completed response. After server confirms request arrival,
|
||||
// the abort is triggered; send() must throw the typed
|
||||
// RequestAbortedException within a bounded deadline.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart' show IOClient;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'Transport: request sink is closed — real IO loopback probe (success)',
|
||||
() async {
|
||||
final videoBytes = <int>[0, 1, 2, 3];
|
||||
|
||||
// Start a local HTTP server that reads the full request body BEFORE
|
||||
// sending the response. If the sink is not closed, the drain hangs.
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() => server.close(force: true));
|
||||
|
||||
server.listen((req) async {
|
||||
await req.drain<void>(); // hangs if sink was not closed
|
||||
req.response
|
||||
..statusCode = 200
|
||||
..headers.contentType = ContentType('video', 'mp4')
|
||||
..contentLength = videoBytes.length
|
||||
..add(videoBytes);
|
||||
await req.response.close();
|
||||
});
|
||||
|
||||
final serverUrl =
|
||||
'http://${server.address.host}:${server.port}/video.mp4';
|
||||
final client = IOClient(
|
||||
HttpClient()..idleTimeout = const Duration(milliseconds: 1),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final requestAbort = Completer<void>();
|
||||
final request = http.AbortableStreamedRequest(
|
||||
'GET',
|
||||
Uri.parse(serverUrl),
|
||||
abortTrigger: requestAbort.future,
|
||||
);
|
||||
// THE FIX: close the sink before send so the pipe completes.
|
||||
unawaited(request.sink.close());
|
||||
|
||||
final response = await client
|
||||
.send(request)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () =>
|
||||
throw TimeoutException('send() did not complete within 5 s'),
|
||||
);
|
||||
|
||||
expect(
|
||||
response.statusCode,
|
||||
200,
|
||||
reason: 'sink closed → server receives full request → replies 200',
|
||||
);
|
||||
await response.stream.drain<void>().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'response drain did not complete within 5 s',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Transport: abort trigger cancels an in-flight download — real IO loopback',
|
||||
() async {
|
||||
// The server signals that the request has arrived (so the abort fires
|
||||
// AFTER the connection is established, not before).
|
||||
final requestArrivedCompleter = Completer<void>();
|
||||
|
||||
// Teardown-controlled gate: the server handler awaits this before
|
||||
// calling response.close(), so the response body stays open until the
|
||||
// test explicitly releases it (on success) or teardown releases it (on
|
||||
// failure). Without this gate, response.close() would send an empty 200
|
||||
// immediately and the abort would race against a completed response —
|
||||
// making the probe scheduling-sensitive rather than a controlled
|
||||
// in-flight cancellation.
|
||||
final releaseResponse = Completer<void>();
|
||||
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() => server.close(force: true));
|
||||
addTearDown(() {
|
||||
if (!releaseResponse.isCompleted) releaseResponse.complete();
|
||||
});
|
||||
|
||||
server.listen((req) async {
|
||||
await req.drain<void>();
|
||||
// Signal that the server has received the request, then hold the
|
||||
// response open until the test releases it or teardown fires.
|
||||
if (!requestArrivedCompleter.isCompleted) {
|
||||
requestArrivedCompleter.complete();
|
||||
}
|
||||
// Await the teardown-controlled gate before attempting to close.
|
||||
// After an abort the client may have already torn down the connection,
|
||||
// so close() may throw — caught and discarded here.
|
||||
await releaseResponse.future;
|
||||
try {
|
||||
await req.response.close();
|
||||
} catch (_) {
|
||||
// Connection may already be torn down by the client abort — expected.
|
||||
}
|
||||
});
|
||||
|
||||
final serverUrl =
|
||||
'http://${server.address.host}:${server.port}/video.mp4';
|
||||
final client = IOClient(
|
||||
HttpClient()..idleTimeout = const Duration(milliseconds: 1),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final requestAbort = Completer<void>();
|
||||
final request = http.AbortableStreamedRequest(
|
||||
'GET',
|
||||
Uri.parse(serverUrl),
|
||||
abortTrigger: requestAbort.future,
|
||||
);
|
||||
unawaited(request.sink.close());
|
||||
|
||||
// Start the request, wait for server-arrival confirmation, then abort.
|
||||
final sendFuture = client.send(request);
|
||||
// Bounded wait: if the server doesn't see the request within 5s, fail.
|
||||
await requestArrivedCompleter.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException(
|
||||
'Loopback server did not receive request within 5 s',
|
||||
),
|
||||
);
|
||||
requestAbort.complete();
|
||||
|
||||
// send() must throw RequestAbortedException within a bounded deadline.
|
||||
// The typed assertion distinguishes an abort from any other exception.
|
||||
await expectLater(
|
||||
sendFuture.timeout(const Duration(seconds: 5)),
|
||||
throwsA(isA<http.RequestAbortedException>()),
|
||||
reason: 'abort must cause send() to throw RequestAbortedException',
|
||||
);
|
||||
|
||||
// Release the server handler so it can exit cleanly. The abort may
|
||||
// have already torn down the connection, so close() in the handler may
|
||||
// throw and be discarded. Teardown also releases this gate; this is
|
||||
// belt-and-suspenders cleanup for the success path.
|
||||
if (!releaseResponse.isCompleted) releaseResponse.complete();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
390
mobile/test/helpers/fake_relay.dart
Normal file
390
mobile/test/helpers/fake_relay.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/auth/auth_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
/// An in-memory relay behind the real [RelaySessionNotifier].
|
||||
///
|
||||
/// Only the process boundary is fake: the websocket (through the session's
|
||||
/// socket factory) and the HTTP bridge (through its HTTP client). The session
|
||||
/// still connects from `build()`, matches REQ/EOSE/OK/CLOSED, batches events,
|
||||
/// backs off and replays live subscriptions. Replies arrive on microtasks, so
|
||||
/// tests drive timers with `fakeAsync` or `tester.pump`, never real sleeps.
|
||||
///
|
||||
/// It is not a relay emulator: authorization, NIP-42 and JSON framing stay
|
||||
/// with the relay and socket tests. A request it cannot answer faithfully is
|
||||
/// recorded and fails the test at teardown, because app code often catches
|
||||
/// relay errors and falls back.
|
||||
class FakeRelay {
|
||||
FakeRelay() {
|
||||
addTearDown(() {
|
||||
expect(unanswerable, isEmpty, reason: 'requests FakeRelay cannot answer');
|
||||
});
|
||||
}
|
||||
|
||||
final keys = nostr.Keys.generate();
|
||||
final stored = <NostrEvent>[];
|
||||
final published = <NostrEvent>[];
|
||||
final reqs = <List<dynamic>>[];
|
||||
final queries = <List<Map<String, dynamic>>>[];
|
||||
final sockets = <FakeRelaySocket>[];
|
||||
final unanswerable = <String>[];
|
||||
final _rejections = <({bool Function(NostrEvent) match, String message})>[];
|
||||
Completer<void>? _okHold;
|
||||
Completer<void>? _echoHold;
|
||||
|
||||
String get pubkey => keys.public;
|
||||
|
||||
late final session = RelaySessionNotifier(
|
||||
socketFactory: _openSocket,
|
||||
httpClient: MockClient(_handleHttp),
|
||||
);
|
||||
|
||||
List<Override> get overrides => [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(() => _FixedRelayConfig(keys.nsec)),
|
||||
authProvider.overrideWith(_SignedInAuth.new),
|
||||
];
|
||||
|
||||
/// Stores history without notifying live subscriptions.
|
||||
void seed(Iterable<NostrEvent> events) => stored.addAll(events);
|
||||
|
||||
/// Another client published [event]: store it and fan it out.
|
||||
void deliver(NostrEvent event) {
|
||||
stored.add(event);
|
||||
_fanOut(event);
|
||||
}
|
||||
|
||||
/// Signs an event as [author] (default: a fresh member, not the app user).
|
||||
NostrEvent sign({
|
||||
required int kind,
|
||||
required List<List<String>> tags,
|
||||
String content = '',
|
||||
int? createdAt,
|
||||
nostr.Keys? author,
|
||||
}) {
|
||||
final event = nostr.Event.from(
|
||||
kind: kind,
|
||||
tags: tags,
|
||||
content: content,
|
||||
createdAt: createdAt,
|
||||
secretKey: (author ?? nostr.Keys.generate()).secret,
|
||||
verify: false,
|
||||
);
|
||||
return NostrEvent.fromJson(event.toMap());
|
||||
}
|
||||
|
||||
/// The next published event that [match]es is answered `OK false` and is
|
||||
/// neither stored nor fanned out.
|
||||
void rejectNext(String message, {bool Function(NostrEvent)? match}) =>
|
||||
_rejections.add((match: match ?? (_) => true, message: message));
|
||||
|
||||
/// Withholds OK for accepted events until the completer completes.
|
||||
Completer<void> holdOk() => _okHold = Completer<void>();
|
||||
|
||||
/// Withholds live fan-out of accepted events until the completer completes.
|
||||
/// The relay dispatches after commit, so echo and OK have no fixed order.
|
||||
Completer<void> holdEcho() => _echoHold = Completer<void>();
|
||||
|
||||
/// Drops the current connection, as a network loss would. Its live
|
||||
/// subscriptions and queued replies die with it; stored history survives.
|
||||
void drop([Object? error]) => sockets.last.drop(error);
|
||||
|
||||
RelaySocket _openSocket({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) {
|
||||
final socket = FakeRelaySocket._(
|
||||
this,
|
||||
onMessage: onMessage,
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
sockets.add(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
void _receive(FakeRelaySocket socket, List<dynamic> message) {
|
||||
switch (message.first) {
|
||||
case 'REQ':
|
||||
reqs.add(message);
|
||||
final subId = message[1] as String;
|
||||
final filters = message.skip(2).cast<Map<String, dynamic>>().toList();
|
||||
final history = _query(filters);
|
||||
if (history == null) return;
|
||||
socket._live[subId] = filters;
|
||||
scheduleMicrotask(() {
|
||||
for (final event in history) {
|
||||
socket._reply(['EVENT', subId, event.toJson()]);
|
||||
}
|
||||
socket._reply(['EOSE', subId]);
|
||||
});
|
||||
case 'CLOSE':
|
||||
socket._live.remove(message[1]);
|
||||
case 'EVENT':
|
||||
_publish(
|
||||
socket,
|
||||
NostrEvent.fromJson(message[1] as Map<String, dynamic>),
|
||||
);
|
||||
default:
|
||||
unanswerable.add('${message.first} frame');
|
||||
}
|
||||
}
|
||||
|
||||
void _publish(FakeRelaySocket socket, NostrEvent event) {
|
||||
published.add(event);
|
||||
final index = _rejections.indexWhere((r) => r.match(event));
|
||||
if (index >= 0) {
|
||||
final message = _rejections.removeAt(index).message;
|
||||
scheduleMicrotask(() => socket._reply(['OK', event.id, false, message]));
|
||||
return;
|
||||
}
|
||||
stored.add(event);
|
||||
final okHold = _okHold?.future;
|
||||
final echoHold = _echoHold?.future;
|
||||
Future<void> ok() async {
|
||||
await okHold;
|
||||
socket._reply(['OK', event.id, true, '']);
|
||||
}
|
||||
|
||||
Future<void> echo() async {
|
||||
await echoHold;
|
||||
_fanOut(event);
|
||||
}
|
||||
|
||||
scheduleMicrotask(ok);
|
||||
scheduleMicrotask(echo);
|
||||
}
|
||||
|
||||
void _fanOut(NostrEvent event) {
|
||||
for (final socket in sockets) {
|
||||
for (final MapEntry(key: subId, value: filters) in socket._live.entries) {
|
||||
if (filters.any((f) => _matches(f, event, live: true))) {
|
||||
socket._reply(['EVENT', subId, event.toJson()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored events for [filters], or null after recording why it cannot
|
||||
/// answer. Each filter applies its own limit before the union, as the
|
||||
/// relay does.
|
||||
List<NostrEvent>? _query(List<Map<String, dynamic>> filters) {
|
||||
if (!_answerable(filters)) return null;
|
||||
final result = <String, NostrEvent>{};
|
||||
for (final filter in filters) {
|
||||
final limit = filter['limit'] as int?;
|
||||
final matches = stored.where((e) => _matches(filter, e)).toList()
|
||||
..sort(_newestFirst);
|
||||
for (final event in limit == null ? matches : matches.take(limit)) {
|
||||
result[event.id] = event;
|
||||
}
|
||||
}
|
||||
return result.values.toList()..sort(_newestFirst);
|
||||
}
|
||||
|
||||
/// Whether [_matches] implements every key in [filters]; records why not.
|
||||
/// Checked before matching, so an empty store cannot hide it.
|
||||
bool _answerable(List<Map<String, dynamic>> filters) {
|
||||
final unknown = {
|
||||
for (final filter in filters)
|
||||
...filter.keys.where(
|
||||
(key) => !_filterKeys.contains(key) && !key.startsWith('#'),
|
||||
),
|
||||
};
|
||||
if (unknown.isEmpty) return true;
|
||||
unanswerable.add('filter keys ${unknown.toList()}');
|
||||
return false;
|
||||
}
|
||||
|
||||
/// NIP-01 matching for a filter [_answerable] accepted.
|
||||
bool _matches(
|
||||
Map<String, dynamic> filter,
|
||||
NostrEvent event, {
|
||||
bool live = false,
|
||||
}) {
|
||||
if (!live && filter['limit'] == 0) return false;
|
||||
bool allows(String key, Object value) =>
|
||||
(filter[key] as List?)?.contains(value) ?? true;
|
||||
if (!allows('ids', event.id) ||
|
||||
!allows('authors', event.pubkey) ||
|
||||
!allows('kinds', event.kind)) {
|
||||
return false;
|
||||
}
|
||||
final since = filter['since'] as int?;
|
||||
final until = filter['until'] as int?;
|
||||
if (since != null && event.createdAt < since) return false;
|
||||
if (until != null && event.createdAt > until) return false;
|
||||
for (final MapEntry(:key, :value) in filter.entries) {
|
||||
if (!key.startsWith('#')) continue;
|
||||
final name = key.substring(1);
|
||||
final wanted = (value as List).cast<String>();
|
||||
if (!event.tags.any(
|
||||
(tag) => tag.length > 1 && tag[0] == name && wanted.contains(tag[1]),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<http.Response> _handleHttp(http.Request request) async {
|
||||
if (request.method != 'POST' ||
|
||||
request.url.path != '/query' ||
|
||||
request.headers['Authorization'] == null) {
|
||||
unanswerable.add('${request.method} ${request.url.path}');
|
||||
return http.Response('unanswerable', 501);
|
||||
}
|
||||
final filters = (jsonDecode(request.body) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
queries.add(filters);
|
||||
final events = filters.length == 1 && filters.single['top_level'] == true
|
||||
? _channelWindow(filters.single)
|
||||
: _query(filters);
|
||||
if (events == null) return http.Response('unanswerable', 501);
|
||||
return http.Response(jsonEncode([for (final e in events) e.toJson()]), 200);
|
||||
}
|
||||
|
||||
/// The NIP-CW top-level channel window: one page of top-level rows plus its
|
||||
/// kind:39006 bounds. Thread summaries and aux events are not synthesized
|
||||
/// yet; a journey that needs them adds them here.
|
||||
List<NostrEvent>? _channelWindow(Map<String, dynamic> filter) {
|
||||
final channel = ((filter['#h'] as List).single as String).toLowerCase();
|
||||
final beforeId = filter['before_id'] as String?;
|
||||
final until = filter['until'] as int?;
|
||||
final limit = filter['limit'] as int;
|
||||
final base = {
|
||||
for (final MapEntry(:key, :value) in filter.entries)
|
||||
if (!_windowKeys.contains(key)) key: value,
|
||||
};
|
||||
if (!_answerable([base])) return null;
|
||||
final rows = <NostrEvent>[];
|
||||
for (final event in stored) {
|
||||
if (!_matches(base, event) || event.threadReference.parentId != null) {
|
||||
continue;
|
||||
}
|
||||
// Rows strictly after the cursor in `created_at DESC, id ASC` order.
|
||||
if (until != null &&
|
||||
beforeId != null &&
|
||||
!(event.createdAt < until ||
|
||||
(event.createdAt == until && event.id.compareTo(beforeId) > 0))) {
|
||||
continue;
|
||||
}
|
||||
rows.add(event);
|
||||
}
|
||||
rows.sort(_newestFirst);
|
||||
final page = rows.take(limit).toList();
|
||||
final hasMore = rows.length > limit;
|
||||
final cursor = until == null || beforeId == null
|
||||
? 'head'
|
||||
: '$until:${beforeId.toLowerCase()}';
|
||||
return [
|
||||
...page,
|
||||
NostrEvent(
|
||||
id: 'bounds-$channel-$cursor',
|
||||
pubkey: 'relay',
|
||||
createdAt: 0,
|
||||
kind: EventKind.channelWindowBounds,
|
||||
tags: [
|
||||
['d', '$channel:$cursor'],
|
||||
],
|
||||
content: jsonEncode({
|
||||
'has_more': hasMore,
|
||||
'next_cursor': hasMore
|
||||
? {'created_at': page.last.createdAt, 'id': page.last.id}
|
||||
: null,
|
||||
}),
|
||||
sig: 'sig',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket the session talks to; [FakeRelay] owns both ends.
|
||||
class FakeRelaySocket extends RelaySocket {
|
||||
FakeRelaySocket._(
|
||||
this._relay, {
|
||||
required super.onMessage,
|
||||
required super.onConnected,
|
||||
required super.onDisconnected,
|
||||
}) : _onMessage = onMessage,
|
||||
_onConnected = onConnected,
|
||||
_onDisconnected = onDisconnected,
|
||||
super(wsUrl: 'wss://relay.test', nsec: null);
|
||||
|
||||
final FakeRelay _relay;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function() _onConnected;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
final _live = <String, List<Map<String, dynamic>>>{};
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
_open = true;
|
||||
_onConnected();
|
||||
}
|
||||
|
||||
@override
|
||||
void send(List<dynamic> payload) {
|
||||
if (_open) _relay._receive(this, payload);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async => dispose();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_open = false;
|
||||
_live.clear();
|
||||
}
|
||||
|
||||
void drop(Object? error) {
|
||||
if (!_open) return;
|
||||
dispose();
|
||||
_onDisconnected(error);
|
||||
}
|
||||
|
||||
void _reply(List<dynamic> message) {
|
||||
if (_open) _onMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
const _filterKeys = {'ids', 'authors', 'kinds', 'since', 'until', 'limit'};
|
||||
const _windowKeys = {
|
||||
'top_level',
|
||||
'include_summaries',
|
||||
'include_aux',
|
||||
'before_id',
|
||||
'until',
|
||||
'limit',
|
||||
};
|
||||
|
||||
int _newestFirst(NostrEvent a, NostrEvent b) => a.createdAt != b.createdAt
|
||||
? b.createdAt.compareTo(a.createdAt)
|
||||
: a.id.compareTo(b.id);
|
||||
|
||||
class _FixedRelayConfig extends RelayConfigNotifier {
|
||||
_FixedRelayConfig(this._nsec);
|
||||
|
||||
final String _nsec;
|
||||
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
RelayConfig(baseUrl: 'https://relay.test', nsec: _nsec);
|
||||
}
|
||||
|
||||
class _SignedInAuth extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async =>
|
||||
const AuthState(status: AuthStatus.authenticated);
|
||||
}
|
||||
37
scripts/mobile-test-shard.sh
Executable file
37
scripts/mobile-test-shard.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Run one shard of the mobile Dart tests, split by file.
|
||||
#
|
||||
# `flutter test --total-shards` splits tests, so every shard still compiles
|
||||
# and loads every test file. Splitting files means each shard compiles only
|
||||
# its own. Files are balanced by line count, largest first, so the split is
|
||||
# deterministic and needs no timing history.
|
||||
#
|
||||
# Usage: mobile-test-shard.sh INDEX TOTAL [flutter test args...]
|
||||
set -euo pipefail
|
||||
index=$1
|
||||
total=$2
|
||||
shift 2
|
||||
cd "$(dirname "$0")/../mobile"
|
||||
|
||||
files=()
|
||||
while IFS= read -r file; do
|
||||
files+=("$file")
|
||||
done < <(
|
||||
find test -name '*_test.dart' -exec wc -l {} + |
|
||||
awk '$2 != "total" { print $1, $2 }' |
|
||||
sort -k1,1nr -k2,2 |
|
||||
awk -v index_="$index" -v total="$total" '
|
||||
{
|
||||
best = 0
|
||||
for (i = 1; i < total; i++) if (load[i] < load[best]) best = i
|
||||
load[best] += $1
|
||||
if (best == index_) print $2
|
||||
}'
|
||||
)
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "Shard $index of $total has no test files" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Shard $index of $total: ${#files[@]} test files"
|
||||
flutter test "$@" "${files[@]}"
|
||||
Reference in New Issue
Block a user