Profiles & display names

Every signed-in user has a public display name and an optional bio, and a public profile page that lists the lessons they've published. None of this exposes the user's email — the hub shows the chosen display name everywhere an author or commenter is named.

Display names

The first time a signed-in user reaches the app they're asked to pick a display name before they can use it. DisplayNameGate.jsx enforces this (wrapping the whole app in main.jsx), and DisplayNameDialog.jsx is the picker; a user can change their name later from the account menu.

The name is not a database column — it's stored in the Supabase user's user_metadata.display_name. The browser can't write metadata directly; it calls the Worker's POST /profile/display-name, which validates the name (and runs the same profanity / name-ban checks as publishing) and writes it through the Supabase Admin API. Because author is denormalised onto each lesson and comment row for fast listing, changing your name also backfills it onto your existing rows.

Bios

A bio is a short free-text "about me" shown on your profile page. It's edited in BioDialog.jsx and saved with POST /profile/bio, which caps the length, runs a profanity check (rejecting with 422 if it fails), and — like the display name — stores it in user_metadata.bio via the Admin API. An empty bio clears it. Bio is profile-only (never denormalised onto rows).

Profile pages

ProfilePage.jsx renders a user's public profile at /users/:id. It reads from the Worker's GET /profiles/:id, which returns the user's display name, bio and follower/following counts, plus their published lessons:

{
  "user": {
    "id": "…",
    "displayName": "Jordan",
    "bio": "Speller & teacher.",
    "followerCount": 12,
    "followingCount": 4,
    "isFollowing": false
  },
  "lessons": [
    {
      "id": "…",
      "title": "…",
      "author": "…",
      "sectionCount": 4,
      "createdAt": "…"
    }
  ]
}

The Worker resolves the profile via the Supabase Admin API and never returns the email — only the display name (falling back to "Anonymous") and bio. The endpoint is served under /profiles/:id on the Worker so it doesn't collide with the SPA's own /users/:id page. The read stays public; isFollowing is only meaningful when the request carries a session token (it reflects whether you follow this profile, and is false for an anonymous view).

Each profile also has a feed at GET /profiles/:id/feed.xml — an Atom feed of the user's lessons and comments (surfaced as "RSS" in the UI).

Following

Any signed-in user can follow another user from their profile page. A follow is one row in the follows table (follower_id → following_id, defined in apps/api/schema.sql), keyed by Supabase user id on both sides so it survives a display-name change. The profile header shows the Follow / Following button (never for your own profile) plus the follower and following counts.

  • POST /profiles/:id/follow (Bearer) — follow the user. Idempotent (ON CONFLICT DO NOTHING): re-following is a no-op, so it doesn't create a second row or re-notify. A genuinely new follow drops a follow notification into the followed user's bell. You can't follow yourself (400) or a user who doesn't exist (404).
  • DELETE /profiles/:id/follow (Bearer) — unfollow.

Both return { following, followerCount } so the button and count update without a refetch. The follower is always taken from the verified session, never the request body.

The follower/following counts in the profile header are clickable: they open a connections dialog (FollowListDialog.jsx) with Followers and Following tabs, each row linking to that user's profile. The lists are public:

  • GET /profiles/:id/followers — the users who follow :id.
  • GET /profiles/:id/following — the users :id follows.

Both return { users: [{ id, displayName, bio }] }, newest-follow first and capped (each id is resolved to its public profile via the Admin API, so no email leaks).

Your following feed

The signed-in home dashboard (HomePage.jsx) shows a "From people you follow" panel: the recent lessons and comments of everyone you follow, merged newest-first. It reads GET /following/activity (Bearer), which looks up the following_ids for the caller and merges those users' published lessons and comments into the same { id, title, summary, link, updated } shape the other dashboard feeds use. It returns an empty feed when you follow no one.

The frontend wrappers are setFollowing(), fetchFollowList() and fetchFollowingActivity() in src/lib/users.js; the Worker handlers are in apps/api/src/routes/follows.js.