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_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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue