db983e54d4
The library is the accounts' shelf, but the tab kept two copies of that fact: the screen read `/me` once as it mounted and then stopped listening, so a visitor who signed in at the studio came back to the panel asking for the login they had just given. One record, read by the studio, the landing page and the shelf alike, is now the only copy — a sign-in anywhere is a sign-in everywhere. - auth.ts (new): the tab's one session record. `useAuth()` reads it through `useSyncExternalStore`, a `/me` already in flight is joined rather than repeated, and a call that fails leaves the record *unknown* rather than signed out, so a cold API never reads as a guest. - TopBar: LIBRARY is a link for a member and a dead button for everyone else. An anchor wearing `aria-disabled` still navigates on a click, and the screen behind it would only send the guest home again — so a guest gets the shape of the button and nothing to press. - Library: the gate is that shared record, not a copy taken at mount. A guest who opens `/library` straight is turned home with `replace`, so a Back press does not walk them into the same wall. The screen is mounted the whole time the tab is open, so the turn is for the address the visitor actually opened: a guest on the studio stays on the studio. - engine/library.ts: the thumbs hand the thread back. Two lanes instead of four, a frame's worth of idle between them, and one job per frame — a second caller joins the decode already running instead of starting another. Verified in Chrome (stubbed API, harness run against both the dev server and the built bundle): a guest gets a dead LIBRARY button and stays on /app; signing in at the studio turns it into the link and opens the shelf, with no second login and no "log in to browse" note; the same after a sign-in on the landing page; /library opened cold as a guest lands on / with Back going to /app; opened cold signed in, the shelf is up. On a folder of 22 frames the open runs 2 decodes at a time (was 5) and repeats no frame (44 decodes, was 56 — 12 frames were read twice); the tiles still come out 22 on the open, 24 by hand, 22 again after a reload.
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
// Who this browser is signed in as, in one place.
|
|
//
|
|
// Three screens ask the same question — the studio (its account menu and its
|
|
// tier), the library (whether to open at all) and the top bar (whether the
|
|
// LIBRARY link is a link). Read separately, each of them answers it once and then
|
|
// keeps that answer for the life of the page — the library is mounted once and
|
|
// never asked again — so a visitor who signs in at the studio left the library
|
|
// behind holding "guest"
|
|
// — the next click on LIBRARY handed them a panel asking for a login they had just
|
|
// given. One record, one reader.
|
|
import { useSyncExternalStore } from 'react';
|
|
import { api, type User } from './api';
|
|
|
|
export interface AuthState {
|
|
user: User | null;
|
|
// The API has answered once: the account is known, guest or not. Before that
|
|
// `user` is null because nobody has said — which is not the same as "guest", and
|
|
// is why the LIBRARY link is held back rather than switched off, and why opening
|
|
// `/library` on a record nobody has answered for yet is not a reason to send the
|
|
// visitor home.
|
|
known: boolean;
|
|
}
|
|
|
|
let state: AuthState = { user: null, known: false };
|
|
let inFlight: Promise<User | null> | null = null;
|
|
const listeners = new Set<() => void>();
|
|
|
|
export function authState(): AuthState {
|
|
return state;
|
|
}
|
|
|
|
export function subscribeAuth(fn: () => void): () => void {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
}
|
|
|
|
// The snapshot is a value the store replaces only when it changes, so React can
|
|
// compare it by identity — one object per answer, not one per render.
|
|
export function useAuth(): AuthState {
|
|
return useSyncExternalStore(subscribeAuth, authState);
|
|
}
|
|
|
|
// Take the answer the caller already has — a login, a logout, `/me` on the studio's
|
|
// own mount — so every subscribed screen moves at once.
|
|
export function setAuthUser(user: User | null): void {
|
|
if (state.known && state.user === user) return;
|
|
state = { user, known: true };
|
|
for (const fn of listeners) fn();
|
|
}
|
|
|
|
// Ask the API again. One request at a time: the studio and the library both ask as
|
|
// they mount, and one page load is one round trip. A call that fails leaves the
|
|
// record as it was — the API being away is not the visitor's account ending.
|
|
export function refreshAuth(): Promise<User | null> {
|
|
if (!inFlight) {
|
|
inFlight = api
|
|
.me()
|
|
.then((me) => {
|
|
setAuthUser(me?.user ?? null);
|
|
return me?.user ?? null;
|
|
})
|
|
.catch(() => null)
|
|
.finally(() => {
|
|
inFlight = null;
|
|
});
|
|
}
|
|
return inFlight;
|
|
}
|