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:
parent
554e1604b4
commit
fb6b78fa19
4 changed files with 2443 additions and 54 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue