2026-10-12 19:07:42 +02:00
|
|
|
import 'dart:async';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
|
feat(profile): profile pages on the account domain, and links verified by rel="me"
A profile now has a page on the web, at the root of the account domain
(https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN,
else on the instance's web server), so the address belongs to the
identity rather than to Learn and stays when other services share it
(ADR 0011). The page shows what the app's profile sheet shows, in English
or German, without JavaScript or cookies, asks search engines not to list
it, marks every link rel="me" so other sites can verify links to it, and
sends a visitor who asked on another host to its own address. Caddy
forwards /@… to the web server, and serves nothing else on the account
domain; compose now passes ACCOUNT_DOMAIN on, which it never did.
A link is verified as Mastodon verifies one: its page links back to the
profile's with rel="me". profile.checkLinks fetches each link's page, at
most ten times an hour, over HTTPS on port 443 and to public addresses
only, connecting to the address it checked and verifying the certificate
for the host, with three redirects, 1 MiB and ten seconds at most, and
keeps what links back on the profile (verifiedLinks, a column of a table
no device syncs). A changed link loses its check, a new handle all of
them. The app shows the page's address to copy, checks a link saved by
itself and on Check links, and marks verified links in the form and in
others' profiles; a check answered after a newer save only adds its
checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 20:29:30 +02:00
|
|
|
import 'package:flutter/services.dart';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
2026-10-12 19:07:42 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
2026-10-12 19:07:42 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_auth_gateway.dart';
|
|
|
|
|
import '../../support/fake_file_picker.dart';
|
|
|
|
|
import '../../support/fake_profile_gateway.dart';
|
|
|
|
|
import '../../support/pump_app.dart';
|
|
|
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
/// Opens the Profile tab of a person with an identity, whose profile the
|
|
|
|
|
/// instance answers through [profiles].
|
|
|
|
|
Future<void> showProfile(
|
|
|
|
|
WidgetTester tester,
|
|
|
|
|
FakeProfileGateway profiles, {
|
|
|
|
|
bool identified = true,
|
|
|
|
|
FakeFilePicker? files,
|
|
|
|
|
}) async {
|
|
|
|
|
tester.view.physicalSize = const Size(1000, 1800);
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
await pumpApp(
|
|
|
|
|
tester,
|
|
|
|
|
testDependencies(
|
|
|
|
|
authGateway: FakeAuthGateway(
|
|
|
|
|
status: SignedIn(
|
|
|
|
|
authUserId: 'github-person',
|
|
|
|
|
isIdentified: identified,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
profileGateway: profiles,
|
|
|
|
|
files: files,
|
|
|
|
|
),
|
|
|
|
|
location: RoutePaths.profile,
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
/// The dialog's field for the handle.
|
|
|
|
|
Finder handleInDialog() => find.descendant(
|
|
|
|
|
of: find.byType(AlertDialog),
|
|
|
|
|
matching: find.byType(TextField),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Types [handle] into the handle's dialog and waits for the instance to
|
|
|
|
|
/// say whether it is free.
|
|
|
|
|
Future<void> typeHandle(WidgetTester tester, String handle) async {
|
|
|
|
|
await tester.enterText(handleInDialog(), handle);
|
|
|
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testWidgets('a person with an identity writes first, then chooses a '
|
|
|
|
|
'handle in a dialog saying whether it is free; that makes the profile '
|
|
|
|
|
'with what was written', (tester) async {
|
|
|
|
|
final profiles = FakeProfileGateway(taken: {'Tizian'});
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Public profile'), findsOneWidget);
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(find.text('Save'), findsNothing);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await tester.enterText(field('Display name'), 'Tizian');
|
|
|
|
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
2026-10-17 14:23:47 +02:00
|
|
|
// Leaving the bio for the name.
|
|
|
|
|
await tester.tap(field('Display name'));
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await tester.pumpAndSettle();
|
2026-10-12 19:07:42 +02:00
|
|
|
// Nothing to save without a handle.
|
|
|
|
|
expect(profiles.saves, isEmpty);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Choose a handle'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Choose your handle'), findsOneWidget);
|
|
|
|
|
await typeHandle(tester, 'tizian');
|
|
|
|
|
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
tester
|
|
|
|
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Choose'))
|
|
|
|
|
.onPressed,
|
|
|
|
|
isNull,
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
);
|
2026-10-12 19:07:42 +02:00
|
|
|
await typeHandle(tester, 'raisondetredev');
|
|
|
|
|
expect(find.text('@raisondetredev is free'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Choose'));
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(find.text('@raisondetredev'), findsOneWidget);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
expect(profiles.profile!.displayName, 'Tizian');
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(profiles.profile!.bio, 'Learning Japanese.');
|
|
|
|
|
expect(find.text('Saved'), findsOneWidget);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
testWidgets('a profile saves a field when it is left or Enter is pressed, '
|
|
|
|
|
'only what changed, without losing what is being written', (
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
tester,
|
|
|
|
|
) async {
|
2026-10-12 19:07:42 +02:00
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
displayName: 'Tizian',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showProfile(tester, profiles);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
await tester.enterText(field('Display name'), 'Tizian K.');
|
|
|
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await tester.pumpAndSettle();
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(profiles.saves.single.displayName, 'Tizian K.');
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
|
|
|
|
// Into the next field: the bio is left, and saves.
|
|
|
|
|
await tester.tap(find.text('Add link'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(profiles.saves.last.bio, 'Learning Japanese.');
|
|
|
|
|
expect(profiles.saves, hasLength(2));
|
|
|
|
|
|
|
|
|
|
await tester.enterText(
|
|
|
|
|
find.widgetWithText(TextField, 'https://…'),
|
|
|
|
|
'https://halkyon.app',
|
|
|
|
|
);
|
|
|
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
|
|
|
// What was written stays as it was written.
|
|
|
|
|
expect(find.text('Tizian K.'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.byTooltip('Remove link'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(profiles.profile!.links, isEmpty);
|
|
|
|
|
expect(profiles.saves, hasLength(4));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('the handle changes through the dialog, which says one is '
|
|
|
|
|
'yours', (tester) async {
|
|
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
displayName: 'Tizian',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('@raisondetredev'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Change your handle'), findsOneWidget);
|
|
|
|
|
expect(find.text('This is your handle'), findsOneWidget);
|
|
|
|
|
await typeHandle(tester, 'tizian_k');
|
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Change'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(profiles.profile!.handle, 'tizian_k');
|
|
|
|
|
expect(profiles.profile!.displayName, 'Tizian');
|
|
|
|
|
expect(find.text('@tizian_k'), findsOneWidget);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('once its person has published, the handle is fixed and the '
|
|
|
|
|
'profile stays', (tester) async {
|
|
|
|
|
await showProfile(
|
|
|
|
|
tester,
|
|
|
|
|
FakeProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
handleFixed: true,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Fixed since you published: others refer to it.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
2026-10-12 19:07:42 +02:00
|
|
|
tester.widget<ChevronField>(find.byType(ChevronField)).onTap,
|
|
|
|
|
isNull,
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
);
|
|
|
|
|
expect(find.text('Remove profile'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('removing a profile asks first, then frees its handle', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Remove profile'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Remove your public profile?'), findsOneWidget);
|
|
|
|
|
await tester.tap(find.text('Remove profile').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Profile removed.'), findsOneWidget);
|
|
|
|
|
expect(profiles.profile, isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a saved profile takes a picture as its avatar, and gives it '
|
|
|
|
|
'up', (tester) async {
|
|
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final files = FakeFilePicker()
|
|
|
|
|
..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3]));
|
|
|
|
|
await showProfile(tester, profiles, files: files);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Choose picture'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(profiles.avatars.single, [1, 2, 3]);
|
|
|
|
|
expect(files.asked.single, contains('jpg'));
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(find.text('Saved'), findsOneWidget);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
await tester.tap(find.text('Remove picture'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(profiles.profile!.avatarUrl, isNull);
|
|
|
|
|
expect(find.text('Remove picture'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a profile not yet saved has no picture to choose', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
await showProfile(tester, FakeProfileGateway());
|
|
|
|
|
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(find.text('Choose your handle to add a picture.'), findsOneWidget);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
expect(find.text('Choose picture'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('an anonymous person is told how to have one', (tester) async {
|
|
|
|
|
await showProfile(tester, FakeProfileGateway(), identified: false);
|
|
|
|
|
|
|
|
|
|
expect(
|
feat(auth): sign in with an email address, keeping only a keyed hash of it
People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.
It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).
Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.
The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 19:42:21 +02:00
|
|
|
find.text('Sign in to have a public profile and publish.'),
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Public profile'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('without the instance, it says so and tries again', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final profiles = FakeProfileGateway()..fail = Exception('offline');
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text("Your public profile can't be reached right now."),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
profiles.fail = null;
|
|
|
|
|
await tester.tap(find.text('Retry'));
|
|
|
|
|
await tester.pumpAndSettle();
|
2026-10-12 19:07:42 +02:00
|
|
|
expect(find.byType(ChevronField), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a save asked for while another is on its way follows it, the '
|
|
|
|
|
'latest of those waiting; a handle chosen holds for the saves after '
|
|
|
|
|
'it', () async {
|
|
|
|
|
final gateway = _HeldProfileGateway(
|
|
|
|
|
profile: const PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final bloc = PublicProfileBloc(ProfileRepository(gateway))
|
|
|
|
|
..add(const PublicProfileEventStarted());
|
|
|
|
|
addTearDown(bloc.close);
|
|
|
|
|
await bloc.stream.firstWhere((state) => state is PublicProfileStateReady);
|
|
|
|
|
|
|
|
|
|
PublicProfileEventSaved saved(String name, {String? handle}) =>
|
|
|
|
|
PublicProfileEventSaved(
|
|
|
|
|
displayName: name,
|
|
|
|
|
bio: '',
|
|
|
|
|
links: const [],
|
|
|
|
|
handle: handle,
|
|
|
|
|
);
|
|
|
|
|
bloc
|
|
|
|
|
..add(saved('A'))
|
|
|
|
|
..add(saved('A', handle: 'tizian'))
|
|
|
|
|
..add(saved('B'));
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
gateway.release();
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
gateway.release();
|
|
|
|
|
await pumpEventQueue();
|
|
|
|
|
|
|
|
|
|
// The second gave way to the third, which kept its handle.
|
|
|
|
|
expect(
|
|
|
|
|
[for (final s in gateway.saves) (s.handle, s.displayName)],
|
|
|
|
|
[
|
|
|
|
|
('raisondetredev', 'A'),
|
|
|
|
|
('tizian', 'B'),
|
|
|
|
|
],
|
|
|
|
|
);
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'the repository leaves blank links out and trims what it saves',
|
|
|
|
|
() async {
|
|
|
|
|
final profiles = FakeProfileGateway();
|
|
|
|
|
await ProfileRepository(profiles).save(
|
|
|
|
|
handle: ' tizian ',
|
|
|
|
|
displayName: ' Tizian ',
|
|
|
|
|
bio: '',
|
|
|
|
|
links: ['', ' https://halkyon.app '],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(profiles.profile!.handle, 'tizian');
|
|
|
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
|
|
|
},
|
|
|
|
|
);
|
feat(profile): profile pages on the account domain, and links verified by rel="me"
A profile now has a page on the web, at the root of the account domain
(https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN,
else on the instance's web server), so the address belongs to the
identity rather than to Learn and stays when other services share it
(ADR 0011). The page shows what the app's profile sheet shows, in English
or German, without JavaScript or cookies, asks search engines not to list
it, marks every link rel="me" so other sites can verify links to it, and
sends a visitor who asked on another host to its own address. Caddy
forwards /@… to the web server, and serves nothing else on the account
domain; compose now passes ACCOUNT_DOMAIN on, which it never did.
A link is verified as Mastodon verifies one: its page links back to the
profile's with rel="me". profile.checkLinks fetches each link's page, at
most ten times an hour, over HTTPS on port 443 and to public addresses
only, connecting to the address it checked and verifying the certificate
for the host, with three redirects, 1 MiB and ten seconds at most, and
keeps what links back on the profile (verifiedLinks, a column of a table
no device syncs). A changed link loses its check, a new handle all of
them. The app shows the page's address to copy, checks a link saved by
itself and on Check links, and marks verified links in the form and in
others' profiles; a check answered after a newer save only adds its
checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 20:29:30 +02:00
|
|
|
|
|
|
|
|
group('verified links', () {
|
|
|
|
|
PublicProfile withLinks(List<String> links) => PublicProfile(
|
|
|
|
|
handle: 'raisondetredev',
|
|
|
|
|
address: 'raisondetredev@halkyon.eu',
|
|
|
|
|
links: links,
|
|
|
|
|
pageUrl: 'https://halkyon.eu/@raisondetredev',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
testWidgets('the profile page to link back to shows, and its address '
|
|
|
|
|
'copies', (tester) async {
|
|
|
|
|
String? copied;
|
|
|
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
|
|
|
SystemChannels.platform,
|
|
|
|
|
(call) async {
|
|
|
|
|
if (call.method == 'Clipboard.setData') {
|
|
|
|
|
copied = (call.arguments as Map)['text'] as String?;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
addTearDown(
|
|
|
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
|
|
|
SystemChannels.platform,
|
|
|
|
|
null,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await showProfile(tester, FakeProfileGateway(profile: withLinks([])));
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Your profile page: halkyon.eu/@raisondetredev'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
await tester.tap(find.byTooltip('Copy the address'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(copied, 'https://halkyon.eu/@raisondetredev');
|
|
|
|
|
expect(find.text('Address copied.'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('a link saved is checked by itself, and gets a checkmark once '
|
|
|
|
|
'its page links back, without a message', (tester) async {
|
|
|
|
|
final profiles = FakeProfileGateway(profile: withLinks([]))
|
|
|
|
|
..linkingBack.add('https://halkyon.app');
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Add link'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.enterText(
|
|
|
|
|
find.widgetWithText(TextField, 'https://…'),
|
|
|
|
|
'https://halkyon.app',
|
|
|
|
|
);
|
|
|
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(profiles.linkChecks, 1);
|
|
|
|
|
expect(find.byTooltip('Links back to your profile'), findsOneWidget);
|
|
|
|
|
expect(find.textContaining('Checked:'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('Check links checks again, and says how many link back', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: withLinks(['https://halkyon.app', 'https://example.org']),
|
|
|
|
|
)..linkingBack.add('https://example.org');
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
expect(find.byTooltip('Links back to your profile'), findsNothing);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Check links'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Checked: 1 of 2 links link back to your profile.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(find.byTooltip('Links back to your profile'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('checking too often says so', (tester) async {
|
|
|
|
|
final profiles = FakeProfileGateway(
|
|
|
|
|
profile: withLinks(['https://halkyon.app']),
|
|
|
|
|
)..refuseLinkChecks = true;
|
|
|
|
|
await showProfile(tester, profiles);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Check links'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
find.text('The links were checked too often. Try again in an hour.'),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a check that ends after a save keeps what was saved, and adds the '
|
|
|
|
|
"check's answer to it", () async {
|
|
|
|
|
final profiles =
|
|
|
|
|
FakeProfileGateway(
|
|
|
|
|
profile: withLinks(['https://a.example']),
|
|
|
|
|
)
|
|
|
|
|
..linkingBack.add('https://a.example')
|
|
|
|
|
..checkWaits = Completer();
|
|
|
|
|
final bloc = PublicProfileBloc(ProfileRepository(profiles))
|
|
|
|
|
..add(const PublicProfileEventStarted());
|
|
|
|
|
addTearDown(bloc.close);
|
|
|
|
|
await expectLater(
|
|
|
|
|
bloc.stream,
|
|
|
|
|
emitsThrough(isA<PublicProfileStateReady>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
bloc.add(const PublicProfileEventLinksChecked());
|
|
|
|
|
await expectLater(
|
|
|
|
|
bloc.stream,
|
|
|
|
|
emits(
|
|
|
|
|
isA<PublicProfileStateReady>().having(
|
|
|
|
|
(state) => state.checkingLinks,
|
|
|
|
|
'checking',
|
|
|
|
|
isTrue,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
bloc.add(
|
|
|
|
|
const PublicProfileEventSaved(
|
|
|
|
|
displayName: 'Tizian',
|
|
|
|
|
bio: '',
|
|
|
|
|
links: ['https://a.example'],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
bloc.stream,
|
|
|
|
|
emitsThrough(
|
|
|
|
|
isA<PublicProfileStateReady>().having(
|
|
|
|
|
(state) => state.outcome,
|
|
|
|
|
'outcome',
|
|
|
|
|
PublicProfileOutcome.saved,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
profiles.checkWaits!.complete();
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
bloc.stream,
|
|
|
|
|
emits(
|
|
|
|
|
isA<PublicProfileStateReady>()
|
|
|
|
|
.having((state) => state.profile?.displayName, 'name', 'Tizian')
|
|
|
|
|
.having(
|
|
|
|
|
(state) => state.profile?.verifiedLinks,
|
|
|
|
|
'verified',
|
|
|
|
|
['https://a.example'],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.
An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.
In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.
Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
|
|
|
}
|
2026-10-12 19:07:42 +02:00
|
|
|
|
|
|
|
|
/// Saves only once [release] lets each through.
|
|
|
|
|
class _HeldProfileGateway({super.profile}) extends FakeProfileGateway {
|
|
|
|
|
final _held = <Completer<void>>[];
|
|
|
|
|
|
|
|
|
|
/// Lets the save waiting longest through.
|
|
|
|
|
void release() => _held.removeAt(0).complete();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<PublicProfile> save({
|
|
|
|
|
required String handle,
|
|
|
|
|
required String displayName,
|
|
|
|
|
required String bio,
|
|
|
|
|
required List<String> links,
|
|
|
|
|
}) async {
|
|
|
|
|
final held = Completer<void>();
|
|
|
|
|
_held.add(held);
|
|
|
|
|
await held.future;
|
|
|
|
|
return await super.save(
|
|
|
|
|
handle: handle,
|
|
|
|
|
displayName: displayName,
|
|
|
|
|
bio: bio,
|
|
|
|
|
links: links,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|