feat(explore): your own studyset is yours, not one to add

Explore offered Add on the author's own published studyset and made a
whole second copy of it beside the original. The instance now names
the studyset an entry was published from to its author alone
(`yourStudysetId`, never to anyone else), and Explore shows it as Yours,
opening the original, while it is in the library. The summary still
adds a copy anyway, to see it as learners get it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-16 22:34:28 +02:00
commit d3ece5c528
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 263 additions and 17 deletions

File diff suppressed because one or more lines are too long

View file

@ -41,6 +41,7 @@ abstract class ExploreEntry
required this.pathCount,
required this.pdfCount,
this.learners,
this.yourStudysetId,
});
factory ExploreEntry({
@ -68,6 +69,7 @@ abstract class ExploreEntry
required int pathCount,
required int pdfCount,
int? learners,
_isc.UuidValue? yourStudysetId,
}) = _ExploreEntryImpl;
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -100,6 +102,11 @@ abstract class ExploreEntry
pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int,
learners: jsonSerialization['learners'] as int?,
yourStudysetId: jsonSerialization['yourStudysetId'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['yourStudysetId'],
),
);
}
@ -163,6 +170,11 @@ abstract class ExploreEntry
/// null for fewer than 5, so that nobody can be told apart.
int? learners;
/// For its author asking, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
/// everyone else.
_isc.UuidValue? yourStudysetId;
/// Returns a shallow copy of this [ExploreEntry]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -191,6 +203,7 @@ abstract class ExploreEntry
int? pathCount,
int? pdfCount,
int? learners,
_isc.UuidValue? yourStudysetId,
});
@override
Map<String, dynamic> toJson() {
@ -220,6 +233,7 @@ abstract class ExploreEntry
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
};
}
@ -251,6 +265,7 @@ abstract class ExploreEntry
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
};
}
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
required int pathCount,
required int pdfCount,
int? learners,
_isc.UuidValue? yourStudysetId,
}) : super._(
publicId: publicId,
location: location,
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount,
pdfCount: pdfCount,
learners: learners,
yourStudysetId: yourStudysetId,
);
/// Returns a shallow copy of this [ExploreEntry]
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
int? pathCount,
int? pdfCount,
Object? learners = _Undefined,
Object? yourStudysetId = _Undefined,
}) {
return ExploreEntry(
publicId: publicId ?? this.publicId,
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount,
learners: learners is int? ? learners : this.learners,
yourStudysetId: yourStudysetId is _isc.UuidValue?
? yourStudysetId
: this.yourStudysetId,
);
}
}

View file

@ -1697,6 +1697,8 @@
"exploreAdd": "Hinzufügen",
"exploreInLibraryShort": "In Bibliothek",
"exploreInLibrary": "In deiner Bibliothek",
"exploreYours": "Deins",
"exploreAddCopyAnyway": "Trotzdem eine Kopie hinzufügen",
"explorePercent": "{percent} %",
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.",
"exploreOpen": "Öffnen",

View file

@ -1997,6 +1997,8 @@
"exploreAdd": "Add",
"exploreInLibraryShort": "In library",
"exploreInLibrary": "In your library",
"exploreYours": "Yours",
"exploreAddCopyAnyway": "Add a copy anyway",
"explorePercent": "{percent} %",
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}},
"exploreAdded": "Added to your library.",

View file

@ -10047,6 +10047,18 @@ abstract class AppLocalizations {
/// **'In your library'**
String get exploreInLibrary;
/// No description provided for @exploreYours.
///
/// In en, this message translates to:
/// **'Yours'**
String get exploreYours;
/// No description provided for @exploreAddCopyAnyway.
///
/// In en, this message translates to:
/// **'Add a copy anyway'**
String get exploreAddCopyAnyway;
/// No description provided for @explorePercent.
///
/// In en, this message translates to:

View file

@ -6333,6 +6333,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get exploreInLibrary => 'In deiner Bibliothek';
@override
String get exploreYours => 'Deins';
@override
String get exploreAddCopyAnyway => 'Trotzdem eine Kopie hinzufügen';
@override
String explorePercent(int percent) {
return '$percent %';

View file

@ -6276,6 +6276,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get exploreInLibrary => 'In your library';
@override
String get exploreYours => 'Yours';
@override
String get exploreAddCopyAnyway => 'Add a copy anyway';
@override
String explorePercent(int percent) {
return '$percent %';

View file

@ -35,8 +35,9 @@ class ExploreBloc(final ExploreRepository _explore)
),
);
try {
final (copies, (:page, :hasMore)) = await (
final (copies, held, (:page, :hasMore)) = await (
_explore.copies(),
_explore.held(),
_explore.list(event.filter),
).wait;
if (query != _query) {
@ -48,6 +49,7 @@ class ExploreBloc(final ExploreRepository _explore)
studysets: page,
hasMore: hasMore,
copies: copies,
held: held,
),
);
} on Object catch (error) {
@ -178,7 +180,9 @@ final class const ExploreNotWritten() extends ExploreOutcome;
/// Explore as its screen shows it: the [filter] and what answers it, a
/// page at a time, which of them the person has a copy of ([copies], by
/// public id, the copy's top studyset), which are being added and how far
/// public id, the copy's top studyset), which studysets are in their
/// library ([held]: one of theirs Explore lists opens rather than being
/// added again), which are being added and how far
/// ([adding], null before the first step), and what the last request came
/// to ([outcome], cleared by the next state).
final class const ExploreState({
@ -188,9 +192,17 @@ final class const ExploreState({
final bool hasMore = false,
final bool loadingMore = false,
final Map<String, String> copies = const {},
final Set<String> held = const {},
final Map<String, double?> adding = const {},
final ExploreOutcome? outcome,
}) with Equatable {
/// The person's own studyset [studyset] was published from, while it is
/// in their library; null for anyone else's.
String? yoursOf(ExploreStudyset studyset) => switch (studyset.yours) {
final id? when held.contains(id) => id,
_ => null,
};
/// This state with what is given changed, and no outcome unless given.
ExploreState copyWith({
ExploreStatus? status,
@ -199,6 +211,7 @@ final class const ExploreState({
bool? hasMore,
bool? loadingMore,
Map<String, String>? copies,
Set<String>? held,
Map<String, double?>? adding,
ExploreOutcome? outcome,
}) => ExploreState(
@ -208,6 +221,7 @@ final class const ExploreState({
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
copies: copies ?? this.copies,
held: held ?? this.held,
adding: adding ?? this.adding,
outcome: outcome,
);
@ -220,6 +234,7 @@ final class const ExploreState({
hasMore,
loadingMore,
copies,
held,
adding,
outcome,
];

View file

@ -46,6 +46,12 @@ class ExploreRepository(
publication: studyset.id,
};
/// The ids of the person's studysets in their library, those deleted
/// left out: whether one Explore names as theirs is still there.
Future<Set<String>> held() async => {
for (final studyset in await _studysets.all()) studyset.id,
};
/// What the latest release of the published studyset the person's copy
/// [rootId] (its top studyset) came from would bring it; null while the
/// copy holds the latest, and for a studyset its author withdrew, whose

View file

@ -188,6 +188,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
paths: entry.pathCount,
pdfs: entry.pdfCount,
learners: entry.learners,
yours: entry.yourStudysetId?.uuid,
);
/// A description as the instance keeps it, its rich text's JSON; empty

View file

@ -52,6 +52,11 @@ final class const ExploreStudyset({
/// How many people learn it here, rounded (5, 10, 20, 50, …); null for
/// fewer than 5 (ADR 0005: shown, never ranked).
final int? learners,
/// For its author, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy); null for
/// everyone else, and from instances before v0.18.
final String? yours,
}) with Equatable {
/// Its author as they are shown: their display name, else their handle.
String get author => displayName.isEmpty ? '@$handle' : displayName;
@ -82,6 +87,7 @@ final class const ExploreStudyset({
paths,
pdfs,
learners,
yours,
];
}

View file

@ -626,9 +626,11 @@ class const LicenseChip({required final String license, super.key})
}
/// Add to library for [studyset]: its progress while it is being added, In
/// your library (opening the copy) once there is a copy. [wide] in the
/// summary sheet, which either closes; [compact] on a card, as Add and In
/// library, leaving the card's line its room.
/// your library (opening the copy) once there is a copy, and Yours (opening
/// it) for the person's own (decided 2026-10-16), the summary also adding a
/// copy anyway, to see it as learners get it. [wide] in the summary sheet,
/// which either closes; [compact] on a card, as Add and In library, leaving
/// the card's line its room.
class const ExploreAddButton({
required final ExploreStudyset studyset,
required final ExploreState state,
@ -647,6 +649,48 @@ class const ExploreAddButton({
padding: const EdgeInsets.symmetric(horizontal: 12),
)
: null;
void add() {
final bloc = context.read<ExploreBloc>();
// From the summary, it goes on in the list: its progress shows on
// the studyset's card, and what came of it below the list.
if (wide) {
Navigator.of(context).pop();
}
bloc.add(ExploreEventAddRequested(studyset));
}
if (state.yoursOf(studyset) case final own?) {
final open = TextButton.icon(
style: wide
? TextButton.styleFrom(minimumSize: const Size.fromHeight(48))
: compact
? TextButton.styleFrom(visualDensity: VisualDensity.compact)
: null,
onPressed: () {
final router = GoRouter.of(context);
if (wide) {
Navigator.of(context).pop();
}
router.go(RoutePaths.studyset(own));
},
icon: const Icon(LucideIcons.user),
label: Text(l10n.exploreYours),
);
if (!wide || state.copies.containsKey(studyset.publicId)) {
return open;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
open,
if (state.adding.containsKey(studyset.publicId))
const Center(child: ButtonLoadingIndicator())
else
TextButton(onPressed: add, child: Text(l10n.exploreAddCopyAnyway)),
],
);
}
if (state.copies[studyset.publicId] case final copy?) {
return TextButton.icon(
style: wide
@ -679,15 +723,7 @@ class const ExploreAddButton({
}
return FilledButton.tonalIcon(
style: style,
onPressed: () {
final bloc = context.read<ExploreBloc>();
// From the summary, it goes on in the list: its progress shows on
// the studyset's card, and what came of it below the list.
if (wide) {
Navigator.of(context).pop();
}
bloc.add(ExploreEventAddRequested(studyset));
},
onPressed: add,
icon: const Icon(LucideIcons.plus),
label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary),
);

View file

@ -36,6 +36,7 @@ ExploreStudyset published(
int paths = 0,
int? learners,
String? basedOn,
String? yours,
}) => ExploreStudyset(
publicId: publicId,
location: 'halkyon.eu/@$handle/$publicId',
@ -53,6 +54,7 @@ ExploreStudyset published(
paths: paths,
learners: learners,
basedOn: basedOn,
yours: yours,
);
/// The JSON a synced [payload] holds.
@ -366,6 +368,64 @@ void main() {
expect(find.text('Add'), findsNothing);
});
group("the person's own studyset", () {
setUp(
() => studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
);
testWidgets('shows as theirs and opens, adding no copy; the summary adds '
'a copy anyway when asked', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Add'), findsNothing);
await tester.tap(find.text('Yours'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);
expect(await StudysetRepository(studysets).all(), hasLength(1));
await openTab(tester, 'Explore');
await tester.tap(find.text('Japanese').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Add a copy anyway'));
await tester.pumpAndSettle();
final all = await StudysetRepository(studysets).all();
expect(all, hasLength(2));
expect(
all.where((studyset) => studyset.content.publishedAt != null),
hasLength(1),
);
});
testWidgets('deleted from the library, it can be added again', (
tester,
) async {
studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
deletedAt: DateTime.utc(2026, 10, 15),
);
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Yours'), findsNothing);
expect(find.text('Add'), findsOneWidget);
});
});
testWidgets('a release in a schema this app does not know is not added, '
'and says why', (tester) async {
final newer = release('japanese', 'Japanese');

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// How many studysets a page of Explore lists.
const explorePageSize = 30;
@ -263,7 +264,10 @@ class ExploreEndpoint extends Endpoint {
final rows = await session.db.unsafeQuery(
'SELECT * FROM ('
'SELECT DISTINCT ON (r."publishedStudysetId") r.*, p."publicId", '
'p."slug", p."learners", f."handle", f."displayName", f."avatarHash" '
'p."slug", p."learners", f."handle", f."displayName", f."avatarHash", '
// The author's own studyset, for the author alone.
'CASE WHEN p."authUserId"::text = @me THEN p."studysetId"::text '
'END AS "yourStudysetId" '
'FROM "studyset_release" r '
'JOIN "published_studyset" p ON p."id" = r."publishedStudysetId" '
'JOIN "public_profile" f ON f."authUserId" = p."authUserId" '
@ -273,7 +277,10 @@ class ExploreEndpoint extends Endpoint {
'${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')} '}'
'ORDER BY latest."releasedAt" DESC, latest."id" DESC'
'${paged ? ' LIMIT @limit OFFSET @offset' : ''}',
parameters: QueryParameters.named(parameters),
parameters: QueryParameters.named({
...parameters,
'me': session.authenticated!.authUserId.uuid,
}),
);
final domain = accountDomain(session.serverpod);
return [
@ -310,6 +317,10 @@ class ExploreEndpoint extends Endpoint {
pathCount: latest['pathCount'] as int,
pdfCount: latest['pdfCount'] as int,
learners: learnersShown(latest['learners'] as int),
yourStudysetId: switch (latest['yourStudysetId']) {
final String id => UuidValue.withValidation(id),
_ => null,
},
),
];
}

View file

@ -38,3 +38,7 @@ fields:
### How many people learn it here, rounded down (5, 10, 20, 50, 100, …);
### null for fewer than 5, so that nobody can be told apart.
learners: int?
### For its author asking, their studyset it was published from (decided
### 2026-10-16: Explore opens it rather than adding a copy of it); null for
### everyone else.
yourStudysetId: UuidValue?

View file

@ -41,6 +41,7 @@ abstract class ExploreEntry
required this.pathCount,
required this.pdfCount,
this.learners,
this.yourStudysetId,
});
factory ExploreEntry({
@ -68,6 +69,7 @@ abstract class ExploreEntry
required int pathCount,
required int pdfCount,
int? learners,
_is.UuidValue? yourStudysetId,
}) = _ExploreEntryImpl;
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -100,6 +102,11 @@ abstract class ExploreEntry
pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int,
learners: jsonSerialization['learners'] as int?,
yourStudysetId: jsonSerialization['yourStudysetId'] == null
? null
: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['yourStudysetId'],
),
);
}
@ -163,6 +170,11 @@ abstract class ExploreEntry
/// null for fewer than 5, so that nobody can be told apart.
int? learners;
/// For its author asking, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
/// everyone else.
_is.UuidValue? yourStudysetId;
/// Returns a shallow copy of this [ExploreEntry]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -191,6 +203,7 @@ abstract class ExploreEntry
int? pathCount,
int? pdfCount,
int? learners,
_is.UuidValue? yourStudysetId,
});
@override
Map<String, dynamic> toJson() {
@ -220,6 +233,7 @@ abstract class ExploreEntry
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
};
}
@ -251,6 +265,7 @@ abstract class ExploreEntry
'pathCount': pathCount,
'pdfCount': pdfCount,
if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
};
}
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
required int pathCount,
required int pdfCount,
int? learners,
_is.UuidValue? yourStudysetId,
}) : super._(
publicId: publicId,
location: location,
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount,
pdfCount: pdfCount,
learners: learners,
yourStudysetId: yourStudysetId,
);
/// Returns a shallow copy of this [ExploreEntry]
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
int? pathCount,
int? pdfCount,
Object? learners = _Undefined,
Object? yourStudysetId = _Undefined,
}) {
return ExploreEntry(
publicId: publicId ?? this.publicId,
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount,
learners: learners is int? ? learners : this.learners,
yourStudysetId: yourStudysetId is _is.UuidValue?
? yourStudysetId
: this.yourStudysetId,
);
}
}

View file

@ -430,6 +430,43 @@ void main() {
}
});
test('tells its author which of their studysets each of theirs was '
'published from, and nobody else', () async {
final japanese = await studyset(alice, 'Japanese');
final published = await publish(alice, japanese, 'japanese');
UuidValue? yoursIn(List<ExploreEntry> entries) => entries
.firstWhere((entry) => entry.publicId == published.publicId)
.yourStudysetId;
expect(
yoursIn(
await endpoints.explore.list(signedIn(alice), ExploreQuery()),
),
japanese,
);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(alice), [
published.publicId,
]),
),
japanese,
);
expect(
yoursIn(await endpoints.explore.list(signedIn(bob), ExploreQuery())),
isNull,
);
expect(yoursIn(await list()), isNull);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(bob), [
published.publicId,
]),
),
isNull,
);
});
test('says which of the studysets asked for are out, and their '
'latest version', () async {
final published = await publish(