feat(base)!: removing a profile makes it private (ADR 0020, D27)
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:
parent
630965d920
commit
d5651c5beb
12 changed files with 255 additions and 60 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue