From acdacf1821891b4341de94f376c01a631ee65c1c Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 13:06:53 +0200 Subject: [PATCH] feat(core): person-to-person ratings transport (kind 30778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallapop-style reputation primitive next to trust: Rating (1..5 stars + optional comment), RatingTransport (rate/retract/ratingsOf/myRatingOf) and a Nostr implementation on the shared channel. Addressable kind 30778, d = subject: one live rating per (rater, subject) — re-rating replaces (a single author can never pile up reviews) and retracting overwrites in place. Client-side dedupe mirrors NIP-01 replacement order for relays that keep history. --- packages/commons_core/lib/commons_core.dart | 3 + .../social/nostr/nostr_rating_transport.dart | 125 ++++++++++++++++++ .../commons_core/lib/src/social/rating.dart | 44 ++++++ .../lib/src/social/rating_transport.dart | 25 ++++ .../test/social/nostr_transports_test.dart | 71 ++++++++++ .../commons_core/test/social/rating_test.dart | 29 ++++ 6 files changed, 297 insertions(+) create mode 100644 packages/commons_core/lib/src/social/nostr/nostr_rating_transport.dart create mode 100644 packages/commons_core/lib/src/social/rating.dart create mode 100644 packages/commons_core/lib/src/social/rating_transport.dart create mode 100644 packages/commons_core/test/social/rating_test.dart diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 7c86a4f..e7ab5ec 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -21,11 +21,14 @@ export 'src/social/nostr/nostr_connection.dart'; export 'src/social/nostr/nostr_message_transport.dart'; export 'src/social/nostr/nostr_offer_transport.dart'; export 'src/social/nostr/nostr_profile_transport.dart'; +export 'src/social/nostr/nostr_rating_transport.dart'; export 'src/social/nostr/nostr_trust_transport.dart'; export 'src/social/nostr/relay_pool.dart'; export 'src/social/offer.dart'; export 'src/social/offer_transport.dart'; export 'src/social/profile_transport.dart'; +export 'src/social/rating.dart'; +export 'src/social/rating_transport.dart'; export 'src/social/sync_transport.dart'; export 'src/social/trust_transport.dart'; export 'src/social/web_of_trust.dart'; diff --git a/packages/commons_core/lib/src/social/nostr/nostr_rating_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_rating_transport.dart new file mode 100644 index 0000000..e8e73a7 --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr/nostr_rating_transport.dart @@ -0,0 +1,125 @@ +import 'package:nostr/nostr.dart'; + +import '../rating.dart'; +import '../rating_transport.dart'; +import 'nostr_channel.dart'; + +/// Ratings carried as Nostr events, on the shared [NostrConnection]. No settled +/// NIP exists for person-to-person reviews, so this uses a custom addressable +/// kind (next to the certification kind): one live rating per (rater, subject), +/// so rating again replaces and retracting overwrites in place. +class NostrRatingTransport implements RatingTransport { + NostrRatingTransport(this._conn); + + final NostrChannel _conn; + + /// Custom Tanemaki rating kind (addressable range, next to trust's 30777). + static const kindRating = 30778; + + @override + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }) { + if (stars < Rating.minStars || stars > Rating.maxStars) { + throw ArgumentError.value( + stars, 'stars', 'must be ${Rating.minStars}..${Rating.maxStars}'); + } + return _publish(subjectPubkey, + stars: stars, comment: comment, retracted: false); + } + + @override + Future retract({required String subjectPubkey}) => + _publish(subjectPubkey, stars: Rating.minStars, comment: '', + retracted: true); + + Future _publish( + String subject, { + required int stars, + required String comment, + required bool retracted, + }) async { + final event = Event.from( + kind: kindRating, + content: comment, + tags: [ + ['p', subject], + ['d', subject], + ['rating', '$stars'], + ['status', retracted ? 'retracted' : 'valid'], + ], + secretKey: _conn.privateKeyHex, + ); + final r = await _conn.publish(event); + if (!r.accepted) throw StateError('relay rejected rating: ${r.message}'); + } + + @override + Future> ratingsOf(String subjectPubkey) async { + final events = await _conn.reqOnce( + Filter(kinds: const [kindRating], pTags: [subjectPubkey]), + ); + // One rating per rater: keep the newest event should a relay return + // several, then drop retractions. + final newestByRater = {}; + for (final e in events) { + final prev = newestByRater[e.pubkey]; + if (prev == null || _newer(e, prev)) newestByRater[e.pubkey] = e; + } + return newestByRater.values + .map(_toRating) + .whereType() + .where((r) => r.subject == subjectPubkey && r.isActive) + .toList(); + } + + @override + Future myRatingOf(String subjectPubkey) async { + final mine = await _conn.reqOnce(Filter( + kinds: const [kindRating], + authors: [_conn.publicKeyHex], + pTags: [subjectPubkey], + )); + Event? newest; + for (final e in mine) { + if (newest == null || _newer(e, newest)) newest = e; + } + final rating = newest == null ? null : _toRating(newest); + return (rating != null && rating.isActive) ? rating : null; + } + + /// NIP-01 replacement order: newer created_at wins; on a tie the + /// lexicographically smallest id does. + static bool _newer(Event a, Event b) => + a.createdAt != b.createdAt + ? a.createdAt > b.createdAt + : a.id.compareTo(b.id) < 0; + + /// Null for malformed events (missing/out-of-range stars). + Rating? _toRating(Event e) { + String? tag(String name) { + for (final t in e.tags) { + if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1]; + } + return null; + } + + final stars = int.tryParse(tag('rating') ?? ''); + if (stars == null || stars < Rating.minStars || stars > Rating.maxStars) { + return null; + } + return Rating( + rater: e.pubkey, + subject: tag('p') ?? tag('d') ?? '', + stars: stars, + ratedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000), + comment: e.content, + retracted: tag('status') == 'retracted', + ); + } + + @override + Future close() => _conn.close(); +} diff --git a/packages/commons_core/lib/src/social/rating.dart b/packages/commons_core/lib/src/social/rating.dart new file mode 100644 index 0000000..99f1fef --- /dev/null +++ b/packages/commons_core/lib/src/social/rating.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; + +/// One "A rates B" review: stars plus an optional short comment. Public by +/// design and signed by the rater. One live rating per (rater, subject) pair — +/// re-rating replaces, so a single author can never pile up reviews. Generic +/// core type — no seed specifics. +class Rating extends Equatable { + Rating({ + required this.rater, + required this.subject, + required this.stars, + required this.ratedAt, + this.comment = '', + this.retracted = false, + }) { + if (stars < minStars || stars > maxStars) { + throw ArgumentError.value(stars, 'stars', 'must be $minStars..$maxStars'); + } + } + + static const minStars = 1; + static const maxStars = 5; + + /// Rater public key (hex). + final String rater; + + /// Rated public key (hex). + final String subject; + + /// 1..5, Wallapop style. + final int stars; + + final DateTime ratedAt; + final String comment; + + /// A rater can take their rating back; a retracted rating doesn't count. + final bool retracted; + + bool get isActive => !retracted; + + @override + List get props => + [rater, subject, stars, ratedAt, comment, retracted]; +} diff --git a/packages/commons_core/lib/src/social/rating_transport.dart b/packages/commons_core/lib/src/social/rating_transport.dart new file mode 100644 index 0000000..4196bb0 --- /dev/null +++ b/packages/commons_core/lib/src/social/rating_transport.dart @@ -0,0 +1,25 @@ +import 'rating.dart'; + +/// The reputation contract over the shared connection: rate / retract / read +/// ratings. Ratings are public signed events (like certifications), so this +/// transport publishes and discovers rather than encrypts. One live rating per +/// (rater, subject) pair — rating again replaces the previous one. +abstract interface class RatingTransport { + /// Publishes (or replaces) this identity's rating of [subjectPubkey]. + Future rate({ + required String subjectPubkey, + required int stars, + String comment = '', + }); + + /// Takes back this identity's rating of [subjectPubkey]. + Future retract({required String subjectPubkey}); + + /// Active ratings of [subjectPubkey], one per rater. + Future> ratingsOf(String subjectPubkey); + + /// This identity's active rating of [subjectPubkey], or null. + Future myRatingOf(String subjectPubkey); + + Future close(); +} diff --git a/packages/commons_core/test/social/nostr_transports_test.dart b/packages/commons_core/test/social/nostr_transports_test.dart index 3ab4f8c..c28b6c0 100644 --- a/packages/commons_core/test/social/nostr_transports_test.dart +++ b/packages/commons_core/test/social/nostr_transports_test.dart @@ -237,4 +237,75 @@ void main() { await trust.close(); }); }); + + group('ratings (custom kind)', () { + test('published by raters, read back with stars and comments', () async { + final alice = await idFor(1); + final bob = await idFor(2); + final carol = await idFor(3); + + final aliceR = NostrRatingTransport(await connFor(alice)); + await aliceR.rate( + subjectPubkey: carol.publicKeyHex, + stars: 5, + comment: 'Lovely seeds', + ); + await aliceR.close(); + final bobR = NostrRatingTransport(await connFor(bob)); + await bobR.rate(subjectPubkey: carol.publicKeyHex, stars: 3); + await bobR.close(); + + final reader = NostrRatingTransport(await connFor(await idFor(99))); + final ratings = await reader.ratingsOf(carol.publicKeyHex); + expect(ratings, hasLength(2)); + final byRater = {for (final r in ratings) r.rater: r}; + expect(byRater[alice.publicKeyHex]!.stars, 5); + expect(byRater[alice.publicKeyHex]!.comment, 'Lovely seeds'); + expect(byRater[bob.publicKeyHex]!.stars, 3); + await reader.close(); + }); + + test('re-rating replaces (one live rating per pair); retract removes', + () async { + final alice = await idFor(1); + final carol = await idFor(3); + + final aliceR = NostrRatingTransport(await connFor(alice)); + await aliceR.rate(subjectPubkey: carol.publicKeyHex, stars: 2); + // createdAt has second granularity; cross the boundary so the + // replacement is unambiguously newer. + await Future.delayed(const Duration(milliseconds: 1100)); + await aliceR.rate( + subjectPubkey: carol.publicKeyHex, + stars: 4, + comment: 'Better this season', + ); + + final mine = await aliceR.myRatingOf(carol.publicKeyHex); + expect(mine!.stars, 4); + final all = await aliceR.ratingsOf(carol.publicKeyHex); + expect(all, hasLength(1)); + expect(all.single.stars, 4); + + await Future.delayed(const Duration(milliseconds: 1100)); + await aliceR.retract(subjectPubkey: carol.publicKeyHex); + expect(await aliceR.myRatingOf(carol.publicKeyHex), isNull); + expect(await aliceR.ratingsOf(carol.publicKeyHex), isEmpty); + await aliceR.close(); + }); + + test('rejects out-of-range stars before touching the relay', () async { + final alice = await idFor(1); + final aliceR = NostrRatingTransport(await connFor(alice)); + expect( + () => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 0), + throwsArgumentError, + ); + expect( + () => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 6), + throwsArgumentError, + ); + await aliceR.close(); + }); + }); } diff --git a/packages/commons_core/test/social/rating_test.dart b/packages/commons_core/test/social/rating_test.dart new file mode 100644 index 0000000..ece600d --- /dev/null +++ b/packages/commons_core/test/social/rating_test.dart @@ -0,0 +1,29 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +void main() { + Rating make(int stars, {bool retracted = false}) => Rating( + rater: 'a', + subject: 'b', + stars: stars, + ratedAt: DateTime(2026), + retracted: retracted, + ); + + test('accepts 1..5 stars', () { + for (var s = Rating.minStars; s <= Rating.maxStars; s++) { + expect(make(s).stars, s); + } + }); + + test('rejects out-of-range stars', () { + expect(() => make(0), throwsArgumentError); + expect(() => make(6), throwsArgumentError); + expect(() => make(-1), throwsArgumentError); + }); + + test('a retracted rating is not active', () { + expect(make(5).isActive, isTrue); + expect(make(5, retracted: true).isActive, isFalse); + }); +}