feat(app,learn): invite to a shared studyset by email or link, and join

The apps' half of ADR 0044's step 5 (D37 to D40), on the instance's
half of the commit before.

halkyon_app's SharedSpaceGateway asks for inviting by mail, the
preview of a link, the code proving an address, accepting a mailed
invitation, a space's link (read, switched, joined) and marking a
share seen; SpaceInfo says whether a share is new and who made it,
a member whether they were invited by mail. joinTokenOf reads a link
or its token as pasted. The fake keeps the mails and codes it would
send, for the apps' tests.

In Learn:
- the Owner's Share sheet invites by handle or by email, lists an
  invitation mailed by its role alone, and switches the space's link,
  copying it while on;
- the Library's menu has Join with a link, and a link opened on
  Android (/join/<token> on learn.halkyon.eu, verified through the
  instance's assetlinks) opens the same on a screen of its own: who
  invites and as whom, then Join, or for a mailed invitation the
  address and the code mailed there;
- a studyset someone shared is new until opened: in the Inbox
  ("@alice shared Japanese with you", Open or Leave), on the Profile
  tab's badge and as New on its library card (NewSharesCubit, read
  again after every merge).

Whats-New: Invite people to a shared studyset by email or with a link, join one from the Library's Join with a link, and see in the Inbox what was shared with you.
Whats-New-De: Lade Leute per E-Mail oder Link zu einem geteilten Lernset ein, tritt einem über „Mit einem Link beitreten“ in der Bibliothek bei und sieh im Posteingang, was mit dir geteilt wurde.

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 19:09:12 +01:00
commit fb6b78fa19
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 2443 additions and 54 deletions

View file

@ -11,8 +11,7 @@ class ServerpodSharedSpaceGateway(final protocol.Caller _base)
@override
Future<List<SpaceInfo>> mine() => _refusing(
() async => [
for (final space in await _base.sharedSpace.mine())
(id: space.id.uuid, kind: space.kind, role: _role(space.role)),
for (final space in await _base.sharedSpace.mine()) _info(space),
],
);
@ -32,6 +31,75 @@ class ServerpodSharedSpaceGateway(final protocol.Caller _base)
),
);
@override
Future<SpaceMember> inviteByEmail(
String space,
String address,
SpaceRole role,
String language,
) => _refusing(
() async => _member(
await _base.sharedSpace.inviteByEmail(
_id(space),
address,
_theirs(role),
language,
),
),
);
@override
Future<SpaceJoinPreview> preview(String token) => _refusing(() async {
final preview = await _base.sharedSpace.preview(token);
return SpaceJoinPreview(
kind: preview.kind,
role: _role(preview.role),
byEmail: preview.byEmail,
inviterHandle: preview.inviterHandle,
inviterName: preview.inviterName,
lapsesAt: preview.lapsesAt,
member: preview.member,
);
});
@override
Future<String> sendJoinCode(String address, {required String language}) =>
_refusing(
() async => (await _base.emailCode.sendCode(
address: address,
language: language,
purpose: protocol.EmailCodePurpose.joinSpace,
)).uuid,
);
@override
Future<SpaceInfo> acceptEmailInvitation(
String token, {
required String request,
required String code,
}) => _refusing(
() async => _info(
await _base.sharedSpace.acceptEmailInvitation(token, _id(request), code),
),
);
@override
Future<String?> joinLink(String space) =>
_refusing(() => _base.sharedSpace.joinLink(_id(space)));
@override
Future<String?> setJoinLink(String space, {required bool on}) =>
_refusing(() => _base.sharedSpace.setJoinLink(_id(space), on));
@override
Future<SpaceInfo> joinByLink(String token) => _refusing(
() async => _info(await _base.sharedSpace.joinByLink(token)),
);
@override
Future<void> markSeen(String space) =>
_refusing(() => _base.sharedSpace.markSeen(_id(space)));
@override
Future<void> setRole(String space, String member, SpaceRole role) =>
_refusing(
@ -78,9 +146,26 @@ class ServerpodSharedSpaceGateway(final protocol.Caller _base)
});
} on protocol.SharedSpaceNotFoundException {
throw const SpaceRefused(SpaceRefusal.notFound);
} on protocol.EmailSignInRefusedException catch (refused) {
throw SpaceRefused(switch (refused.reason) {
protocol.EmailSignInRefusal.unavailable => SpaceRefusal.unavailable,
protocol.EmailSignInRefusal.notAnAddress => SpaceRefusal.notAnAddress,
protocol.EmailSignInRefusal.tooManyCodes => SpaceRefusal.tooManyMails,
protocol.EmailSignInRefusal.mailFailed => SpaceRefusal.mailFailed,
protocol.EmailSignInRefusal.wrongCode => SpaceRefusal.wrongCode,
_ => SpaceRefusal.codeExpired,
});
}
}
static SpaceInfo _info(protocol.SharedSpaceInfo space) => (
id: space.id.uuid,
kind: space.kind,
role: _role(space.role),
isNew: space.isNew,
sharedBy: space.sharedBy,
);
static SpaceMember _member(protocol.SharedSpaceMemberInfo member) =>
SpaceMember(
id: member.id.uuid,
@ -90,6 +175,7 @@ class ServerpodSharedSpaceGateway(final protocol.Caller _base)
joinedAt: member.joinedAt,
invited: member.invited,
you: member.you,
byEmail: member.byEmail,
);
static SpaceRole _role(protocol.SharedSpaceRole role) => switch (role) {

View file

@ -14,8 +14,54 @@ enum SpaceRole() {
}
/// A shared space the person is a member of: the sync engine's space, what
/// it holds in the app's words, and their role in it.
typedef SpaceInfo = ({String id, String kind, SpaceRole role});
/// it holds in the app's words, and their role in it; whether it is new to
/// them, someone else having made them a member they haven't seen since,
/// and that one's handle while it is (ADR 0044, D40).
typedef SpaceInfo = ({
String id,
String kind,
SpaceRole role,
bool isNew,
String? sharedBy,
});
/// What a link to join a shared space says before anyone joins (ADR 0044,
/// D39): what it holds and as whom, who invites, never its content's names.
final class const SpaceJoinPreview({
/// What the space holds, in the app's words.
required final String kind,
/// The role joining gives.
required final SpaceRole role,
/// Whether it is an invitation mailed to an address, which joining
/// proves with a mailed code (D37); else the space's own link.
required final bool byEmail,
/// The handle of who invites; null without a public profile.
required final String? inviterHandle,
/// Their display name; empty without one.
required final String inviterName,
/// When a mailed invitation lapses; null for a link.
required final DateTime? lapsesAt,
/// Whether the person is a member already.
required final bool member,
});
/// The token a link to join names, from what the person pasted: the link
/// (`https://learn.halkyon.eu/join/<token>`, with anything after the
/// token left off) or the token alone; null for nothing.
String? joinTokenOf(String pasted) {
final text = pasted.trim();
final at = text.indexOf('/join/');
final token = (at < 0 ? text : text.substring(at + '/join/'.length))
.split(RegExp(r'[/?#\s]'))
.first;
return token.isEmpty ? null : token;
}
/// A member of a shared space, or someone invited who hasn't joined yet, as
/// the members see them: by their public profile, never by account ID.
@ -39,6 +85,10 @@ final class const SpaceMember({
/// Whether this is the person.
required final bool you,
/// Whether this is an invitation mailed to an address (D38), which
/// members never see: neither handle nor name.
final bool byEmail = false,
});
/// Why a call about a shared space was refused; nothing changed.
@ -74,7 +124,23 @@ enum SpaceRefusal() {
/// The code proved another address than the one invited (D37).
otherAddress,
/// No such space the person is in, or no such member in it.
/// What was typed is no email address.
notAnAddress,
/// Too many mails for that address, or from this network, this hour.
tooManyMails,
/// The mail couldn't be sent.
mailFailed,
/// The mailed code isn't right.
wrongCode,
/// The mailed code is no longer valid: used, too many tries, too old.
codeExpired,
/// No such space the person is in, or no such member in it; for a link,
/// one that holds no more.
notFound,
}
@ -104,6 +170,43 @@ abstract interface class SharedSpaceGateway() {
/// where they use the app on this instance, else invited until they do.
Future<SpaceMember> invite(String space, String handle, SpaceRole role);
/// Mails an invitation to [space] as [role] to [address], worded in
/// [language] (D37, D38): its link admits whoever proves the address.
Future<SpaceMember> inviteByEmail(
String space,
String address,
SpaceRole role,
String language,
);
/// What the link of [token] invites to (D39).
Future<SpaceJoinPreview> preview(String token);
/// Mails a code to [address], worded in [language], to accept an
/// invitation sent there; the request it answers.
Future<String> sendJoinCode(String address, {required String language});
/// Joins the space the invitation mailed with [token] is to, once [code],
/// mailed for [request], proves its address (D37); the space.
Future<SpaceInfo> acceptEmailInvitation(
String token, {
required String request,
required String code,
});
/// The link of [space] while someone may join by it; null while not.
Future<String?> joinLink(String space);
/// Switches [space]'s link on, a new one, or off for good; the Owner's.
/// The link while on.
Future<String?> setJoinLink(String space, {required bool on});
/// Joins the space whose link [token] is, as a Viewer (D5, D39).
Future<SpaceInfo> joinByLink(String token);
/// Marks [space] seen (D40): no longer new to the person.
Future<void> markSeen(String space);
/// Gives the member [member] of [space] the role [role]; the Owner's.
Future<void> setRole(String space, String member, SpaceRole role);

View file

@ -3,14 +3,23 @@ import 'package:halkyon_app/src/shared_space/shared_space_gateway.dart';
import 'package:halkyon_app/src/testing/fake_shared_keys_gateway.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
typedef _Member = ({String id, String person, SpaceRole role, DateTime joined});
typedef _Member = ({
String id,
String person,
SpaceRole role,
DateTime joined,
String? addedBy,
bool isNew,
});
/// The instance's shared spaces in memory, as halkyon_base keeps them, its
/// members kept in step with [keys] (their public keys, the keys sealed to
/// them): people known by account and handle, spaces with their Owner,
/// Editors and Viewers, and the Owner's rules. Each person reaches it
/// through their own [as]. Invitations waiting (ADR 0044, D13) aren't
/// kept: everyone known here uses the app.
/// Editors and Viewers, and the Owner's rules, invitations mailed (D37,
/// D38: [mailed], each code to join in [codeFor]) and links (D39), and what
/// is new to whom (D40). Each person reaches it through their own [as].
/// Invitations by handle waiting (ADR 0044, D13) aren't kept: everyone
/// known here uses the app.
class FakeSharedSpaceServer(
/// The keys of the spaces' members, which this keeps in step.
final FakeSharedKeysServer keys,
@ -18,6 +27,23 @@ class FakeSharedSpaceServer(
final _people = <String, ({String handle, PublicKeys? keys})>{};
final _spaces = <String, ({String kind, List<_Member> members})>{};
var _next = 0;
final _invitations =
<
String,
({String id, String space, String address, SpaceRole role, String by})
>{};
final _codes = <String, ({String address, String code})>{};
final _links = <String, String>{};
/// The invitations mailed, in order: to whom and the link's token.
final mailed = <({String address, String token})>[];
/// The last code mailed to [address] to join.
String codeFor(String address) =>
_codes.values.lastWhere((mail) => mail.address == address).code;
/// Whether a mail to an address is refused, as a mail service down.
bool mailRefused = false;
/// The spaces made, by id, in order.
Iterable<String> get spaces => _spaces.keys;
@ -60,12 +86,14 @@ class FakeSharedSpaceServer(
return at;
}
void _join(String space, String person, SpaceRole role) {
void _join(String space, String person, SpaceRole role, {String? by}) {
_spaces[space]!.members.add((
id: _id('member'),
person: person,
role: role,
joined: clock.now().toUtc(),
addedBy: by,
isNew: by != null && by != person,
));
keys.addMember(
space,
@ -84,6 +112,8 @@ class FakeSharedSpaceServer(
person: member.person,
role: role,
joined: member.joined,
addedBy: member.addedBy,
isNew: member.isNew,
);
keys.addMember(
space,
@ -94,6 +124,27 @@ class FakeSharedSpaceServer(
);
}
SpaceInfo _info(String space, _Member member) => (
id: space,
kind: _spaces[space]!.kind,
role: member.role,
isNew: member.isNew,
sharedBy: member.isNew ? _people[member.addedBy]?.handle : null,
);
/// Makes [person] a member of [space] as [role] by themselves, unless they
/// are one; their membership.
SpaceInfo _joinBy(String space, String person, SpaceRole role) {
final members = _spaces[space]?.members;
if (members == null) {
throw const SpaceRefused(SpaceRefusal.notFound);
}
if (!members.any((m) => m.person == person)) {
_join(space, person, role);
}
return _info(space, members.firstWhere((m) => m.person == person));
}
void _leave(String space, int at) {
final member = _spaces[space]!.members.removeAt(at);
keys.removeMember(space, member.person);
@ -114,8 +165,7 @@ class _View(final FakeSharedSpaceServer _server, final String _person)
Future<List<SpaceInfo>> mine() async => [
for (final MapEntry(key: id, value: space) in _server._spaces.entries)
for (final member in space.members)
if (member.person == _person)
(id: id, kind: space.kind, role: member.role),
if (member.person == _person) _server._info(id, member),
];
@override
@ -130,8 +180,178 @@ class _View(final FakeSharedSpaceServer _server, final String _person)
invited: false,
you: member.person == _person,
),
for (final invitation in _server._invitations.values)
if (invitation.space == space)
SpaceMember(
id: invitation.id,
handle: null,
displayName: '',
role: invitation.role,
joinedAt: null,
invited: true,
you: false,
byEmail: true,
),
];
@override
Future<SpaceMember> inviteByEmail(
String space,
String address,
SpaceRole role,
String language,
) async {
_server._asOwner(space, _person);
if (role == SpaceRole.owner) {
throw const SpaceRefused(SpaceRefusal.ownerRole);
}
if (!address.contains('@')) {
throw const SpaceRefused(SpaceRefusal.notAnAddress);
}
if (_server.mailRefused) {
throw const SpaceRefused(SpaceRefusal.mailFailed);
}
final to = address.trim().toLowerCase();
_server._invitations.removeWhere(
(_, invitation) => invitation.space == space && invitation.address == to,
);
final token = _server._id('token');
final id = _server._id('invitation');
_server._invitations[token] = (
id: id,
space: space,
address: to,
role: role,
by: _person,
);
_server.mailed.add((address: to, token: token));
return (await members(space)).firstWhere((m) => m.id == id);
}
@override
Future<SpaceJoinPreview> preview(String token) async {
if (_server._invitations[token] case final invitation?) {
return SpaceJoinPreview(
kind: _server._spaces[invitation.space]!.kind,
role: invitation.role,
byEmail: true,
inviterHandle: _server._people[invitation.by]?.handle,
inviterName: '',
lapsesAt: clock.now().toUtc().add(const Duration(days: 14)),
member: _server._spaces[invitation.space]!.members.any(
(m) => m.person == _person,
),
);
}
for (final MapEntry(key: space, value: link) in _server._links.entries) {
if (link == token) {
final members = _server._spaces[space]!.members;
return SpaceJoinPreview(
kind: _server._spaces[space]!.kind,
role: SpaceRole.viewer,
byEmail: false,
inviterHandle: _server
._people[members
.firstWhere((m) => m.role == SpaceRole.owner)
.person]
?.handle,
inviterName: '',
lapsesAt: null,
member: members.any((m) => m.person == _person),
);
}
}
throw const SpaceRefused(SpaceRefusal.notFound);
}
@override
Future<String> sendJoinCode(
String address, {
required String language,
}) async {
if (!address.contains('@')) {
throw const SpaceRefused(SpaceRefusal.notAnAddress);
}
final request = _server._id('request');
_server._codes[request] = (
address: address.trim().toLowerCase(),
code: 'CODE-${_server._next}',
);
return request;
}
@override
Future<SpaceInfo> acceptEmailInvitation(
String token, {
required String request,
required String code,
}) async {
final invitation = _server._invitations[token];
if (invitation == null) {
throw const SpaceRefused(SpaceRefusal.notFound);
}
final mailed = _server._codes[request];
if (mailed == null) {
throw const SpaceRefused(SpaceRefusal.codeExpired);
}
if (mailed.code != code.trim()) {
throw const SpaceRefused(SpaceRefusal.wrongCode);
}
_server._codes.remove(request);
if (mailed.address != invitation.address) {
throw const SpaceRefused(SpaceRefusal.otherAddress);
}
_server._invitations.remove(token);
return _server._joinBy(invitation.space, _person, invitation.role);
}
@override
Future<String?> joinLink(String space) async {
_server._asOwner(space, _person);
return switch (_server._links[space]) {
final token? => 'https://learn.example.org/join/$token',
null => null,
};
}
@override
Future<String?> setJoinLink(String space, {required bool on}) async {
_server._asOwner(space, _person);
if (!on) {
_server._links.remove(space);
return null;
}
_server._links[space] = _server._id('link');
return await joinLink(space);
}
@override
Future<SpaceInfo> joinByLink(String token) async {
for (final MapEntry(key: space, value: link) in _server._links.entries) {
if (link == token) {
return _server._joinBy(space, _person, SpaceRole.viewer);
}
}
throw const SpaceRefused(SpaceRefusal.notFound);
}
@override
Future<void> markSeen(String space) async {
final members = _server._spaces[space]?.members ?? [];
final at = members.indexWhere((m) => m.person == _person);
if (at >= 0) {
final member = members[at];
members[at] = (
id: member.id,
person: member.person,
role: member.role,
joined: member.joined,
addedBy: member.addedBy,
isNew: false,
);
}
}
@override
Future<SpaceMember> invite(
String space,
@ -152,8 +372,8 @@ class _View(final FakeSharedSpaceServer _server, final String _person)
if (current.any((m) => m.person == person)) {
throw const SpaceRefused(SpaceRefusal.alreadyMember);
}
_server._join(space, person, role);
return (await members(space)).last;
_server._join(space, person, role, by: _person);
return (await members(space)).lastWhere((m) => !m.invited);
}
@override
@ -169,6 +389,13 @@ class _View(final FakeSharedSpaceServer _server, final String _person)
@override
Future<void> remove(String space, String member) async {
final members = _server._asOwner(space, _person);
final mailed = _server._invitations.length;
_server._invitations.removeWhere(
(_, invitation) => invitation.space == space && invitation.id == member,
);
if (_server._invitations.length < mailed) {
return;
}
_server._leave(space, _server._other(members, member, _person));
}

View file

@ -34,7 +34,13 @@ void main() {
expect(invited.handle, 'Bob');
expect(invited.role, SpaceRole.viewer);
expect(await bob.mine(), [
(id: space, kind: 'studyset', role: SpaceRole.viewer),
(
id: space,
kind: 'studyset',
role: SpaceRole.viewer,
isNew: true,
sharedBy: 'alice',
),
]);
expect(keys.as('bob').keysNeeded(space), completion(isEmpty));
@ -96,4 +102,84 @@ void main() {
expect((await bob.mine()).single.role, SpaceRole.owner);
},
);
test('an invitation mailed admits only who proves its address, as its '
'role; its members see it by email alone', () async {
final invited = await alice.inviteByEmail(
space,
'Dana@example.org',
SpaceRole.editor,
'en',
);
expect(invited.byEmail, isTrue);
expect(invited.handle, isNull);
final token = server.mailed.single.token;
await expectLater(
bob.inviteByEmail(space, 'erin@example.org', SpaceRole.viewer, 'en'),
refused(SpaceRefusal.notFound),
);
final preview = await bob.preview(token);
expect(preview.byEmail, isTrue);
expect(preview.role, SpaceRole.editor);
expect(preview.inviterHandle, 'alice');
final other = await bob.sendJoinCode('bob@example.org', language: 'en');
await expectLater(
bob.acceptEmailInvitation(
token,
request: other,
code: server.codeFor('bob@example.org'),
),
refused(SpaceRefusal.otherAddress),
);
final right = await bob.sendJoinCode('dana@example.org', language: 'en');
await expectLater(
bob.acceptEmailInvitation(token, request: right, code: 'WRONG'),
refused(SpaceRefusal.wrongCode),
);
final joined = await bob.acceptEmailInvitation(
token,
request: right,
code: server.codeFor('dana@example.org'),
);
expect(joined.role, SpaceRole.editor);
expect(joined.isNew, isFalse);
await expectLater(bob.preview(token), refused(SpaceRefusal.notFound));
});
test(
"a space's link admits whoever opens it as a Viewer while it is on",
() async {
await expectLater(
bob.setJoinLink(space, on: true),
refused(SpaceRefusal.notFound),
);
final link = (await alice.setJoinLink(space, on: true))!;
final token = joinTokenOf(link)!;
expect((await bob.preview(token)).role, SpaceRole.viewer);
expect((await bob.joinByLink(token)).role, SpaceRole.viewer);
await alice.setJoinLink(space, on: false);
await expectLater(
server.as('carol').joinByLink(token),
refused(SpaceRefusal.notFound),
);
},
);
test('a space shared with someone is new to them until seen', () async {
await alice.invite(space, 'bob', SpaceRole.editor);
expect((await alice.mine()).single.isNew, isFalse);
expect((await bob.mine()).single.sharedBy, 'alice');
await bob.markSeen(space);
expect((await bob.mine()).single.isNew, isFalse);
});
test('a pasted link or token names the token', () {
expect(joinTokenOf('https://learn.halkyon.eu/join/abc_D-9'), 'abc_D-9');
expect(joinTokenOf(' learn.halkyon.eu/join/abc?utm=1 '), 'abc');
expect(joinTokenOf('abc\n'), 'abc');
expect(joinTokenOf(' '), isNull);
});
}