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 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-10-20 09:31:16 -07:00
parent 8be241350a
commit cb9a2b4d05
3 changed files with 225 additions and 3 deletions

View File

@@ -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

View File

@@ -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<String, dynamic>()),
];
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<String, dynamic>(),
);
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<String, dynamic>(),
);
final initial = head;
var preflights = 0;
final bothRead = Completer<void>();
final bothPublished = Completer<void>();
final published = <NostrEvent>[];
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();
},
);
}

View File

@@ -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"
}
}