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
12 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.