feat(base,learn): suggest changes within a shared studyset

ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest
changes within a shared studyset, which its Owner and Editors accept or
decline.

A suggestion or report is written as Explore's are, in its writer's own
synced space, with kinds of its own (space-suggestion, space-report)
and the space's id in feedback_thread's publicationId, so no synced
table changes and older apps, skipping kinds they don't know, keep
syncing. Where the studyset is private it is sealed with its key, bound
to the space; a reply to it is sealed for its thread.

Learn's server gathers a space's threads for its members alone
(feedback.threadsInSpace, NotFound for anyone else): only threads and
replies by members now, decisions only from rows in the space itself,
where the engine lets its Owner and Editors alone write; sealed
payloads pass in base64 with their key and nonce. The inbox adds the
threads its caller decides on or wrote. The module tells a host a
person's spaces and each space's members (sharedSpacesOf,
membersOfSpaces). No migration: no table changes.

In the app, a Viewer's card and note offer Suggest a change: the fields
write into the suggestion alone, sent with a message. A shared
studyset's menu lists its Suggestions, where its Owner and Editors
accept a change as a revision, or a new card or note into the
studyset, and decide in the space. A suggestion naming a file that
isn't the studyset's, or bringing files of its own, isn't taken.

Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 20:34:06 +01:00
commit 82c777b1a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 2949 additions and 427 deletions

View file

@ -1,7 +1,8 @@
### What a published studyset's author decided about a thread of reports and
### suggestions (FeedbackThread): 'closed', 'reopened', 'hidden', 'shown',
### 'accepted' or 'declined' (a suggestion). Written in the author's own
### space, and only the author's count; the latest of each pair (by its id,
### space, and only the author's count; in a shared space, written in that
### space, where only its Owner and Editors write (Halkyon's ADR 0044, D46); the latest of each pair (by its id,
### a UUID v7) stands. Never changed. Synced.
class: FeedbackDecision
table: feedback_decision

View file

@ -31,6 +31,26 @@ class FeedbackEndpoint extends Endpoint {
viewer: session.authenticated!.authUserId,
publicIds: {publicId},
);
/// The suggestions and reports within the shared space [space] (Halkyon's
/// ADR 0044, D46, D47), for its members alone, the most recently active
/// first. Throws [NotFoundException] for anyone else, as for a space that
/// doesn't exist.
Future<List<FeedbackView>> threadsInSpace(
Session session,
UuidValue space,
) async {
final viewer = session.authenticated!.authUserId;
final role = (await sharedSpacesOf(session, viewer))[space];
if (role == null) {
throw NotFoundException(what: 'space');
}
return await gatherSpaceFeedback(
session,
viewer: viewer,
spaces: {space: role},
);
}
}
/// The person's inbox of reports and suggestions: those on their published
@ -42,7 +62,9 @@ class InboxEndpoint extends Endpoint {
Set<Scope> get requiredScopes => {identified};
/// The threads on the caller's published studysets and those they wrote,
/// the most recently active first.
/// and within the shared spaces they decide in, as an Owner or Editor,
/// and those they wrote there (Halkyon's ADR 0044, D46), the most
/// recently active first.
Future<List<FeedbackView>> inbox(Session session) async {
final person = session.authenticated!.authUserId;
// SELECT "publicId" FROM "published_studyset" WHERE "authUserId" = $1
@ -53,14 +75,37 @@ class InboxEndpoint extends Endpoint {
))
studyset.publicId,
};
return await gatherFeedback(
final published = await gatherFeedback(
session,
viewer: person,
publicIds: mine,
mine: true,
);
final inSpaces = [
for (final view in await gatherSpaceFeedback(
session,
viewer: person,
spaces: await sharedSpacesOf(session, person),
mine: true,
))
if (view.mine || view.forMe) view,
];
if (inSpaces.isEmpty) {
return published;
}
// Both the most recently active first, by when each was last active.
return [...published, ...inSpaces]..sort(
(a, b) => _lastActive(b).compareTo(_lastActive(a)),
);
}
/// When [view] was last active: written or replied to. A decision's time
/// isn't in the view, so the merged inbox orders by the others alone.
static DateTime _lastActive(FeedbackView view) => [
view.createdAt,
for (final message in view.messages) message.createdAt,
].reduce((a, b) => a.isAfter(b) ? a : b);
/// How many threads of the inbox have something new.
Future<int> unseen(Session session) async =>
(await inbox(session)).where((thread) => thread.unseen).length;

View file

@ -245,6 +245,224 @@ Future<List<FeedbackView>> gatherFeedback(
return [for (final (_, view) in views) view];
}
/// The threads within shared spaces [viewer] may see (Halkyon's ADR 0044,
/// D44, D46, D47): those in [spaces] (each a space [viewer] is a member
/// of), and with [mine] those [viewer] wrote in any space they are in, the
/// most recently active first.
///
/// Rows come from people's own synced spaces, so what counts is decided
/// here, as with [gatherFeedback]: a thread only of a space kind, naming
/// the space, by someone who is a member now; a reply only from a member;
/// a decision only from a row in the space itself, where the engine lets
/// its Owner and Editors alone write. A hidden thread shows to its writer
/// and the space's Owner and Editors alone. A sealed payload (a private
/// shared studyset's) passes as it is, in base64 with its key and nonce,
/// which the members' devices open.
Future<List<FeedbackView>> gatherSpaceFeedback(
Session session, {
required UuidValue viewer,
required Map<UuidValue, SharedSpaceRole> spaces,
bool mine = false,
}) async {
if (spaces.isEmpty) {
return const [];
}
final viewerSpace = mine ? await _spaceOf(session, viewer) : null;
// SELECT … FROM "feedback_thread" WHERE "kind" = ANY($1)
// AND ("publicationId" = ANY($2) OR "spaceId" = $3)
final threads = await FeedbackThread.db.find(
session,
where: (t) =>
t.kind.inSet(_spaceKinds) &
(viewerSpace == null
? t.publicationId.inSet(spaces.keys.toSet())
: t.publicationId.inSet(spaces.keys.toSet()) |
t.spaceId.equals(viewerSpace)),
);
// Only the spaces the viewer is in: another's thread they wrote counts
// no more once they went.
final inView = [
for (final thread in threads)
if (spaces.containsKey(thread.publicationId)) thread,
];
if (inView.isEmpty) {
return const [];
}
final threadIds = {for (final thread in inView) thread.id!};
final members = await membersOfSpaces(session, {
for (final thread in inView) thread.publicationId,
});
// SELECT … FROM "feedback_message" WHERE "threadId" = ANY($1)
final messages = await FeedbackMessage.db.find(
session,
where: (t) => t.threadId.inSet(threadIds),
);
// SELECT … FROM "feedback_decision" WHERE "threadId" = ANY($1)
final decisions = await FeedbackDecision.db.find(
session,
where: (t) => t.threadId.inSet(threadIds),
);
// Whose each space is: a person's own space bears their id, a shared
// space its own.
// SELECT … FROM "offline_sync_spaces" WHERE "id" = ANY($1)
final uuidOf = {
for (final space in await OfflineSyncSpace.db.find(
session,
where: (t) => t.id.inSet(<int>{
for (final thread in inView) thread.spaceId!,
for (final message in messages) message.spaceId!,
for (final decision in decisions) decision.spaceId!,
}),
))
space.id!: space.uuidSpaceId,
};
final profiles = await profilesOf(session, {
for (final ofSpace in members.values) ...ofSpace.keys,
});
// SELECT … FROM "feedback_seen" WHERE "authUserId" = $1
// AND "threadId" = ANY($2)
final seen = {
for (final row in await FeedbackSeen.db.find(
session,
where: (t) => t.authUserId.equals(viewer) & t.threadId.inSet(threadIds),
))
row.threadId: row.seenAt,
};
FeedbackPerson personOf(UuidValue person) => switch (profiles[person]) {
final profile? => FeedbackPerson(
handle: profile.handle,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
),
null => FeedbackPerson(handle: '', displayName: ''),
};
final views = <(DateTime, FeedbackView)>[];
for (final thread in inView) {
final space = thread.publicationId;
final roles = members[space] ?? const {};
final writer = uuidOf[thread.spaceId];
final payload = _passed(thread.payload, sealed: thread.keyId != null);
if (writer == null || !roles.containsKey(writer) || payload == null) {
continue;
}
bool decides(UuidValue person) => switch (roles[person]) {
SharedSpaceRole.owner || SharedSpaceRole.editor => true,
_ => false,
};
final mineToo = writer == viewer;
final forMe = decides(viewer);
// Decisions written in the space itself, the latest of each pair
// standing.
var status = 'open';
var hidden = false;
DateTime? decidedAt;
final ofThread = [
for (final decision in decisions)
if (decision.threadId == thread.id && uuidOf[decision.spaceId] == space)
decision,
]..sort((a, b) => a.id!.uuid.compareTo(b.id!.uuid));
for (final decision in ofThread) {
switch (decision.decision) {
case 'closed' || 'accepted' || 'declined':
status = decision.decision;
case 'reopened':
status = 'open';
case 'hidden':
hidden = true;
case 'shown':
hidden = false;
}
decidedAt = _timeOf(decision.id!);
}
if (hidden && !mineToo && !forMe) {
continue;
}
final replies = <FeedbackMessageView>[];
// When others than the viewer replied.
final othersReplied = <DateTime>[];
for (final message
in messages.where((message) => message.threadId == thread.id).toList()
..sort((a, b) => a.id!.uuid.compareTo(b.id!.uuid))) {
final by = uuidOf[message.spaceId];
final text = _passed(message.payload, sealed: message.keyId != null);
if (by == null || !roles.containsKey(by) || text == null) {
continue;
}
replies.add(
FeedbackMessageView(
id: message.id!,
by: personOf(by),
fromAuthor: decides(by),
payload: text,
keyId: message.keyId,
nonce: _base64(message.nonce),
createdAt: _timeOf(message.id!),
),
);
if (by != viewer) {
othersReplied.add(_timeOf(message.id!));
}
}
final createdAt = _timeOf(thread.id!);
// What others did last: what is new to the viewer.
final theirs = [
if (!mineToo) createdAt,
...othersReplied,
if (mineToo && !forMe) ?decidedAt,
];
final lastSeen = seen[thread.id];
final active = [
createdAt,
for (final reply in replies) reply.createdAt,
?decidedAt,
].reduce((a, b) => a.isAfter(b) ? a : b);
views.add((
active,
FeedbackView(
threadId: thread.id!,
publicId: space,
studysetTitle: '',
location: '',
kind: thread.kind,
writer: personOf(writer),
payload: payload,
space: space,
keyId: thread.keyId,
nonce: _base64(thread.nonce),
createdAt: createdAt,
messages: replies,
status: status,
hidden: hidden,
mine: mineToo,
forMe: forMe,
unseen:
(mineToo || forMe) &&
theirs.any((at) => lastSeen == null || at.isAfter(lastSeen)),
),
));
}
views.sort((a, b) => b.$1.compareTo(a.$1));
return [for (final (_, view) in views) view];
}
/// The kinds of thread within a shared space (D46).
const _spaceKinds = {'space-report', 'space-suggestion'};
/// A payload as a view passes it: sealed, in base64; readable, its JSON's
/// text; null for a blank one or one that isn't readable as it says.
String? _passed(ByteData payload, {required bool sealed}) => sealed
? (payload.lengthInBytes == 0 ? null : _base64(payload))
: _text(payload);
String? _base64(ByteData? bytes) =>
bytes == null ? null : base64Encode(Uint8List.sublistView(bytes));
/// The id of [person]'s own space; null while they have none.
Future<int?> _spaceOf(Session session, UuidValue person) async =>
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1

View file

@ -10,11 +10,13 @@ database: sync
fields:
### The thread replied in.
threadId: UuidValue
### The encryption key's id; null, plaintext, the only value (ADR 0002).
### The encryption key's id; null, plaintext, but in a private shared
### studyset (ADR 0002, ADR 0019).
keyId: String?
### Its content: UTF-8 JSON with a `schemaVersion` and its text.
payload: ByteData
### The encryption nonce; null, as for keyId.
### The encryption nonce, or a readable row's signature; null, as for
### keyId.
nonce: ByteData?
indexes:
# Deleting a space finds its rows through this index (see studyset.spy.yaml).

View file

@ -2,10 +2,15 @@
class: FeedbackMessageView
fields:
id: UuidValue
### Who wrote it, and whether that is the published studyset's author.
### Who wrote it, and whether that is the published studyset's author
### (in a shared space, its Owner or an Editor).
by: FeedbackPerson
fromAuthor: bool
### What it says: its payload as written.
### What it says: its payload as written; sealed (keyId), in base64.
payload: String
### The key a reply in a private shared studyset is sealed with, and its
### nonce in base64; null while readable.
keyId: String?
nonce: String?
### When, in UTC.
createdAt: DateTime

View file

@ -11,15 +11,20 @@ table: feedback_thread
database: sync
fields:
### The published studyset it is on (its public id): a column, for the
### server to gather by.
### server to gather by; for a thread in a shared space, the space's id
### (Halkyon's ADR 0044, D46).
publicationId: UuidValue
### 'report' or 'suggestion'; a kind this app doesn't know is skipped.
### 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
### shared space, sealed with its studyset's key where that is private.
### A kind an app doesn't know is skipped.
kind: String
### The encryption key's id; null, plaintext, the only value (ADR 0002).
### The encryption key's id; null, plaintext, but in a private shared
### studyset (ADR 0002, ADR 0019).
keyId: String?
### Its content: UTF-8 JSON with a `schemaVersion`.
payload: ByteData
### The encryption nonce; null, as for keyId.
### The encryption nonce, or a readable row's signature; null, as for
### keyId.
nonce: ByteData?
indexes:
# Deleting a space finds its rows through this index (see studyset.spy.yaml).

View file

@ -1,19 +1,28 @@
### A thread of reports and suggestions as the app shows it: on which
### published studyset, by whom, what it says (its payload's JSON), its
### replies, and where it stands.
### published studyset, or in which shared space (Halkyon's ADR 0044, D46),
### by whom, what it says (its payload's JSON, or sealed), its replies, and
### where it stands.
class: FeedbackView
fields:
threadId: UuidValue
### The published studyset: its public id, title and location.
### The published studyset: its public id, title and location; in a
### shared space, the space's id, and neither title nor location.
publicId: UuidValue
studysetTitle: String
location: String
### 'report' or 'suggestion'.
### 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
### shared space.
kind: String
### Who wrote it.
writer: FeedbackPerson
### What it says: its payload as written.
### What it says: its payload as written; sealed (keyId), in base64.
payload: String
### The shared space it is in (D46); null on a published studyset.
space: UuidValue?
### The key a thread in a private shared studyset is sealed with, and
### its nonce in base64; null while readable.
keyId: String?
nonce: String?
### When it was written, in UTC.
createdAt: DateTime
### Its replies, oldest first.
@ -22,7 +31,8 @@ fields:
status: String
### Whether the author hid it: then only its writer and the author see it.
hidden: bool
### Whether the caller wrote it; whether the caller is the author.
### Whether the caller wrote it; whether the caller is the author (in a
### shared space, its Owner or an Editor, who decide on it).
mine: bool
forMe: bool
### Whether something in it is new to the caller.

View file

@ -1041,6 +1041,25 @@ class Endpoints extends _is.EndpointDispatch {
params['publicId'],
),
),
'threadsInSpace': _is.MethodConnector(
name: 'threadsInSpace',
params: {
'space': _is.ParameterDescription(
name: 'space',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['feedback'] as _i3n4p88h.FeedbackEndpoint)
.threadsInSpace(
session,
params['space'],
),
),
},
);
connectors['inbox'] = _is.EndpointConnector(

View file

@ -15,7 +15,8 @@ import 'package:serverpod/serverpod.dart' as _is;
/// What a published studyset's author decided about a thread of reports and
/// suggestions (FeedbackThread): 'closed', 'reopened', 'hidden', 'shown',
/// 'accepted' or 'declined' (a suggestion). Written in the author's own
/// space, and only the author's count; the latest of each pair (by its id,
/// space, and only the author's count; in a shared space, written in that
/// space, where only its Owner and Editors write (Halkyon's ADR 0044, D46); the latest of each pair (by its id,
/// a UUID v7) stands. Never changed. Synced.
abstract class FeedbackDecision
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {

View file

@ -69,13 +69,15 @@ abstract class FeedbackMessage
/// The thread replied in.
_is.UuidValue threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -254,13 +256,15 @@ class FeedbackMessageTable extends _is.Table<_is.UuidValue?> {
/// The thread replied in.
late final _is.ColumnUuid threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _is.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
late final _is.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _is.ColumnByteData nonce;
@override

View file

@ -22,6 +22,8 @@ abstract class FeedbackMessageView
required this.by,
required this.fromAuthor,
required this.payload,
this.keyId,
this.nonce,
required this.createdAt,
});
@ -30,6 +32,8 @@ abstract class FeedbackMessageView
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) = _FeedbackMessageViewImpl;
@ -43,6 +47,8 @@ abstract class FeedbackMessageView
jsonSerialization['fromAuthor'],
),
payload: jsonSerialization['payload'] as String,
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -51,14 +57,21 @@ abstract class FeedbackMessageView
_is.UuidValue id;
/// Who wrote it, and whether that is the published studyset's author.
/// Who wrote it, and whether that is the published studyset's author
/// (in a shared space, its Owner or an Editor).
_i4q3oto5.FeedbackPerson by;
bool fromAuthor;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The key a reply in a private shared studyset is sealed with, and its
/// nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When, in UTC.
DateTime createdAt;
@ -70,6 +83,8 @@ abstract class FeedbackMessageView
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
String? keyId,
String? nonce,
DateTime? createdAt,
});
@override
@ -80,6 +95,8 @@ abstract class FeedbackMessageView
'by': by.toJson(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -92,6 +109,8 @@ abstract class FeedbackMessageView
'by': by.toJsonForProtocol(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -102,18 +121,24 @@ abstract class FeedbackMessageView
}
}
class _Undefined {}
class _FeedbackMessageViewImpl extends FeedbackMessageView {
_FeedbackMessageViewImpl({
required _is.UuidValue id,
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) : super._(
id: id,
by: by,
fromAuthor: fromAuthor,
payload: payload,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
);
@ -126,6 +151,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
}) {
return FeedbackMessageView(
@ -133,6 +160,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
by: by ?? this.by.copyWith(),
fromAuthor: fromAuthor ?? this.fromAuthor,
payload: payload ?? this.payload,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
);
}

View file

@ -72,19 +72,24 @@ abstract class FeedbackThread
int? spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
_is.UuidValue publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
String kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -278,19 +283,24 @@ class FeedbackThreadTable extends _is.Table<_is.UuidValue?> {
late final _is.ColumnInt spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
late final _is.ColumnUuid publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
late final _is.ColumnString kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _is.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
late final _is.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _is.ColumnByteData nonce;
@override

View file

@ -16,8 +16,9 @@ import '../feedback/feedback_message_view.dart' as _irmjyw1o;
import '../feedback/feedback_person.dart' as _i4q3oto5;
/// A thread of reports and suggestions as the app shows it: on which
/// published studyset, by whom, what it says (its payload's JSON), its
/// replies, and where it stands.
/// published studyset, or in which shared space (Halkyon's ADR 0044, D46),
/// by whom, what it says (its payload's JSON, or sealed), its replies, and
/// where it stands.
abstract class FeedbackView
implements _is.SerializableModel, _is.ProtocolSerialization {
FeedbackView._({
@ -28,6 +29,9 @@ abstract class FeedbackView
required this.kind,
required this.writer,
required this.payload,
this.space,
this.keyId,
this.nonce,
required this.createdAt,
required this.messages,
required this.status,
@ -45,6 +49,9 @@ abstract class FeedbackView
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_is.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -69,6 +76,11 @@ abstract class FeedbackView
jsonSerialization['writer'],
),
payload: jsonSerialization['payload'] as String,
space: jsonSerialization['space'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['space']),
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -86,22 +98,33 @@ abstract class FeedbackView
_is.UuidValue threadId;
/// The published studyset: its public id, title and location.
/// The published studyset: its public id, title and location; in a
/// shared space, the space's id, and neither title nor location.
_is.UuidValue publicId;
String studysetTitle;
String location;
/// 'report' or 'suggestion'.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space.
String kind;
/// Who wrote it.
_i4q3oto5.FeedbackPerson writer;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The shared space it is in (D46); null on a published studyset.
_is.UuidValue? space;
/// The key a thread in a private shared studyset is sealed with, and
/// its nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When it was written, in UTC.
DateTime createdAt;
@ -114,7 +137,8 @@ abstract class FeedbackView
/// Whether the author hid it: then only its writer and the author see it.
bool hidden;
/// Whether the caller wrote it; whether the caller is the author.
/// Whether the caller wrote it; whether the caller is the author (in a
/// shared space, its Owner or an Editor, who decide on it).
bool mine;
bool forMe;
@ -133,6 +157,9 @@ abstract class FeedbackView
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
_is.UuidValue? space,
String? keyId,
String? nonce,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -152,6 +179,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJson(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJson()),
'status': status,
@ -173,6 +203,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJsonForProtocol(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'status': status,
@ -189,6 +222,8 @@ abstract class FeedbackView
}
}
class _Undefined {}
class _FeedbackViewImpl extends FeedbackView {
_FeedbackViewImpl({
required _is.UuidValue threadId,
@ -198,6 +233,9 @@ class _FeedbackViewImpl extends FeedbackView {
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_is.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -213,6 +251,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind,
writer: writer,
payload: payload,
space: space,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
messages: messages,
status: status,
@ -234,6 +275,9 @@ class _FeedbackViewImpl extends FeedbackView {
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
Object? space = _Undefined,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -250,6 +294,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind ?? this.kind,
writer: writer ?? this.writer.copyWith(),
payload: payload ?? this.payload,
space: space is _is.UuidValue? ? space : this.space,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
messages: messages ?? this.messages.map((e0) => e0.copyWith()).toList(),
status: status ?? this.status,

View file

@ -42,6 +42,7 @@ learningCount:
- reportCopies:
feedback:
- threadsOf:
- threadsInSpace:
inbox:
- inbox:
- unseen: