feat(feedback): report, suggest, and an inbox in the Profile tab

The app's side of stage 4 (ADR 0005, rule 11). The card's Report sheet
offers "Send to the author too" on a card from a release; a copy's menu
reports the studyset as a whole and lists what is reported and
suggested on it, as Explore's summary does; the editors of cards, notes
and steps suggest them to the author as the learner has them, never
under "no derivatives". The Profile tab holds the inbox: the threads on
the person's published studysets and those they wrote, the newest
activity first, with a count on the tab. A thread shows a suggestion
beside the author's own item and its replies; its writer and the author
reply, and the author accepts (the content becomes their own edit, its
files fetched from the writer's), declines, closes, hides or blocks.
Writing needs an identity; reading what is public doesn't.

ADR 0005 gets an addendum for stage 4.

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 01:29:09 +02:00
commit 1c1a1aada8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -159,6 +159,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.