perf(learn): discussions and feedback show what the person does at once

In a discussion, accepting an answer (or none), hiding or showing a post
and deleting one waited for the instance with every action disabled
(busy). DiscussionBloc now shows the discussion as the action leaves it
at once (Discussion.withAccepted, withPost, DiscussionPost.copyWith), the
actions staying available, and puts it back as it was where the instance
refuses (the refusal's snack bar as before) or can't be reached, then
reads it anew. Answering, commenting and editing still wait, as what
the instance makes of them is theirs to say.

In the feedback inbox, a thread opened shows read at once, a failure to
record it said to nobody (the next read shows the truth), and a decision
(closing, reopening, hiding, showing, declining) shows at once and is
put back with the failure's snack bar where it isn't written
(FeedbackThread.seen, decided). Accepting a suggestion still waits, as
it changes the author's content and can fail with SuggestionNotTaken.

Whats-New: Accepting, hiding and deleting in discussions, and closing or declining feedback, show at once.
Whats-New-De: Annehmen, Ausblenden und Löschen in Diskussionen sowie Schließen oder Ablehnen von Rückmeldungen zeigen sich sofort.
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-07 23:13:42 +01:00
commit 107e6f9913
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 416 additions and 11 deletions

View file

@ -12,7 +12,10 @@ import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.da
/// comments, and what the person does in it: answering, commenting,
/// editing and deleting their own, accepting an answer to their question,
/// and, as the studyset's author, hiding posts and blocking people.
/// Reading it marks it read; every action reads it anew.
/// Reading it marks it read; every action reads it anew. Accepting,
/// hiding, showing and deleting show at once (optimistic state), and as
/// they were again where the instance refuses or can't be reached;
/// writing waits for the instance, [DiscussionState.busy] meanwhile.
class DiscussionBloc(
final DiscussionRepository _discussions, {
required final String _questionId,
@ -62,6 +65,10 @@ class DiscussionBloc(
emit,
() => _discussions.delete(event.postId),
DiscussionOutcome.deleted,
shown: (discussion) => discussion.withPost(
event.postId,
(post) => post.copyWith(deleted: true),
),
),
);
on<DiscussionEventAccepted>(
@ -71,6 +78,7 @@ class DiscussionBloc(
event.answerId == null
? DiscussionOutcome.unaccepted
: DiscussionOutcome.accepted,
shown: (discussion) => discussion.withAccepted(event.answerId),
),
);
on<DiscussionEventHidden>(
@ -78,6 +86,10 @@ class DiscussionBloc(
emit,
() => _discussions.hide(event.postId, hidden: event.hidden),
event.hidden ? DiscussionOutcome.hidden : DiscussionOutcome.shown,
shown: (discussion) => discussion.withPost(
event.postId,
(post) => post.copyWith(hidden: event.hidden),
),
),
);
on<DiscussionEventBlocked>(
@ -106,12 +118,28 @@ class DiscussionBloc(
}
/// Does [action], then reads the discussion anew, saying how it went.
///
/// Given what the action makes of the discussion ([shown]), it shows that
/// at once rather than being busy until the instance answers, and the
/// discussion as it was again where the action fails.
Future<void> _act(
Emitter<DiscussionState> emit,
Future<void> Function() action,
DiscussionOutcome outcome,
) async {
emit(state.copyWith(busy: true));
DiscussionOutcome outcome, {
Discussion Function(Discussion discussion)? shown,
}) async {
final before = state.discussion;
if (shown != null && before != null) {
emit(state.copyWith(discussion: shown(before)));
} else {
emit(state.copyWith(busy: true));
}
void putBack() {
if (shown != null && before != null) {
emit(state.copyWith(discussion: before));
}
}
try {
await action();
await _load(emit);
@ -124,6 +152,7 @@ class DiscussionBloc(
),
);
} on DiscussionRefusedException catch (error) {
putBack();
emit(
state.copyWith(
busy: false,
@ -134,6 +163,7 @@ class DiscussionBloc(
);
} on Object catch (error) {
log('Acting in a discussion failed', name: 'Discussions', error: error);
putBack();
await _load(emit);
emit(
state.copyWith(

View file

@ -132,6 +132,24 @@ final class const DiscussionPost({
final bool hidden = false,
final bool mine = false,
}) with Equatable {
/// This post deleted, or hidden or shown again, as the instance will
/// have it once it has done so.
DiscussionPost copyWith({bool? deleted, bool? hidden}) => DiscussionPost(
id: id,
kind: kind,
createdAt: createdAt,
parentId: parentId,
by: by,
fromAuthor: fromAuthor,
title: title,
content: content,
text: text,
editedAt: editedAt,
deleted: deleted ?? this.deleted,
hidden: hidden ?? this.hidden,
mine: mine,
);
@override
List<Object?> get props => [
id,
@ -169,6 +187,56 @@ final class const Discussion({
final bool author = false,
final DiscussionRefusal? refusal,
}) with Equatable {
/// This discussion with [answerId] accepted (none where null), the
/// accepted answer first and the others oldest first, as the instance
/// will have it once it has done so: shown before it has (optimistic
/// state).
Discussion withAccepted(String? answerId) => _with(
answers: [...answers]
..sort((a, b) {
if ((a.id == answerId) != (b.id == answerId)) {
return a.id == answerId ? -1 : 1;
}
return a.createdAt.compareTo(b.createdAt);
}),
acceptedAnswerId: answerId,
);
/// This discussion with the post [postId] (the question, an answer or a
/// comment) changed by [change], as [withAccepted] shows it.
Discussion withPost(
String postId,
DiscussionPost Function(DiscussionPost post) change,
) {
DiscussionPost changed(DiscussionPost post) =>
post.id == postId ? change(post) : post;
return _with(
question: changed(question),
answers: [for (final answer in answers) changed(answer)],
comments: [for (final comment in comments) changed(comment)],
acceptedAnswerId: acceptedAnswerId,
);
}
Discussion _with({
required String? acceptedAnswerId,
DiscussionPost? question,
List<DiscussionPost>? answers,
List<DiscussionPost>? comments,
}) => Discussion(
question: question ?? this.question,
publicId: publicId,
studysetTitle: studysetTitle,
location: location,
target: target,
answers: answers ?? this.answers,
comments: comments ?? this.comments,
acceptedAnswerId: acceptedAnswerId,
asker: asker,
author: author,
refusal: refusal,
);
/// The comments on [postId], the question or an answer, oldest first.
List<DiscussionPost> commentsOn(String postId) => [
for (final comment in comments)

View file

@ -8,7 +8,11 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// A list of threads of reports and suggestions: the person's inbox (no
/// [_publicId]), or those on the published studyset [_publicId]; and what
/// the person does in them. Every action reads the list anew.
/// the person does in them. Every action reads the list anew. Opening a
/// thread shows it read at once, and deciding on one (closing, hiding,
/// declining) shows the decision at once, as it was again where the
/// instance can't record it (optimistic state); accepting a suggestion,
/// which changes the person's content and may not take, waits for it.
class FeedbackListBloc(
final FeedbackRepository _feedback, {
final String? _publicId,
@ -30,7 +34,16 @@ class FeedbackListBloc(
}
}
if (thread.unseen) {
await _act(emit, () => _feedback.markSeen(thread));
// Read at once; where the instance doesn't record it, it says so
// as the list is next read, and nobody need be told.
emit(state.copyWith(threads: _replaced(thread.seen)));
try {
await _feedback.markSeen(thread);
} on Object catch (error) {
log('Marking read failed', name: 'Feedback', error: error);
return;
}
await _load(emit);
}
});
on<FeedbackListEventReplied>(
@ -45,6 +58,7 @@ class FeedbackListBloc(
emit,
() => _feedback.decide(event.thread, event.decision),
outcome: FeedbackOutcome.decided,
shown: event.thread.decided(event.decision),
),
);
on<FeedbackListEventAccepted>(
@ -82,20 +96,41 @@ class FeedbackListBloc(
}
}
/// The threads with [thread] in place of the one of its ID.
List<FeedbackThread> _replaced(FeedbackThread thread) => [
for (final each in state.threads)
if (each.threadId == thread.threadId) thread else each,
];
/// Does [action], says [outcome] once it is done, and reads the list
/// anew; says what went wrong otherwise.
/// anew; says what went wrong otherwise. Given the thread as the action
/// leaves it ([shown]), shows it so at once, and as it was again where
/// the action fails.
Future<void> _act(
Emitter<FeedbackListState> emit,
Future<void> Function() action, {
FeedbackOutcome? outcome,
FeedbackThread? shown,
}) async {
final before = shown == null ? null : state.byId(shown.threadId);
if (shown != null) {
emit(state.copyWith(threads: _replaced(shown)));
}
void putBack() {
if (before != null) {
emit(state.copyWith(threads: _replaced(before)));
}
}
try {
await action();
} on SuggestionNotTaken {
putBack();
emit(state.copyWith(outcome: FeedbackOutcome.notTaken));
return;
} on Object catch (error) {
log('Acting on feedback failed', name: 'Feedback', error: error);
putBack();
emit(state.copyWith(outcome: FeedbackOutcome.failed));
return;
}
@ -136,14 +171,15 @@ final class const FeedbackListState({
final Map<String, Map<String, Object?>?> current = const {},
final FeedbackOutcome? outcome,
}) with Equatable {
/// This state with [current] as given, and [outcome] (cleared unless
/// given).
/// This state with [threads] and [current] as given, and [outcome]
/// (cleared unless given).
FeedbackListState copyWith({
List<FeedbackThread>? threads,
Map<String, Map<String, Object?>?>? current,
FeedbackOutcome? outcome,
}) => FeedbackListState(
status: status,
threads: threads,
threads: threads ?? this.threads,
current: current ?? this.current,
outcome: outcome,
);

View file

@ -101,6 +101,43 @@ final class const FeedbackThread({
/// Whether something in it is new to the person.
final bool unseen = false,
}) with Equatable {
/// This thread read by the person, as the instance will have it once it
/// has recorded so.
FeedbackThread get seen => _with(unseen: false);
/// This thread with [decision] taken, as the instance will have it once
/// it has recorded it: shown before it has (optimistic state).
FeedbackThread decided(FeedbackDecision decision) => switch (decision) {
FeedbackDecision.closed => _with(status: FeedbackStatus.closed),
FeedbackDecision.reopened => _with(status: FeedbackStatus.open),
FeedbackDecision.hidden => _with(hidden: true),
FeedbackDecision.shown => _with(hidden: false),
FeedbackDecision.accepted => _with(status: FeedbackStatus.accepted),
FeedbackDecision.declined => _with(status: FeedbackStatus.declined),
};
FeedbackThread _with({FeedbackStatus? status, bool? hidden, bool? unseen}) =>
FeedbackThread(
threadId: threadId,
publicId: publicId,
studysetTitle: studysetTitle,
location: location,
kind: kind,
writer: writer,
target: target,
createdAt: createdAt,
category: category,
body: body,
proposed: proposed,
files: files,
messages: messages,
status: status ?? this.status,
hidden: hidden ?? this.hidden,
mine: mine,
forMe: forMe,
unseen: unseen ?? this.unseen,
);
@override
List<Object?> get props => [
threadId,

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
@ -287,6 +289,86 @@ void main() {
);
});
group('accepting, hiding and deleting show at once', () {
const bob = PublicPerson(handle: 'bob');
late FakeDiscussionGateway gateway;
late DiscussionBloc bloc;
final answer = DiscussionPost(
id: 'a1',
kind: DiscussionPostKind.answer,
createdAt: DateTime.utc(2026, 10, 16),
by: const PublicPerson(handle: 'ann'),
content: RichContent.plain('Topic marker.'),
);
Future<void> outcome(DiscussionOutcome outcome) => expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.outcome,
'outcome',
outcome,
),
),
);
setUp(() async {
gateway = FakeDiscussionGateway(me: bob, author: true);
final question = gateway
.addQuestion('Meaning of は', answers: [answer])
.question
.id;
bloc = blocOf(gateway, question)..add(const DiscussionEventLoaded());
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.status,
'status',
DiscussionStatus.ready,
),
),
);
});
test(
'accepted before the instance answers, nothing waiting on it',
() async {
final hold = gateway.hold = Completer<void>();
bloc.add(const DiscussionEventAccepted('a1'));
await pumpEventQueue();
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
expect(bloc.state.busy, isFalse);
hold.complete();
await outcome(DiscussionOutcome.accepted);
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
},
);
test('the instance out of reach, an answer accepted, a post hidden or '
'deleted is as it was again, and the person told', () async {
final hold = gateway.hold = Completer<void>();
gateway.fail = Exception('offline');
bloc.add(const DiscussionEventAccepted('a1'));
await pumpEventQueue();
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
hold.complete();
await outcome(DiscussionOutcome.failed);
expect(bloc.state.discussion!.acceptedAnswerId, isNull);
bloc.add(const DiscussionEventHidden('a1', hidden: true));
await outcome(DiscussionOutcome.failed);
expect(bloc.state.discussion!.answers.single.hidden, isFalse);
bloc.add(const DiscussionEventDeleted('a1'));
await outcome(DiscussionOutcome.failed);
expect(bloc.state.discussion!.answers.single.deleted, isFalse);
});
});
test('a discussion gone or not the person’s to see is missing', () async {
final bloc = blocOf(FakeDiscussionGateway(), 'nothing')
..add(const DiscussionEventLoaded());

View file

@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/feedback_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
/// The author's inbox shows what they do at once: a thread read, a
/// decision taken; accepting a suggestion waits, as it changes content.
void main() {
late FakeFeedbackWriteGateway written;
late FakeFeedbackServerGateway server;
late FeedbackListBloc bloc;
final report = FeedbackThread(
threadId: 'thread-1',
publicId: 'pub-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.report,
writer: const PublicPerson(handle: 'bob'),
target: const FeedbackTarget(
kind: FeedbackTargetKind.card,
item: 'card-1',
version: 2,
),
createdAt: DateTime.utc(2026, 10, 9),
category: ReportCategory.typo,
body: '猫 is misspelt.',
forMe: true,
unseen: true,
);
FeedbackThread shown() => bloc.state.byId('thread-1')!;
setUp(() async {
written = FakeFeedbackWriteGateway();
server = FakeFeedbackServerGateway(threads: [report]);
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
FakeAssetSealing(),
);
addTearDown(assets.dispose);
bloc = FeedbackListBloc(
FeedbackRepository(
written,
server,
CardRepository(FakeCardGateway()),
NoteRepository(FakeNoteGateway()),
LearningPathRepository(FakeLearningPathGateway()),
assets,
),
)..add(const FeedbackListEventLoaded());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emitsThrough(
isA<FeedbackListState>().having(
(state) => state.status,
'status',
FeedbackListStatus.ready,
),
),
);
});
test('a thread opened is read at once; where the instance fails to '
'record it, nobody is told', () async {
final hold = server.holdSeen = Completer<void>();
server.fail = Exception('offline');
bloc.add(FeedbackListEventOpened(report));
await pumpEventQueue();
expect(shown().unseen, isFalse);
hold.complete();
await pumpEventQueue();
expect(bloc.state.outcome, isNull);
});
test('a thread closed shows closed before the decision is written; one '
"that isn't written is open again, and the person told", () async {
final hold = written.holdDecisions = Completer<void>();
bloc.add(FeedbackListEventDecided(report, FeedbackDecision.closed));
await pumpEventQueue();
expect(shown().status, FeedbackStatus.closed);
expect(written.decisions, isEmpty);
hold.complete();
await pumpEventQueue();
expect(written.decisions.single.decision, FeedbackDecision.closed);
written
..holdDecisions = null
..failDecisions = Exception('disk full');
bloc.add(FeedbackListEventDecided(report, FeedbackDecision.hidden));
await expectLater(
bloc.stream,
emitsThrough(
isA<FeedbackListState>().having(
(state) => state.outcome,
'outcome',
FeedbackOutcome.failed,
),
),
);
expect(shown().hidden, isFalse);
});
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
@ -17,6 +19,10 @@ class FakeDiscussionGateway({
/// Thrown by every call while set: the instance can't be reached.
Exception? fail;
/// While set, deleting, accepting and hiding wait for it to complete, so
/// that a test sees what shows before the instance answers.
Completer<void>? hold;
/// The questions, by id, with what is in them.
final questions = <String, Discussion>{};
@ -232,12 +238,14 @@ class FakeDiscussionGateway({
@override
Future<void> delete(String postId) async {
await hold?.future;
_failing();
questions.remove(postId);
}
@override
Future<void> accept(String questionId, String? answerId) async {
await hold?.future;
_failing();
final discussion = questions[questionId] ?? (throw DiscussionMissing());
questions[questionId] = _with(
@ -253,6 +261,7 @@ class FakeDiscussionGateway({
@override
Future<void> hide(String postId, {required bool hidden}) async {
await hold?.future;
_failing();
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
@ -18,6 +19,12 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
/// The decisions written, by thread.
final decisions = <({String threadId, FeedbackDecision decision})>[];
/// When set, writing a decision fails with it.
Exception? failDecisions;
/// While set, writing a decision waits for it to complete.
Completer<void>? holdDecisions;
@override
Future<String> insertThread({
required String publicationId,
@ -40,7 +47,13 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
Future<void> insertDecision(
String threadId,
FeedbackDecision decision,
) async => decisions.add((threadId: threadId, decision: decision));
) async {
await holdDecisions?.future;
if (failDecisions case final error?) {
throw error;
}
decisions.add((threadId: threadId, decision: decision));
}
static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
@ -62,6 +75,9 @@ class FakeFeedbackServerGateway({
/// When set, every request fails with it.
Exception? fail;
/// While set, marking a thread read waits for it to complete.
Completer<void>? holdSeen;
/// Replaces what the instance gathers.
set gathered(List<FeedbackThread> threads) => _threads = [...threads];
@ -95,6 +111,7 @@ class FakeFeedbackServerGateway({
@override
Future<void> markSeen(String threadId) async {
await holdSeen?.future;
await _maybeFail();
seen.add(threadId);
}