From cb9a2b4d0550f4e7fa0e5d240f800a11868e441a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 20 Oct 2026 09:31:16 -0700 Subject: [PATCH] test(mobile): establish companion desktop filter interoperability Link the separate buzz-app implementation and qualify the older desktop client in this repository. Exercise signed encrypted golden payloads, sibling preservation and crossed whole-record winners; document last-write-wins conflicts and recovery limits. Co-authored-by: Carl Signed-off-by: Taylor Ho --- mobile/README.md | 28 +++- .../channel_filter_interop_test.dart | 145 ++++++++++++++++++ .../test/fixtures/channel_filter_desktop.json | 55 +++++++ 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 mobile/test/features/channels/channel_filter/channel_filter_interop_test.dart create mode 100644 mobile/test/fixtures/channel_filter_desktop.json diff --git a/mobile/README.md b/mobile/README.md index e8fdec9649..6d926ae87c 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -247,17 +247,39 @@ Unreads uses the shared unread computation, including manually marked unread channels and observed unread thread replies. Active uses verified message timestamps; unavailable activity leaves channels visible with Retry. -Mobile reads and writes the desktop-compatible encrypted kind-30078 -`channel-filter` preference for the current account and community. It preserves +Mobile reads and writes the encrypted kind-30078 `channel-filter` preference +for the current account and community. Its desktop counterpart is the separate +[buzz-app implementation](https://github.com/block/buzz-app/pull/1297), including +both browser and packaged-native writers. The older `desktop/` client in this +repository does not implement this lane. Cross-client use requires that companion +implementation; this PR does not add filters to the older desktop client. +Mobile preserves metadata registers, null All tombstones, durations, and unrelated record fields. A cached copy is display-only until a complete relay read succeeds. Writes read the fresh record, publish one update, and confirm the mode and duration on relay readback. Failed saves roll back locally; the sheet retains the draft for Retry or Cancel. +Modes and durations share **whole-record last-write-wins** authority: the retained +Nostr event supplies both maps. `meta.g` supplies mode projection and tombstones, +not a separate per-section merge protocol. A fresh preflight preserves edits it +has seen, but crossed writes can lose an unseen edit, including another section's +edit. Readback confirms the requested mode and duration together; load/refresh +adopts the retained winning record. There is no conflict-free merge guarantee. + +Within a supported v1 record, malformed groups, durations and register siblings +are discarded while valid siblings, reset tombstones and unrelated fields remain. +An explicit user save publishes and confirms that canonical repair. Oversized +records and unsupported schema versions remain read-only; cached display data +never becomes repair authority. + Preference refresh happens on load, foreground resume, and pull to refresh; instant cross-device preference synchronization is not promised. Filter choices are disabled while preferences are unavailable; hidden-channel navigation remains -available. Deleting a custom section confirms its filter reset before removal. +available. Deleting a custom section immediately persists its local removal, +even offline. Unfiltered sections need no filter publication. Other sections get +best-effort filter cleanup; failure reports that cleanup was unavailable without +restoring the section. Never-reused section IDs make retained orphan filters +unreachable; no automatic cleanup retry or offline filter-save queue is added. ## Checks diff --git a/mobile/test/features/channels/channel_filter/channel_filter_interop_test.dart b/mobile/test/features/channels/channel_filter/channel_filter_interop_test.dart new file mode 100644 index 0000000000..5acf0845b2 --- /dev/null +++ b/mobile/test/features/channels/channel_filter/channel_filter_interop_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:buzz/features/channels/channel_filter/channel_filter_manager.dart'; +import 'package:buzz/features/channels/channel_filter/channel_filter_store.dart'; +import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final fixture = jsonDecode( + File('test/fixtures/channel_filter_desktop.json').readAsStringSync(), + ); + final keys = nostr.Keys(fixture['testSecretHex'] as String); + final crypto = ChannelSortCrypto(keys.nsec, keys.public); + NostrEvent signed(String content, int at) => NostrEvent.fromJson( + nostr.Event.from( + kind: EventKind.readState, + tags: [ + ['d', 'channel-filter'], + ], + content: content, + secretKey: fixture['testSecretHex'] as String, + createdAt: at, + verify: false, + ).toMap(), + ); + + test( + 'desktop encrypted golden head is adopted and preserved in mobile writes', + () async { + var history = [ + NostrEvent.fromJson((fixture['head'] as Map).cast()), + ]; + final manager = ChannelFilterManager( + pubkey: keys.public, + crypto: crypto, + fetch: () async => history, + cache: (_) {}, + publish: (content, at) async => history = [signed(content, at)], + ); + await manager.refresh(); + expect(manager.ready, isTrue); + expect(manager.store.toJson(), fixture['plaintext']); + expect(manager.store.modeFor('channels'), ChannelFilterMode.active); + expect(manager.store.windowFor('channels'), 604800); + await manager.save( + 'forums', + ChannelFilterMode.active, + windowSeconds: 86400, + ); + final saved = jsonDecode(crypto.decrypt(history.single.content)); + final mobileGolden = NostrEvent.fromJson( + (fixture['mobileHead'] as Map).cast(), + ); + nostr.Event.fromJson(jsonEncode(mobileGolden.toJson())); + final mobilePayload = jsonDecode(crypto.decrypt(mobileGolden.content)); + expect(saved['groups'], mobilePayload['groups']); + expect( + saved['activeWindowSeconds'], + mobilePayload['activeWindowSeconds'], + ); + expect(saved['groups'], {'channels': 'active', 'forums': 'active'}); + expect(saved['activeWindowSeconds'], { + 'channels': 604800, + 'forums': 86400, + }); + expect( + saved['meta']['g']['channels'], + fixture['plaintext']['meta']['g']['channels'], + ); + manager.dispose(); + }, + ); + + test( + 'crossed edits converge to one whole-record winner with mode and duration together', + () async { + var head = NostrEvent.fromJson( + (fixture['head'] as Map).cast(), + ); + final initial = head; + var preflights = 0; + final bothRead = Completer(); + final bothPublished = Completer(); + final published = []; + ChannelFilterManager device() => ChannelFilterManager( + pubkey: keys.public, + crypto: crypto, + cache: (_) {}, + fetch: () async { + if (preflights < 2) { + preflights++; + if (preflights == 2) bothRead.complete(); + await bothRead.future; + return [initial]; + } + return [head]; + }, + publish: (content, at) async { + published.add(signed(content, at)); + if (published.length == 2) { + // NIP-01: newest created_at, then lowest event ID wins. + published.sort( + (a, b) => a.createdAt != b.createdAt + ? b.createdAt.compareTo(a.createdAt) + : a.id.compareTo(b.id), + ); + head = published.first; + bothPublished.complete(); + } + await bothPublished.future; + }, + )..ready = true; + final a = device(); + final b = device(); + final results = await Future.wait([ + a + .save('channels', ChannelFilterMode.active, windowSeconds: 86400) + .then((_) => true, onError: (_) => false), + b + .save('channels', ChannelFilterMode.unreads) + .then((_) => true, onError: (_) => false), + ]); + expect(results.where((success) => success).length, 1); + await a.refresh(); + await b.refresh(); + final winner = jsonDecode(crypto.decrypt(head.content)); + expect(a.store.toJson(), winner); + expect(b.store.toJson(), winner); + expect( + a.store.modeFor('channels') == ChannelFilterMode.active + ? a.store.windowFor('channels') + : b.store.windowFor('channels'), + a.store.modeFor('channels') == ChannelFilterMode.active + ? 86400 + : 604800, + ); + a.dispose(); + b.dispose(); + }, + ); +} diff --git a/mobile/test/fixtures/channel_filter_desktop.json b/mobile/test/fixtures/channel_filter_desktop.json new file mode 100644 index 0000000000..059a5521a1 --- /dev/null +++ b/mobile/test/fixtures/channel_filter_desktop.json @@ -0,0 +1,55 @@ +{ + "producer": "block/buzz-app browser-host/sidebar-sort.mjs at 475e2afef07519b065fe65c36a63cbb01c3d333a", + "testSecretHex": "0000000000000000000000000000000000000000000000000000000000000001", + "head": { + "kind": 30078, + "content": "AiUaHZzIMpxRgskz71o22p/SmQcBDyoc+ZK13jmjZ8fS4iTUQ8UvHpihm/tPLvv8u3MRx/Lg1UVv3Ba6kPfhZrWyUp8QVOAThoaA3OSnqYn6oyiK7o2dacoj7o/mGcjumIgYMBx0bi2DOUU/1CvZd/MPNTcWT/aBx7OWHXDM7hGbP+JWupageYTBMcK3Yh4zam9F/VGGWnVA5xkAcusLHffQckk1eQbTiDj4e1S4THRv/1n9U5RFgS31A4owYf+IQqS6dvK5j+xcs9TCjgiHBmDLN1BGkpF1e+khoJD/hgXUcTbbhjMmsAuhwRr6TKu9mBUPYwJlQHhPI93qpwvyTwCqaQ==", + "created_at": 1700000000, + "tags": [ + [ + "d", + "channel-filter" + ], + [ + "t", + "channel-filter" + ] + ], + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "id": "6e7a80b302ef51ded8d80ddd6fe5306141576cb496450d24def70bc544fd85ed", + "sig": "2562bcb9cdfaaa6d78c50dca53ea215b055817bdfd715f7e8bdfd9a24dce88ffed0b753c0f8c91f4f3e1dc561c224da04348db31288abd1f4dbe7c603ee1ead7" + }, + "plaintext": { + "version": 1, + "groups": { + "channels": "active" + }, + "meta": { + "v": 1, + "g": { + "channels": [ + 1700000000000, + "2a60ddc6f815f2de", + "active" + ] + } + }, + "activeWindowSeconds": { + "channels": 604800 + } + }, + "mobileHead": { + "id": "af147924f0b3376b3bd0bb6dbfab4f403d0f7a24044a6df289b069d15e0b4325", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1792513727, + "kind": 30078, + "tags": [ + [ + "d", + "channel-filter" + ] + ], + "content": "Aq9hWkmNY3trUoHFyAnlb5kdFTrAeogOYD8u/CNR4vB8ayOIsf6M7QplHVP9kKMLOms1hcubPzvnMpocYuc+nITZzmNuOOjVnclgm4v850mdyha9riHF5/JLn5u/0N/Z9l6tV0iynyFAy7rkJQPq7ulKazvv5hXjlCs+Nsd6jzN2OFRoVIefatSWcmQcMyHIvzxnVw3uM8tbajQfgL49/JmLfG86js0eBQ+2LbUqxfd0T0tyYqh7CAP+HO9vLOdhDaz0Fbi5xyG756kE5vg37QaWZxU9BsmCD9Zy+NQIT2b7ywUuERCVme+qdICKTeR/Nia5O6G9ZjPMW7hfU1gdDNzxTycQ8fwuCTIXRNA3sIZQCqsxylG2GahAQaP8HwdyOJ4SMZU323JA+oK4bs2epxxs45b+z40mXaAQgvGHSvT8K4g=", + "sig": "795bd134835f7d7130409ac1084b7bd0360d119940f0a90c525cd18575a9978c275c28c8cfdc711032987d581e4283bdcf75e98fed5847352ebb30566c177912" + } +}