feat(core): person-to-person ratings transport (kind 30778)
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.
This commit is contained in:
parent
fef174e8c4
commit
a24831530d
6 changed files with 297 additions and 0 deletions
|
|
@ -21,11 +21,14 @@ export 'src/social/nostr/nostr_connection.dart';
|
||||||
export 'src/social/nostr/nostr_message_transport.dart';
|
export 'src/social/nostr/nostr_message_transport.dart';
|
||||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||||
export 'src/social/nostr/nostr_profile_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/nostr_trust_transport.dart';
|
||||||
export 'src/social/nostr/relay_pool.dart';
|
export 'src/social/nostr/relay_pool.dart';
|
||||||
export 'src/social/offer.dart';
|
export 'src/social/offer.dart';
|
||||||
export 'src/social/offer_transport.dart';
|
export 'src/social/offer_transport.dart';
|
||||||
export 'src/social/profile_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/sync_transport.dart';
|
||||||
export 'src/social/trust_transport.dart';
|
export 'src/social/trust_transport.dart';
|
||||||
export 'src/social/web_of_trust.dart';
|
export 'src/social/web_of_trust.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<void> 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<void> retract({required String subjectPubkey}) =>
|
||||||
|
_publish(subjectPubkey, stars: Rating.minStars, comment: '',
|
||||||
|
retracted: true);
|
||||||
|
|
||||||
|
Future<void> _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<List<Rating>> 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 = <String, Event>{};
|
||||||
|
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<Rating>()
|
||||||
|
.where((r) => r.subject == subjectPubkey && r.isActive)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Rating?> 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<void> close() => _conn.close();
|
||||||
|
}
|
||||||
44
packages/commons_core/lib/src/social/rating.dart
Normal file
44
packages/commons_core/lib/src/social/rating.dart
Normal file
|
|
@ -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<Object?> get props =>
|
||||||
|
[rater, subject, stars, ratedAt, comment, retracted];
|
||||||
|
}
|
||||||
25
packages/commons_core/lib/src/social/rating_transport.dart
Normal file
25
packages/commons_core/lib/src/social/rating_transport.dart
Normal file
|
|
@ -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<void> rate({
|
||||||
|
required String subjectPubkey,
|
||||||
|
required int stars,
|
||||||
|
String comment = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Takes back this identity's rating of [subjectPubkey].
|
||||||
|
Future<void> retract({required String subjectPubkey});
|
||||||
|
|
||||||
|
/// Active ratings of [subjectPubkey], one per rater.
|
||||||
|
Future<List<Rating>> ratingsOf(String subjectPubkey);
|
||||||
|
|
||||||
|
/// This identity's active rating of [subjectPubkey], or null.
|
||||||
|
Future<Rating?> myRatingOf(String subjectPubkey);
|
||||||
|
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
|
|
@ -237,4 +237,75 @@ void main() {
|
||||||
await trust.close();
|
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<void>.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<void>.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
packages/commons_core/test/social/rating_test.dart
Normal file
29
packages/commons_core/test/social/rating_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue