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
2 changed files with 2949 additions and 427 deletions

View file

@ -2,22 +2,35 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// A [FeedbackWriteGateway] in memory: what was written, in order.
class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
/// The threads written: their published studyset, kind and payload JSON.
/// The threads written: their published studyset, kind and payload JSON
/// (opened, where [FakeFeedbackEnvelopes] sealed it), and within a shared
/// space, the space, its row id and its key.
final threads =
<
({String publicationId, FeedbackKind kind, Map<String, Object?> json})
({
String publicationId,
FeedbackKind kind,
Map<String, Object?> json,
String? space,
String? id,
String? keyId,
})
>[];
/// The replies written, by thread.
final messages = <({String threadId, Map<String, Object?> json})>[];
/// The replies written, by thread, with their key.
final messages =
<({String threadId, Map<String, Object?> json, String? keyId})>[];
/// The decisions written, by thread.
final decisions = <({String threadId, FeedbackDecision decision})>[];
/// The decisions written, by thread, and the shared space they went in.
final decisions =
<({String threadId, FeedbackDecision decision, String? space})>[];
/// When set, writing a decision fails with it.
Exception? failDecisions;
@ -30,29 +43,43 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
String? space,
String? id,
String? keyId,
ByteData? nonce,
}) async {
threads.add((
publicationId: publicationId,
kind: kind,
json: _json(payload),
space: space,
id: id,
keyId: keyId,
));
return 'thread-${threads.length}';
return id ?? 'thread-${threads.length}';
}
@override
Future<void> insertMessage(String threadId, ByteData payload) async =>
messages.add((threadId: threadId, json: _json(payload)));
Future<void> insertMessage(
String threadId,
ByteData payload, {
String? id,
String? keyId,
ByteData? nonce,
}) async =>
messages.add((threadId: threadId, json: _json(payload), keyId: keyId));
@override
Future<void> insertDecision(
String threadId,
FeedbackDecision decision,
) async {
FeedbackDecision decision, {
String? space,
}) async {
await holdDecisions?.future;
if (failDecisions case final error?) {
throw error;
}
decisions.add((threadId: threadId, decision: decision));
decisions.add((threadId: threadId, decision: decision, space: space));
}
static Map<String, Object?> _json(ByteData payload) =>
@ -96,6 +123,15 @@ class FakeFeedbackServerGateway({
];
}
@override
Future<List<FeedbackThread>> threadsInSpace(String space) async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.space == space) thread,
];
}
@override
Future<List<FeedbackThread>> inbox() async {
await _maybeFail();
@ -131,3 +167,61 @@ class FakeFeedbackServerGateway({
return files[sha256]!;
}
}
/// [FeedbackEnvelopes] in memory: the studysets [spaces] holds are shared
/// in those spaces, those [keys] holds private under those keys; sealing
/// marks a payload with its key and leaves it readable, so tests read what
/// was written, and opening refuses a key not in [keys]'s values or a row
/// sealed for another.
class FakeFeedbackEnvelopes({
final Map<String, String> spaces = const {},
final Map<String, String> keys = const {},
final Map<String, String> titles = const {},
}) implements FeedbackEnvelopes {
var _ids = 0;
@override
Future<String?> spaceOf(String studysetId) async => spaces[studysetId];
@override
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
@override
String newId() => 'row-${++_ids}';
@override
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
required String bound,
}) => (
keyId: keyId,
nonce: keyId == null
? null
: ByteData.sublistView(utf8.encode('$table|$id|$bound')),
payload: plain,
);
@override
ByteData? open(
Envelope envelope, {
required String table,
required String id,
required String bound,
}) {
if (envelope.keyId case final keyId?) {
final nonce = envelope.nonce;
if (!keys.containsValue(keyId) ||
nonce == null ||
utf8.decode(Uint8List.sublistView(nonce)) != '$table|$id|$bound') {
return null;
}
}
return envelope.payload;
}
@override
Future<String> titleOf(String studysetId) async => titles[studysetId] ?? '';
}

View file

@ -39,6 +39,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repositor
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
@ -162,6 +163,7 @@ Dependencies testDependencies({
ExploreGateway? exploreGateway,
FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway,
FeedbackEnvelopes? feedbackEnvelopes,
DiscussionGateway? discussionGateway,
SharedSpaceGateway? sharedSpaces,
SharedKeys? sharedKeys,
@ -414,6 +416,7 @@ Dependencies testDependencies({
notes,
paths,
assets,
feedbackEnvelopes ?? FakeFeedbackEnvelopes(),
),
discussions: DiscussionRepository(
discussionGateway ?? FakeDiscussionGateway(),