feat(base)!: removing a profile makes it private (ADR 0020, D27)
Some checks failed
CI / check (push) Failing after 30m48s
CI / deploy (push) Has been skipped

Every account keeps a handle now, so removing a public profile no
longer deletes it and frees the handle: it takes away what the profile
shows and leaves it private, as a profile is until its person goes
public (D5).

- `profile.remove` and the register's `removeProfile` (members ask the
  register) empty the display name, bio, links with their checks and
  the avatar, keep the row with its handle, and return what is left,
  null for none. `/@handle` and `profiles.byHandle` then answer as for
  a handle nobody has. Once the person has published, removing is
  refused as before.
- Learn: the profile form starts afresh from what is left, "Remove
  profile" shows only while something is filled in, and the dialog says
  the handle stays.
- The module's README and ADR 0020 amended.

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-24 18:26:56 +02:00
commit d5651c5beb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 255 additions and 60 deletions

View file

@ -514,7 +514,7 @@
"publicProfileHandleConfirmChange": "Ändern",
"publicProfileRemove": "Profil entfernen",
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
"publicProfileRemoveMessage": "Dein Handle wird für andere frei.",
"publicProfileRemoveMessage": "Anzeigename, Text über dich, Links und Bild werden entfernt. Dein Handle bleibt deins und privat, bis du dein Profil wieder ausfüllst oder etwas veröffentlichst.",
"publicProfileRemoved": "Profil entfernt.",
"publicProfileFailed": "Die Instanz ist nicht erreichbar. Versuche es erneut.",
"publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.",

View file

@ -585,7 +585,7 @@
"publicProfileHandleConfirmChange": "Change",
"publicProfileRemove": "Remove profile",
"publicProfileRemoveTitle": "Remove your public profile?",
"publicProfileRemoveMessage": "Your handle becomes free for others to take.",
"publicProfileRemoveMessage": "Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.",
"publicProfileRemoved": "Profile removed.",
"publicProfileFailed": "Couldn't reach the instance. Try again.",
"publicProfileUnreachable": "Your public profile can't be reached right now.",

View file

@ -3085,7 +3085,7 @@ abstract class AppLocalizations {
/// No description provided for @publicProfileRemoveMessage.
///
/// In en, this message translates to:
/// **'Your handle becomes free for others to take.'**
/// **'Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.'**
String get publicProfileRemoveMessage;
/// No description provided for @publicProfileRemoved.

View file

@ -1869,7 +1869,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get publicProfileRemoveTitle => 'Dein öffentliches Profil entfernen?';
@override
String get publicProfileRemoveMessage => 'Dein Handle wird für andere frei.';
String get publicProfileRemoveMessage =>
'Anzeigename, Text über dich, Links und Bild werden entfernt. Dein Handle bleibt deins und privat, bis du dein Profil wieder ausfüllst oder etwas veröffentlichst.';
@override
String get publicProfileRemoved => 'Profil entfernt.';

View file

@ -1852,7 +1852,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get publicProfileRemoveMessage =>
'Your handle becomes free for others to take.';
'Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.';
@override
String get publicProfileRemoved => 'Profile removed.';

View file

@ -216,9 +216,12 @@ class PublicProfileBloc(final ProfileRepository _profiles)
if (state case PublicProfileStateReady(:final profile?, saving: false)) {
emit(PublicProfileStateReady(profile: profile, saving: true));
try {
await _profiles.remove();
final left = await _profiles.remove();
emit(
const PublicProfileStateReady(outcome: PublicProfileOutcome.removed),
PublicProfileStateReady(
profile: left,
outcome: PublicProfileOutcome.removed,
),
);
} on ProfileRefused catch (refused) {
emit(

View file

@ -25,9 +25,10 @@ abstract interface class ProfileGateway() {
/// when it can.
Future<ProfileProblem?> checkHandle(String handle);
/// Removes the person's public profile. Throws [ProfileRefused] once they
/// have published.
Future<void> remove();
/// Removes what the person's public profile shows, making it private
/// again with its handle (ADR 0020, D27); returns what is left, null while
/// they have none. Throws [ProfileRefused] once they have published.
Future<PublicProfile?> remove();
/// Shows [png] as the person's avatar; returns the profile with it.
/// Throws [ProfileRefused] for anything but a PNG of at most 1 MB.

View file

@ -43,9 +43,10 @@ class ProfileRepository(
Future<ProfileProblem?> checkHandle(String handle) =>
_gateway.checkHandle(handle.trim());
/// Removes the person's public profile. Throws [ProfileRefused] once they
/// have published.
Future<void> remove() => _gateway.remove();
/// Removes what the person's public profile shows, making it private
/// again with its handle (ADR 0020, D27); returns what is left, null while
/// they have none. Throws [ProfileRefused] once they have published.
Future<PublicProfile?> remove() => _gateway.remove();
/// Shows [picture] (a PNG, JPEG, WebP or GIF) as the person's avatar,
/// drawn anew as a square PNG ([avatarPng]): metadata such as where a

View file

@ -51,7 +51,12 @@ class ServerpodProfileGateway(final protocol.Client _client)
};
@override
Future<void> remove() => _refusing(_profiles.remove);
Future<PublicProfile?> remove() => _refusing(
() async => switch (await _profiles.remove()) {
final profile? => _profile(profile),
null => null,
},
);
@override
Future<PublicProfile> setAvatar(Uint8List png) => _refusing(

View file

@ -12,7 +12,8 @@ import 'package:material_ui/material_ui.dart';
/// The person's public profile (ADR 0005, rule 2), in the Profile tab: what
/// it is for, and a form of its handle, display name, bio and links that
/// saves as it is written; a way to remove it until the person publishes.
/// saves as it is written; a way to remove what it shows, making it private
/// with its handle (ADR 0020, D27), until the person publishes.
class const PublicProfileSection({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
@ -23,7 +24,16 @@ class const PublicProfileSection({super.key}) extends StatelessWidget {
);
}
class const _Section() extends StatelessWidget {
class const _Section() extends StatefulWidget {
@override
State<_Section> createState() => _SectionState();
}
class _SectionState() extends State<_Section> {
/// How often the profile was removed here: the form starts afresh each
/// time, from what is left.
var _removals = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -35,6 +45,11 @@ class const _Section() extends StatelessWidget {
now.outcome != null &&
now.outcome != PublicProfileOutcome.saved,
listener: (context, state) {
if (state case PublicProfileStateReady(
outcome: PublicProfileOutcome.removed,
)) {
setState(() => _removals++);
}
final message = switch (state) {
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
l10n.publicProfileRemoved,
@ -107,7 +122,7 @@ class const _Section() extends StatelessWidget {
_ProfileForm(
// Kept while the person writes, as saves come back; afresh
// once a profile is made or removed.
key: ValueKey(profile != null),
key: ValueKey((profile != null, _removals)),
profile: profile,
saving: saving,
checkingLinks: checkingLinks,
@ -272,6 +287,14 @@ class _ProfileFormState() extends State<_ProfileForm> {
_saveIfChanged();
}
/// Whether [profile] shows anything removing would take away: with the
/// handle alone it is private already (ADR 0020, D5).
static bool _showsSomething(PublicProfile profile) =>
profile.displayName.isNotEmpty ||
profile.bio.isNotEmpty ||
profile.links.isNotEmpty ||
profile.avatarUrl != null;
Future<void> _remove() async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<PublicProfileBloc>();
@ -450,7 +473,7 @@ class _ProfileFormState() extends State<_ProfileForm> {
),
),
],
if (profile != null && !fixed) ...[
if (profile != null && !fixed && _showsSomething(profile)) ...[
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,

View file

@ -212,25 +212,56 @@ void main() {
expect(find.text('Remove profile'), findsNothing);
});
testWidgets('removing a profile asks first, then frees its handle', (
tester,
) async {
testWidgets('removing a profile asks first, then makes it private: the '
'handle stays, what was filled in goes (ADR 0020, D27)', (tester) async {
final profiles = FakeProfileGateway(
profile: const PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
displayName: 'Raison',
bio: 'Learning Japanese.',
links: ['https://halkyon.app'],
),
);
await showProfile(tester, profiles);
expect(find.widgetWithText(TextField, 'Raison'), findsOneWidget);
await tester.tap(find.text('Remove profile'));
await tester.pumpAndSettle();
expect(find.text('Remove your public profile?'), findsOneWidget);
expect(find.textContaining('Your handle stays yours'), findsOneWidget);
await tester.tap(find.text('Remove profile').last);
await tester.pumpAndSettle();
expect(find.text('Profile removed.'), findsOneWidget);
expect(profiles.profile, isNull);
expect(profiles.profile?.handle, 'raisondetredev');
expect(profiles.profile?.displayName, '');
expect(find.text('@raisondetredev'), findsOneWidget);
// The form starts afresh from what is left.
expect(find.widgetWithText(TextField, 'Raison'), findsNothing);
expect(find.widgetWithText(TextField, 'Learning Japanese.'), findsNothing);
expect(
find.widgetWithText(TextField, 'https://halkyon.app'),
findsNothing,
);
// Nothing is left to remove.
expect(find.text('Remove profile'), findsNothing);
});
testWidgets('offers no removing while nothing is filled in: the handle '
'alone is private already', (tester) async {
await showProfile(
tester,
FakeProfileGateway(
profile: const PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
),
),
);
expect(find.text('@raisondetredev'), findsOneWidget);
expect(find.text('Remove profile'), findsNothing);
});
testWidgets('a saved profile takes a picture as its avatar, and gives it '

View file

@ -145,14 +145,23 @@ class FakeProfileGateway({
}
@override
Future<void> remove() async {
Future<PublicProfile?> remove() async {
if (fail case final error?) {
throw error;
}
if (profile?.handleFixed ?? false) {
final current = profile;
if (current == null) {
return null;
}
if (current.handleFixed) {
throw ProfileRefused(ProfileProblem.published);
}
profile = null;
// Private again, the handle kept (ADR 0020, D27).
return profile = PublicProfile(
handle: current.handle,
address: current.address,
pageUrl: current.pageUrl,
);
}
/// The links whose pages link back to the profile page.

View file

@ -103,7 +103,10 @@
one `HandleField` with its `HandleCheckCubit` (asked 300 ms after typing pauses, only
for the handle typed last, the instance's suggestion free without asking) serves the
password setup's handle step, between the phrase check and the code, and
SimpleLogin's sign-up and keys steps.
SimpleLogin's sign-up and keys steps. Then D27: `profile.remove` (and the register's
`removeProfile`, members asking it) empties the display name, bio, links and their
checks and the avatar, keeps the row with its handle, and returns what is left; Learn's
profile form starts afresh from it and offers no removing while nothing is filled in.
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
register), ADR 0017 (a password, TOTP and the keys).
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their

View file

@ -835,14 +835,17 @@ class EndpointProfile extends _isc.EndpointRef {
{},
);
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once your handle is fixed:
/// others refer to it.
_ida.Future<void> remove() => caller.callServerEndpoint<void>(
'halkyon_base.profile',
'remove',
{},
);
/// Removes what your public profile shows (display name, bio, links,
/// avatar), making it private again: the handle stays yours (Halkyon
/// Learn's ADR 0020, D27). Returns it; null when you have none. Throws
/// [ProfileRefusedException] once your handle is fixed: others refer to
/// it.
_ida.Future<_i8qdmv6n.Profile?> remove() =>
caller.callServerEndpoint<_i8qdmv6n.Profile?>(
'halkyon_base.profile',
'remove',
{},
);
}
/// The public profiles of others, found by their handles. Any signed-in

View file

@ -24,7 +24,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |
| `instance` | `basics`: the app it serves, GitHub's client ID if any and when GitHub sign-in ends, whether codes by mail, password sign-in and SimpleLogin are on, the source link, the oldest app build, where data and backups are | anyone |
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove`: the caller's own public profile | `identified` |
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only

View file

@ -82,10 +82,12 @@ class ProfileEndpoint extends Endpoint {
Future<Profile> removeAvatar(Session session) =>
_register(session).removeAvatar(session, _caller(session));
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once your handle is fixed:
/// others refer to it.
Future<void> remove(Session session) =>
/// Removes what your public profile shows (display name, bio, links,
/// avatar), making it private again: the handle stays yours (Halkyon
/// Learn's ADR 0020, D27). Returns it; null when you have none. Throws
/// [ProfileRefusedException] once your handle is fixed: others refer to
/// it.
Future<Profile?> remove(Session session) =>
_register(session).removeProfile(session, _caller(session));
}

View file

@ -102,9 +102,11 @@ abstract interface class AccountRegister {
/// `ProfileNotFoundException` while they have no profile.
Future<Profile> removeAvatar(Session session, UuidValue person);
/// Removes [person]'s public profile, freeing its handle; nothing when they
/// have none. Throws `ProfileRefusedException` once their handle is fixed.
Future<void> removeProfile(Session session, UuidValue person);
/// Removes what [person]'s public profile shows (display name, bio, links,
/// avatar), making it private again, the handle kept theirs (Halkyon
/// Learn's ADR 0020, D27); returns it, null when they have none. Throws
/// `ProfileRefusedException` once their handle is fixed.
Future<Profile?> removeProfile(Session session, UuidValue person);
/// Fixes [person]'s handle, as others now refer to it (Halkyon Learn: their
/// first publish); nothing when it is fixed already or they have no

View file

@ -166,8 +166,11 @@ Future<Map<String, Object?>?> _answer(
);
return {'profile': profile.toJson()};
case RegisterOperation.removeProfile:
await register.removeProfile(session, arguments.id('person'));
return const {};
final profile = await register.removeProfile(
session,
arguments.id('person'),
);
return {'profile': profile?.toJson()};
case RegisterOperation.fixHandle:
await register.fixHandle(session, arguments.id('person'));
return const {};

View file

@ -318,22 +318,37 @@ final class LocalRegister implements AccountRegister {
}
@override
Future<void> removeProfile(Session session, UuidValue person) async {
Future<Profile?> removeProfile(Session session, UuidValue person) async {
final mine = await _rowOf(session, person);
if (mine == null) {
return;
return null;
}
if (mine.firstPublishedAt != null) {
throw ProfileRefusedException(reason: ProfileRefusal.published);
}
// DELETE FROM "public_profile" WHERE "authUserId" = $1
await PublicProfile.db.deleteWhere(
// UPDATE "public_profile" SET "displayName" = '', "bio" = '',
// "links" = '[]', "verifiedLinks" = NULL, "avatarHash" = NULL,
// "updatedAt" = $1 WHERE "id" = $2
final emptied = await PublicProfile.db.updateRow(
session,
where: (t) => t.authUserId.equals(person),
mine.copyWith(
displayName: '',
bio: '',
links: const [],
// Not the default: the generated copyWith tells an explicit null
// (clear it) from no argument (keep it), which the lint can't see.
// ignore: avoid_redundant_argument_values
verifiedLinks: null,
// As above.
// ignore: avoid_redundant_argument_values
avatarHash: null,
updatedAt: clock.now().toUtc(),
),
);
if (mine.avatarHash case final hash?) {
await forgetAvatar(session, hash);
}
return await profileViewOf(session, emptied);
}
@override

View file

@ -222,8 +222,12 @@ final class RemoteRegister implements AccountRegister {
)!;
@override
Future<void> removeProfile(Session session, UuidValue person) =>
_ask(RegisterOperation.removeProfile, {'person': person.uuid});
Future<Profile?> removeProfile(Session session, UuidValue person) async =>
_profile(
(await _ask(RegisterOperation.removeProfile, {
'person': person.uuid,
}))['profile'],
);
@override
Future<void> fixHandle(Session session, UuidValue person) =>

Some files were not shown because too many files have changed in this diff Show more