Household page and members
The Household page is where a person looks after the people they listen with: the
people on this device (Profiles), the
household members they added on their servers, and
Listening in the house. A household member is a real
account on the server (capability household_members), so what it may do is the
server's to decide (the
cross-repo contract);
the wire is Household members and its
client side State and data. On the device a
member is just another connection in a profile of its own.
The user-facing side is Your household.
The page
HouseholdPage (src/components/household/household-page.tsx) is rendered in two
places: the phone You hub's household segment (between Settings and Account,
embedded, since the hub's large title already says Household) and the pushed page
/household (src/app/(app)/(home,library,search,offline,me)/household.tsx, in the
array group like /settings, so back returns where the listener was and a cold link
opens it in Home). Every way in to the pushed page goes through openHousehold()
(src/lib/open.ts, householdHref() in paths.ts; a no-op on the page already): the
profile menu, "Who's listening?" > Manage household, the link at the end of Settings'
App group, Home's house row and the web palette. In the phone You hub, the segmented
control and the embedded Settings' link (SettingsContent's onOpenHousehold) switch
to the Household segment with router.setParams instead. /settings?section=household (isHouseholdSection)
replaces itself with /household, so an old link lands on the page and back
doesn't bounce into the redirect.
The page is a plain column of three parts:
DevicePeople("On this device"): see Profiles.HouseholdMembers(src/components/household/members-section.tsx), below.HouseSharing(house-sharing.tsx): the per-server house sharing switches (Listening in the house).
The members section
HouseholdMembers renders, per connection of the person listening whose server has
household_members, the members list (useHouseholdMembers), a MemberCard each and
Add someone to your household with "N of M added" (AddMemberDialog), or one line
for the list's reason (addBlock, which adds at_cap from members.length >= max).
A member's own page names their owner instead ("Adam manages your account.", "Only
Adam can add people or change what you can listen to."; see
Naming the owner). The pure rules are household-model.ts (the
name rule, the default access per kind, collectionIdsFor, the error mapping,
memberConnection, confirmsName).
Refusals are read by kind with apiErrorKind (see
Error mapping) and said in the app's
own words: writeError / writeErrorText for the member writes, hereFailure for
signing a member in here.
Adding a member
AddMemberDialog: the form is the name (cut to 40 code points, as the server counts
them), the Kids switch and AccessPicker; the access default follows the Kids switch
(kid: collections, grown-up: all) until the owner picks one. "Create <Name>'s
books" creates nothing yet: it adds a pending, ticked row ("New collection, created
when you save"); when the owner already has a collection of that name the button is
"Share <Name>'s books" and ticks that one instead. On save,
useMemberBooks().run(booksName, write) creates the collection first, runs the write
with its id in collection_ids, and deletes it again when the write is refused (a
4xx or a CapabilityError); with no answer, or a 5xx, the write may have landed, so it
stays and the message says so (booksRunText). Then:
- a kid is
addMember({ …, sign_in_here: true }), and itssessiongoes tosignInMemberHere, then the offer to set the owner's PIN when their profile has none; - a grown-up is
addMember, thenMemberPairing(memberPairing) shows the QR, with "They'll also use this phone" (useSignInHere:memberSessionthenaddMemberHere).
A member's card
MemberCard carries:
- what they can listen to, and its change dialog (the same picker; moving
collectionstoallsendscollection_ids: []throughcollectionIdsForand says first that it stops sharing); - the Kids switch (
PATCH kind, confirmed when it makes a kid; when the member is signed in here, their connection'suser.member_kindis updated at once so their password card and kids mode follow); - their devices (
useMemberDevices, each with Sign out; their session on this device is marked "This device", found by reading the member's ownGET /me/deviceswith their token here, the only list that markscurrent, and signing that one out marks their connectionneedsReconnectat once withmarkNeedsReconnect); - "Set up their own device" (
MemberPairing), and "Sign in on this device" while they aren't here; - Rename (the server's name and, when they are here, their profile's);
- "Delete their account" (
ConfirmDialog, then typing the name,confirmsName), kept apart from Remove from this device (shown while they are here; see Profiles).
Signing a member in here
addMemberHere(session, { name }) (src/components/connect/add-member-here.ts) is the
variant of finishConnect that signs a household member in beside the owner, with
no navigation:
- the member's connection is stored with the owner's
serverUrland server name (it is the owner's server, and the answer was read through the owner's client), and both connections learn the addresses the answer carried; setSession(..., { makeDefault: false }): the default connection stays where it is (see Session);placeConnection(id, { newProfile: true, keepActive: true, name }): a profile of its own named as the owner typed, and neverswitchProfile, so the owner stays the person listening and their book plays on;- a kid's account starts in kids mode with the default bedtime
(
kidsOnForKidAccount: see Kids mode).
It refuses, changing nothing, with AddMemberHereError and a code: already_here
when the device already holds that user on that server (memberSignedInHere; signing
them in again would replace the connection, PIN and all), choose_adult while the
person listening is in kids mode with a grown-up on the device (mayAddPerson), and
not_ready when the profiles can't be read or the device has no connection to that
server. It resolves to { connectionId, profileId }.
Around it, in src/components/household/use-sign-in-here.ts:
signInMemberHere(mint, member, serverId)asksmemberSignedInHerefirst (so no session is minted thataddMemberHerewould refuse), thenmint()(the add'ssession, ormemberSession) andaddMemberHere. It never throws: it resolves to{ status: 'done' | 'already' }or{ status: 'failed', reason? }, the reason beingaddMemberHere's code or the server's refusal (too_many,member_disabled,not_found).useSignInHere(owner)wraps it withuseMemberSessionand a busy flag.
Signing a member in again
A person on this device with a connection whose sign-in ended (needsReconnect) gets a
Signed out row with Sign in again (SignInAgainRow). When that connection is a
member the person listening owns (memberOwnerHere) and its token is dead (not a
server reset), it is useMemberSession then renewMemberHere(session) (in
add-member-here.ts), at once and with no password: only the token changes, through
the same store as the add, so the connection keeps its id, its person's profile, PIN
and kids settings, and the person listening and the default connection stay
(not_ready when the device doesn't hold that member). Otherwise it is
reconnectConnection, the reconnect banner's sign-in, which keeps the person.
Refusals say why in the app's words (sessionRefusalText: too many tries, a disabled
member, one no longer in the household).
ApiProvider (src/api/provider.tsx) reads a connection's answers again whenever its
token changes (useReadAgainOnSignIn): whatever failed while it was signed out, and
its server, libraries and collections lists (readAgainOnSignIn). It runs in the
provider's effect, after every screen's own, so the queries already hold the new
client, and a kid's shelf recovers without a reload.
Naming the owner
Copy that names a member's owner (the members section, kids mode's lines, the password
card, the sign-out warnings, the remove confirmation) goes through
managerName(user, { connection, people }) in src/lib/account.ts, or
useManagerName(connectionId, user?) in src/stores/profiles.ts (with
useDevicePeople()), never managed_by.username directly: the name of the profile
holding the owner's own sign-in to that server on this device, else the owner's
username (the player's managed_by carries no typed name; managerName would read a
display_name there only if a server sent one). kidsManager(profile, people) is the
same rule for a kids profile's connection.
Testing
In src/components/household/: household-page.test.tsx, members-section.test.tsx,
member-card.test.tsx, household-model.test.ts and use-sign-in-here.test.ts;
add-member-here.test.ts in src/components/connect/; the member hooks in
src/api/hooks-members.test.tsx; the page's routes in
src/components/shell/route-tree-household.test.tsx and
route-tree-household-cold.test.tsx; src/api/provider.test.tsx (read again on a new
token).