Functions
Each exported function.
Function: PosPage
- Purpose: App Router page for
/pos. WrapsPosScreeninAppShellandOnboardingGate screen="profile". - Inputs: None.
- Returns / side effects: The page element. No direct I/O.
- Used by: Route
/pos.
Function: PosAmountPage
- Purpose: App Router page for
/pos/amount. WrapsPosAmountinAppShellandOnboardingGate screen="profile". The top-left arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. It does not jump to/pos. - Inputs: None.
- Returns / side effects: The page element. No direct I/O.
- Used by: Route
/pos/amount.
Function: PosScreen
- Purpose: Signed-in QR page on
/pos. RendersPosTill. - Inputs: None.
- Returns / side effects: The till card.
- Used by:
/pos.
Function: PosAmount
- Purpose: Amount-only card on
/pos/amount. Keypad and Create payment. No QR and no address. A failed till load shows the alert and Try again. A created charge, an already open charge, or a member who cannot charge returns to/pos. - Inputs: None. Reads the auth store.
- Returns / side effects: React element. Calls
createPosCharge. On success,router.replace('/pos'). - Used by:
PosAmountPage.
Function: PosTill
- Purpose: The QR card on
/posonly. Centered truncated 21.gifts address, the same Open CryptoPay QR as the profile card including on a smartphone, content-width Set an amount linking to/pos/amountwhen no charge is open, otherwise the open charge (countdown, including 0:00, sat amount, default fiat when a gift-day rate exists, and Cancel) until the server returns no charge. A slower refresh cannot replace a newer create or cancel. No keypad on this card. No paid status. The wallet does not mount this card. - Inputs: None. Reads the auth store.
- Returns / side effects: React element. Calls
fetchPosStateandcancelPosCharge. - Used by:
PosScreen.
Function: resetPosTillWriteForTests
- Purpose: Drop the module-level till write so a later test does not wait on a request the previous test left hanging.
- Inputs: None.
- Returns / side effects: Sets the in-flight write to null. Production clears that write when the request settles.
- Used by:
PosScreentests.
Function: fetchPosState
- Purpose:
GET /pos/chargefor the signed-in till. - Inputs: Bearer
sessionToken. - Returns / side effects:
{ charge, history }. Throws when the response is not OK. - Used by:
PosTill,PosAmount.
Function: createPosCharge
- Purpose:
POST /pos/chargewith{ amountSats }. - Inputs: Bearer
sessionTokenand a whole sat amount. - Returns / side effects: The created charge. Throws with the API error string.
- Used by:
PosAmount.
Function: cancelPosCharge
- Purpose:
DELETE /pos/charge. - Inputs: Bearer
sessionToken. - Returns / side effects: Resolves when the open charge is cancelled. Throws otherwise.
- Used by:
PosTill.
Function: proxyPosGet
- Purpose: Same-origin proxy of api
GET /pos. - Inputs: Incoming
Request. - Returns / side effects: Upstream response.
- Used by:
GET /pos/charge.
Function: proxyPosPost
- Purpose: Same-origin proxy of api
POST /pos. - Inputs: Incoming
Request. - Returns / side effects: Upstream response.
- Used by:
POST /pos/charge.
Function: proxyPosDelete
- Purpose: Same-origin proxy of api
DELETE /pos. - Inputs: Incoming
Request. - Returns / side effects: Upstream response.
- Used by:
DELETE /pos/charge.
Function: GET
- Purpose: Shared export name for App Router GET handlers. Healthz and
/maps/keyuseexport function GET(/maps/keyis always 200{ key: string | null }); same-origin api proxies re-export unique functions asGET(including/forum/messages,/forum/messages/placeswhich re-exportsproxyMessagesPlacesGet,/forum/messages/hiddenwhich re-exportsproxyMessagesHiddenGet,/forum/notificationswhich re-exportsproxyNotificationsGet,/messages/[id]/photo,/messages/[id]/photo/[file]({1-9}.{jpg|jpeg|png|webp}, proxy always requests{n}.jpgfrom the api),/conversations/[id]/messages/[messageId]/photo,/conversations/[id]/messages/[messageId]/photo/[file]({1-9}.{jpg|jpeg|png|webp}, proxy always requests{n}.jpgfrom the api),/messages/[id]/[file],/messages/[id]/repaymentwhich re-exportsproxyMessagesRepaymentGet,/view-key/[viewKey],/push/vapid-public,/trust/graph,/trust/proposalswhich re-exportsproxyTrustProposalsGet,/funding/applicationswhich re-exportsproxyFundingApplicationsGet,/funding/daily-rosterwhich re-exportsproxyFundingDailyRosterGet, and/funding/applications/[accountId]which callsproxyFundingApplicationGet)./translatere-exportsproxyTranslateAvailableGet(api GET/translate). HTML/messagesis the inbox page, not a GET proxy. HTML/notificationsis the notifications page, not a GET proxy. HTML/moderateis the moderation hub, not a GET proxy. HTML/moderate/hiddenis the hidden-notes page, not a GET proxy. HTML/moderate/proposalsis the confirm/reject queue, not a GET proxy. HTML/moderate/applicationsis the grant-application queue, not a GET proxy. HTML/grants/payments/commentand/grants/payments/amountsare the daily payment pages, not GET proxies. HTML/moderate/applications/[accountId]is the grant-application review, not a GET proxy. HTML/moderate/groupis the closed staff-room page, not a GET proxy. The signed-in HTML page/trust-chainisTrustChainPage, not this GET.GET /l/[code]redirects an 8-hex short code or callsnotFound().GET /links/[code]re-exportsproxyShortLinkGet. HTML/posis the till page, not a GET proxy;GET /pos/chargere-exportsproxyPosGet./forum/messages/[id]/editsre-exportsproxyMessagesEditsGet. - Inputs: Incoming
Requeston proxy routes (plus asyncparamson dynamic photo,/messages/[id]/photo/[file](id+filematching{1-9}.{jpg|jpeg|png|webp}; proxy always requests{n}.jpgfrom the api),/conversations/[id]/messages/[messageId]/photo(id+messageId),/conversations/[id]/messages/[messageId]/photo/[file](id+messageId+filematching{1-9}.{jpg|jpeg|png|webp}; proxy always requests{n}.jpgfrom the api), file, and view-key); none on healthz or/maps/key;/translatetakes the incomingRequest. - Returns / side effects:
Response. Healthz is{ status: 'ok' }200;/maps/keyis always 200{ key: string | null };/translateis 200{ available: boolean }or 502; proxies return the upstream api response (JSON or raw photo/video bytes). - Used by: Container probes, browser/wallet same-origin calls, and
fetchTranslateAvailableviaGET /translate.GET /.well-known/nostr.jsonproxies NIP-05.GET /.well-known/lnurlp/[username]proxies LUD-16.
Function: OPTIONS
- Purpose: CORS preflight for
/.well-known/nostr.jsonand/.well-known/lnurlp/[username]. - Inputs: none.
- Returns / side effects: 204 with
Access-Control-Allow-Origin: *. - Used by: Damus NIP-05 fetch and LUD-16 payRequest preflight.
Function: isForumVideoFile
- Purpose: True when a picker file is MP4, WebM, QuickTime, or MPEG-4 video (type
video/mp4/video/webm/video/quicktime/video/x-m4v, or.mp4/.webm/.mov/.m4vname). - Inputs:
File. - Returns / side effects: boolean.
- Used by:
ForumLoaderattach control.
Function: prepareForumVideo
- Purpose: Size-check (32 MiB) and capture a first-frame JPEG poster when the browser can decode the clip. If capture fails (iPhone HEVC / Dolby Vision), still return ok with a fallback JPEG poster.
- Inputs:
File. - Returns / side effects:
{ ok: true, video }withfile, JPEGposter, andpreviewUrl; or{ ok: false, error: 'unsupported' | 'tooLarge' }. Errors are only type/name (unsupported) and oversize (tooLarge) — a failed poster capture is not an error. - Used by:
ForumLoader.
Function: postMessageVideo
- Purpose: Multipart
POST /forum/messageswithvideo+ optionalposter, optionalgoalCurrencyandgoalAmount(trimmed draft, comma stays; omitted from the form when either is missing, and on replies), optionalplaceLat/placeLng/placeLabel(only when a pin is set; replies must not send them), and optionalshopUsername(leading@stripped; omitted when blank). NotgoalSats. - Inputs: session token,
{ text, video, poster?, goalCurrency?, goalAmount?, place?, shopUsername? }. - Returns / side effects:
ForumMessage. On 400, 404, or 429 uses the api error string when present; otherwise throwsCould not post your message. - Used by:
ForumLoadersubmit.
Function: forumVideoSrc
- Purpose: Build the same-origin forum video path for a message from its MIME type so playback uses
.mp4,.webm, or.movcorrectly. - Inputs:
messageIdstring and optionalcontentType(video/mp4|video/webm|video/quicktime| null | undefined). - Returns / side effects:
/messages/{id}/video.mp4|.webm|.mov(defaults to.mp4when type is missing or unknown). No I/O. - Used by:
ForumBoardplaybacksrcwhen no local preview URL is set.
Function: HandbookCopyLink
- Purpose: Client button beside a handbook heading, chapter, screen heading, or figure-card permalink. Copies
origin + pathname + #idto the clipboard, setslocation.hash, and flashes a check icon for 1.2s (textareaexecCommandfallback). - Inputs:
targetId(DOM id without#) andlabel(interpolated intohandbook.copyLinkviauseTranslationsas{ label }). Optionaltone('app' | 'dark', default'dark'). Copied Check usestext-accentwhen dark andtext-app-fgwhenapp. - Visible UI: Idle
Link2icon; copiedCheckicon. No visible "Copy link" or "Copied" text (titleandaria-labelkeep the accessible name). - Returns / side effects: A
<button type="button">. Clipboard write; hash update. No network. - Used by:
HandbookPage(page title),HandbookMarkdown(every heading),HandbookFigure,HandbookSectionHeading, andModerateHandbookScreen.
Function: HandbookMarkdown
- Purpose: Render parsed handbook markdown as Tailwind-styled headings, paragraphs, lists, links, and images. Every heading has a sibling
HandbookCopyLink. A paragraph whose only inline is an image becomes aHandbookFigure(thumbnail, lightbox, deep link) instead of<p><img>. - Inputs:
markdownstring andidPrefixfor heading ids. - Returns / side effects: React fragment. No network.
- Used by:
HandbookFunctionsPageandHandbookEndpointsPage.
Function: HandbookIntro
- Purpose: Server-presentational chrome for the
/handbooktitle, intro sentence, and section-navaria-label(already-translated copy). - Inputs:
title,introBefore,introAfter,navAria(already-translated strings),headingAction(node beside the h1, e.g. copy-link), andchildren(the section links). - Returns / side effects: Heading, intro with the api-handbook GitHub link, and a nav whose accessible name comes from
navAria. No network. - Used by:
HandbookPage,HandbookScreensPage,HandbookFunctionsPage,HandbookEndpointsPage.
Function: HandbookPage
- Purpose: Async Next.js hub for
/handbook. Resolves locale viagetRequestLocaleand links to/handbook/screens,/handbook/functions, and/handbook/endpointswithout dumping those markdown files. Title copy-link useshandbook.title; intro chrome via already-translated props onHandbookIntro. - Inputs: None (calls
getRequestLocale()). - Returns / side effects: The handbook hub inside
MarketingLayout. - Used by: Route
/handbook.
Function: StatsLoader
- Purpose: Client loader for
/stats. Fetches gift totals on mount and retry, ignores stale responses after unmount, and rendersStatsDashboard. - Inputs: None.
- Returns / side effects: React element. Calls
fetchGiftStats. - Used by:
StatsPage.
Function: StatsDashboard
- Purpose: Renders gift KPIs (
formatBitcoin(totalSats)plusformatFiatDisplayof the preferred fiat fromuseFiatPreference; a null fiat total is—, notCHF 0) and SVG diagrams (cumulative spend over time, by person, by month), plus loading/error/empty states. FiatPicker (CHF | EUR | USD | PHP) above the KPI cards only when unsigned (useHydrateSession().ready && session === null). Signed-in visitors still display and scale with the preferred code and cannot change it here. Total spend over time links each non-zero UTC day on the chart (not as a wrapping text list) to/stats/{day}. Each of Total spend over time, By person, and By month usesSegmentedControl tone="gift" shell="dark"for ₿ | {preferred FiatCode} via BarScale'btc' | 'fiat'(default ₿). Over time shows one cumulative series. Person and month rescale bar size while labels stay both units. Footnote is the USD daily-close sentence, or{code} is USD at each gift's UTC-day close, converted with that day's ECB rate.for CHF/EUR/PHP. - Inputs:
stats,error,loading,onRetry. - Returns / side effects: React element. Reads
useFiatPreference. No network. - Used by:
StatsLoader.
Function: StatsPage
- Purpose: Next.js page for
/stats. RendersStatsLoader. - Inputs: None.
- Returns / side effects: The statistics screen inside
MarketingLayout. RendersStatsLoader. - Used by: Route
/stats.
Function: GiftDayPage
- Purpose: Next.js page for
/stats/[day]. Invalid UTC days callnotFound(). Valid days renderDayLoader. - Inputs:
paramsPromise{ day }. - Returns / side effects: The day screen inside
MarketingLayout. - Used by: Route
/stats/[day].
Function: DayLoader
- Purpose: Client loader for
/stats/[day]. FetchesGET /gifts?day=, date input navigates, retry on error. FiatPicker on the loaded table only when unsigned (useHydrateSession().ready && session === null). The summary line is{n} gift(s) · ₿ · formatFiatDisplay(total, selected fiat, numberFormat). - Inputs:
dayUTCYYYY-MM-DD. - Returns / side effects: React element. Calls
fetchGiftDay. ReadsuseFiatPreferenceanduseNumberFormatand passes both intoGiftDayTable. - Used by:
GiftDayPage.
Function: GiftDayTable
- Purpose: Table of individual gifts on one UTC day (Time, Recipient, ₿, {FiatCode}), or empty copy No gifts recorded on this day.
- Inputs:
day: GiftDay,fiat: FiatCode, and requirednumberFormat(ch/us/de) for ₿ and fiat cells. - Returns / side effects: React element. Fourth column header is the selected code; cells use
formatBitcoinandformatFiatDisplaywithnumberFormat. No network. - Used by:
DayLoader.
Function: FiatPicker
- Purpose: Four-way CHF | EUR | USD | PHP control, no ₿. Optional
shelldefault'dark'AND optionaltonedefault'gift'(unsigned chart, stats, day). Profile settings (FiatPreferenceSwitcher) passtone="neutral". RequiredariaLabel(Profile and the unsigned activity chart pass catalogprofile.fiatCurrency). Chart scale stays a separate ₿ | selected fiat control. Unsigned chart / stats / day mounts meanuseHydrateSession().ready && session === null, not merely a null session. Forum, the public thread (PublicMessageLoader), and the pay sheet do not mount it. - Inputs:
value(FiatCode) andonChange; optionalshell('app' | 'dark', default'dark'); optionaltone('gift' | 'neutral', default'gift'); requiredariaLabel. - Returns / side effects: React element. No network.
- Used by:
FiatPreferenceSwitcher(always, signed-in profile);AccountActivityChart,StatsDashboard,DayLoaderwhen unsigned.
Function: fetchGiftDay
- Purpose: GET
/gifts?day=and parse the per-day gift list payload. - Inputs: UTC
daystring. - Returns / side effects:
GiftDay. Throws visitor copy on non-OK or invalid JSON. - Used by:
DayLoader.
Function: isUtcDay
- Purpose: Validate a UTC calendar day string
YYYY-MM-DD. - Inputs: Candidate
day. - Returns / side effects:
trueonly for a real calendar date. No I/O. - Used by:
GiftDayPage,DayLoader.
Function: proxyGiftsGet
- Purpose: Same-origin proxy helper for api
GET /gifts(forwardsday). - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: Route GET
/gifts.
Function: Home
- Purpose: Next.js page for
/. Marketing landing: pitch, how it works, why, project donate (#project, address[email protected]for running 21.gifts itself — distinct from/donateforum gifts), FAQ, CTAs to/login(Ask for help) and/donate(Send help), plusPwaInstall(tone="dark"placement="hero") after Send help, all viatranslatefor the negotiated locale. - Inputs: None. Calls
getRequestLocale(). - Returns / side effects: The home screen element.
- Used by: Route
/.
Function: LanguageSwitcher
- Purpose: Custom language listbox (not a native
<select>) that persists the visitor's override in alocalecookie and refreshes the App Router tree. Public / unsigned chrome only (Globe pill + absolute popover). Signed-in language lives on Profile. - Inputs:
tone(darkfor marketing chrome,lightfor login, donate, unsigned/rules, unsigned/messages/[id], and/view/[viewKey]). Reads current locale viauseTranslationsand an optional session token from storage. Noembeddedprop. - Returns / side effects: Combobox + absolute popover listbox with endonym labels. A same-locale click is a no-op. With a session token, a new locale first calls
bumpLocaleGeneration(), thensetAccountLocale(token, next, false); failure, a stale generation, or a response for a different account writes no cookie and does not refresh, while success merges onlylocaleonto the current same-id account before writing the cookie and refreshing. Without a token it does not bump or POST and writeslocale=<code>; Path=/; Max-Age=31536000; SameSite=Laxplus; Secureon HTTPS beforerouter.refresh(), as before. Never set on first visit. - Used by:
MarketingHeader(always visible),/login,/donate, unsigned/rules, unsigned/messages/[id],/view/[viewKey].
Function: LanguagePreferenceSwitcher
- Purpose: Profile identity-card settings section: uppercase
language.labelkicker andSegmentedControl tone="neutral"(default one-rowrounded-fulltrack, same as ThemeSwitcher) for English / Deutsch / Español / Filipino. Always visible on the signed-in Profile card. Not page chrome, not a Menu disclosure. - Inputs: None. Reads current locale via
useTranslationsand an optional session token from storage. Catalog keyslanguage.label,aria.language. Option labels are native endonyms (not catalogized). - Returns / side effects: Settings row matching
PushTogglechrome. A same-locale click is a no-op. With a session token, a new locale first callsbumpLocaleGeneration(), thensetAccountLocale(token, next, false); failure, a stale generation, or a response for a different account writes no cookie and does not refresh, while success merges onlylocaleonto the current same-id account before writing the cookie and refreshing. Without a token it does not bump or POST and writes the locale cookie, includingSecureon HTTPS, then refreshes as before. - Used by:
ProfileScreen.
Function: NumberFormatSwitcher
- Purpose: Profile identity-card settings section: uppercase
numberFormat.labelkicker andSegmentedControl tone="neutral"for the three sample labels (10'000.23/10,000.23/23.000,33). Always visible on the signed-in Profile card. Not page chrome, not a Menu disclosure. - Inputs: None. Reads
numberFormat/setNumberFormatfromuseNumberFormat. Catalog keysnumberFormat.label,aria.numberFormat. Option labels are samples fromformatGroupedNumber. - Returns / side effects: Settings row matching
PushTogglechrome. Pressing an option callssetNumberFormat(cookie write viaNumberFormatProvider). - Used by:
ProfileScreen.
Function: NameForm
- Purpose: Logged-in form to set or edit a display name. Onboarding (
variant="onboarding"): field at the top, Continue and labeled Skip at the bottom. Profile / overlay: icon-only actions (no Skip). OptionalonSavedafter a successful save. - Inputs: Reads
useAuthStore. User input: name string. Visitor-facing copy viauseTranslations. Empty and request failures are typed keys so they re-render after a locale change. - Returns / side effects: React element or
nullwhen logged out. POST/me/nameon save; POST/me/setup/skipon Skip. Mergesname,setup, andmissing. - Used by:
NameSetupon/setup/name,ProfileScreenon/profile, andRequirementsOverlay.
Function: LocationForm
- Purpose: Logged-in profile row to set, edit, or clear a free-text location. Icon-only actions (pencil / check / X / trash). Empty after trim is a valid save and clears. Not an onboarding step and has no Skip.
- Inputs: Reads
useAuthStore. OptionalstartEditingopens the field on mount. User input: location string. Visitor-facing copy viauseTranslations(location.*). Request failures uselocation.errorRequest. - Returns / side effects: React element or
nullwhen logged out. POST/me/locationon save or clear. Merges onlylocationso a concurrent name or address write is not overwritten. - Used by:
ProfileScreenon/profile.FundingApplyScreenrenders it in the apply walk, which is shown while applications are paused only for a verified account namedjoey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected.
Function: LightningAddressForm
- Purpose: Logged-in form to link, edit, or unlink a Wallet of Satoshi address. Onboarding (
variant="onboarding"): field at the top, Continue and labeled Skip at the bottom. Profile / overlay: icon-only actions (no Skip). OptionalonSavedafter a successful address save (not Skip or unlink). - Inputs: Reads
useAuthStore. User input: address string. Visitor-facing copy viauseTranslations. Empty, not-found, request, andnotZapfailures are typed keys (la.errorEmpty,la.errorNotFound,la.errorRequest,la.errorNotZap) so they re-render after a locale change. AfternotZap, Continue/Save stays disabled while the trimmed draft equals the blocked address; changing the draft clears the alert and re-enables; restoring the blocked address re-locks. Inline alerts (empty/notFound/request/notZap) are not separate screen variants. - Returns / side effects: React element or
nullwhen logged out. POST/me/lightning-addresson save; POST/me/setup/skipon Skip. Merges address fields plussetupandmissing. - Used by:
AddressSetupon/setup/address,ProfileScreenon/profile, andRequirementsOverlay.
Function: LocaleProvider
- Purpose: Client context provider that exposes the negotiated locale and a bound
thelper to visitor-facing components. - Inputs:
locale,messagesfor that locale, andchildren. - Returns / side effects: React provider element. No network; does not write cookies.
- Used by:
RootLayoutwraps every page; consumed viauseTranslations(see that function).
Function: NumberFormatProvider
- Purpose: Client context provider that exposes the negotiated number-format style and a setter that writes the
numberFormatcookie. Nest isLocaleProvider→NumberFormatProvider initial={numberFormat}→FiatPreferenceProvider initial={fiat}→ThemeProvider. - Inputs:
initial(NumberFormatStylefromgetRequestNumberFormat) andchildren. - Returns / side effects: React provider element.
setNumberFormatwritesnumberFormat=<id>; Path=/; Max-Age=31536000; SameSite=Laxand; Secureon HTTPS. Same-id is a no-op when the cookie is alreadych/us/de; selectingchwhile the cookie is absent still writes so the choice persists. - Used by:
RootLayoutwraps every page; consumed viauseNumberFormat(see that function).
Function: FiatPreferenceProvider
- Purpose: Client context provider that exposes the preferred fiat and a setter that writes the
fiatcookie. Nest isLocaleProvider→NumberFormatProvider→FiatPreferenceProvider initial={fiat}→ThemeProvider. - Inputs:
initial(FiatCodefromgetRequestFiat) andchildren. - Returns / side effects: React provider element. Syncs in-memory code to
initialwhen that prop changes and no valid cookie is set (locale default afterrouter.refresh()with no cookie). A validfiatcookie wins over a staleinitial.setFiatdoes not rewrite the cookie when it already equalsnext; in-memory state still syncs tonextif it is stale. Otherwise writesfiat=<code>; Path=/; Max-Age=31536000; SameSite=Laxand; Secureon HTTPS. - Used by:
RootLayoutwraps every page; consumed viauseFiatPreference.
Function: useFiatPreference
- Purpose: Reads preferred
FiatCodeandsetFiatfrom {@link FiatPreferenceProvider}. - Inputs: None (context).
- Returns / side effects:
{ fiat, setFiat }. Throws outside the provider. - Used by:
FiatPreferenceSwitcher,AccountActivityChart,ForumBoard,PublicMessageLoader,StatsDashboard,DayLoader.
Function: FiatPreferenceSwitcher
- Purpose: Profile identity-card settings row: uppercase
profile.fiatCurrencykicker plusFiatPickershell="app"tone="neutral"(same chrome as ThemeSwitcher / NumberFormatSwitcher / LanguagePreferenceSwitcher; selected isbg-app-btn, not orange). The only signed-in control that writes thefiatcookie. Unsigned chart / stats / day FiatPickers still write that cookie and keep defaulttone="gift". - Inputs: None. Uses
useFiatPreference,useTranslations, and an optional session token from storage. - Returns / side effects: A same-fiat choice is a no-op. With a session token, a new choice first calls
bumpFiatGeneration(), thensetAccountFiat(token, next, false); failure, a stale generation, or a response for a different account does not callsetAccountorsetFiat, while success merges onlyfiatonto the current same-id account beforesetFiat. Without a token it does not bump or POST and only callssetFiat. UnsignedFiatPickerinstances remain cookie-only. - Used by:
ProfileScreen.
Function: AccountPreferenceSync
- Purpose: Reconciles nullable signed-in account locale and fiat preferences with the screen and supported preference cookies once per account id.
- Inputs: Hydrated auth
sessionandaccount, screen locale, fiat context, preference generations, and the router. - Returns / side effects: Returns
null; fills explicitnullvalues withonlyIfUnset=trueonly when the generation captured before hydration still matches, mirrors a stored fiat into its cookie even when the visible code already matches, merges only that one field onto the current account, stops when the session is gone, ignores missing keys, and discards a stale response without replacing the other preference. - Used by:
RootLayoutinsideFiatPreferenceProvider.
Function: bumpLocaleGeneration
- Purpose: Marks a newer explicit locale choice so older asynchronous locale work cannot overwrite it.
- Inputs: None.
- Returns / side effects: Increments and returns the module-local locale generation counter.
- Used by: Language controls before an authenticated locale POST.
Function: localeGeneration
- Purpose: Reads the current locale generation for stale-response guards.
- Inputs: None.
- Returns / side effects: Returns the module-local counter without changing it.
- Used by: Language controls and
AccountPreferenceSync.
Function: bumpFiatGeneration
- Purpose: Marks a newer explicit fiat choice so older asynchronous fiat work cannot overwrite it.
- Inputs: None.
- Returns / side effects: Increments and returns the module-local fiat generation counter.
- Used by:
FiatPreferenceSwitcherbefore an authenticated fiat POST.
Function: fiatGeneration
- Purpose: Reads the current fiat generation for stale-response guards.
- Inputs: None.
- Returns / side effects: Returns the module-local counter without changing it.
- Used by:
FiatPreferenceSwitcherandAccountPreferenceSync.
Function: parseFiatCode
- Purpose: Accept a raw cookie/option string if it is exactly one of
CHF|EUR|USD|PHP; otherwise returnfallback. - Inputs:
value(optional string) andfallback(FiatCode). - Returns / side effects: A
FiatCode. No side effects. - Used by:
getRequestFiat,FiatPreferenceProvider.
Function: getRequestFiat
- Purpose: Cookie
fiatif valid; otherwisedefaultFiatForLocale(locale). Never writes. - Inputs: Request
locale. - Returns / side effects:
FiatCodefor this request. - Used by:
RootLayout.
Function: InAppBrowserView
- Purpose: Shared escape UI when a passkey ceremony cannot run inside Telegram or another in-app browser: heading Open this page in your browser, body copy, optional iOS hint, Open in browser, and Copy link.
- Inputs: None. Uses
useTranslations,openInSystemBrowser, andorigin + pathnameas the URL to open or copy (so on/view/<key>the invite URL is used). - Returns / side effects: Fragment with the escape controls. No WebAuthn. Clipboard via
execCommand('copy')fallback thennavigator.clipboard. - Used by:
LoginCard(in-app / unsupported branch) andViewProfileClaim(same branch under the public view card).
Function: LoginCard
- Purpose: Login UI: one Log in button (authenticate-first), an account-choice card after browser
NotAllowedError(Log in with existing account / Open a new account), an unknown-passkey card after authenticate finishUnknown credential(headinglogin.unknownHeading, mutedlogin.unknownBody, Open a new account and secondary Try again that callspasskey.login; dismissing the Try again login prompt stays on this card and does not open the account-choice card), preparing, error, or an in-app browser escape card viaInAppBrowserView(Open in browser + Copy link, no passkey ceremony). After success,OnboardingGateleaves/login. Open a new account opens the name form. Passkey and account exist only after a valid name is submitted and the create ceremony is finished. Error useslogin.errorplus Try again, except a wrong-account 403 (wrongAccountor passkey error equal to that api string) which useslogin.wrongAccountwith the same layout. Try again on that hint callsclearWrongAccountthenpasskey.login(neverretry, so it cannot create another account). Generic errors still callpasskey.retry. When the mounted user agent is iOS below 18, Start, Choice, and Unknown show a mutedlogin.iosVersionline (installed version and minimum iOS 18). When the mounted user agent is Android below 9, Start, Choice, and Unknown show a mutedlogin.androidVersionline (installed version and minimum Android 9). iOS is chosen when both would match. The alert is that same sentence whenpasskey.errorislogin.iosVersionorlogin.androidVersion, and the muted line is then hidden. Other errors keep their own copy and still show the muted line. The line is absent until after mount. Desktop pictures omit it. Phone pictures include it, because those baselines use an iPhone user agent below iOS 18. - Inputs: Uses
usePasskeyLogin,useAuthStore,isInAppBrowser,iosPasskeyBlock,androidPasskeyBlock, andInAppBrowserView. - Returns / side effects: React element covering idle/choice/unknown/name/starting/error/wrong-account/in-app. A signed-in account shows the preparing spinner until redirect. Detects in-app browsers after mount; never starts WebAuthn from the in-app card.
- Used by: Screen
/login.
Function: LoginPage
- Purpose: Next.js page for
/login. The visible heading lives inLoginCard(login.heading). - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLeft(wordmarkHomeWordmark:/unsigned,/welcomewhen a session is hydrated) andLanguageSwitchertop-right, wrappingOnboardingGatearoundLoginCard. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. Signed-in visitors are sent to/setup/name,/setup/username,/setup/address,/setup/rules, or/welcome. The recovery phrase is not part of that path. - Used by: Route
/login.
Function: DonatePage
- Purpose: Next.js page for
/donate. Guest-visible Send help explainer: pick a forum message, then send Bitcoin; CTA to/welcome. No address/amount form and no QR. - Inputs: None. Calls
getRequestLocale()for localized copy. - Returns / side effects:
AppShellwithProfileChromeLeft(wordmarkHomeWordmark:/unsigned,/welcomewhen a session is hydrated) andLanguageSwitchertop-right; heading, lead, Open the forumButtonLink. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. No OnboardingGate. - Used by:
- **Route /donate - Home CTA home.ctaSend - LanguageSwitcher on /donate**
Function: AddressSetup
- Purpose: Second post-login screen: Wallet of Satoshi address form after the name is saved. No
LogoutButton. - Inputs: Reads
account.namefromuseAuthStorefor the greeting. - Returns / side effects: Heading Your Wallet of Satoshi address at the top and
LightningAddressForm(variant="onboarding") with Continue at the bottom of the screen. NoLogoutButton. - Used by: Screen
/setup/address.
Function: AddressSetupPage
- Purpose: Next.js page for
/setup/address. - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLefttop-left (non-linkWordmarkspan) andSignedInChrometop-right, andOnboardingGatearoundAddressSetup. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. - Used by: Route
/setup/address.
Function: RulesSetup
- Purpose: Third post-login screen: one living-room rules chapter at a time. Intermediate Continue clicks only advance the chapter. The last I agree to these rules POSTs and merges
rulesAgreedAt,setup, andmissinginto the auth-store account. - Inputs:
chapters— ordered server-renderedRulesDocumentelements (one perRULES_CHAPTER_IDSid). - Returns / side effects: Heading, prompt, progress, current chapter, error alert, full-width Continue until the last chapter, then I agree to these rules, Wordmark is a non-link span. Chapter 0 shows one arrow that returns to the previous in-app view in this tab, or
/welcomewhen this tab has none. Later chapters replace that arrow with the previous-chapter arrow. One arrow. Continue and Back also reset the fill inner scroller to the top. POSTs/me/rules-agreementviaagreeToRulesonly on the last chapter. Rendersnullwithout a session or whenchaptersis empty. - Used by: Screen
/setup/rules.
Function: RulesSetupPage
- Purpose: Next.js page for
/setup/rules. - Inputs: None. Calls
getRequestLocale()/getCatalogfor the rules body. - Returns / side effects: Fill
AppShell(align="start") withSignedInChrometop-right andOnboardingGatearoundRulesSetup(RULES_CHAPTER_IDSmapped toRulesDocumentchapters,showNav={false},chapter={id}).RulesSetupportals the one top-left arrow: chapter 0 returns to the previous in-app view, or/welcomewhen this tab has none; later chapters reuse that slot for the previous chapter. - Used by: Route
/setup/rules.
Function: LogoutButton
- Purpose: Matching icon+text log-out inside the signed-in Menu dropdown (not a free top-right action); clears the session and returns the visitor to
/login. - Inputs:
useAuthStore.clearAuth,usePasskeyLogin.cancel,useRouter,disablePush. - Returns / side effects: Full-width Menu-row icon+text button (same row chrome as Home / Profile / Contact). Best-effort
disablePush(unsubscribe) while the session token is still valid, then clears the session androuter.replace('/login'); adisablePushfailure does not block log out. - Used by:
SignedInChromeMenu dropdown.
Function: NameSetup
- Purpose: Display-name form when
account.setup === 'name'. - Inputs: None besides
NameFormstore reads. - Returns / side effects: Heading Your name at the top and
NameForm(variant="onboarding") with Continue at the bottom of the screen. NoLogoutButton. - Used by: Screen
/setup/name.
Function: UsernameForm
- Purpose: Username field and Continue. Posts
POST /me/username. Cannot skip. - Inputs: Auth store session; optional
onSaved. - Returns / side effects: Taken/invalid/request stay on the form. Success updates the store and calls
onSaved. - Used by:
UsernameSetup,RequirementsOverlay.
Function: UsernameSetup
- Purpose: Post-login screen to choose the unique
@21.giftsusername. Cannot skip. - Inputs: Auth store session;
UsernameForm. - Returns / side effects: Heading Your 21.gifts name, hint, field, Continue. Posts
POST /me/username. Taken/invalid stay on the form. - Used by: Screen
/setup/username.
Function: UsernameSetupPage
- Purpose: Next.js page for
/setup/username. - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLefttop-left (non-linkWordmarkspan) andSignedInChrometop-right, andOnboardingGatearoundUsernameSetup. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. - Used by: Route
/setup/username.
Function: NameSetupPage
- Purpose: Next.js page for
/setup/name. - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLefttop-left (non-linkWordmarkspan) andSignedInChrometop-right, andOnboardingGatearoundNameSetup. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. - Used by: Route
/setup/name.
Function: openInSystemBrowser
- Purpose: Best-effort handoff from an in-app WebView to the system browser so the visitor can complete a passkey login or invite claim in Safari or Chrome.
- Inputs: Absolute
httpsloginurl, and optionalSystemBrowserHost(win; defaults toglobalThis.window). Missing window is a no-op. - Returns / side effects: On Android, sets
location.hrefto a Chrome Intent URL with an encoded fallback. Else ifTelegram.WebApp.openLinkis a function, calls it. Else on iOS Telegram (JS bridges or UATelegram), setslocation.hreftox-safari-+url. Otherwise callshost.open(url, '_blank', 'noopener,noreferrer'). No network of its own. - Used by:
InAppBrowserViewOpen in browser (viaLoginCardandViewProfileClaim), the/loginand/view/[viewKey]in-app e2e flows, and handbook coverage for those in-app variants.
Function: OnboardingGate
- Purpose: Hydrates the session and sends the visitor to the matching post-login screen (or keeps a complete account on
/profile,/wallet, and/members/[accountId]). - Inputs:
screen(login/wallet/name/username/address/rules/welcome/profile),children, and optionalallowGuest. Members usescreen="profile"./walletusesscreen="wallet"./welcomepassesallowGuest. - Returns / side effects: Children on the correct screen, otherwise a spinner. Follows
nextOnboardingPath. The recovery phrase is not a setup step and does not replace the opened page;nextOnboardingPathnever returns/wallet./walletitself stays on screen whensetupis'wallet'or the next step is/welcome.allowGueston/welcomerenders the children with no session instead of/login. Profile and members stay only when the next step is/welcome. Name, username, address, and rules still redirect when that is the next step. Otherrouter.replacetargets are/login,/setup/name,/setup/username,/setup/address,/setup/rules, or/welcome(nextOnboardingPathnever returns/profile). - Used by: Screens
/login,/wallet,/setup/name,/setup/username,/setup/address,/setup/rules,/welcome,/profile,/pos,/members/[accountId],/contact,/messages,/notifications,/moderate,/moderate/hidden,/moderate/proposals,/grants,/grants/apply,/grants/applications,/grants/applications/[accountId],/trust-chain,/shops,/statistics.
Function: SignedInChrome
- Purpose: Top-right signed-in chrome: one Menu control; open it for icon+label dropdown rows (Home
/welcomelucideHomenav.home— when the path is already/welcome, HomepreventDefaults and dispatchesFORUM_HOME_EVENTinstead of a no-op navigation; Shops (/shops, lucideStore,nav.shops); Point of sale (/pos, lucideBanknote,pos.nav); User Profile (/profile, lucideUser,profile.title) with no given or received amounts; Grants (/grants, lucideHandCoins,nav.grants) for every signed-in member; Wallet (/wallet); ScrollText Living room rules/rules; Habit-Tracker (/habit-tracker, lucideListChecks,nav.habitTracker); Trust Chain; Statistics (/statistics, lucideBarChart3,nav.statistics) for every signed-in account, immediately after Trust Chain, with no unread count; Moderation (/moderate, lucideShield,nav.moderate) only whenroleAtLeast(account?.role, 'moderator')—aria-labelnav.moderateUnreadwith{ count }whenmoderationUnreadCount> 0 (staff-room unread plus open-proposal count) elsenav.moderate; visiblenav.moderateplusml-autotabular-nums count when > 0; Notifications (/notifications, lucideBell,nav.notifications, unread countml-autoonly whenunreadCount> 0,aria-labelnav.notificationsUnreadthen); Messages/messages(nav.inbox, unread countml-autoonly when inbox unread > 0,aria-labelnav.inboxUnreadthen); MessageCircle Contact/contact; optional Download Install app viaPwaInstallplacement="menu"when install is offered; LogOut log out; then a quiet Version line (app.version,getAppVersion())). On mount with a session, callsresyncPushSubscription. Clicking Notifications asks for OS permission viaenablePushwhen it is not already granted and Service Worker plusPushManagerexist (otherwise resync, which no-ops without those APIs). Whenaccount.setupis null andaccount.hasPostedis false, also mountsIntroduceYourselfOverlay(Close dismisses this mount only; Write an introduction callsrequestForumComposeso a remount afterrouter.push('/welcome')stays hidden). - Inputs: Session
accountandsessionfromuseAuthStore(introduce overlay gate and push resync). ComposesuseUnreadCount(open)(default write-badge: writes the home-screen badge),PwaInstall(placement="menu", closes Menu viaonMenuAction), andLogoutButtoninside the Menu dropdown. - Returns / side effects: Relative Menu button (
aria-expanded,aria-controls) in the AppShell page-frame header ([data-app-chrome]). The panel stays mounted. Wide (frame content box at least 576px; untilframeWidthis measured,matchMedia('(max-width: 36rem)'), and a missingmatchMediacounts as wide): it portals to the trigger parent asabsolute right-0 z-50 mt-2 w-72(18rem, not a percentage, because a percentage would resolve against the trigger). When that wide panel's bottom would pass the window, the same panel usesmt-0,px-2 py-0, and the version line usespy-0. Rows staymin-h-11. If that compact panel still passes the window and a non-negative lift keeps the bottom inside, it is afixedoverlay. Itsstylesets the measuredtop,left, andwidthso the bottom sits on the window and the top is never less than 0. Thatleftis the trigger parent's right edge minus the panel width, so a horizontal move of the frame does not keep the last inline left. It does not scroll, and it does not shorten a row. When that lift would still leave the bottom outside, the wide menu uses the same sheet as a narrow frame (w-full,html[data-menu-sheet='1'], no scroll) and does not use a negative top. The Menu button isrelative z-[60]only while that fixed overlay is up, so a click on Menu still closes it when the panel covers the button, and a dialog in the same header still covers Menu. A window resize or a later change in the panel's own size measures again. The panel returns tomt-2andp-2only when the compact box still has 48px of room under the window, so the two layouts do not alternate. The scrim#signed-in-menu-scrimportals to[data-menu-scrim-host]inside[data-app-frame]asabsolute inset-0 z-40 rounded-3xl(the radius is set on the scrim; the host isdisplay: contents, soinheritwould be 0). Narrow (content box under 576px): it portals to[data-menu-sheet-host](px-8, the same horizontal inset as[data-scroll-page]) inside the one scrollport asw-fulland stays in that host while closed (hidden), reads the scrollportscrollTopbefore it setshtml[data-menu-sheet='1'](which hides[data-scroll-page]), sets that scroll position to 0, and writes the saved position back after the page is shown again. Not a body portal.PwaInstallstays mounted via thehiddenclass when closed. When open, icon+label rows: Home (/welcome, lucideHome,nav.home), Shops (/shops, lucideStore,nav.shops), Point of sale (/pos, lucideBanknote,pos.nav), Profile link (/profile, lucideUser,profile.title) with no given or received amounts, Grants (/grants, lucideHandCoins,nav.grants) for every signed-in member, Wallet (/wallet, lucideWallet,wallet.title), Living room rules (/rules), Habit-Tracker (/habit-tracker, lucideListChecks,nav.habitTracker), Trust Chain (/trust-chain), Statistics (/statistics, lucideBarChart3,nav.statistics) for every signed-in account, immediately after Trust Chain, with no unread count, Moderation (/moderate, lucideShield,nav.moderate) only whenroleAtLeast(account?.role, 'moderator')—aria-labelnav.moderateUnreadwith{ count }whenmoderationUnreadCount> 0 (staff-room unread plus open-proposal count) elsenav.moderate; visiblenav.moderateplusml-autotabular-nums count when > 0, Notifications (/notifications, lucideBell,nav.notifications, unread count on the right when greater than zero), Messages (/messages,nav.inbox, inbox unread count on the right when greater than zero), Contact (/contact), optional Install app, and log out, then a quiet Version line (app.version,getAppVersion()). Escape always closes Menu and restores focus to Menu. LocaluseStatedismissed flag forIntroduceYourselfOverlay(initialized fromconsumeSkipIntroduceOverlay); does not writeforumLawsDismissedor any account field. - Used by:
NameSetupPage,UsernameSetupPage,AddressSetupPage,RulesSetupPage,WelcomePage,ShopsPage,PosPage,ProfilePage,GrantsPage,FundingApplyPage,WalletPage,MemberProfilePage,ContactPage,MessagesPage,NotificationsPage,ModeratePage,HiddenNotesPage,ProposalsPage,FundingApplicationsPage,FundingApplicationDetailPage,TrustChainPage,StatisticsPage,HabitTrackerPage,RulesPageChrome,PublicMessageChrome.
Function: ProfilePage
- Purpose: Next.js page for
/profile. - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGatearoundProfileScreen. - Used by: Route
/profile.
Function: ProfileChromeLeft
- Purpose: Shared top-left chrome: one icon-only arrow (44px, ArrowLeft) plus a wordmark. The arrow returns to the previous in-app view in this tab, or
/welcomewhen this tab has none. One arrow. The wordmark is not that control. An ask-wizard or shop-wizard override replaces the history link with a button. OptionalonBackClickruns on an unmodified click (wallet hides the words or closes Advanced functions before leaving). Optionalwordmarkreplaces the default wordmark. OptionalwordmarkHrefdefaults to/welcomeand is ignored whenwordmarkis set.tone="dark"is the ink marketing header.hideWithoutHistoryomits the arrow only when this tab has no earlier view and no wizard override. - Inputs: Optional
onBackClick,wordmark,wordmarkHref,tone, andhideWithoutHistory; catalog viauseTranslations; previous path viapreviousViewPath. - Returns / side effects: A link (
profile.backwhen this tab has no earlier view, otherwisenav.back), the override button, or no arrow whenhideWithoutHistoryhas neither history nor an override, plus the wordmark. The first client render matches SSR (/welcome,profile.back) unlesshideWithoutHistoryhides that arrow. An unmodified primary click callsonBackClickorgoToPreviousViewand does not follow the href. Modified clicks follow the href. No network. - Used by:
ProfilePage,WelcomePage,MarketingHeader,LoginPage,DonatePage,PayLinkScreen,ViewProfilePage,NameSetupPage,UsernameSetupPage,AddressSetupPage,WalletChromeLeft,WalletScreenView(the visible/walletBack, viaAppShellTopLeft),ShopsPage,GrantsPage,FundingApplyPage,MemberProfilePage(/members/[accountId]),ContactPage,MessagesPage(viaMessagesChromeLeft),MessagesChromeLeft,NotificationsPage,ModeratePage,HiddenNotesPage,ProposalsPage,FundingApplicationsPage,FundingApplicationDetailPage,ModeratorGroupPage,TrustChainPage,RulesPageChrome,PublicMessageChrome.WalletPagemountsWalletChromeLeftas pagetopLeft; the card's registration wins while Wallet is shown.
Function: resetWalletReturn
- Purpose: Clear the stored
/walletreturn path. The top-left arrow does not read this slot. It returns to the previous in-app view in this tab, or/welcomewhen this tab has none. - Inputs: None.
- Returns / side effects:
void. Drops the shared tab slot andsessionStoragekey21gifts.walletReturn. Does not touchlocalStorage. - Used by: Wallet return tests,
RememberWalletReturntests,WalletChromeLefttests (afterEach).
Function: rememberWalletReturn
- Purpose: Remember a safe in-app path (not Wallet itself) as the
/walletback target. Rejects protocol-relative URLs,.., whitespace,#, overlong paths, and/walletitself without clobbering a previous good path. - Inputs:
pathstring (pathname, optionally with a query string). - Returns / side effects:
void. Writes aglobalThisslot andsessionStorage(21gifts.walletReturn) so every copy of the module in this tab sees the same path. NolocalStorage. No-op during SSR. - Used by:
RememberWalletReturn.
Function: walletBackHref
- Purpose: Read the path
rememberWalletReturnstored, or/welcomewhen this tab has not stored one. The visible top-left arrow does not use this value. That arrow uses the in-app view stack. - Inputs: None (reads the shared tab slot, which loads
sessionStoragethe first time). - Returns / side effects: The stored path string, or
WALLET_BACK_FALLBACK(/welcome). No network. The chrome arrow does not read this value. - Used by: Unit tests of the wallet-return slot. Not the chrome arrow.
Function: RememberWalletReturn
- Purpose: Client recorder mounted in the root layout. On each pathname/search change, calls
rememberWalletReturnwith the current in-app path. The top-left arrow does not read this slot. - Inputs:
usePathnameanduseSearchParams. Non-empty query is appended as?…. - Returns / side effects:
null. CallsrememberWalletReturnduring render and again from an effect. No network. - Used by:
RootLayout(insideThemeProvider, wrapped inSuspense).
Function: PushOpenListener
- Purpose: Listens for
21gifts-push-openfrom the service worker and callsrouter.pushonly for a same-origin path, so an already open installed phone page can follow the notification URL. On mount, when the page becomes visible, and when it returns from the back-forward cache, it also opens a path the worker stored in the21gifts-push-opencache (at most 60 seconds old) in case a frozen page missed the message. A query may contain://. The page answers the worker; if that answer does not arrive and the window cannot navigate, the worker opens the href. Each click has its own id, and only that id is dropped after the page follows it. - Inputs:
MessageEventonnavigator.serviceWorker, plus a JSON{ url, at, id }cache record at/push-open/{id}. Accepts onlydata.type === '21gifts-push-open'with a stringdata.urlthat starts with/, contains no\, and parses as this origin. A query or hash may contain://. Drops a missing, stale, future, or unsafe cache record. A click id that is no longer the stored id is left in place. - Returns / side effects:
null. Navigates withrouter.pushwhen the path is a same-origin in-app URL; ignores anything else. No network. - Used by:
RootLayout(insideThemeProvider, afterAccountPreferenceSync).
Function: WalletChromeLeft
- Purpose: Page
/walletchrome. RendersProfileChromeLeftonly. The arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. One arrow. The wordmark is not that control. - Inputs: None. The previous path comes from the view stack inside
ProfileChromeLeft. - Returns / side effects:
ProfileChromeLeft. The card's Back replaces this one while Wallet is shown. No network. - Used by:
WalletPage.
Function: MessagesChromeLeft
- Purpose: Client
/messageschrome. Always rendersProfileChromeLeft. It does not read?c=and it does not send every open thread to/messages. The view stack does: a thread opened from the list returns to the list; a thread opened from somewhere else returns there. - Inputs: None.
- Returns / side effects:
ProfileChromeLeft. The arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. One arrow. The wordmark is not that control. No network. - Used by:
MessagesPage.
Function: previousViewPath
- Purpose: Read the in-app path this tab showed immediately before the current view. The top-left arrow uses it. The wordmark is not that control.
- Inputs: None. Reads the tab slot. When the slot is missing, reads
21gifts.viewHistoryand restores it. An empty store stays missing. - Returns / side effects: The previous path, or
nullon the server and when this tab has no earlier in-app view. No network. - Used by:
ProfileChromeLeft,RulesSetup.
Function: goToPreviousView
- Purpose: Leave for the previous in-app view in this tab, or open
/welcomewhen this tab has none. Does not jump to a fixed parent and does not leave the site. - Inputs: None. Reads
previousViewPath. - Returns / side effects:
location.assignof the previous path, orlocation.assign('/welcome')when this tab has none. Does not callhistory.back()and does not pop the stack before that load, so a second click assigns the same path. The next document steps the cursor back to the opened path. No network. - Used by:
ProfileChromeLeft,RulesSetup,WalletScreenView.
Function: recordCurrentView
- Purpose: Record the current in-app path on this tab's view stack, or restore a stamped index on browser back and forward. A router
pushStateis a push, even whenhistory.lengthdoes not grow. A URL-changingreplaceStatereplaces the current entry once this document is anchored, so a replaced screen is not a previous view. The first path of a new document is a push, unless it is the arrow's one-shot target, which steps the cursor back. Unsafe paths are ignored. A real return to an earlier path is a new entry, not a collapse. The stack caps at 50 once a push is committed. A push that is not yet committed does not drop the oldest view; a later replace collapses that push first. A dropped entry keeps its absolute stamp, so browser back still finds it. - Inputs: A pathname, optionally with a query string. Optional
stampHistorydefaults to true. False updates the stack and does not writegiftsView, so the history entry being left keeps its stamp. - Returns / side effects: Updates the module slot and
sessionStorage(stackandcursor, plusbaseafter a capped drop). WhenstampHistoryis true, stampsgiftsViewwithhistory.replaceStatewithout pushing a history entry. A stored stack that contains an unsafe path is ignored. A thrown storage write leaves the slot intact. No network. - Used by:
ViewHistoryRoot.
Function: resetViewHistory
- Purpose: Clear this tab's in-app view stack so the top-left arrow opens
/welcomeuntil another view is recorded. - Inputs: None.
- Returns / side effects: Deletes the module slot and removes
21gifts.viewHistoryfromsessionStorage. No network. - Used by: Unit tests that isolate the view stack.
Function: ViewHistoryRoot
- Purpose: Record the current pathname and search on the in-app view stack and provide the chrome back override. The top-left arrow returns to the previous in-app view in this tab, or
/welcomewhen this tab has none. One arrow. The wordmark is not that control. - Inputs:
children— the page tree. - Returns / side effects:
ChromeBackProvideraroundchildren. A location recorder writes the stack during render without stamping history, and stampsgiftsViewfrom a layout effect after the router commits the history entry. A pathname the location has not reached yet is not recorded, so a query is not stored as its own hop. A replace committed after that render collapses the provisional push, so a replaced screen is not a previous view. Search comes fromwindow.location.searchwhen it matches the pathname, so a full navigation is stored before the load event. A suspended search recorder re-renders on a query-only change, including a cleared query, and records the live location — the same string as the location recorder, and only once that location has reached the pathname. No network. - Used by:
RootLayout, insideThemeProvider, besideRememberWalletReturn.
Function: useChromeBack
- Purpose: Read and set the top-left arrow override for an in-page step such as the ask wizard. Without a provider, the override is null and the setter is a no-op, so a wizard render does not throw.
- Inputs: None. Reads
ChromeBackProvidercontext when it is present. - Returns / side effects:
{ override, setOverride }. Does not navigate by itself. No network. - Used by:
ProfileChromeLeft,ForumAskWizard,ShopAddWizard, the welcome top-left chrome.
Function: ChromeBackProvider
- Purpose: Hold the top-left back overrides, one slot per caller. The chrome arrow runs the latest in-page step instead of the view history. Pay-sheet dismiss stays Close (
X), not this control. - Inputs:
children. - Returns / side effects: Context provider. Each caller has its own slot. The arrow shows the last set override. Clearing or unmounting a caller restores the previous. No network.
- Used by:
ViewHistoryRoot.
Function: ProfileScreen
- Purpose: Signed-in profile: single
max-w-smidentity card with a resting header (round profile photo fromGET /pictures/meand wide image fromGET /banners/mewhen that slot is stored; a missing one is the button Add a profile photo or Add a wide image; choosing a wide image opens a 5:2 cropper in place of Add a wide image (Add a profile photo stays; Use this crop stores the framed JPEG; the X (Cancel crop) stores nothing; saving the profile photo, including from About me, reloads the header and leaves that open crop in place; a failed or empty reload clears that header picture, and the Add button stays hidden until the reload settles); neither is the About me photo), a compact Given/Received activity chart, About me (AboutMeSectionowner: empty prompt + Write your About me, or filled text and/or photo + edit; Languages Translate on the filled read-only text whenaboutMessageIdis set; the editor is not translatable; copy-profile-link on the card — never a forum post), name, location, then the same public facts as/members/:id(MemberProfileScreenfactsOnly: role pill, funding-program icon (pressing it reveals that one sentence; on this screen the pressed result isfunding-program-press),[email protected], pay QR, Shop sticker, Posts/Reactions counts, and the activity feed), then Wallet of Satoshi address forms, thenPushToggle(Notifications pills: All/Active/Mentions always; This device On/Off when Push APIs are ready), a language settings row (LanguagePreferenceSwitcher) after push and before theme, a theme settings row (ThemeSwitcher), a fiat settings row (FiatPreferenceSwitcher), and a number-format settings row (NumberFormatSwitcher) last. Never showsforum.loadingon the card. The signed-in menu Profile row shows no amounts. Back + wordmark live inProfileChromeLeft. - Inputs:
useAccountTotalsfor bothreceiveOverTimeanddonateOverTime(also returnsfailed); pass both toAccountActivityChart;AboutMeSection(putAboutMetext plus optional photo,fetchAboutMePhotowhenaboutMeHasPhoto,fetchProfilePhoto,fetchWideBanner,name={account.name});NameForm,LocationForm, andLightningAddressFormfor edits;fetchMember(session, account.id)thenMemberProfileScreenwithfactsOnlyfor the public gifts facts;PushToggle;LanguagePreferenceSwitcher;ThemeSwitcher;FiatPreferenceSwitcher;NumberFormatSwitcher; catalog viauseTranslations. - Returns / side effects: When a profile photo or wide image is stored, that header is above the heading (wide image
aspect-[5/2], round photo overlapping its lower edge when both exist). Heading Profile, compact chart (empty:profile.chartEmptywith no chart FiatPicker, no SVG / no ₿|fiat scale; a thrown activity load showsprofile.chartError(role="alert") instead ofprofile.chartEmpty; in-flight staysprofile.chartEmpty; never Loading…; populated: legend + ₿ | selected fiat + SVG). The only FiatPicker on the card isFiatPreferenceSwitcher. About me, name form, location form, then the public member facts (role pill, funding-program icon (pressing it reveals that one sentence; on this screen the pressed result isfunding-program-press),[email protected], QR, Shop sticker, post/reaction counts, and the on-demand feed), then the Wallet of Satoshi address form, thenPushToggle(Notifications pills), Language (English / Deutsch / Español / Filipino), Theme (System / Light / Dark), Fiat currency (CHF|EUR|USD|PHP), and Number format (10'000.23/10,000.23/23.000,33) as the last settings row — all inside one identity card (no second panel). A failedfetchMembershowsforum.errorand Try again and leaves the editors up.MissingRequirementsErrorreplaces to/setup/rules. No Message button and no staff actions. Back + wordmark live inProfileChromeLeft. - Used by:
ProfilePage.
Function: AboutMeSection
- Purpose: Profile-card About me block: heading plus filled text and/or photo (the note photo shows the whole picture (
object-contain,max-h-80) and is not cropped, not the profile photo or the wide image), or the owner empty prompt (profile.about.emptyTell others who you are. and labeled Write your About me). Filled means trimmedaboutMeis a real bio (not the display name) orhasPhotois true. Owner mode can edit (write / pencil, save, cancel) viaonSave. The editor has three separate attaches, each its own ImagePlus and remove: the About me photo (JPEG/PNG/WebP viaprepareForumPhoto, no video), the round profile photo, and the wide image. Each previews on its own. Choosing a wide image opens a 5:2 cropper above its preview; Use this crop stores that JPEG and the X (Cancel crop) stores nothing. Closing the editor, or a successful save, drops an unsaved crop. A whole portrait is not stored as the wide image. Optional icon-only copy-profile-link (profile.copyLinkCopy link to this profile) whenprofileUrlis set; the URL is never shown as visible text. Public mode with no filled text, no photo, and no copy URL rendersnull. A filled read-only body usesTranslatableNoteBodywhenmessageIdis a non-empty string; otherwiseLinkedText. The editor is not translatable. - Inputs:
aboutMe(string | null),mode(owner|public), optionalname(string | null) for the filled comparison ((name ?? '').trim(); blank name applies only the trimmed-non-empty check), optionalhasPhoto, optionalloadPhoto(() => Promise<Blob>), optionalprofileUrl, optionalmessageId(stored About me note id; omitted or blank keepsLinkedText), optionalonSave(text, photo?)(photoomitted keeps,nullclears, object sets), optionalloadPicture/onSavePicturefor the round profile photo and optionalloadBanner/onSaveBannerfor the wide image (neither is the About me note photo; a non-image blob stays empty), optionalstartEditingto open the owner editor on mount. - Returns / side effects: React element or
null. Clipboard write for copy. CallsonSaveon owner save,onSavePicturewhen the profile photo changes, andonSaveBannerwhen the wide image changes. Loads a blob URL whenhasPhotoandloadPhotoare set and revokes it on unmount. Loads and revokes the profile-photo and wide-image blob URLs the same way whenloadPictureorloadBanneris set. - Used by:
ProfileScreen(owner,name={account.name}),MemberProfileScreen(public,name={profile.name}),ViewProfileScreen(public,name={profile.name}), andFundingApplyScreen(apply walk, shown while applications are paused only for a verified account namedjoey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected).
Function: PushToggle
- Purpose: Profile identity-card Notifications section: uppercase heading
profile.push.heading, aSegmentedControl tone="neutral"All / Active / Mentions (profile.push.level.all/active/mentions, groupprofile.push.level.label) whenever a session exists (including while Push APIs are still being inspected), a muted hint (profile.push.level.hint), and a second On / OffSegmentedControl(profile.push.on/off, grouparia.pushThis device) only whenserviceWorker/PushManagerare ready. No Bell / IconButton / trailing slot. Changing the level POSTs/me/notification-levelviapostNotificationLeveland mergesnotificationLevelinto the current store account when the session still matches; a second change while the POST is in flight is ignored; failure keeps the previous value and showsprofile.push.level.error. The device pill callsenablePush/disablePush; same-value or busy is a no-op. Renders nothing without a session. On iPhone Safari outside standalone, showsprofile.push.installHintunder the device pill. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations;postNotificationLevel/accountNotificationLevel/setAccount;enablePush/disablePush/isIosSafari/isStandaloneDisplay. - Returns / side effects: Heading, All/Active/Mentions control (always when a session exists), muted hint, and (when Push APIs are ready) a This-device On/Off
SegmentedControl. Level change callspostNotificationLevelthen mergesnotificationLevel; On callsenablePushwhen not subscribed, Off callsdisablePushwhen subscribed; may showprofile.push.unavailableon push failure orprofile.push.level.erroron level failure. - Used by:
ProfileScreen.
Function: useUnreadCount
- Purpose: Load signed-in unread notification, inbox, staff-room, and open-proposal counts in parallel (
GET /forum/notifications+GET /conversations, plusGET /conversations/moderator-groupandGET /trust/proposalswhenroleAtLeast(account?.role, 'moderator')).refreshKeyretriggers the fetches (Menu open). Does not mark notifications or conversations read. - Inputs:
refreshKeyboolean; optionaloptions?: { writeBadge?: boolean }(omit the object to default). Default writes the home-screen badge.writeBadge: falsefetches and returns the four counts but does not callsetUnreadAppBadge(including the logout / session-null0write). - Returns / side effects:
{ unreadCount, inboxUnreadCount, moderationUnreadCount, proposalCount }.unreadCountis the notifications unread count.inboxUnreadCountis the number of conversation rows withunread: true.proposalCountis the open-proposal list length.moderationUnreadCountis staff-room unread (0or1) plusproposalCount, for the Menu Moderation row. Hub Open proposals usesproposalCountalone. When badge writes are enabled (the default), the home-screen badge is notification unread + inbox unread + staff-room unread (0or1) — open-proposal count is not added (proposal rows already sit in notification unread) — written once all started fetches settle. A role below moderator skips the staff-room and proposals fetches and contributes0. A thrown staff-room or proposals fetch contributes0without failing the other sides. Either of the other sides failing contributes0to the sum; the other sides still write. Epoch skip applies to that sum write. No session → all four counts0; badge0only when writes are enabled andloadSession() === null(real logout). A hydrating store (sessionnull, token still in storage) does not clear the badge. A cancelled fetch updates neither React state nor the badge. - Used by:
SignedInChrome(default write),ModerateScreen(writeBadge: false).
Function: setUnreadAppBadge
- Purpose: Set or clear the installed PWA home-screen unread badge via the Badging API (
navigator.setAppBadge/navigator.clearAppBadge). Whencount > 0andsetAppBadgeexists, sets that number; otherwise clears whenclearAppBadgeexists. Missing APIs are a no-op. Rejections are swallowed so unsupported or denied badge writes never throw into the UI. - Inputs:
count(number) — notification unread plus inbox unread plus staff-room unread (0or1). Positive values request a badge;0(and any non-positive) request a clear. - Returns / side effects:
void. Fire-and-forget promises; does not await. No network. - Used by:
useUnreadCount,NotificationsLoader,refreshUnreadAppBadge,useAuthStore.clearAuth.
Function: bumpUnreadAppBadgeEpoch
- Purpose: Increment the home-screen badge epoch so in-flight unread fetches do not overwrite a mark-all-read clear, and after inbox mark-read so they do not overwrite the remaining sum.
- Inputs: None.
- Returns / side effects: The new epoch number.
- Used by:
NotificationsLoader,InboxLoader,ModeratorGroupScreen,useAuthStore.clearAuth.
Function: unreadAppBadgeEpoch
- Purpose: Read the current home-screen badge epoch. Capture before an async unread fetch; skip
setUnreadAppBadgeif it changed. - Inputs: None.
- Returns / side effects: Current epoch number. No network.
- Used by:
useUnreadCount,NotificationsLoader,refreshUnreadAppBadge.
Function: refreshUnreadAppBadge
- Purpose: Refresh the installed PWA home-screen badge to notification unread plus inbox unread plus staff-room unread (
0or1). SignaturerefreshUnreadAppBadge(sessionToken, inboxUnreadOverride?, moderationUnreadOverride?). FetchesGET /forum/notificationsand, unless an inbox override is passed,GET /conversations. When the moderation override is omitted, fetchGET /conversations/moderator-grouponly whenroleAtLeast(account?.role, 'moderator')(unreadtrue →1, else0; throw/404 →0); a role below moderator contributes0without starting the request. When the moderation override is set, skip that fetch. Any side failing contributes 0. Captures the badge epoch at start; skips the write if the epoch changed orloadSession()is not stillsessionToken. Never rejects. - Inputs:
sessionToken(string). OptionalinboxUnreadOverride(number) — when set, skip the conversations fetch and use that inbox unread count (e.g. the local list after mark-read). OptionalmoderationUnreadOverride(number) — when set, skip the staff-room fetch and use that count (0or1). When the moderation override is omitted, skip the staff-room fetch unless the signed-in auth-store account is at least moderator. - Returns / side effects:
Promise<void>. CallssetUnreadAppBadgewith the sum only when the epoch is unchanged andloadSession() === sessionToken. Fire-and-forget safe. - Used by:
InboxLoaderafter a successful thread load and mark-read (inbox override only; fetches staff-room only when the account is at least moderator).ModeratorGroupScreenafter opening the room (moderation override0).
Function: pushTagForNotification
- Purpose: Collapse tag for one in-app notification, matching the api:
forum_post:<parentId>,forum_reply:<replyId>,forum_mention:<replyId>,zap:<replyId>,moderator_appointed:<parentId>. Any other type, includingmoderator_proposal, is null. - Inputs:
type,parentId, andreplyId. - Returns / side effects: Tag string or
null. No network. - Used by:
NotificationsLoaderbefore it closes the local banner for a row that is not a proposal, andForumLoaderafter the welcome appointment pill'smarkNotificationReadsucceeds.
Function: currentPushEndpoint
- Purpose: Read this browser's Web Push subscription endpoint so a mark-read request can ask the api to skip this device. Calls
getRegistration()and does not wait onready. - Inputs: None. Uses
navigator.serviceWorkerwhen it exists. - Returns / side effects: The endpoint string, or
undefinedwhen Push APIs are missing, the lookup rejects, or the endpoint is empty. Never throws. - Used by:
markNotificationRead,markAllNotificationsRead,markNotificationsReadForMessage, andmarkVisibleForumNoteRead.
Function: closeLocalPushNotifications
- Purpose: Close shown Web Push notifications whose tag is in the list, so a note already read on this device does not stay on screen. Calls
getRegistration()and does not wait onready. - Inputs: Tag strings. An empty list is a no-op.
- Returns / side effects: Nothing. Missing
serviceWorkerorgetNotificationsis a no-op. Never throws. - Used by:
markAllNotificationsRead,markNotificationsReadForMessage,markVisibleForumNoteRead,NotificationsLoaderwhen a row is opened, andForumLoaderafter the welcome appointment pill'smarkNotificationReadsucceeds.
Function: vapidPublicKeyToBytes
- Purpose: Decode a VAPID application server public key (url-safe base64) to bytes for
pushManager.subscribe. - Inputs: Url-safe base64 public key string.
- Returns / side effects:
Uint8Array. No network. - Used by:
enablePush.
Function: registerPushWorker
- Purpose: Register the push-only service worker at
/sw.js(scope/) and wait until ready. - Inputs: None (uses
navigator.serviceWorker). - Returns / side effects:
ServiceWorkerRegistration. - Used by:
enablePush,disablePush,resyncPushSubscription.
Function: push service worker
- Purpose: Push-only service worker at
/sw.js. A payload whosetypeisdismisscloses the named tags, shows and closes a silentdismiss-ack, sets the badge tomax(0, floor(unreadCount))including 0, and returns before the Sunday check. On the device's local Sunday, a push whosetypeis not exactlyconversationdoes not stay on screen and does not change the badge: it callsshowNotificationwith tagsunday-quietand closes that note in the samewaitUntil, so the browser does not invent its own banner. Every other push, including a private message, shows a notification (registration.showNotification) and, whennavigator.setAppBadge(orregistration.setAppBadgeas fallback) exists, sets the home-screen badge: floorpayload.unreadCountfirst, use it when that integer is greater than 0, otherwise1.setAppBadgerejections are swallowed sowaitUntilstill followsshowNotification. MissingsetAppBadgestill shows the notification. A thrown weekday lookup does not pause notifications. No asset or offline cache. A notification click stores a short-lived21gifts-push-openentry so a reloaded page can still open the path whenpostMessagewas missed. - Inputs: Push
eventwith optional JSON payload (type,title,body,url,tag,tags,unreadCount). - Returns / side effects: When
typeisdismiss, closes the named tags, shows and closes a silentdismiss-ack, sets the badge tomax(0, floor(unreadCount))whenunreadCountis finite, and returns before the Sunday check. On a non-conversation Sunday push,event.waitUntilshows and closessunday-quiet. Otherwiseevent.waitUntilofshowNotificationplus optionalsetAppBadgeviaPromise.all. Install skips waiting; activate claims clients. Notification click closes the note, stores the in-app path in the21gifts-push-opencache, then the focused same-origin window (or the first one, if none is focused) is focused and, after that, receives{ type: '21gifts-push-open', url }(pathname + search + hash) beforenavigate(only when that function exists and the URL differs; rejection or null still focuses). No window:clients.openWindowon the same-origin href. Empty, invalid, or foreigndata.urlbecomes/welcome. - Used by: Browser Web Push runtime (registered by
registerPushWorker).
Function: isStandaloneDisplay
- Purpose: Detect installed / standalone display mode (
display-mode: standaloneor iOSnavigator.standalone). - Inputs: None (reads
window/navigator). - Returns / side effects:
boolean. No network. - Used by:
PushToggle,shouldOfferIosInstall,PwaInstall.
Function: isIosSafari
- Purpose: Detect iPhone/iPod stock Safari (Safari in UA, not CriOS/FxiOS).
- Inputs: None (reads
navigator.userAgent). - Returns / side effects:
boolean. No network. - Used by:
PushToggleonly.
Function: iosPasskeyBlock
- Purpose: Read an iPhone, iPad, or iPod user agent and decide whether a new account can finish. iOS 18 is the first version with the extra key this sign-in needs. Older iOS returns the installed version and the minimum. Other browsers, iOS 18 or newer, and an iOS user agent with no version token return null.
- Inputs:
userAgentstring (navigator.userAgent). - Returns / side effects:
{ installed, required }whererequiredis18andinstalledismajor.minorormajor.minor.patch, or null. No network and no DOM writes. - Used by:
LoginCard(muted line after mount) andusePasskeyLogin(a new account that cannot finish fails withlogin.iosVersionand is not stored).
Function: iosInstalledVersion
- Purpose: Read the installed iOS version from an iPhone, iPad, or iPod user agent, including iOS 18 and newer.
iosPasskeyBlockstill returns null on those newer versions. - Inputs:
userAgentstring. - Returns / side effects: Installed string (
major.minorormajor.minor.patch), or null when the token is missing or the browser is not iOS. No network. - Used by:
usePasskeyLogin, which puts that version at the front of a failed login or register diagnostic.
Function: androidPasskeyBlock
- Purpose: Android below 9 cannot finish a new-account passkey. Android 9 is the first version whose passkeys can carry the extra key. Older Android returns the installed version and the minimum. Android 9 and newer, other browsers, and an Android user agent with no version token return null.
- Inputs:
userAgentstring. - Returns / side effects:
{ installed, required }whererequiredis9andinstalledis major, major.minor, or major.minor.patch, or null. No network. - Used by:
LoginCard(muted line after mount) andusePasskeyLogin(a new account that cannot finish fails withlogin.androidVersionand is not stored).
Function: androidInstalledVersion
- Purpose: Read the installed Android version, including Android 9 and newer.
androidPasskeyBlockstill returns null on those newer versions. - Inputs:
userAgentstring. - Returns / side effects: Installed string, or null when the token is missing. No network.
- Used by:
usePasskeyLogin, which puts that version at the front of a failed login or register diagnostic.
Function: shouldOfferIosInstall
- Purpose: True when an iPhone/iPod browser whose UA contains Safari (stock Safari, Chrome CriOS, Firefox FxiOS, Edge EdgiOS) is not standalone and not an in-app browser — the condition for the iOS Home Screen install sheet.
- Inputs: None. Reads
navigator.userAgentplusisStandaloneDisplayandisInAppBrowser. Does not callisIosSafari. - Returns / side effects:
boolean. No network. - Used by:
PwaInstall.
Function: PwaInstall
- Purpose: Client install control for the PWA. First paint is
null(no layout slot). After mount, hidden when standalone or in-app. On iPhone Home Screen browsers (Safari, Chrome, Firefox, Edge) (shouldOfferIosInstall) shows a labeled control that opens a three-steprole="dialog"sheet (Share → Add to Home Screen → if Open as Web App is shown, leave it on). On Chromium, listens forbeforeinstallprompt(preventDefault, store event), shows the control, and on click callsevent.prompt()then drops the event (hides) regardless of accepted/dismissed;appinstalledalso hides. Placements:header(compact secondary),hero(secondary md),menu(SignedInChrome Download + label row). - Inputs:
placement(header|hero|menu); optionaltone(app|dark, defaultapp); optionalonMenuAction(menu row closes the Menu after click). Catalog viauseTranslations. - Returns / side effects: Install button and optional iOS sheet, or
null. No new dependencies; Tailwind only. - Used by:
MarketingHeader(tone="dark"placement="header"),Homehero (tone="dark"placement="hero"),SignedInChromeMenu (placement="menu").
Function: enablePush
- Purpose: Register the worker, fetch the VAPID key, request notification permission, subscribe, and POST the subscription to the api. Shares a serial queue with
resyncPushSubscriptionanddisablePush; a later disable no-ops a queued enable. - Inputs:
sessionToken. - Returns / side effects:
void. ThrowsNotification permission deniedorPush is not configured(and other api errors). - Used by:
PushToggle,SignedInChrome.
Function: resyncPushSubscription
- Purpose: When
Notification.permissionis alreadygrantedand a localpushManagersubscription exists, POST that endpoint to the api. Does not callrequestPermissionorsubscribe(). Opt-out (no local subscription) is a no-op. POST failure leaves the local subscription in place. Shares a serial queue withenablePushanddisablePush; a later disable no-ops a queued resync so it cannot POST after DELETE. - Inputs:
sessionToken. - Returns / side effects:
void. No-op when permission is notgranted, Push APIs are missing, orgetSubscription()is null. Throws api errors frompostPushSubscriptionwithout unsubscribing. - Used by:
SignedInChrome(mount and Notifications click when permission is already granted).
Function: disablePush
- Purpose: When a local push subscription exists, DELETE its endpoint on the api then
unsubscribe()locally. Bumps a generation so in-flight enable/resync cannot POST after this opt-out, and waits for the shared serial queue so DELETE is the last server mutation. - Inputs:
sessionToken. - Returns / side effects:
void. No-op when there is no subscription. Localunsubscribe()still runs if the api DELETE fails. - Used by:
PushToggleandLogoutButton.
Function: fetchVapidPublicKey
- Purpose: GET
/push/vapid-publicwith the bearer session and return the VAPID public key string. - Inputs:
sessionToken. - Returns / side effects:
string. ThrowsPush is not configuredon 503; other non-2xx throw with status. - Used by:
enablePush.
Function: postPushSubscription
- Purpose: POST
/me/push-subscriptionswith bearer +{ endpoint, keys }and validate the response. - Inputs:
sessionToken, subscription endpoint + p256dh/auth keys. - Returns / side effects:
void. ThrowsPush is not configuredon 503; 400 uses api error when present. - Used by:
enablePush,resyncPushSubscription.
Function: deletePushSubscription
- Purpose: DELETE
/me/push-subscriptionswith bearer +{ endpoint }. - Inputs:
sessionToken,endpoint. - Returns / side effects:
void. 404 is success (already gone). ThrowsPush is not configuredon 503. - Used by:
disablePush.
Function: proxyPushVapidPublicGet
- Purpose: Bearer proxy GET
/push/vapid-publicto the 21.gifts api. - Inputs: Incoming
Requestwith Bearer session. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/push/vapid-public.
Function: proxyMePushSubscriptionsPost
- Purpose: Bearer proxy POST
/me/push-subscriptionsto the 21.gifts api. - Inputs: Incoming
Requestwith Bearer session and JSON body. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/me/push-subscriptions.
Function: proxyMePushSubscriptionsDelete
- Purpose: Bearer proxy DELETE
/me/push-subscriptionsto the 21.gifts api. - Inputs: Incoming
Requestwith Bearer session and JSON body. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route DELETE
/me/push-subscriptions.
Function: manifest
- Purpose: Next.js
MetadataRoute.Manifestfor installable 21.gifts (/manifest.webmanifest). - Inputs: None.
- Returns / side effects: Manifest with name/short_name
21.gifts, start_url/welcome, displaystandalone, theme/background colors, apple-touch-icon at 180×180, plusicon-192.pngandicon-512.png. - Used by: App Router manifest route.
Function: AccountActivityChart
- Purpose: Compact dual-line cumulative SVG of Given and Received. FiatPicker only when unsigned (
useHydrateSession().ready && session === null; public view). Signed-in mounts (ProfileScreen,MemberProfileScreen, signed-in/view) omit it. Code fromuseFiatPreference. Empty/all-zero sats: unsigned shows picker plusprofile.chartEmptyrole="status"; signed-in empty isprofile.chartEmptyalone (no SVG, no ₿|{fiat} scale). Empty/all-zero sats andfailedshowprofile.chartError(role="alert",text-app-danger) instead ofprofile.chartEmpty; unsigned FiatPicker stays above. In-flight and successful empty stayprofile.chartEmpty, never Loading…. Nullusd/cumulativeUsdwith positive cumulative sats still draws the satoshi chart (legend + scale + SVG). Populated: unsigned shows picker, then legend +SegmentedControl tone="gift"options ₿ | selected FiatCode (profile.chartScale), then SVG; signed-in populated starts at legend + ₿|{code} scale. Scale state isActivityScale'sat' | 'fiat'. Ticks: satformatBitcoin; fiatformatFiatTick(USD may useformatUsdTick); USD scale uses an em dash when any rawcumulativeUsdisnull; em dash when every source cumulative for the selected non-USD code isnull. Wrapperrole="group"usesprofile.chartTitleasaria-label. No title heading; page heading is Profile. Given isdonatedOverTimefrom account activity (no longer a hardcoded zero series). - Inputs:
received(AccountActivity.receivedOverTime); optionaldonated(default[]) fromAccountActivity.donatedOverTime; optionalfailed(default false). - Returns / side effects: When unsigned, FiatPicker, then when the series is empty or all zeros:
profile.chartEmpty(role="status") — no legend, ₿|{fiat} scale, or SVG — orprofile.chartError(role="alert",text-app-danger) whenfailed. Signed-in empty isprofile.chartEmptyalone (orprofile.chartErrorwhenfailed). In-flight and successful empty stayprofile.chartEmpty, never Loading…. Nullusd/cumulativeUsdwith positive cumulative sats still draws the satoshi chart. Otherwise one chrome row (legend left, ₿ | selected FiatCode right) and SVG. Client state for scale; unsigned picker writes thefiatcookie. No network. - Used by:
ProfileScreen,ViewProfileScreen,MemberProfileScreen.
Function: Button
- Purpose: Labeled app button with primary (filled), secondary (bordered), or accent fill. Optional
toneapp(default) ordarkfor marketing-ink shells (same class split asButtonLink). Sizesm/md/lg(lgis full width). All sizesmin-h-11. - Inputs: Native button props plus optional
variant(defaultprimary), optionalsize(defaultmd), optionaltone(defaultapp), optional leadingicon, andchildrenlabel. Defaulttype="button". - Returns / side effects: A
<button>element. No network. Used across login, forum retry, public note retry, PWA install on dark shells, and forms. - Used by:
PublicMessageLoader,LightningAddressForm,ForumBoard,PwaInstall, setup and contact screens.
Function: ButtonLink
- Purpose: Labeled pill link matching
Buttonanatomy (primary/secondary/accent,sm/md/lg,toneappordark). - Inputs:
href, optionalvariant/size/tone/icon/className,childrenlabel. - Returns / side effects: A Next.js
<Link>for path hrefs, or a native<a>for protocol hrefs (https:). No network. Used on marketing CTAs, donate Open the forum, and rules nav. The 404 page does not use it. - Used by:
Home,MarketingHeader,DonatePage,RulesDocument.
Function: SegmentedControl
- Purpose: Mutually exclusive option group with
tonegift(compact ₿|USD cells, optionalshellapp/dark) orneutral(full-width pills for staff inbox and profile notifications). Gift hit target ismin-h-11 min-w-11on mobile and desktop.shellis ignored forneutral. Neutralrole="group"is the pill; the forum composer Post/Ask!grid grid-cols-2 !rounded-2xlstill lays out those option buttons viaclassNameon the group. The four forum view filters areForumModeSelect, not this control. Notrailingslot. - Inputs:
value,options(value+label, optionalbadge/badgeAriaLabel; chip omitted whenbadgeis missing or ≤ 0),onChange,ariaLabel,tone, optionalshell(defaultapp, gift only), optionalclassName(on the group). - Returns / side effects: A
role="group"track oftype="button"options witharia-pressed. Neutral is that group as the pill (classNamelands there, including the composer Post/Ask!grid grid-cols-2 !rounded-2xl). No network. - Used by:
ForumBoard(tone="neutral"),PushToggle(tone="neutral", twice: level + device),AccountActivityChart(tone="gift"),StatsDashboard(tone="gift" shell="dark").
Function: ForumModeSelect
- Purpose: Closed combobox for the living-room forum view (Active, No gifts yet, All, Most popular). The trigger shows the selected label and a chevron. A positive unpaid badge is also shown as a count chip on the closed trigger. Opening the trigger shows a listbox of option rows; the selected row has a check. Choosing a row calls onChange and closes the list. Shops does not mount it. Post/Ask stays a SegmentedControl.
- Inputs:
value;options(value,label, optionalbadge, optionalbadgeAriaLabel);onChange(value);ariaLabel(catalogforum.modeLabel). - Returns: A relative wrapper with the combobox button and, only while open, the listbox.
Function: IconButton
- Purpose: Icon-only control with a required
aria-label, variant (primary/secondary/ghost), size (sm/md/lg), and optionaltoneapp(default) ordarkfor marketing-ink shells (ghost+dark is paper hover andfocus-visible:outline-paper).smis 24px paint with a 44px::beforehit slop;mdis 44px;lgis 48px. - Inputs: Native button props;
aria-labelis required for accessible naming. Defaultvariant="secondary",size="md",tone="app",type="button". - Returns / side effects: A
<button>wrapping the icon child. No network. Used for attach/post/pay/copy/dismiss controls on the forum board and the handbook copy-link on marketing ink. - Used by:
ForumBoard,LightningAddressForm,InboxScreen,HandbookImageViewer,HandbookLightbox,ContactScreen,NameForm,RulesSetup,HandbookCopyLink.
Function: Card
- Purpose: Primary app content panel using semantic card tokens (
bg-app-card, border, shadow) with optional max-width (sm/md/xl).surfacedefaulttruedraws that nested visual panel.surface={false}is a width + flex + gap column for page body inside the AppShell frame (no radius, border, bg, shadow, orp-8). Card never hosts page chrome. - Inputs:
children, optionalclassName, optionalmaxWidth(defaultsm), optionalsurface(defaulttrue;falseomits panel classes). - Returns / side effects: A
<section>wrapper. No network. Nested panel for public notes and overlays; page-body columns (LoginCard, profile, welcome, inbox) usesurface={false}. - Used by:
PublicMessageLoader,LoginCard, profile and setup screens.
Function: AmountEntry
- Purpose: Every typed amount. Gift
SegmentedControl(₿ and the member's fiat code) plus the other unit under the field.keypad(the/pos/amountpage only) replaces the input with a non-focusable amount and an always-visible 3-column keypad. The page heading is already Amount, so the keypad does not repeat it; the amount keeps that name for assistive tech. Bitcoin offers no decimal key and ignores a typed decimal. Fiat shows the number-format decimal and keeps two fraction digits. A key scales down and darkens while it is held. When the amount changes and the device can vibrate, and reduced motion is off, it vibrates for 10 ms. Other screens keep the input. Switching the keypad to fiat rewrites the draft with the number-format decimal. Bitcoin shows the preferred fiat. Fiat showsformatBitcoin. The input box, its placeholder, and where the digits start do not change when the unit changes. A numeric placeholder is not rewritten. The switch and the counter do change. A signed-in toggle POSTs/me/amount-unitand writesaccount.amountUnit. No session keeps the choice on the control and starts at ₿. A locked invoice shows the sat amount and disables the switch.layout="composer"(inbox) puts the switch beside the input.layout="inline"(forum reply) puts the amount before the switch on one line. Both keep the label for assistive tech only and put the counter under the input. - Inputs:
label,value,onValueChange,rateDay, optionalid,disabled,placeholder,className,lockedSats,onUnitChange,layout(fielddefault,composer, orinline), optionalkeypad(default false; the/pos/amountpage only). - Returns / side effects: A labeled input, or with
keypada non-focusable amount plus the keypad and no input. The keypad does not show the label again. The switch and a counter line when an amount is defined. Signed-in toggle callssetAmountUnit. No other network. - Used by:
ForumAskWizard,ForumBoardpay sheet and reply,InboxScreen,PayLinkScreen,PosAmount.
Function: Field
- Purpose: Labeled text input or textarea using shared app field tokens; id is generated from the label when omitted.
- Inputs:
label, optionalid/className,multiline(textarea when true), plus native input or textarea attributes. - Returns / side effects: A
<label>wrapping an<input>or<textarea>. No network. - Used by: Labeled text primitive. Amount fields use
AmountEntryinstead.
Function: APP_HEIGHT_BOOTSTRAP_SCRIPT
- Purpose: Blocking bootstrap IIFE string injected as a raw head script before paint. Sets
--app-heightto the visible viewport (visualViewport.height, elseinnerHeight) and writesvisualViewport.offsetTopto--app-offset-top. The offset is never added into the height and the script does not takemaxwithinnerHeight. A taller frame would be a second scroll. Scale guard: skips both writes whenvisualViewport.scaleis present and not ≈ 1. - Inputs: None (constant string).
- Returns / side effects: Non-empty IIFE source mentioning
visualViewport,--app-height, and--app-offset-top. It does not mentionMath.maxand it does not add the offset into the height. - Used by:
RootLayout<head>script.
Function: AppHeightViewport
- Purpose: Minimal visual-viewport fields (
height, optionaloffsetTop, optionalscale) used to resolve--app-heightand--app-offset-topwithout depending on the DOMVisualViewporttype in tests.offsetToppositions the body and is not part of the height. - Inputs:
heightin CSS pixels; optionaloffsetTop; optionalscale(omit when unknown — do not passundefined). - Returns / side effects: A structural type only — no runtime value. Callers pass
{ height }or{ height, offsetTop, scale }intoresolveAppHeightandresolveAppOffsetTop. - Used by:
resolveAppHeight,resolveAppOffsetTop;useAppHeight(livewindow.visualViewport).
Function: resolveAppHeight
- Purpose: Chooses the pixel value for
--app-height. The result isvisualViewport.height(elseinnerHeight).offsetTopis not added. A frame taller than the visible viewport would scroll the document under the one scrollport. Pinch-zoom skip: returnsnull(caller must not write) whenscaleis present and not ≈ 1 (|scale - 1| > 0.01). Null/undefined visualViewport falls back toinnerHeight. - Inputs:
innerHeight(window.innerHeight);visualViewport(window.visualViewportor a stub; null/undefined allowed;offsetTopoptional and not added into the result). - Returns / side effects: Rounded CSS-pixel height, or
nullto skip the write. No DOM writes of its own. - Used by:
useAppHeight(AppHeightSync); bootstrap IIFE inlines the same visible-height path.
Function: resolveAppOffsetTop
- Purpose: Chooses the pixel value for
--app-offset-top, which positions the fixedbodyon the visual viewport. The value is roundedvisualViewport.offsetTop, or 0 when the viewport or the offset is missing. It is never added into--app-height. Pinch-zoom returnsnullso the caller skips both writes. - Inputs:
visualViewport(AppHeightViewportor null/undefined).scalepresent and not ≈ 1 (|scale - 1| > 0.01) skips the write. - Returns / side effects: Rounded CSS-pixel offset, 0 when there is no viewport, or
nullwhile pinch-zoomed. No DOM writes of its own. - Used by:
useAppHeight(AppHeightSync); the bootstrap IIFE writes the same offset before paint.
Function: useAppHeight
- Purpose: After hydration, keeps
--app-heightequal tovisualViewport.height(elseinnerHeight) and--app-offset-topequal to the visual-viewport offset so the fixed body sits in the visible area. The offset is never added into the height. Pinch-zoom skips both writes. Viewport resize and scroll reveal the focused field; focus does the same on the next animation frame. - Inputs: None (reads
window.visualViewport/innerHeightinside auseEffect). - Returns / side effects:
void. Sets--app-heightand--app-offset-topondocument.documentElement. Registers window resize/orientationchange, document focusin/focusout, and visualViewport resize/scroll listeners; cleans them up on unmount. Reveals the focused input, textarea, or select inside the active scrollport. - Used by:
- **AppHeightSync (same file; root layout mount) - Every hydrated app page (via that mount) - Fill/flow AppShell layouts** that consume --app-height
Function: AppHeightSync
- Purpose: Client-only root mount that calls
useAppHeightso--app-heightstays the visible viewport height and--app-offset-topkeeps the fixed body on that viewport after the blocking bootstrap script runs in<head>. The offset is never added into the height. - Inputs: None.
- Returns / side effects: Renders
null; side effect is the hook. Mounted as the first child of<body>inRootLayout. - Used by:
- **RootLayout** (src/app/layout.tsx) - All app and marketing routes under that layout - **useAppHeight consumers** that rely on a single shared mount
Function: AppShell
- Purpose: App page shell driven by
--app-height. Always draws onerounded-3xlpage frame.fillandflowshare that geometry: locked height, framegrow shrink basis-0 self-stretch(notflex-1), frame-header chrome row, one[data-scrollport](Scrollport), footer host. The document does not scroll. Prefer this over Tailwind viewport-height utilities on app routes. Chrome (wordmark + Menu / language) is the frame’s first row ([data-app-chrome]).<main>has nooverflow-hidden. The frame ([data-app-frame]) publishes its content-box width asframeWidth.[data-menu-scrim-host]sits on the frame.[data-menu-sheet-host](px-8, the same horizontal inset as the page) and[data-scroll-page]sit inside the one scrollport. Card never hosts page chrome. Neverjustify-centeron<main>or the scrollport. The center wrapper setsjustify-content: centerand thensafe center, so content that fits stays centered, and wheresafeis supported a thread taller than the frame starts at the top and remains scrollable inside that one scrollport. - Inputs:
children, requiredmode(fill|flow; both values render the same frame), optionaltopLeft/topRight, optionalclassName, optionalalign(start|center). - Returns / side effects: A
<main>layout with a rounded page frame, chrome row, header/footer portals, and inner scroller.useAppShellScrollerreads that scroller from context. No network. - Used by:
- Fill and flow app routes (LoginPage, DonatePage, setup, contact, inbox, notifications, public note, ProfilePage, WalletPage, ShopsPage, ViewProfilePage, MemberProfilePage) - **PageChrome** (still mode="flow"; AppShell draws the unified frame — welcome and public rules) - **AppShellHeader / AppShellFooter / AppShellTopLeft slot registrars - useAppShellScroller** (ForumBoard pull-to-refresh, ForumLoader atTop / scroll-to-top, InboxScreen open-thread pin to bottom / one-shot list reset to top)
Function: Scrollport
- Purpose: The only layout scrollport. Renders
[data-scrollport]. Overflow is theglobals.cssrule, not a Tailwind utility: clip untildata-scroll-active, then the active port scrolls vertically only (overflow-x: clipandoverflow-y: auto). The innermost bound port scrolls. Among siblings, the most recently bound one scrolls and getsdata-scroll-active. Every other port getsdata-scroll-locked.htmlandbodystayoverflow: clip. - Inputs:
children, optionalclassName, optionalscrollRef, optionalonClick. - Returns / side effects: A
div. Binds on mount and releases on unmount. No network. - Used by:
AppShell, marketing layout,NotFound,HandbookLightbox.
Function: bindScrollport
- Purpose: Push a scrollport onto the active stack and resync. Binding the same element twice does not stack it twice. The innermost bound port may scroll; among those, the most recently bound. That port gets
data-scroll-active. - Inputs: The mounted scrollport element.
- Returns / side effects: Sets
data-scroll-activeon the port that may scroll anddata-scroll-lockedon the others, and clips stray scrolling elements. No network. - Used by:
Scrollporton mount.
Function: releaseScrollport
- Purpose: Remove a scrollport from the active stack and resync. The innermost remaining port scrolls; among siblings, the most recently bound. Releasing an element that is not on the stack does nothing.
- Inputs: The unmounted scrollport element.
- Returns / side effects: Sets
data-scroll-activeon the port that may scroll anddata-scroll-lockedon the others, and clips stray scrolling elements. No network. - Used by:
Scrollporton unmount.
Function: syncScrollSurfaces
- Purpose: Leave exactly one scrollport unlocked. Every other element whose computed overflow can scroll (
auto,scroll, or legacyoverlay, on either axis) is forced to clip. Textareas grow to their text instead. Inputs and selects are left to the browser. - Inputs: None. Reads the active stack and
document.body. - Returns / side effects: Sets
data-scroll-activeon the one port that may scroll anddata-scroll-lockedon the others, and may set inline overflow or textarea height. No network. - Used by:
bindScrollport,releaseScrollport, andScrollSurfaceGuard.
Function: ScrollSurfaceGuard
- Purpose: Root watcher. On mount, and whenever class, style, or children change, it runs
syncScrollSurfacesso a scrolling overflow added later cannot become a second surface. Typing in a textarea resyncs immediately so the field grows instead of scrolling. - Inputs: None.
- Returns / side effects: Renders
null. Observesdocument.bodyand listens forinput. Disconnects on unmount. No network. - Used by:
RootLayout, mounted once for every page.
Function: useAppShellScroller
- Purpose: Returns the AppShell inner
[data-scrollport]element, ornulloutside AppShell. - Inputs: None (reads AppShell context).
- Returns / side effects:
HTMLElement | null. No network. - Used by:
ForumBoard(pull-to-refresh pageScrollTop),ForumLoader(atTop / scroll-to-top),InboxScreen(open-thread pin to bottom / one-shot list reset to top), AppShell unit tests.
Function: AppShellHeader
- Purpose: Registers flex-none header content into the nearest
AppShellpage frame (DOM portal into the shell<header>host). Without anAppShellancestor, renders children inline. - Inputs:
children(typically an onboardingh1). - Returns / side effects: Portal into the shell header host when present; otherwise the children. Layout only.
- Used by:
- **NameSetup - AddressSetup - RulesSetup**
Function: AppShellFooter
- Purpose: Registers flex-none footer content (CTAs) into the nearest
AppShellpage frame (DOM portal into the shell<footer>host;pb-8on that host). Without anAppShellancestor, renders children inline. - Inputs:
children(typically Continue / Skip / Agree buttons). - Returns / side effects: Portal into the shell footer host when present; otherwise the children. Layout only.
- Used by:
- **NameForm (onboarding) - LightningAddressForm (onboarding) - RulesSetup**
Function: AppShellTopLeft
- Purpose: Registers top-left chrome into the nearest
AppShellvia DOM portal; child registration wins over the pagetopLeftprop. The host is the frame chrome row ([data-app-chrome]), not a Card. Without anAppShellancestor, renders children inline. - Inputs:
children(back control + wordmark, etc.). - Returns / side effects: Portal into the shell top-left host when present; otherwise the children. Layout only.
- Used by:
- **RulesSetup (chapter back + wordmark) - WalletScreenView** (Wallet Back and wordmark; wins over the page topLeft) - **AppShell unit tests** (child portal wins over the page topLeft prop)
Function: PageChrome
- Purpose: Wrapper around
AppShell(stillmode="flow") with optional top-left (wordmark) and top-right (menu / language) slots. Chrome is the page-frame header, not page-absolute. PreferAppShelldirectly on app routes. - Inputs:
children, optionaltopLeft, optionaltopRight, optionalclassNameon the outer<main>. - Returns / side effects: Layout only (
AppShell mode="flow"; AppShell draws the unified frame). No network. - Used by: Flow app routes (
WelcomePage,RulesPage) plus unit tests and theuibarrel. Fill routes useAppShelldirectly.
Function: Wordmark
- Purpose: Text brand mark
21.gifts(header 17px/700, footer 15px/700). Link whenhrefis set; otherwise a<span>(marketing footer). - Inputs: optional
href, optionaltone(app/dark), optionalsize(header/footer, defaultheader), optionalclassName, optionalonClickforwarded to the link only. - Returns / side effects: A Next.js
<Link>or<span>. No network. - Used by:
HomeWordmark,ForumHomeWordmark,MarketingFooter,ProfileChromeLeft,RulesSetup, setup name/username/address pages, andRulesPageChrome.
Function: ForumHomeWordmark
- Purpose: Welcome-page wordmark: linked
21.giftsto/welcomethatpreventDefaults and dispatchesFORUM_HOME_EVENTsoForumLoadercan scroll to top and apply new notes without a full reload. - Inputs: None. Uses
WordmarkandFORUM_HOME_EVENT. - Returns / side effects: A client wordmark link. Dispatch only; no fetch of its own.
- Used by:
WelcomePage.
Function: HomeWordmark
- Purpose: Session-aware header wordmark: linked
21.giftsto/welcomewhenuseHydrateSessionis ready anduseAuthStorehas a session, otherwise/. Real navigation (nopreventDefault, unlikeForumHomeWordmark). - Inputs: Optional
tone,size,className,onClickforwarded toWordmarkwhen set. UsesuseHydrateSessionanduseAuthStore. - Returns / side effects: A client
Wordmarklink. Hydrates the session; no other network of its own. - Used by:
MarketingHeader,LoginPage,DonatePage,ViewProfilePage,PayLinkScreen.
Function: PublicMessageChrome
- Purpose: Client chrome wrapper for public
/messages/[id],/messages/[id]/author, and/messages/[id]/repayment-list: when a session is hydrated (ready && session !== null), mounts signed-in shell (ProfileChromeLeft+SignedInChrome); otherwiseProfileChromeLeftwith wordmark href/and lightLanguageSwitcher. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. - Inputs:
children(page body:PublicMessageLoaderfromPublicMessagePage,ExternalAuthorProfilefromExternalAuthorPage, orCreditLedgerfromRepaymentListPage). UsesuseHydrateSessionanduseAuthStoreforsession. - Returns / side effects: Fill
AppShell(align="center") with the matching top-left / top-right slots aroundchildren. No network beyond session hydration. - Used by:
PublicMessagePage,ExternalAuthorPage,RepaymentListPage.
Function: PublicMessagePage
- Purpose: Next.js page for
/messages/[id]— public HTML note by UUID. Unsigned visitors see a read-only thread. Signed-in React on the root note, copy, reply, and Gift on a payable nested reply run throughPublicMessageLoader→PublicMessageThread. NoOnboardingGate, top-level composer, or envelope. Wrapped inPublicMessageChrome(signed-in or unsigned chrome depending on hydrated session). - Inputs: Dynamic route params (
id). - Returns / side effects:
PublicMessageLoaderinsidePublicMessageChrome(chrome is no longer always unsigned Wordmark + LanguageSwitcher). Also exportsgenerateMetadatafor per-note Open Graph / Twitter tags. Unsigned chrome isProfileChromeLeftwith wordmark href/, not a bare wordmark. - Used by: Route
/messages/[id].
Function: generateMetadata
- Purpose: Next.js App Router metadata for
/messages/[id]. Loads the public note vialoadPublicMessageForOgand maps it throughpublicMessageOgMetadataso crawlers see the author, text, and photo without running JS. Missing or failed fetches inherit the root layout preview. - Inputs:
{ params: Promise<{ id: string }> }from the dynamic route. - Returns / side effects:
Promise<Metadata>— per-note Open Graph / Twitter tags when the note is found, or{}so the site-wide layout preview is inherited. Does not render the visible page. - Used by: Route
/messages/[id](PublicMessagePage).
Function: loadPublicMessageForOg
- Purpose: Server fetch of api
GET /messages/:idfor Open Graph. Invalid UUIDs skip the network. Timeouts, non-OK responses, JSON/schema failures, and thrown errors (getApiUrl, network, abort) all returnnulland never throw. - Inputs:
idstring from the route (forum message UUID). - Returns / side effects:
ForumMessageornull. Usescache: 'no-store',Accept: application/json, andAbortSignal.timeout(2500). - Used by:
generateMetadataon/messages/[id].
Function: publicMessageOgMetadata
- Purpose: Maps a loaded public note (or
null) to Next.jsMetadata. Found notes use the author name as title and never the marketing layout description, even whentextis empty. Photo notes setog:imageto/messages/{id}/photo; others keep/og.png. A note with anyviavalue (written without a 21.gifts account) gets fully generic metadata instead: titleExternal author on 21.gifts, descriptionA reply from someone outside 21.gifts who sent bitcoin to a post., and always the default/og.pngimage — nothing from the note'sname,textor photo reaches a link preview. Both strings are plain English constants, not from the catalog. - Inputs: Route
idandnote(ForumMessage | null). - Returns / side effects:
{}whennoteisnull. For a note with aviavalue the generic External title, description and default image described above. Otherwise title, description (trimmed text or `${name} on 21.gifts, truncated above 300 code units with…), Open Graph (type: website,urlhttps://21.gifts/l/plus the first 8 hex of a UUID id (otherwisehttps://21.gifts/messages/{id}),siteName21.gifts), and Twittersummary_large_image`. - Used by:
generateMetadataon/messages/[id].
Function: PublicMessageLoader
- Purpose: Client loader for the public thread page: validates UUID, waits for hydrate
ready, then fetches. Staff (roleAtLeast(account.role, 'moderator')+ session) usefetchForumMessage(session, routeId)andfetchReplies(session, root.id)so a soft-hidden row can load; others usefetchPublicMessage(routeId)/fetchPublicReplies. IfparentIdis set, fetches that parent (null→ missing) then replies of the parent. Ready only with root + replies (empty replies → parent only). Replies throw → error + Try again (whole chain). Until hydrateready, onlyforum.loading(no fetch, no cards). After ready, when the loaded root or the highlighted reply hasdeletedAt/deletedBy, arole="status"forum.hiddenNoticeline sits above the thread (permalink target). Unsigned visitors (no session or no account) keep thePublicThreadCardstack (no pay/copy): vertical stack parentCardthen reply Cards withpl-4. Gift-only replies (empty text, sats > 0): sats line viaformatBitcoinplus optional preferred-fiat·formatFiatDisplayof the amount stored when the payment was made (a stored string as-is, a null or missing field on a passedstoredobject uses the gift-day rate), no empty<p>. When the route id is a reply, that unsigned reply card (or wrapper) hasdata-permalink-target="true"andring-1 ring-app-fg. Photo/video per unsigned card viafetchPublicMessagePhoto. Unsigned gallery isForumPhotoGallery(horizontal snap row,data-scroll-x, 88% peek,current/totalchip, dots);data-photo-indexkeeps the original still index when an earlier extra still fails to load. Translate (Languages icon) under unsigned note and reply bodies viaNoteTranslatewhen the language differs from the UI locale. Avia === 'nostr'reply or note shows a non-interactive External<span>badge next to the name (no hint state on this card) and renders its body as plain text (TranslatableNoteBodyplain) instead ofForumQuotedBody. Each unsigned card is ₿ plus optional preferred-fiat·formatFiatDisplayof the amount stored when the payment was made (useFiatPreference; cookie, otherwise locale default); otherwise the gift-day rate. On an unsigned reply withreceivedSatsgreater than zero, that card usesReplyDirectionAmountsinstead of the single amount: sent and received stay separate lines and are not added. A top-level card keeps one amount, the collected total.fetchGiftStats/latestRateDayfeed the pay sheet and an unpaid invoice preview. A posted goal bar uses the stored visitor snapshot when that string exists, otherwise this gift-day rate. A top-level unsigned note with a positivegoalSatsalso showsForumGoalBar, and the same Loan, Donation, or #Shop tag as the forum beside the name. Whenready && session && accountand the root is loaded, mountsPublicMessageThreadinstead of the unsigned cards, passingseedReplyas the highlighted hidden reply (omit the prop when none). Auth CTA once below either stack fromuseHydrateSessionis Log in when unsigned. Signed-in visitors have no text back link under the thread. The top-left arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. - Inputs:
idstring from the route. Also readssessionandaccountfrom the auth store and hydrateready. - Returns / side effects: States loading / missing / error (with Try again) / ready unsigned stack or signed-in
PublicMessageThread. Malformed UUID → missing without an api call. Photo blob URLs revoked on unmount or id change. Inline<video>keeps the clip aspect ratio (max-h-80 max-w-full, no full-width black canvas). A failed<video>errorevent hides the player and falls back to the photo when present.NoteTranslateon unsigned note and reply bodies (GET/translateon mount, POST on Translate). Unsigned stack has no pay, composer, copy, or FiatPicker. Hydrate-not-ready is onlyforum.loading(no unsigned cards). A hydrate drop after a note is loaded keeps the cards and showsforum.loadingin the footer. - Used by:
PublicMessagePage.
Function: PublicMessageThread
- Purpose: Signed-in permalink thread: one root on
ForumBoardwithcomposerHiddenandtruncate={false}so the original body stays full, auto-expand viafetchReplies, and the same React, copy, reply, overlay, photo, and poll behavior as/welcome. React (forum.react, lucide Reply) on the root note; on every nested reply Gift when payable, copy (forum.copyReplyLink, the reply's own origin/l/<8 hex>, first group of that reply id) always, and trash for staff. StaffonDeletedon the root callsonRootDeleted(loader → missing); a nested reply is dropped from the list. PassespermalinkTargetIdso only a matching nested reply is ringed. OwnspayHostand passes it to ForumBoard: a compose-fee reply invoices 1 sat to 21.gifts on the composer slot (payHost: composer,payMessageId= compose-target note); extra gifts and Gift-open stay on the card (payHost: card). - Inputs:
{ root, highlightId, seedReply?, onRootDeleted }as insrc/components/PublicMessageThread.tsx:rootisForumMessage,highlightIdisstring | null(route id when it is a reply UUID),seedReplyis the opened hidden reply when the route id is a hidden child,onRootDeletedis() => void. Session and account from the auth store. - Returns / side effects: React tree. Auto-expands the root so Write a reaction is available. After Bearer
fetchRepliesof the root, mergeseedReplyinto the list when it is missing so the signed-in board still shows the hidden permalink target. PassespermalinkTargetId={highlightId}. No top-level composer or feed filters. OwnspayHostand passes it to ForumBoard: a compose-fee reply invoices 1 sat to 21.gifts on the composer slot (payHost: composer,payMessageId= compose-target note); extra gifts and Gift-open stay on the card (payHost: card).
- Used by:
PublicMessageLoader.
Function: ViewProfilePage
- Purpose: Next.js page for
/view/[viewKey]— public read-only profile by view key. NoOnboardingGate, noSignedInChrome. - Inputs: Dynamic route params (
viewKey). - Returns / side effects: Exports
metadata.referrer = 'no-referrer'.AppShellwithProfileChromeLeft(wordmarkHomeWordmark:/unsigned,/welcomewhen a session is hydrated) and lightLanguageSwitchertop-right; body isViewProfileLoader. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. The card itself has no back control. - Used by: Route
/view/[viewKey].
Function: ViewProfileLoader
- Purpose: Client loader for the public view page: validates the key, fetches the public profile, then
fetchViewActivityeven if the Lightning Address is blank. Does not useuseAuthStore. - Inputs:
viewKeystring from the route. - Returns / side effects: States loading / missing / error (with Try again) / ready card. In ready, renders
ViewProfileScreenplusViewProfileClaimunder the card (passesviewKeyandhasPasskeyfrom the fetched profile). Malformed keys (not 64 lowercase hex) → missing without an api call. AfterfetchViewProfile, always callsfetchViewActivity(even when address is blank) and maps both series onto the card. Activity failure still shows the card; the chart showsprofile.chartError, notprofile.chartEmpty. Successful empty series staysprofile.chartEmpty. Chart never swapped forforum.loading. - Used by:
ViewProfilePage.
Function: ViewProfileScreen
- Purpose: Presentational read-only identity card matching signed-in profile chrome: heading Profile,
AccountActivityChart, About me inside the card (not a forum post; publicAboutMeSectionwithname={profile.name}shows filled text and/or photo, or omits the heading when neither; Languages Translate whenaboutMessageIdis set), name, location, and public[email protected]rows (labelsname.heading/location.heading/profile.giftsHeading) without edit or Message actions. Unset location showslocation.unset. Missing username showsview.noGiftsAddress. Copy-profile-link on the card (profile.copyLink). When a username is set, a centeredQrCode(labelprofile.giftsQr) under the address encodesopenCryptoPayQrValue(https://<domain>/pl/?lightning=plus the uppercase LNURL ofhttps://<domain>/.well-known/lnurlp/<local>), including on a smartphone. A missing username shows no QR. - Inputs:
{ profile, viewKey, received, donated }—receivedisAccountActivity['receivedOverTime']; optionaldonatedisAccountActivity['donatedOverTime']; optionalactivityFailed(default false), passed to the chart asfailed.viewKeybuilds the copy URL/view/<viewKey>.profileincludesaboutMe,aboutMeHasPhoto, andlocation. PublicAboutMeSectionhasPhotofromaboutMeHasPhotowithloadPhoto(fetchViewAboutMePhoto). - Returns / side effects: No menu, logout, back, edit forms, or Message. Copy-profile-link on the card; URL/key never shown as visible text. Language switcher lives on the page, not in this card. When
activityFailedis true and the series is empty,profile.chartError. - Used by:
ViewProfileLoader.
Function: ViewProfileClaim
- Purpose: Public passkey claim control under the
/view/[viewKey]card. Unclaimed invites (hasPasskeyfalse) bind a passkey to the existing profile (name + Wallet of Satoshi already set), including when another 21.gifts session is already signed in. - Inputs:
viewKey(64 lowercase hex) andhasPasskeyfrom the public profile. UsesusePasskeyLogin,useAuthStore,useRouter,isInAppBrowser, andInAppBrowserView. - Returns / side effects: Waits for
useHydrateSessionready. Claimed (hasPasskeytrue) →null(even in Telegram, even if signed in). In-app on mount orunsupported→ same card chrome as login wrappingInAppBrowserView(no yellow Activate). Else in a real browser: yellow banner withview.activationRequiredand Activate (view.activate) even whenaccount !== null; click sets a claim-attempted flag,cancel+clearAuthwhen a session exists, thenregister(viewKey)(stays on the view page). Success →router.replace(nextOnboardingPath(account))only when that claim was attempted (pre-existing sessions do not redirect on mount). 409 →view.alreadyClaimedplus Fingerprint that callsauthenticate(); after that attempt the yellow Activate banner does not return (successful login hides the control; a dismissed prompt keeps the already-claimed copy). Statusunknownshowsview.claimErrorand Try again callsauthenticate(), neverregister, and never the login unknown card. Other errors / starting stay visible even with a session →view.claimError+ Try again (view.retry) or spinner. - Used by:
ViewProfileLoader(ready state only).
Function: fetchViewProfile
- Purpose: Fetches a public read-only profile by view key via the same-origin proxy.
- Inputs:
viewKeystring. - Returns / side effects: Validated
ViewProfile, ornullon 404. Throws on other non-2xx or a body that failsviewProfileSchema. Hits/view-key/${encodeURIComponent(viewKey)}. - Used by:
ViewProfileLoader.
Function: proxyViewGet
- Purpose: Same-origin proxy of api
GET /view/:viewKey(public; no auth). - Inputs: Incoming
RequestandviewKeypath segment. - Returns / side effects: Proxied upstream
Responsefor/view/${encodeURIComponent(viewKey)}. - Used by: App Router
GETon/view-key/[viewKey].
Function: alignActivitySeries
- Purpose: Align receive and donate cumulative account-activity series onto one sorted UTC-day axis for the profile chart.
- Inputs:
received(AccountActivity.receivedOverTime) anddonated(AccountActivity.donatedOverTime). - Returns / side effects:
ActivityPoint[]. Empty+empty →[]. Empty donated → zero Given on each received day. Non-empty both → day union with step-hold carry-forward. Also aligns CHF/EUR/PHP cumulatives (nullstring →0); missing series side stays at the carried value (0 until first point). - Used by:
AccountActivityChart.
Function: activityValue
- Purpose: Read one cumulative chart value from an aligned activity point.
- Inputs:
point,series(donated|received),scale(sat|fiat); when'fiat',fiat(FiatCode) selectscumulative*Usd|*Chf|*Eur|*Php.'sat'ignoresfiat. - Returns / side effects: Number used to place the polyline.
- Used by:
AccountActivityChart,activityMaxY.
Function: activityMaxY
- Purpose: Y-axis max for the dual-line chart: max of both series at the active scale, or
1when empty/all zeros. - Inputs:
points,scale(sat|fiat), optionalfiat(FiatCode) — same asactivityValue. - Returns / side effects: Positive number for SVG scale.
- Used by:
AccountActivityChart.
Function: useAccountTotals
- Purpose: Session-based GET
/me/activityviafetchAccountActivity; returns given/received sats plusdonateOverTimeandreceiveOverTime. Fetches even with a blank Lightning Address and does not callfetchGiftStats. - Inputs: Reads
sessionandaccount.lightningAddressfromuseAuthStore; callsfetchAccountActivitywhenever a session exists. - Returns / side effects:
{ donatedSats, receivedSats, donateOverTime, receiveOverTime, loading, failed }. Fetch start:failedfalse, series empty, chart showsprofile.chartEmpty. Success:failedfalse and series from the payload. Thrown fetch:failedtrue, zeros, empty series, chartprofile.chartError. Session null:failedfalse, no fetch. On each fetch start (including session or Lightning Address change) totals and series reset to zeros/empty andfailedis false;AccountActivityChartthen showsprofile.chartEmpty(no SVG) while in flight. Drops stale or cancelled responses when the session or address changes mid-flight and does not apply them. - Used by:
ProfileScreen.
Function: WelcomePage
- Purpose: Next.js page for
/welcome. - Inputs: None.
- Returns / side effects: Flow
PageChrome(AppShellwrapper) withProfileChromeLefthideWithoutHistoryandForumHomeWordmarktop-left,WelcomeTopRight(the member menu when a session exists, otherwise a link to/login), andOnboardingGatescreen="welcome"allowGuestaroundWelcomeScreen. The arrow is omitted only when this tab has no earlier view. An ask-wizard step uses that same slot. - Used by: Route
/welcome.
Function: WelcomeScreen
- Purpose: Living-room screen. With a session it is the fourth post-login screen after name, address, and living-room rules agreement are saved. Without a session it is the public active list. Embeds
ForumLoaderbelow the heading. Page column ismax-w-xl(Card surface={false}) so the AppShell frame is the only page-levelrounded-3xl. - Inputs: Reads
sessionandaccount.namefromuseAuthStore. - Returns / side effects: Gift icon with an integrated Bitcoin symbol. Heading Welcome, {name} when the account has a name, otherwise Welcome. Forum board. A session gets the composer.
ForumLoadersetsreadOnlywhen there is no session.WelcomePageshows Log in in the chrome. No name or address form. No donate CTA. NoLogoutButtonin the page column. - Used by: Screen
/welcome.
Function: forumTextPreview
- Purpose: Collapse a public forum note or reply body to a 280-character UTF-16
.lengthcollapsed preview (FORUM_TEXT_PREVIEW_LIMIT) for the feed/profile timeline analog. Show more /truncated: trueonly whentext.lengthis greater than twice that (560,FORUM_TEXT_FULL_LIMIT). A body of 560 characters or fewer is returned unchanged. Word/line boundary cut when the last space or newline in the slice sits at index>= floor(limit * 0.8); trailing spaces/tabs trimmed; no ellipsis in the helper. If the hard cut splits an http(s) URL (no whitespace across the boundary), that incomplete trailing URL is dropped from the preview soLinkedTextcannot autolink a shorter href. - Inputs:
text— full body; optionallimitis this collapsed preview length (default 280); the full-body ceiling islimit * 2. - Returns / side effects:
{ preview, truncated }. Does not append…. No network. - Used by:
ForumNoteText.
Function: ForumNoteText
- Purpose: Client paragraph for remaining note/reply text on the feed and profile (via
ForumQuotedBody/QuotedForumNotewhentruncateis true). Empty text returns null. Bodies autolink http(s) URLs viaLinkedText. Optionalplainforwards plain rendering toLinkedTexton both the full and truncated (Show more) path. Bodies longer than 560 characters (twice the 280-character preview) render a collapsed preview of 280 characters,…, and inline Show more (forum.showMore, app inline link) for callers that do not passforceExpanded. OptionalforceExpandedrenders the full body and omits Show more. Expand in place; no Show less. Click and keydownstopPropagationso the parent cardrole="button"does not toggle replies. On/messages/[id], the original body isLinkedText(ForumQuotedBodywithtruncate={false}). - Inputs:
ForumNoteTextProps—text,className, optionalplain, optionalforceExpanded(default false). - Returns / side effects:
LinkedText(<p>plus optional overlay), ornullwhentext === ''. Local React expand state only. - Used by:
ForumQuotedBody/QuotedForumNote(remaining text and nested quoted note bodies whentruncateis true) andTranslatableNoteBody(original body only whentruncateis true, not the translation).
Function: readJpegTakenAt
- Purpose: Read a JPEG APP1 Exif capture time before the canvas re-encode strips it. Does not read GPS.
- Inputs: JPEG file bytes.
- Returns / side effects:
YYYY-MM-DDTHH:MM:SSplus the matching offset when the file has one, or null. Prefers DateTimeOriginal, then DateTimeDigitized, then IFD0 DateTime. No I/O. - Used by:
prepareForumPhoto.
Function: ForumPhotoGallery
- Purpose: Horizontal snap row for a note with
photoCount > 1. Earlier slides are 88% wide so the next still peeks; the last slide is full width. The row isdata-scroll-x(sideways only, vertical axis clipped) and is not a second page scroll. Acurrent/totalchip (forum.galleryPosition) sits on the visible still. Dots (gap-5,IconButtonsmghost,forum.galleryDot) scroll that row. Emptyphotosreturnsnull. Single still has the chip and no dots. The capture time is stored, not shown. - Inputs:
photos({ index, url }[]),alt, optionalclassName(ForumBoard passesmt-2), optionalonPhotoClick(ForumBoardstopCardToggle). - Returns / side effects: React element. Local scroll index only. No network. Blob
<img>URLs from the parent. - Used by:
ForumBoard,PublicThreadCardinPublicMessageLoader.
Function: ShopAddWizard
Guided shop submission on /shops. Closed state is only Add a shop. The open steps are photos, a map place, text, an optional 21.gifts username, then a summary. Post on the summary is the only submit. The add photo step shows Next only. Edit mode shows an icon-only Close (X) on that step only. Its accessible name is Cancel, and there is no visible Cancel word. It closes the editor. From the place step on, the top-left arrow returns to the previous step. The card has no Back button. Photos, place, text, and the username can each be left empty; an empty summary still follows the forum rule that a note needs text or media.
- Purpose: Replace the living-room composer on the shops feed. The shop tag is added when the note is sent, not typed in the text step. That text step lists people as soon as
@is typed, the same way a post does. The summary shows the photo count (stills plus a video), the place name or coordinates, the text, and@username, or None when that part was skipped. - Inputs:
posting,draft/onDraftChange,photoDrafts,videoDraft,onPickFiles,onRemovePhoto,onClearPhoto,place/onPlaceChange,username/onUsernameChange,onSubmit, optionalonCancel(edit mode only),resetToken(closes the wizard after a successful send),maxLength. Optionalmode(createdefault, oredit),submitLabel,keptMedia,onRemoveKept, andimagesOnly. - Returns / side effects: React tree. No network. File selection calls
onPickFiles. The summary button callsonSubmit(Post, orsubmitLabelsuch as Save changes). In edit mode, the icon-only Close (X) on the photo step callsonCancel. Its accessible name is Cancel, and there is no visible Cancel word. From the place step on, the top-left arrow returns to the previous step and is disabled whilepostingis true. The card does not draw Back. - Used by:
ForumBoardwhenshopComposeris set, andShopNoteEditControlin edit mode.
Function: ForumAskWizard
Ask composer on /welcome. A donation is four steps and the counter reads {step} of 4. A credit is nine steps and the counter reads {step} of 9. Step 1 starts with the One-time / Daily pill and a Donation / Credit pill under it. Photos and text follow the credit confirmations. The preview shows the same pills and ForumGoalBar at 0 collected versus the ask. The heading and the step counter share one row (step on the right). The labeled Post on the preview is the only Ask submit. Switching to Credit on the preview returns to step 1. A shown bitcoin amount on the plan also shows the visitor's fiat when a rate exists.
- Purpose: Walk Ask for money so Continue on the amount step still requires a local 1..10_000_000-sat parse. The amount step and the preview each start with a One-time / Daily
SegmentedControl(tone="neutral", two columns,rounded-2xl). A Donation / Credit pill sits under One-time / Daily, same classes, Donation pressed by default. The photo and text steps do not show either pill. Default cadence is once. One-time / Daily is not posted. The post itself sendsgoalCurrencyandgoalAmount. Only Credit is posted asgoalRepayabletrue, together withgoalTermDays. A donation stays on the four steps. A credit, after the amount, explains that the debt is fixed in the selected currency (bitcoin uses a shorter sentence, with no conversion, and warns that a rising bitcoin price can hurt the author because they bear the full price movement; a fiat credit says payments stay bitcoin and are only priced in that currency at the rate of each payment, not exchanged, and that the author receives bitcoin and bears the full price risk if the price falls before they spend it), then offers 30 days, 1 year, 2 years, or a custom whole number of days from 1 to 3650, then states that repayment is daily and starts the day after the credit is paid in full, and shows the daily amount. The next screen repeats the amount, the currency sentence, the term, the daily rule, and the daily amount. Its button confirms the author wants the credit. The screen after that states the daily plan, and its button confirms the author can repay that plan. There is no checkbox. Photos are step 7 of 9, the message is step 8 of 9, and the preview is step 9 of 9 with Credit pressed, a Loan tag beside the author, and the daily plan. Post is the only submit. The credit counter reads n of 9. The daily amount is the total split across the days, and any remainder is added to the last day. A fiat-draft preview shows the typed amount plus local Bitcoin and no third currency; a Bitcoin-draft preview shows Bitcoin plus the last gift-day rate. - Inputs:
step/onStepChange,askDraft/onAskDraftChange,draft/onDraftChange,posting,photoDrafts,videoDraft,onPickFiles,onRemovePhoto,onClearPhoto,authorName,onPost, optionalrateDay(Bitcoin-draft preview counterpart of the typed ask), optionalcomposerMaxLength(defaultFORUM_MESSAGE_MAX_LENGTH, 8000), optionalaskCadence('once'|'daily', default once) andonAskCadenceChange, optionalaskObligation('donation'|'credit', default donation) andonAskObligationChange. - Returns / side effects: React tree. The One-time / Daily pill and the Donation / Credit pill are on step 1 and step 4. Continue on step 1 stays disabled until the parsed sats are 1..10_000_000. Continue on step 2 goes to step 3 with photos optional. Step 4 Post stays disabled without text, photo, or video, then calls
onPost. No network. - Used by:
ForumBoardwhencomposeIntentisask.
Function: parseForumAskAmount
- Purpose: Parse the Ask amount draft into a whole-sat goal.
- Inputs:
rawstring. - Returns / side effects: Integer 1..10_000_000, or
nullwhen empty, non-digits, 0, or above the max. - Used by:
ForumAskWizard,ForumLoader.
Function: parseCreditTermDays
- Purpose: Days a credit is repaid over. Presets are 30, 365, and 730. Custom is a typed whole number.
- Inputs: Preset
30|365|730|'custom', and the raw custom field. - Returns / side effects: Day count from 1 to 3650, or
nullwhen custom is empty, not digits, or out of range. No I/O. - Used by:
ForumAskWizard.
Function: creditSmallestUnits
- Purpose: Smallest units of a typed credit. Bitcoin is whole sats. Fiat is cents.
- Inputs: Amount draft and whether that draft is bitcoin.
- Returns / side effects: Non-negative bigint, or
nullwhen the draft is not that shape. No I/O. - Used by:
ForumAskWizard,ForumGoalBar.
Function: splitCreditPlan
- Purpose: Split a credit into equal daily units. Any remainder is added to the last day so the days sum to the debt.
- Inputs: Total sats or cents, and a day count from 1 to 3650.
- Returns / side effects:
{ perDay, last, days, remainder }, ornullwhen the day count is outside that range. No I/O. - Used by:
ForumAskWizard,ForumGoalBar.
Function: ForumGoalBar
SVG progress bar for a top-level forum note. One SVG: orange fill in user units 0–100; overflow past 100% continues in green (app-success) from x=100 at most another 100 units (visual max 200%). The {percent}% label is a sibling, so it stays readable, and is uncapped (110, 250, …). Above the track, the posted line is never one currency when a visitor figure exists. Ask label, then the defined fiat when goalCurrency is USD/CHF/EUR/PHP, then formatBitcoin(goalSats), then the visitor's default fiat unless the ask was defined in that same fiat. That visitor figure is the frozen snapshot when the string is present, otherwise the gift-day rate when one is loaded. If neither exists, the line stays bitcoin. Joined with ·. A legacy ask (goalSats, no goalCurrency) is bitcoin plus the visitor's fiat when that figure exists. Renders nothing when goalSats is missing or <= 0. Lengths use SVG width / x / viewBox attributes, not React style.
- Purpose: Show collected-versus-goal progress on a top-level note that has a positive
goalSats. BTC and legacy useforumGoalPercent(sats, goalSats); a fiat Ask usesforumFiatGoalPercentof the payment snapshot (nullcollected is 0%, not sats). Orange through 100%, green overflow, uncapped label. The amount line pairs currencies when a visitor figure exists: defined fiat plus bitcoin when the ask is in the visitor's fiat; otherwise bitcoin plus the visitor's fiat (snapshot, or the gift-day rate when the snapshot is missing and a rate is loaded). If neither a snapshot string nor a loaded rate exists, a bitcoin ask stays bitcoin. A third fiat is shown only when the definition currency is a different fiat. A repayable ask shows a Loan tag beside the author. Any other ask shows a Donation tag. Pressing the tag explains it. The bar does not repeat that sentence. A shop note keeps the #Shop link. - Inputs:
sats(collected),goalSats(whole-sat counterpart), optionalgoalCurrency/goalAmount/ goal snapshots / payment snapshots, optionalrateDay, optionalpreview, optionalgoalRepayable, optionalgoalTermDays, optionalmessageId(posted credit; omitted in the wizard), optionalledgerCollapsed(feed and profile show a Repayment list link to/messages/<id>/repayment-listand do not mountCreditLedger; the open note leaves it false and rendersCreditLedgerwithlist="summary"). Non-positive or non-finitegoalSatsyieldsnull. - Returns / side effects: The bar, or
null. A posted credit withmessageIdandledgerCollapsedfalse loads the public ledger (CreditLedgersummary), which fetchesGET /messages/:id/repayment. WhenledgerCollapsedis true the bar shows only the Repayment list link and does not fetch. No other network. - Used by:
ForumBoard(top-level notes only),PublicMessageLoader, andForumAskWizard(preview at 0 collected).
Function: forumGoalPercent
Integer percent for a forum goal label. Uncapped (110, 250, …). Uses Math.floor((sats * 100) / goalSats) when goalSats is a positive finite number. Returns 0 when the goal is missing, not finite, or <= 0. Negative or non-finite collected sats count as 0 so the label never goes negative. Unchanged sats path for BTC and legacy; fiat Asks use forumFiatGoalPercent.
- Purpose: Compute the uncapped whole-percent label for
ForumGoalBaron BTC and legacy Asks. Fiat Asks useforumFiatGoalPercent. - Inputs:
sats(collected) andgoalSats(whole-sat goal). - Returns / side effects: Floored percent number. No I/O.
- Used by:
ForumGoalBar.
Function: forumFiatGoalPercent
Integer percent of a fiat Ask from decimal strings. Uncapped. Scales collected and goalAmount to 8 decimal places with BigInt and floors the quotient. Does not use sats and does not use the goal snapshots.
- Purpose: Percent label for a fiat-defined Ask in the definition currency.
- Inputs:
collected(payment snapshotamountUsd/amountChf/amountEur/amountPhp, two-decimal string or null) andgoalAmount(defined amount string). - Returns / side effects: Floored percent. Null, missing, or non-two-decimal collected is 0. Unusable or zero goal is 0. No I/O.
- Used by:
ForumGoalBar.
Function: formatDefinedGoalAmount
Defined Ask amount for the goal line. Prefix $ for USD and ₱ for PHP, otherwise CODE . At most two fraction digits pad to two (200 → ₱200.00). More than two keep every digit (10.125 → ₱10.125). One comma is the decimal mark. Grouping uses the visitor number format. Not the two-decimal snapshot formatter.
- Purpose: Show
goalAmountexactly, not the two-decimal snapshot of the same currency. - Inputs:
amountstring, fiatcode, number-formatstyle. - Returns / side effects: Prefixed string, or
''when the amount cannot be shown. No I/O. - Used by:
ForumGoalBar.
Function: RepaymentPlanChart
- Purpose: Draw the repayment plan. Each day is a bar for the amount due that day. A line starts at the whole debt and falls to zero on the last day. The horizontal axis is the first day and the last day. When the credit is fully given those labels are the UTC dates. Before that they are day numbers. The words Per day and Still owed name the bar and the line.
- Inputs:
amounts(one positive number per day, same unit),from,to, andtotalText(the whole debt, already formatted). - Returns / side effects: A figure, or
nullwhen every amount is missing. No network. - Used by:
CreditLedger,ForumAskWizardplan and confirmation.
Function: revealInScrollport
- Purpose: Keeps a focused field or reply form inside the one active scrollport. A target taller than the scroller minus 24px is aligned to the scroller bottom minus 12px. Otherwise only a bottom or top overflow is corrected by that same 12px margin. It never calls
scrollIntoViewand it never scrolls the document. - Inputs: The active scrollport element and the target element (input, textarea, select, or reply form).
- Returns / side effects: Void. Mutates that scrollport's
scrollToponly. No network and nowindowscroll. - Used by:
revealReplyForm,ForumBoardcomposer and reply focus,useAppHeightafter a viewport change.
Function: revealReplyForm
- Purpose: Keeps a forum reply form inside the AppShell scroller by delegating to
revealInScrollportafter the null guards. That helper also corrects a top overflow and a target taller than the scroller. A missing scroller or form leaves the scroll position unchanged. - Inputs: The shell scroller (
HTMLElementor null) and the reply<form>(or null). - Returns / side effects: Void. Mutates that scrollport's
scrollToponly when both arguments are present. No network. NoscrollIntoView. - Used by:
ForumBoardafter an expanded reply composer is laid out, and only when no pay sheet is open.
Function: revealPaySheet
- Purpose: Pulls an open pay sheet back into the AppShell scroller only when a jump has placed its top above the shell. A sheet that starts below the fold is left alone, so the note above it stays on screen. The reply composer is not pinned while a sheet is open.
- Inputs: The shell scroller (
HTMLElementor null) and the pay sheet element (or null). - Returns / side effects: Void. When the sheet top is above the shell, subtracts that gap from
scrollTop. A sheet that starts below the fold, a missing scroller or sheet, and a top that is already inside leave the scroll position unchanged. No network. - Used by:
ForumBoardwhile a pay sheet is mounted, including when that sheet or the reply form changes size.
Function: ForumVideo
- Purpose: Playable note video. No native controls; one play button while paused; a click on the playing video pauses it; the fullscreen button sits in an absolutely positioned wrapper because the small icon button stays relative; the frame is
mx-auto w-fitso the picture stays centered and both controls sit on it. The fullscreen button callsrequestFullscreenon that frame, orwebkitEnterFullscreenwhen that API is missing. Composer and ask previews do not use it. - Inputs: Native video attributes except controls, controlsList, and playsInline.
- Returns / side effects: The video, play button, and fullscreen button. No network.
- Used by:
ForumBoardfeed video,PublicMessageLoaderpublic note video.
Function: ForumBoard
- Purpose: Presentational public forum: each post card body without the action row is the expand/collapse control (
forum.expand/forum.collapse,role="button"wrapping header, media, and body text, not anIconButton; the footer ₿ amount button and the reply-count button (when shown) also callonToggleExpand; React on every top-level note (forum.react, lucide Reply; expands the reply composer; omitted whendeletedAtis set), Pay today's repayment for the signed-in author of a top-level repayable note whensats >= goalSatsand the note is not deleted (disabled whilepayBusyor whilepayInvoice.messageIdis this note; arepayNoticeerror rendersrole="alert"(rateLimit,authorWallet, orrequest)), Translate (Languages icon,controlSlotIdonTranslatableNoteBodyin this row), a copy-linkIconButtonon every note and every reply, optional Gift on a payable reply, and optional delete sit outside that control as siblingIconButtons thatstopPropagation; whenshopNoteEditis set, the shop pencil sits in that right-hand footer after copy and before the place control, and the reply count stays in the left group; whenshopPlaceEditis set, a compact place control (Add a place or Edit place) sits in that footer after the pencil and before the account control; whenshopAccountEditis set, a compact account control (Add an account or Edit account) sits after the place control; Gift/Delete/reply composer also omit whendeletedAtis set). Optional dismissible living-room laws hint box (X control; two laws plus links to/rulesand/contact) whenlawsVisible, Active/No gifts yet/All/Most popularForumModeSelect(closed dropdown; selected label and chevron on the trigger; unpaid count chip on the closed trigger whenunpaidNewCount> 0 and unpaid is not selected), list of posts (name, the author row usesMessageKindTags(Loan and Donation buttons that show a sentence,#Shopstays a link), optional Founder / Moderator / Initiator / Verified role pill on notes and replies whenroleis one of those four (basishas no pill), top-level shop notes show a#Shoplink to/shopsand hide#21GiftsShop, timestamp, optional inline photo from blob URLs (single still whenphotoCount≤ 1; a horizontal snap row (ForumPhotoGallery,data-scroll-x, 88% peek) whenphotoCount > 1— sideways only,current/totalchip, dots of loaded blob URLs keyed${id}:${index}) then caption text below the photo, a link to/map?pin=<id>(the label, or coordinates when the label is null) on a top-level note that has a place, an@usernamelink to/members/{id}on a top-level note that has a shop account, optional inline<video>playback for notes with video (player keeps the clip aspect ratio withmax-h-80 max-w-full, no full-width black canvas), remaining body text throughForumQuotedBody/ForumNoteText(280-character preview plusforum.showMoreonly when the body is longer than 560 on the feed; signed-in/messages/[id]passestruncate={false}so the original stays full), Translate (Languages icon) / Show original / Show translation in the footer icon row viaTranslatableNoteBody(controlSlotId), which portalsNoteTranslatewithplacement="row"when the board has that row, a reply or note withvia === 'nostr'(a Nostr zapper with no 21.gifts account) renders an External button in place of a role pill, sharing the same per-message hint toggle state as the role pill's hint, and renders its body viaTranslatableNoteBodyplain(no quoted-note unfurl, URLs stay visible text) instead ofForumQuotedBody, ₿ amount plus optional preferred-fiat·formatFiatDisplayof the amount stored when the payment was made (a stored string as-is; a null or missing field on a passed payment uses the gift-day rate) with a Gift pay icon only when the card is a payable reply (parentIdset); the card's copy control is labelledforum.copyReplyLinkwhen the card is a reply andforum.copyLinkotherwise, and always copies the card's own origin/l/<8 hex>(first UUID group) when that id is a UUID, otherwise origin/messages/<id>) or empty/loading/error, messenger-style composer (Send a post / Ask for money pill; Post is Add a photo or video ImagePlus, then Add a place (MapPin,PlaceField) whenonPlaceDraftChangeis set, left of the textarea, Post Send icon to the right; Ask isForumAskWizard(the amount step and the preview open with the One-time / Daily pill; the photo and text steps do not); whenallowAskis false the Post/Ask pill and Ask wizard are omitted;shopComposershowsShopAddWizardinstead of the post composer), optional photo draft preview (Selected photo) whenphotoDrafts.length === 1with Remove photo X, composer gallery whenphotoDrafts.length > 1(ulof Selected photo alts + per-index Remove photo), optional video draft preview with Remove video X — icon-only, catalogaria-labels,maxLengthfrom optionalcomposerMaxLength(default 8000)), in-card reply composer (amount row first:AmountEntrylayout="inline"id="forum-reply-amount"with the bitcoin/fiat switch on that same line, then one text line and Post aligned beside it), and a pay sheet on a payable reply or, whenpayHostiscomposer, on the top-level composer: amount form with a live equivalent in the preferred fiat only when the conversion is non-null (no picker; after mint the equivalent uses the invoice amount, not an empty draft's 21-sat default), then every user-agent amount CTA is Continue (forum.payContinue) and only requests the invoice; after mint every user-agent shows the same invoice card (confirm sentence, walletButtonthat setswindow.location.hrefto the Android Intent orwalletofsatoshi:); the card mountsQrCodeonly when the user-agent is not a smartphone (isSmartphoneUserAgent, not viewport); walletButtonusesforum.payOpenWallet/ ariaforum.payOpenWalletAria(no custom-scheme<a>); Close (X) dismisses the sheet and stays on the page. That control is not Back. Gift-only replies show send ₿… (forum.giftReply) plus the same optional preferred-fiat·as notes; a reply with text and sats shows that formatted amount under the text. WhenreceivedSatsis greater than zero,forum.receivedOnReplyis a separate line and is not added tosats. A text reply that has both usesforum.sentOnReplyfor the sent line, and the two lines share a left rule. Clicking a role pill toggles a short explanation under that card header (one open at a time). Selector stays visible in every board state unlessmodeSelectoris false orcomposerHiddenis true. WhenmodeSelectoris false the list is every loaded row (mode all), including zero-sat basis notes; an empty loaded list still usesemptyKey, notforum.emptyPaid. Uses optionalemptyKey(forum.emptydefault,shops.emptyon shops) when the loaded list is empty,forum.emptyPaidwhen Active or Most popular hide every loaded row (Most popular remains paid-only; Active also keeps unpaid moderator notes; a top-level note withgoalSats> 0 and zero sats is not Active), andforum.emptyUnpaidfor No gifts yet when that filter hides all loaded rows. Propsmessagesare newest-first (API window); Active, No gifts yet, and All keep that order (newest at the top). Most popular stays sats-descending. The composer sits under the mode selector / filters, above the newest-first list; replies remain oldest-first. WhenonRefreshis passed, pull-to-refresh from the top of the AppShell inner scroller (useAppShellScroller) calls it; whilerefreshing(or a pull that reached the arm threshold) a visually hidden (sr-only)role="status"withforum.refreshingis mounted for assistive tech only — idle markup has no status node so welcome screenshots stay unchanged. WhennewPostsAvailableis true, a labeled primaryButton(forum.newPosts, decorative lucideArrowUp) issticky top-2in the scroller; the node is omitted when the flag is false. WhenmoderatorAppointedAvailableis true, a labeled primaryButton(forum.moderatorAppointed, sizesmwithshadow-lg, decorative lucideArrowUp) in the same visual language as New posts issticky top-2when it is the only pill and callsonShowModeratorAppointed; the node is omitted when the flag is falsy. When both this andnewPostsAvailableare true, the appointment pill stayssticky top-2and New posts issticky top-14. Listens forFORUM_COMPOSE_EVENT/requestForumCompose: focuses the new-post textarea withpreventScrolland thenrevealInScrollportwhen the shell scroller exists and consumes pending compose only when that textarea exists (composerHiddenboards keep the flag)./messages/<uuid>andhttp(s)://<host>/l/<8 hex>HTTP(S) URLs in a body unfurl into a nested post and the URL is removed from the visible text once that note loads. - Inputs:
ForumBoardProps—messages, optionalreadOnly(signed-out living room: no composer, pay, delete, or @mention links; an author name with a non-emptyaccountIdis still the profile button, including whenreadOnlyis true; Enter or Space on that button opens the profile and does not toggle the card, because the card key handler ignores a descendant target; the mode selector stays),error(boolean load-failure flag),loading, optionalrefreshing/onRefresh(omitonRefreshto disable pull-to-refresh), optionalnewPostsAvailable/onShowNewPosts(pill omitted when the flag is falsy), optionalmoderatorAppointedAvailable/onShowModeratorAppointed(pill omitted when the flag is falsy),posting,draft,onDraftChange,askDraft/onAskDraftChange, optionalallowAsk(default true; false omits the Post/Ask pill and the wizard) / optionalcomposeIntent/onComposeIntentChange/askStep/onAskStepChange/askCadence('once'|'daily', default once) /onAskCadenceChange/authorName,onPost,onRetry,formError(empty/tooLong/ask/request/rateLimit/unsupported/tooLarge/tooMany;ForumFormError = 'empty' | 'tooLong' | 'ask' | 'request' | 'rateLimit' | 'unsupported' | 'tooLarge' | 'tooMany' | null), controlledmode/onModeChange, optionalmodeSelector(default true), optionalnearEndRef(callback ref on the visible note about eight rows from the end soForumLoadercan prefetch the next page), optionalunpaidNewCount(default 0), requiredlawsVisible/onDismissLaws,photoDrafts(ForumPhotoPayload[]),placeDraft(ForumPlacePin | null), optionalonPlaceDraftChange(omit it and the place control is absent, so replies have none),videoDraft,onPickFiles(files: File[]),onRemovePhoto(index: number),onClearPhoto,photoUrls,videoUrls, optionalcomposerHidden(hides the new-post composer; the textarea is absent so compose-pending is not consumed), optionalcomposerMaxLength(defaultFORUM_MESSAGE_MAX_LENGTH; shops pass 7986 so the#21GiftsShopsuffix fits), optionalemptyKey(forum.emptydefault,shops.emptyon shops), plus pay sheet props (payMessageId, optionalpayHost(composerkeeps the sheet on the top-level composer;cardbinds to the matching parent or reply; omit/nullinfers from the list),payDraft,payBusy,payError(amount/request/rateLimit/authorWallet/deleted),payInvoice, optionalreplyPayPreview(string | null, defaultnull; when set with a matching card invoice,ForumReplyPayPagereplaces the reply composer),payWaiting, optionalrateDay(FiatRateDay | null; omit ornullleaves unsent previews bitcoin-only because no rate is loaded; a presentrateDayis the gift-day rate when no stored string exists; a stored string stays visible),onPayOpen, optionalviewerAccountId, optionalonRepay, optionalrepayNotice(rateLimit/authorWallet/requestasrole="alert"on that card),onPayDraftChange,onPaySubmit,onPayCancel), expand/replies (expandedId,onToggleExpand,replies,repliesLoading,repliesError,onRetryReplies, reply composer with optionalreplyAmountDraft/onReplyAmountDraftChange,replyFormErrorincludingamountfor a non-numeric or overflowing paid-reply sats field anddeletedwhen that note is missing or was deleted), optionalshopComposer(shops;ShopAddWizardinstead of the post composer),shopUsername/onShopUsernameChange/shopResetToken, optionalshopNoteEdit/onShopNoteUpdated(moderator pencil on a top-level shop note), optionalshopPlaceEdit(true on shops; the footer place control is absent when omitted) andonShopPlaceUpdated(applies a saved or cleared pin to that row), optionalshopAccountEdit(true on shops; the footer account control is absent when omitted) andonShopAccountUpdated(applies a saved or cleared shop account to that row), and optionalonDeleted(moderatorDeletePostControlon the parent footer and on nested replies withkind="reply"), optionalpermalinkTargetId(nested reply ring only:data-permalink-target="true"andring-1 ring-app-fg; parent notes are not ringed), and optionaltruncate(default true;PublicMessageThreadpasses false). Gift-only replies (text === ''andsats > 0) renderforum.giftReplywithformatBitcoinplus the same optional preferred-fiat·formatFiatDisplaysuffix as notes (the gift-day rate when the stored field is null or missing); text plus a gift shows that formatted amount under the text.receivedSatsgreater than zero addsforum.receivedOnReplywith the received fiat fields, not a sum. A text reply with both amounts labels the sent lineforum.sentOnReply. The video-draft X still callsonClearPhoto; each photo-draft X callsonRemovePhoto(index). - Returns / side effects: React tree. Copy-link always copies origin
/l/<8 hex>(first UUID group) when the id is a UUID, otherwise origin/messages/{id}— a note copies its own id, a reply copies its own id (no parent resolution). Filters viavisibleForumMessages. Load error copy isforum.errorviat(), neverError.message. Formats timestamps viaformatForumTime. Hides empty text paragraphs; never points<img src>at/messages/.../photowithout a blob URL. Feed video isForumVideo(no native controls; one play button while paused; one fullscreen button on the picture). Inline feed<video>keeps the clip aspect ratio (max-h-80 max-w-full, no full-width black canvas). A failed<video>errorevent hides that player (photo fallback when a blob URL exists). Clicking a role pill toggles a short explanation under that card header (one open at a time). Dismiss control callsonDismissLawsonly; persistence is owned byForumLoader. ForumBoard itself does not fetch; nestedNoteTranslateGETs/translateon mount and POSTs on Translate. No mode state of its own. AfteronPaySubmitresolves to an invoice, ForumBoard does notwindow.location.assign; every user-agent shows the same invoice card; the walletButtonsetswindow.location.hrefto the Android Intent orwalletofsatoshi:;QrCodeis mounted only when the user-agent is not a smartphone. AFORUM_COMPOSE_EVENTfocuses the new-post composer when it is mounted; acomposerHiddenboard leaves pending compose set for a later visible board. While a pay sheet is open,revealPaySheetruns instead ofrevealReplyForm. The scroll position from before the sheet opened is restored, so inserting the sheet does not scroll the note away, and a sheet that starts below the fold is not pulled to the top. The reply composer on that note is disabled while its invoice card is open and no reaction preview is set. A paid reaction replaces the reply composer with ForumReplyPayPage and the typed sentence is the preview. Gift-on-reply and composer-hosted sheets stay ForumPaySheet. The close control sits in an absolutely positioned wrapper at the sheet's top left, because the small icon button itself staysrelative. - Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread.
Function: ReplyDirectionAmounts
- Purpose: Money on a reply. What the author sent and what later arrived stay two lines and are never added. A gift-only reply (
text === ''andsats > 0) isforum.giftReply. A reply with text and sats, and no later receipt, is the bare formatted amount under the body. WhenreceivedSatsis greater than zero, that receipt isforum.receivedOnReplyon its own line. A text reply that also sent sats labels that lineforum.sentOnReply, and the two lines share a left rule. Zero or a missing receipt draws no received line. Nothing to show returns null. - Inputs:
text,sats, optionalreceivedSats,rateDay,fiat,numberFormat, optionalsentfiat fields forsats, optionalreceivedfiat fields forreceivedSats. - Returns / side effects: React element, or null when both amounts are absent. No network. Bitcoin via
formatBitcoin. Fiat viapreferredFiatSuffix(stored string, otherwise the gift-day rate). - Used by:
ForumBoardon every nested reply, andPublicThreadCardinPublicMessageLoaderwhen the unsigned card is a reply withreceivedSatsgreater than zero.
Function: ForumPaySheet
- Purpose: The amount form and invoice card already used for a payable forum reply. A habit comment uses this same sheet: Amount, Continue, then the confirmation, the desktop QR, and Pay with Wallet of Satoshi. It does not show the raw invoice. Close stays on the page.
- Inputs:
messageId, the pay draft, busy, error, invoice, waiting flag, draft and unit callbacks, submit, cancel, the gift-day rate, optionalratePending(default false; Continue does not submit while it is true), whether to show the QR, and a click handler so the sheet does not toggle the card under it. - Returns / side effects: The in-card sheet (
data-pay-sheet). Continue callsonPaySubmitonly when it is not busy andratePendingis false. The wallet button setswindow.location.hrefto the Android Intent orwalletofsatoshi:. No network of its own. - Used by:
ForumBoardandHabitComments.
Function: ForumReplyPayPage
- Purpose: Replaces the reply composer after a paid reaction is sent: it is a component in that same slot, not a new URL, and shows only a read-only preview of the submitted reply, the Pay ₿… amount line with the preferred-fiat suffix, the pay control (desktop QR plus Wallet of Satoshi button; smartphone: button only), and Waiting for payment… while polling.
- Inputs:
preview(raw submitted text; a trimmed-empty string omits the paragraph),amountSats,pr,payWaiting,payBusy,showPaymentQr,rateDay,onCancel. - Returns / side effects: React element with
data-reply-pay-pageanddata-pay-sheet. Close (X) callsonCanceland stays on this view. It is not the top-left back arrow. The wallet button setswindow.location.hrefto the Android Intent orwalletofsatoshi:href. An empty trimmed preview omits the paragraph. The preview paragraph usespl-12so the close control, including its hit slop, does not cover the first glyphs. - Used by:
ForumBoard.
Function: ContactLoader
- Purpose: Client loader for in-app contact on
/contact. Session fromuseAuthStore; returns null without a session. Posts viapostContact. On success fetches conversations and navigates to/messagesor/messages?c=for the official 21.gifts thread. No local success-hide of the form. UsesnextContactRequirementso missing name/rules openRequirementsOverlay(no Skip); Lightning Address is not required for contact. - Inputs: None (reads session from the auth store).
- Returns / side effects: React element wrapping
ContactScreen, ornull. Owns draft/posting/formError. Empty or whitespace drafts setempty; trimmed text longer than 8000 characters setstooLongand does not callpostContact. After a successful post,fetchConversationsthenrouter.pushto the inbox andpostingstays true until unmount. A failed post setsrequestand clearspostingso Send can retry. - Used by: Screen
/contact.
Function: ContactPage
- Purpose: Next.js page for
/contact. - Inputs: None.
- Returns / side effects:
AppShellwithProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGatearoundContactLoader. - Used by: Route
/contact.
Function: ContactScreen
- Purpose: Presentational in-app contact: heading Contact, lead, link to living-room rules, and a messenger-style composer (textarea with icon-only Send
IconButton, catalogaria-labelcontact.send,maxLength8000). Success is owned byContactLoader(navigate to the inbox thread), not a local success copy. - Inputs:
ContactScreenProps—posting,draft,onDraftChange,onPost,formError(empty/tooLong/request). - Returns / side effects: React element. No network.
- Used by:
ContactLoader.
Function: middleware
- Purpose: Send an old
/mapaddress to the shops map. There is no map page. - Inputs: The incoming request whose path is
/map. - Returns / side effects: Redirect to
/shops#map. A query string such as?pin=is kept (/shops?pin=#map). - Used by: Route
/map.
Function: PlacesMapScreen
- Purpose: Presentational map card: heading Map, then every live forum pin from
fetchPlaces. Without a Google key the pins are links. With a key the same pins are also markers. A 21.gifts author name links to/members/:accountId; an external name stays text. A moderator sees the shop pencil on a pin withshop: true; it loads that note and opens the same steps as editing a shop post. A failed load showsforum.editShopNoteLoadFailed, not the save-failure sentence.embeddedreturns that body without the heading or card. A long pin name or label wraps inside the row. With a key, one pin, or a?pin=that matches a pin, centers that pin at zoom 14. Several pins and no matching?pin=frame every pin (fitBounds, padding 32). A fitted zoom above 14 is set back to 14. - Inputs: Optional
embedded(default false). Catalog viauseTranslations. Session fromuseAuthStore. Optional?pin=id. - Returns / side effects:
Card maxWidth="xl"surface={false}, or only the body whenembedded. Fetches places and/maps/key. - Used by:
ShopsScreen.
Function: fetchPlaces
- Purpose: Load every live top-level forum pin for the signed-in session.
- Inputs:
sessionTokenbearer. - Returns / side effects:
ForumPlaceRow[]fromGET /forum/messages/places. A row may includeshop: truewhen the note is a shop. Throws the visitor-facing load error on a non-2xx status, a network failure, or a body that fails the schema. - Used by:
PlacesMapScreen.
Function: proxyMessagesPlacesGet
- Purpose: Same-origin Bearer proxy of api
GET /messages/places. - Inputs: Incoming request.
- Returns / side effects: The upstream response. No session of its own.
- Used by:
GET /forum/messages/places.
Function: PlaceField
- Purpose: Optional place control on the top-level forum composer, and the compact editor a moderator uses on a shop note. Opens a map when
/maps/keyreturns a key. Confirm stores{ lat, lng, label }. No key shows the unavailable sentence and does not confirm a pin. On the device's local Sunday neither the preview nor the map panel is rendered; the pin stays inside the Sunday writing gate. Otherwise, once the frame box is measured, the preview and the map panel are fixed to the window and kept inside the app frame, and are not sized from the viewport (vw). Until that box is measured they sit under the pin as an absolute anchor. The map shrinks so the confirm control stays inside the window, and the page still has one scroller. - Inputs:
place,disabled,onChange. OptionalbuttonSize(lgdefault, orsm),buttonVariant(secondarydefault, orghost),showPreview(default true),ariaLabel(default Add a place), andonCommit. WithoutonCommit, Done and Remove callonChangeand close. WithonCommit, those buttons wait; a rejection keeps the panel open and shows the save error; a success closes without callingonChange. When preview is hidden and a pin is already set, Remove stays inside the open panel, including when the map is unavailable. - Returns / side effects: Attach button, optional preview, and panel. Fetches
/maps/keywhen opened. - Used by:
ForumBoard(top-level composer) andShopPlaceControl.
Function: fitBoxInFrame
- Purpose: Places one popover inside the app frame. The width is the preferred width when it fits, otherwise the room between the insets. The left edge stays on the anchor unless that would cross an inset. Vertically it opens downward when that side has at least 280px and at least as much room as above, otherwise upward, otherwise from the top inset across the whole frame height. It returns null when the frame has no positive room or the preferred width is not positive.
- Inputs: Frame left, right, top, and bottom, anchor left, top, and bottom, gap, preferred width, inset, and viewport height, all in pixels.
- Returns / side effects:
{ left, width, maxHeight, top, bottom }with exactly one of top or bottom set, or null. No DOM writes and no network. - Used by:
PlaceFieldfor the place preview and the map panel.
Function: pageFrameProblems
- Purpose: Reads the live document and lists sideways scroll of the document, sideways scroll of the active page scroller, and boxes whose border box leaves the window by more than one pixel on the left, right, top, or bottom. Content inside
[data-scrollport]may sit past the top or bottom of the window unless it is position:fixed. A position:fixed box is still reported. A box inside[data-scroll-x]is ignored for the horizontal check unless it is position:fixed and its border box already crosses the left or right edge of the window. The sideways row itself is not ignored. - Inputs: None. Uses the open document,
window.innerWidth, andwindow.innerHeight. - Returns / side effects: A string array. Empty means the page stays in the window. No DOM writes and no network.
- Used by:
shotScreenine2e/visual.spec.ts, before every screenshot, and the behavior testFunction: pageFrameProblems.
Function: ShopPlaceControl
- Purpose: Moderator-only place editor on a listed shop note. Hidden on replies, hidden notes, non-shop text, unsigned sessions, and ranks below moderator.
- Inputs:
message(top-level shop note) andonUpdated(message id plus the saved pin, ornullwhen cleared). - Returns / side effects: Compact
PlaceField(showPreviewfalse, small ghost Add a place / Edit place).onCommitcallssetMessagePlace; a failed save keeps the panel open and showsforum.placeSaveFailed. - Used by:
ForumBoardwhenshopPlaceEditandonShopPlaceUpdatedare set (ForumLoaderfeed="shops").
Function: ShopNoteEditControl
- Purpose: Moderator-only pencil on a listed shop note. Opens the same five steps as adding a shop, already filled in, plus the edit history. Hidden on replies, hidden notes, non-shop text, unsigned sessions, and ranks below moderator.
- Inputs:
message(top-level shop note),onUpdated(the saved note), optionalexistingPhotos, optionalexistingVideoUrl, and optionalstartOpen. - Returns / side effects: Small ghost pencil (Edit shop note). The summary button is Save changes. It writes only the text, place, account, or stills that changed. A video stays. Already-shown stills are copied onto addresses this editor owns before the steps open, so a partial save can revoke the feed addresses without blanking the open editor. A failed copy opens the steps with
forum.editShopNoteFailedand no stills, and does not revoke the feed addresses. Stills are not replaced unless every current still loaded. A failed save keeps the panel open and showsforum.editShopNoteFailed. History rows show who, when, and the field (text, place, or account). A failed history load showsforum.editHistoryFailed. - Used by:
ForumBoardwhenshopNoteEditandonShopNoteUpdatedare set (ForumLoaderfor a moderator on every feed),ShopTable, andPlacesMapScreenfor a pin withshop: true.
Function: ShopAccountControl
- Purpose: Moderator-only shop-account editor on a listed shop note. Hidden on replies, hidden notes, non-shop text, unsigned sessions, and ranks below moderator.
- Inputs:
message(top-level shop note) andonUpdated(message id plus the saved{ id, username, name }, ornullwhen cleared). - Returns / side effects: Small ghost Add an account / Edit account (User icon). The open field starts at
@, or@usernamewhen an account is already attached, and the same People list as a forum post is visible immediately. Choosing a row fills@username. A space or any other non-username hides the list. The panel is fixed to the window so the page does not scroll sideways. A typed prefix immediately drops usernames that do not start with it. It shows at most eight people and does not scroll on its own. Rows drop until the box fits under the app chrome, and the list hides rather than covering that chrome. Save account strips one leading@. Optional Remove account. Empty or unknown username showsforum.shopAccountMissingand keeps the panel open; other failures showforum.shopAccountSaveFailed. Success callssetMessageShopAccountthenonUpdatedand closes. - Used by:
ForumBoardwhenshopAccountEditandonShopAccountUpdatedare set (ForumLoaderfeed="shops").
Function: ShopsPage
- Purpose: Next.js page for
/shops. FlowAppShell(align="start") withProfileChromeLeft+SignedInChrome,OnboardingGatescreen="welcome", andShopsScreen. Noroute.tsbeside the page. - Inputs: None.
- Returns / side effects: The shops screen.
- Used by: Route
/shops.
Function: ShopsScreen
- Purpose: Presentational shops card: heading Shops, lead
shops.lead, a Post / Map / Table pill, then the selected body. Post isForumLoaderfeed="shops". Map isPlacesMapScreenembedded. Table isShopTable. - Inputs: Catalog via
useTranslations. The view stays unset until the hash is read./shops#mapand/shops#tableopen that view;/shops#post, a missing hash, or an unknown hash open Post. Choosing an option writes the same hash. Post clears it. - Returns / side effects:
Card maxWidth="xl"surface={false}. The post list, map, and table mount only after the hash is read. The map and table fetch only after their option is selected. - Used by:
ShopsPage.
Function: ShopsViewSwitch
- Purpose: Neutral
SegmentedControllabelled Shop view that switches the shops page between post, map, and table. - Inputs:
value(post,map, ortable) andonChange. - Returns / side effects: A
SegmentedControltone="neutral". The pressed option uses the app button fill. No network. - Used by:
ShopsScreen.
Function: ShopTable
- Purpose: Table of shop notes with name, place, and operator. A moderator sees the same shop pencil on each row.
- Inputs: Session from
useAuthStore. Catalog viauseTranslations. - Returns / side effects: Loads
GET /messages?hashtag=21GiftsShop&mode=all. Name is the first note line. Place links to/map?pin={id}(label, or coordinates). Operator links to/members/{id}as@username. A missing place or operator is an em dash (shops.missing). Show more loads the next page. If a page has no shop rows and another page exists, Show more stays and the empty sentence does not. If that page fails, the rows already shown stay, with the forum error and Try again, which reloads the same page. A failed first page replaces the table with that error. A missing-requirements response opens/setup/rulesinstead. Null without a session. - Used by:
ShopsScreen.
Function: MessageKindTags
- Purpose: Author row for a note, plus one tag per kind. Loan and Donation are buttons. Pressing one shows its explanation and pressing it again hides it. #Shop stays a link to
/shops. - Inputs:
kindsfromnoteKinds.childrenis the author name and any role tag. - Returns / side effects: The row and, when a loan or donation tag is open, a
role="status"sentence. No network. - Used by:
ForumBoard, the public note page, and theForumAskWizardpreview.
Function: noteKinds
- Purpose: Which tags a note shows. A reply has none. A repayable ask is a loan. Any other positive ask is a donation. A
#21GiftsShoptoken adds shop. - Inputs:
parentId,text, optionalgoalSats, optionalgoalRepayable. - Returns / side effects:
loan,donation, andshopin that order, or an empty list. No network. - Used by:
ForumBoard,ForumAskWizard.
Function: isShopNote
- Purpose: True when
textcontains a#21GiftsShoptoken (case-insensitive; next character not[A-Za-z0-9_]). - Inputs:
text: string. - Returns / side effects: boolean. No network.
- Used by:
ForumLoader,ForumBoard,ensureShopHashtag.
Function: stripShopHashtag
- Purpose: Remove
#21GiftsShoptokens from display text and collapse leftover blank lines. - Inputs:
text: string. - Returns / side effects: Display string (empty if only the hashtag was present). No network.
- Used by:
ForumBoard.
Function: ensureShopHashtag
- Purpose: Append
\n\n#21GiftsShopwhen missing; empty/whitespace becomes#21GiftsShop; already-tagged text is unchanged. - Inputs:
text: string. - Returns / side effects: Text to POST when the author is unpaid-write exempt, otherwise the compose-fee invoice comment. No network.
- Used by:
ForumLoader(shops compose).
Function: RulesDocument
- Purpose: Presentational living-room rules body from catalog keys: lead with the The test callout, three rule cards (
rules.lawKickerwith{n}, title, body, optional test callout), welcome / allowed / better-not / forbidden lists rendered as bordered cards with lucide glyphs (Checktext-app-fg,Checkmuted,Minus,Xtext-app-danger), the Our house closing block (rules.houseBody+rules.houseClosing), and an optional Contact CTA to/contact. There is no forum back CTA in the document. - Inputs:
messagescatalog for the request locale; optionalshowNav(defaulttrue); optionalchapter(RulesChapterId). Whenchapteris set, only that chapter is rendered and the public nav is omitted (showNavignored). WhenshowNavisfalseandchapteris omitted, the public Contact nav is omitted. - Returns / side effects: React element. Server component — uses
translate, notuseTranslations. No network. - Used by:
RulesPage,RulesSetupPage.
Function: RulesPageChrome
- Purpose: Client chrome wrapper for public
/rules: when a session is hydrated (ready && session !== null), mounts signed-in shell (ProfileChromeLeft+SignedInChrome); otherwise mountsProfileChromeLeftwith wordmark href/plusLanguageSwitcher. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. - Inputs:
children(heading +RulesDocumentfromRulesPage). UsesuseHydrateSessionanduseAuthStoreforsession. - Returns / side effects:
PageChromewith the matching top-left / top-right slots aroundchildren. No network beyond session hydration. - Used by:
RulesPage.
Function: RulesPage
- Purpose: Next.js page for
/ruleswith localized heading and living-room rules document, wrapped inRulesPageChrome(signed-in or unsigned chrome depending on hydrated session). - Inputs: None. Calls
getRequestLocale()for the page title and document catalog. - Returns / side effects: Heading +
RulesDocumentinsideRulesPageChrome(chrome is no longer always unsigned Wordmark + Language). - Used by: Route
/rules.
Function: ForumLoader
- Purpose: Client loader for the public forum on
/welcomeand/shopswithfeed="shops"(filter#21GiftsShop, hide laws, append hashtag on compose,emptyKeyshops.empty, passshopPlaceEditandonShopPlaceUpdatedso a moderator can set, replace, or clear the pin on an existing shop note, andshopAccountEditandonShopAccountUpdatedso a moderator can set, replace, or clear the shop account). Session and account fromuseAuthStore; shops return null without a session, and the living room loads the public active page instead. The laws hint stays hidden without a session. Living-room default remains Active (first paint{ mode: 'active', limit: 20 }, no hashtag). Shops always requestsmode: 'all'withhashtag: '21GiftsShop'and does not mount the mode selector, the Post/Ask pill, orForumAskWizard(allowAsk: false); it mountsShopAddWizardthroughshopComposerand does not apply active/unpaid/popular. A moderator session passesshopNoteEditon every feed, not only shops. A mode switch resets the loaded pages and refetches page 1 of the new mode without blanking the current list. AnIntersectionObserversentinel about eight notes from the visible end prefetches the next page whennextCursoris present. An empty shops page showsshops.emptyimmediately. The 30-second visible-tab poll,visibilitychange, persistedpageshow, and pull-to-refresh fetch page 1 only, without a cursor; applying page 1 does not replace already-loaded older pages. Fetches viafetchMessages, loads photos viafetchMessagePhotointo blob URLs (effect keyed onphotoIdsKeyso payable-poll list refreshes do not cancel in-flight photo fetches), loadsrateDayvia the shareduseLatestRateDay()hook on mount (failure leavesrateDaynull), posts viafetchComposeTargetthenpostMessageInvoice(1 sat to 21.gifts,payHost: composer) for anyone below verified, otherwisepostMessagewith aphotosarray sopostMessagedual-sends{ photo, photos }(non-emptyphotoswins; max 10) orpostMessageVideo(multipart clip); composer submit is ignored while a note POST is in flight (syncnotePostInFlightRef, not only thepostingprop); prepares picks viaprepareForumPhoto/isForumVideoFile/prepareForumVideo; ownsphotoDrafts(plural, notphotoDraft),placeDraft, andvideoDraft/videoUrls;onPickFilesappends stills and setsformErrortooManywhen the selectedFilelist (not successful prepares) would exceed 10, preparing only the firstremainingslots; video xor stills; video-only posts are allowed. Pay invoices viapostMessageInvoice(optionaltextwhen the reply composer pays) and waits onfetchPublicMessagewhile the pay sheet is open (no attempt cap; aborts in-flight wait on Close / clear). A top-level note passessinceSatsand closes whensatsrises. An existing reply passessinceReceivedSatsand closes whenreceivedSatsrises. After that rise, the row is merged into loadedreplies/messages(no thread refetch). Today's repayment usespostRepaymentInvoice(POST /messages/:id/repayment) andrepayNotice; success opens the invoice card on the note; failure setsrepayNoticewithrateLimit/authorWallet/request; the alert sits on the card. Owns Active/No gifts yet/All/Most popular feed mode (default Active) and the No gifts yet last-visit stamp (21gifts.forum-unpaid-seen): hydrates it on mount, stamps on entering unpaid and while unpaid as the list refreshes, and passesunpaidNewCounttoForumBoard(0 while unpaid is selected or messages are still null). After a successful living-room post withcreated.sats === 0, switches mode to All so the author sees the note, including a positivegoalSatsAsk, because Active and Most popular hide a note that has not received sats. If All is already selected, loaded pages andnextCursorstay. Shops already lists withmode: 'all'and does not switch. The sheet clears when the pay target is no longer in the visible parent list and not in loadedreplies(mode change, collapse, or delete), not when Active merely hides the parent note. Also pollsGET /forum/messagesuntil the merged list is payable (8 attempts, 2s; local extras kept until GET echoes), cancelled-flag fetch likeStatsLoader. Silently re-fetches onvisibilitychange(hidden→visible), onpageshowwhenpersistedis true, on aFORUM_LIST_POLL_MSvisible-tab interval, onFORUM_HOME_EVENT(wordmark / Menu Home while already on/welcome), and when the board pull-to-refresh callsonRefresh— shared load path with mount/retry; silent refresh does not flip the board to the loading copy when a list already exists, keeps the list when a silent refresh fails, and does not auto-scroll the newest note when a newer note arrives from refresh. When not at top (shellScrollTop(scroller) < 8is at-top;window.scrollYis only the no-shell fallback) andhasUnseenForumPostsis true, the fetch is held andForumBoardshows New posts; at the top the list is applied. With a session, fetchesGET /forum/notificationsvia existingfetchNotifications(cancelled-flag fetch like other ForumLoader loads) and, when an unreadmoderator_appointedrow exists (readAt === null), drives the ForumBoard pill (moderatorAppointedAvailable/onShowModeratorAppointed); click marks that row read viamarkNotificationReadand clears the flag, staying on/welcomewithout auto-scroll; a fetch error leaves the banner hidden; a mark-read failure leaves the banner (does not crash). The payable poll updates sats, payable, and the four payment snapshots (amountUsd,amountChf,amountEur,amountPhp) on already-listed ids only and does not insert unseen ids. Owns the living-room laws hint visibility fromaccount.forumLawsDismissedand persists dismiss viadismissForumLaws(optimistic; applies the response or restores the previous flag only when the session token is unchanged and an account is still present). Owns expand/replies (fetchReplies, retry, moderator nested-reply hide viaonDeleted/deleteMessagewithout removing the parent, reply composer: empty reply text and an empty amount invoices 21 sats even for verified; unpaidpostMessagewithinReplyToonly when there is text and the amount is empty, and only for verified; below verified invoices 1 sat to 21.gifts via the compose-target note (payHost: composer); expand is ignored while a reply posts). A signed-in note card fully inside the shell scrollport callsmarkVisibleForumNoteRead; expanding still callsmarkNotificationsReadForMessage. After a failed media POST following compose-pay, the pay sheet is cleared (payWaiting/payInvoice/payMessageId/payHost) so a later compose invoices 21.gifts again. UsesnextPostRequirementso a missing name, username, Lightning Address, or rules agreement opensRequirementsOverlay(no Skip) before a post or reply retries. After a successful top-level post or reply, setshasPosted: trueon the session account when the session token is unchanged and an account is still present (no persist-flag / Skip-forever POST). - Inputs: Optional
feed('living-room'default,'shops'). Reads session and account from the auth store. - Returns / side effects: React element wrapping
ForumBoard, ornull. Owns draft/askDraft/composeIntent/askStep/askCadence/placeDraft/photoDrafts/videoDraft/photoUrls/videoUrls/posting/formError/feedMode/loaded-pages/nextCursor/pay/expand/replies/refreshing/rateDaystate, the No gifts yet last-visit stamp, and retry attempts.askCadencedefaults to once and returns to once after the visitor's own compose is paid (postAfterPayorswitchToAllin the pay poll) and inapplyCreatedNote. A gift or a reply does not clear it. Empty text without photos and without a video setsempty; trimmed text longer than 8000 characters setstooLongand does not callpostMessage/postMessageVideo. Photo-only and video-only posts are allowed. Empty reply text with an empty amount invoices 21 sats (pay-sheet default) and does not setempty, including when the parent omitsaccountId. A non-exempt reply with text and an empty amount still invoices 1 sat instead of settingamount.0is billed as 1 sat. Non-digits and overflowing amounts still setamount(forum.errorReplyPayment). When the parent omitsaccountId, an unpaidpostMessageis attempted only for a reply that has text and an empty amount, and a payment 403 starts a 1-sat invoice. Fetch failure sets the error flag without clearing an already-posted list; the board still shows Try again. A failed silent refresh with an existing list does not set the error flag. A late GET merges locally posted rows that the response does not yet contain; a POST whose id is already in the list is not prepended again. An empty or whitespace-only pay amount requests 21 sats and does not fillpayDraft. Invoice 400 author's-wallet copy maps toauthorWallet; a missing or deleted note (NoteDeletedError, HTTP 404) maps todeletedon the pay sheet and on a paid reply; a 404 on the platform compose-target invoice staysrequest; other invoice failures stayrequest; rate limit staysrateLimit. Revokes photo and video blob URLs on unmount. May POST/me/forum-laws-dismissed. After a successful top-level post or reply, writeshasPosted: trueon the session account only when the session token is unchanged and an account is still present (no persist-flag POST). On shops, passesshopComposerso the board mountsShopAddWizard,shopPlaceEdit/onShopPlaceUpdatedso a saved or cleared pin updates that row, andshopAccountEdit/onShopAccountUpdatedso a saved or cleared shop account updates that row. For a moderator on every feed, including the living room, passesshopNoteEdit/onShopNoteUpdated. PasseslawsVisible/onDismissLaws,mode/onModeChange,nearEndRef,unpaidNewCount,refreshing/onRefresh,newPostsAvailable/onShowNewPosts,moderatorAppointedAvailable/onShowModeratorAppointed, andrateDaytoForumBoard. Stamps last visit on unpaid; mode is not persisted. Does not passError.messageto the board. A paid reaction replaces the reply composer with ForumReplyPayPage and the typed sentence is the preview; the draft and amount stay until payment is observed, then both clear together with the preview; Close clears the preview and leaves the draft and amount in the composer. Gift-on-reply and composer-hosted sheets stay as they are. - Used by:
WelcomeScreen,ShopsScreen.
Function: hasDisplayName
- Purpose: True when the account has a non-null display name that is non-empty after trim.
- Inputs:
account. - Returns / side effects: Boolean. No side effects.
- Used by:
nextOnboardingPath,NameForm.
Function: hasLightningAddress
- Purpose: True when the account has a non-null Wallet of Satoshi address that is non-empty after trim.
- Inputs:
account. - Returns / side effects: Boolean. No side effects.
- Used by:
nextOnboardingPath,LightningAddressForm.
Function: hasAgreedToRules
- Purpose: True when the account has a non-null
rulesAgreedAttimestamp (epoch ms of first living-room rules agreement). - Inputs:
account. - Returns / side effects: Boolean. No side effects.
- Used by: UI that still checks agreement state (overlays, payable); wizard order uses
account.setuponly.
Function: nextOnboardingPath
- Purpose: Picks
/setup/name,/setup/username,/setup/address,/setup/rules, or/welcomefromaccount.setup.setup === 'wallet'maps to name, username, lightning-address, rules, or/welcomefrom the other fields; it does not map to/wallet. Other values stay a 1:1 map (skips advancesetupwithout clearingmissing). Username still cannot be skipped. - Inputs:
accountwith requiredsetupandmissing. - Returns / side effects: Path string. No side effects.
- Used by:
OnboardingGate.
Function: skipSetup
- Purpose: Skips the current onboarding name or Lightning Address step without filling the field.
- Inputs: Bearer session and
step(name|lightning-address). - Returns / side effects: Updated
AccountfromPOST /me/setup/skip; callers mergesetupandmissinginto the auth store. - Used by:
NameFormandLightningAddressFormonboarding Skip buttons.
Function: fetchMember
- Purpose: Loads a signed-in member profile by account id.
- Inputs: Bearer session and
accountId. - Returns / side effects: Validated
MemberProfile, includingpostCountandreplyCount, ornullon 401/404. ThrowsMissingRequirementsErroron 409. Hits/forum/members/:id. - Used by:
MemberProfileLoader.
Function: fetchMemberPosts
- Purpose: Loads a member's top-level forum posts with
GET /forum/members/:id/posts. - Inputs: Bearer session and
accountId. - Returns / side effects: Parses
forumListSchemaand returns the messages newest-first, capped by the api at 200. A 401/404 uses the same visitor-facing message-list failure asfetchMessages; a 409missing_requirementsthrowsMissingRequirementsErroras infetchMember/fetchMessages. - Used by:
MemberProfileScreen.
Function: fetchMemberReplies
- Purpose: Loads a member's forum replies with
GET /forum/members/:id/replies. - Inputs: Bearer session and
accountId. - Returns / side effects: Parses
forumListSchemaand returns the messages newest-first, capped by the api at 200; reply messages may be payable when the author has a published event and a Lightning Address, and may includeparentId. A 401/404 uses the same visitor-facing message-list failure asfetchMessages; a 409missing_requirementsthrowsMissingRequirementsErroras infetchMember/fetchMessages. - Used by:
MemberProfileScreen.
Function: parseMissingRequirements
- Purpose: Parses a 409
{ error: 'missing_requirements', missing: [...] }body. - Inputs: Unknown JSON body.
- Returns / side effects:
MissingRequirementsErrorornull. - Used by:
fetchMessages,postMessage,postMessageVideo,postContact,fetchMember,fetchMemberPosts,fetchMemberReplies.
Function: MissingRequirementsError
- Purpose: Typed error for api 409 missing name/rules (or lightning-address) requirements.
- Inputs:
missingarray from the api body. - Returns / side effects: Error instance with
missingfield; not shown as a generic toast. - Used by: Forum and contact loaders (open
RequirementsOverlay) and member fetch.
Function: WrongAccountError
- Purpose: Typed error for api 403 when the visitor signed in with an account whose session is refused.
- Inputs: None; message is the exact api English string.
- Returns / side effects: Error instance named
WrongAccountError. Callers clear the session and showlogin.wrongAccount. - Used by:
fetchMe,finishPasskeyAuthentication,finishPasskeyRegistration,useHydrateSession,usePasskeyLogin.
Function: NoteDeletedError
- Purpose: Typed error when a forum reply or a payment invoice gets HTTP 404 because that note is missing or was deleted.
- Inputs: None. The message is exactly
This note was deleted. - Returns / side effects: Error instance named
NoteDeletedError. Reply and pay-sheet callers showforum.errorNoteDeletedinstead of the generic send error. A 404 on the platform compose-target invoice stays the generic request error. - Used by:
postMessage,postMessageInvoice,ForumLoader,PublicMessageThread,MemberProfileScreen.
Function: isWrongAccountError
- Purpose: Detects a wrong-account rejection (
WrongAccountErroror anErrorwhose message is exactlyWRONG_ACCOUNT_ERROR). - Inputs:
errorunknown. - Returns / side effects:
truefor that instance or exact message;falseotherwise. - Used by:
useHydrateSession,usePasskeyLogin.
Function: UnknownCredentialError
- Purpose: Typed error for api 400 when authenticate finish rejects a credential the server does not store (
Unknown credential). - Inputs: None; message is the exact api English string.
- Returns / side effects: Error instance named
UnknownCredentialError.usePasskeyLoginmay signal the credential unknown and sets statusunknown.LoginCardshows the unknown login card.ViewProfileClaimshowsview.claimErrorand Try again callsauthenticate(), neverregister. - Used by:
finishPasskeyAuthentication,usePasskeyLogin.
Function: isUnknownCredentialError
- Purpose: Detects an unknown-credential rejection (
UnknownCredentialErroror anErrorwhose message is exactlyUNKNOWN_CREDENTIAL_ERROR). - Inputs:
errorunknown. - Returns / side effects:
truefor that instance or exact message;falseotherwise. - Used by:
usePasskeyLogin.
Function: nextPostRequirement
- Purpose: Picks the next field to collect before a forum post (
rules, thenname, thenusername, thenlightning-address). - Inputs:
missingarray from the account or a 409 body. - Returns / side effects:
'rules','name','username','lightning-address', ornull. No side effects. - Used by:
ForumLoader,MemberProfileScreen,RequirementsOverlayflow.
Function: nextContactRequirement
- Purpose: Picks the next field to collect before a contact send (
rulesbeforenamebeforeusername). Lightning-address gaps returnnull— contact does not require a Wallet of Satoshi address. - Inputs:
missingarray from the account or a 409 body. - Returns / side effects:
'rules','name','username', ornull. No side effects. - Used by:
ContactLoader,RequirementsOverlayflow.
Function: RequirementsOverlay
- Purpose: Modal to add a missing name (
NameFormprofile), username (UsernameForm), Wallet of Satoshi address (LightningAddressFormprofile), or agree to rules before retrying a post. No Skip. - Inputs:
requirement(name|username|rules|lightning-address),onDismiss,onSatisfied. - Returns / side effects: Dialog UI; merges account fields on success then calls
onSatisfied. Title/aria-labelfromrequirements.nameTitle,requirements.usernameTitle,requirements.rulesTitle, orrequirements.addressTitle. - Used by:
ForumLoader,ContactLoader,MemberProfileScreen.
Function: IntroduceYourselfOverlay
- Purpose: Modal that tells a signed-in member whose onboarding is complete (
setup === null) and who has not posted (hasPosted === false) to introduce themselves in the forum. Close (X) dismisses this mount only. Primary CTA Write an introduction is aButtonthat dismisses, focuses the welcome composer (requestForumCompose/FORUM_COMPOSE_EVENT), androuter.push('/welcome')only when the path is not already/welcome. No Skip-forever. Hidden whenhasPostedis true or omitted (older api) and whilesetupis not null. - Inputs:
onDismiss. - Returns / side effects: Dialog UI (
role="dialog"aria-modal="true", fixed inset cardz-50). Title/aria-labelfromintroduce.title; bodyintroduce.body; CTAintroduce.ctaas catalogButtontype="button"size="lg"; closeintroduce.close. Does not writeforumLawsDismissedor any account field. - Used by:
SignedInChrome.
Function: MemberProfileLoader
- Purpose: Client loader for
/members/[accountId]: UUID check,fetchMember, thenfetchMemberActivityeven if the Lightning Address is blank. It does not prefetch post/reply feeds;postCountandreplyCountarrive with the profile JSON. - Inputs: Route
accountId; session from auth store. - Returns / side effects: Loading / missing (
view.missing) / error+retry /MemberProfileScreen.fetchMember409missing_requirements→/setup/rules.fetchMemberActivity409missing_requirements→/setup/rules. Any other activity error (other thanMissingRequirementsError) keeps the card and the chart showsprofile.chartError.MissingRequirementsErrorstill goes to/setup/rules. - Used by:
MemberProfilePage.
Function: MemberProfileScreen
- Purpose: Signed-in member identity card (chart from given and received activity, About me inside the card not as a forum post (Translate, Languages icon, when
profileMessage.idis set), name, location, public[email protected](profile.giftsHeading), role pill, optional funding-program icon-only button (pressing it reveals that one sentence; that result is the separate screenshot statefunding-program-open) whenfundingReviewedAtis a number, copy-profile-link, and post/reaction count toggles) plus stackedForumBoardactivity feeds loaded on demand. Location is read-only (location.unsetwhen empty). PublicAboutMeSection(name={profile.name}) shows filled text and/or photo, or omits the heading when neither. A labeled MessageButton(profile.message) with a decorative Mail icon sits on the card when another member has aprofileMessage— not on a post. Staff Trust Chain actions sit behind the closed Moderator functions disclosure when the viewer is a moderator and the subject is someone else. Clicking a count opens its feed below the card; clicking it again collapses it. There is no separately pinned profile-noteForumBoard; the posts feed lists that note when present. A feed shorter than its profile count gets a mutedprofile.activityLatesttruncation line. Posts show React and do not show Send Bitcoin; a payable reply card in the replies feed shows Gift. Nested Gift Continue looks up sats on the visible feed only (reactions-feed cards when activity is replies; expanded-thread replies otherwise). Collapsing or switching Posts/Reactions cancels a Gift whose target is in the expanded thread or the reactions feed; a parent composer invoice stays. Expanding a reply withparentIdnavigates to/messages/{parentId}. Verified members mayPOST /messagesunpaid only when there is text and the amount is empty; empty text and an empty amount invoices 21 sats even for verified; everyone else invoices ≥ 1 sat with optional text; typed0is always billed as 1 sat even for exempt. When a note omitsaccountId, a text reply with an empty amount tries unpaidPOST /messages; a payment 403 starts a 1-sat compose invoice to 21.gifts on the composer slot (payHost: composer,payMessageId= compose-target note). Extra gifts and Gift-open stay on the card (payHost: card). Loads visible inline photos for posts and replies feeds viafetchMessagePhotoblob URLs, same as the home forum top-level cards, retrying a transient fetch once, leaving the row text-only after a second failure, and revoking object URLs on unmount. Blob URLs may also be fetched for expanded thread replies, but ForumBoard does not paint photos on nested replies. LoadsGET /gifts/statsintorateDayvialatestRateDay(failure leavesnull) and passes it to everyForumBoardso unsent previews can use the latest rate; feed ₿ amounts show optional preferred-fiat·from the amount stored when the payment was made (a stored string as-is, a null or missing field on a passedstoredobject uses the gift-day rate) (no FiatPicker on the chart or the feed; member profiles are always signed-in). UsesnextPostRequirementso a missing name, username, Wallet of Satoshi address, or rules agreement opensRequirementsOverlay(no Skip) before a reply retries. When a username is set, a centeredQrCode(labelprofile.giftsQr) under the address encodesopenCryptoPayQrValue(https://<domain>/pl/?lightning=plus the uppercase LNURL ofhttps://<domain>/.well-known/lnurlp/<local>), including on a smartphone. A missing username shows no QR. When a username is set, a labeled Shop stickerButtonunder the QR opensShopStickerOverlaywith the sameopenCryptoPayQrValueand the public handle, including on a smartphone; Close and Escape unmount it. - Inputs:
MemberProfile(includesaboutMe,aboutMeHasPhoto, andlocation) plus received and donated series; optionalfactsOnly(default false); optionalactivityFailed(default false), passed to the chart asfailed; session/account from the auth store. PublicAboutMeSectionhasPhotofromaboutMeHasPhoto/profileMessage.hasPhotowithloadPhoto(fetchMessagePhoto). - Returns / side effects: React tree with About me, copy-profile-link, optional Message, staff Trust Chain actions, and a read-only location row on the card; lazily fetches the selected member posts or replies; fetches
GET /gifts/statsintorateDay; fetches photos for displayedhasPhotocards into blob URLs viafetchMessagePhotoand revokes them on unmount; mayPOSTinvoice/conversation/replies and navigate to/messages?c=or a reply's/messages/{parentId}. WhenactivityFailedis true and the series is empty,profile.chartError.factsOnlystill has no chart.factsOnlyreturns only the role pill, the funding-program icon, the gifts address, QR, Shop sticker, count buttons, the activity feed, andRequirementsOverlay— no heading, chart, About me, name, location, Message, or staff block.ProfileScreenuses that mode so the owner sees the same public facts. - Used by:
MemberProfileLoader,ProfileScreen(factsOnly).
Function: MemberProfilePage
- Purpose: Route
/members/[accountId]with profile onboarding gate and signed-in chrome. - Inputs: Dynamic
accountId. - Returns / side effects:
AppShellwithProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGatearoundMemberProfileLoader. - Used by: App Router.
Function: proxyMeSetupSkipPost
- Purpose: Proxies
POST /me/setup/skipto the api. - Inputs: App Router request (Bearer +
{ step }). - Returns / side effects: Upstream response.
- Used by:
/me/setup/skiproute.
Function: proxyMembersGet
- Purpose: Proxies
GET /members/:accountIdto the api. - Inputs: App Router request and
accountId. - Returns / side effects: Upstream response via
/members/${encodeURIComponent(accountId)}. - Used by:
/forum/members/[accountId]route.
Function: proxyMembersPostsGet
- Purpose: Proxies
GET /members/:accountId/poststo the api. - Inputs: App Router request and
accountId. - Returns / side effects: Upstream response via
/members/${encodeURIComponent(accountId)}/posts. - Used by:
/forum/members/[accountId]/postsroute.
Function: proxyMembersRepliesGet
- Purpose: Proxies
GET /members/:accountId/repliesto the api. - Inputs: App Router request and
accountId. - Returns / side effects: Upstream response via
/members/${encodeURIComponent(accountId)}/replies. - Used by:
/forum/members/[accountId]/repliesroute.
Function: useHydrateSession
- Purpose: Rehydrates a persisted session token into the auth store.
- Inputs: Reads
loadSessionand callsfetchMe. - Returns / side effects:
{ ready }. Sets or clears auth.WrongAccountErrorfromfetchMecallsclearAuthandsetWrongAccount(true)thenreadyso the stale token is not left. Generic/mefailures log and do not set the flag.readyis false until storage//mehas settled so setup screens do not bounce to/login. Unmount invalidates in-flight work. - Used by:
OnboardingGate.
Function: QrCode
- Purpose: SVG QR for a string (LNURL or bolt11). Optional
logocenters that image at 48px and sets error correctionH; profile cards passprofileQrLogo, the inlined apple-touch icon. - Inputs:
value(required) andlabel(required accessible name, already translated). Optionallogo. - Returns / side effects: React element.
- Used by:
ForumBoard,InboxScreen, andPayLinkScreenonly when the UA is not a smartphone (a specific invoice).PosTill,MemberProfileScreen, andViewProfileScreenalso on a smartphone when the value exists.
Function: RootLayout
- Purpose: Root HTML shell: negotiated
lang(en/de/es/fil), global CSS, English metadata (title, icons, Open Graph, Twitter), blockingAPP_HEIGHT_BOOTSTRAP_SCRIPTthenTHEME_BOOTSTRAP_SCRIPTin<head>,suppressHydrationWarningon<html>, token body classes (bg-app-bg text-app-fg),AppHeightSync,LocaleProviderwith the request catalog,NumberFormatProviderwithinitialfromgetRequestNumberFormat(),FiatPreferenceProviderwithinitialfromgetRequestFiat(), andThemeProvider.AccountPreferenceSyncandPushOpenListenersit insideThemeProviderbesideRememberWalletReturn, andViewHistoryRootwraps the page children. The top-left arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. Nest is Locale → NumberFormat → FiatPreference → Theme. - Inputs:
childrenReact nodes. CallsgetRequestLocale()forhtml langand messages,getRequestNumberFormat()for the number-format provider, andgetRequestFiat(locale)for the fiat provider. - Returns / side effects: The document wrapper for every route.
- Used by: All screens.
Function: clearSession
- Purpose: Removes the bearer token from
localStorage. - Inputs: None.
- Returns / side effects: void. No-op during SSR (
windowundefined). - Used by:
useAuthStore.clearAuth.
Function: fetchTrustChain
- Purpose: GET
/trust/graph(same-origin proxy of apiGET /trust-chain) with Bearer and parse the Trust Chain graph. Optionalaroundloads one hop (?around=). - Inputs: Bearer
sessionToken, optionalaroundaccount id. - Returns / side effects:
TrustChain. Throws visitor copy when the api is down, the body is invalid, or the response is 401/403. - Used by:
TrustChainLoader.
Function: postTrustVerify
- Purpose: POST
/trust/verifywith{ accountId, confirmedName }so a moderator verifies a basis member. - Inputs: Bearer
sessionToken, subjectaccountId,confirmedName. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
MemberVerifyScreen.
Function: postTrustPropose
- Purpose: POST
/trust/propose-moderatorwith{ accountId }. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
MemberTrustActions.
Function: postTrustConfirm
- Purpose: POST
/trust/confirm-moderatorwith{ accountId }(caller must not be the proposer). - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
MemberTrustActions,ProposalsScreen.
Function: postTrustReject
- Purpose: POST
/trust/reject-moderatorwith{ accountId }. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
ProposalsScreen.
Function: postTrustAppoint
- Purpose: POST
/trust/appoint-moderatorwith{ accountId }(founder only). - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
MemberTrustActions.
Function: fetchTrustProposals
- Purpose: GET
/trust/proposals(same-origin Bearer proxy of apiGET /trust/proposals) and parsemoderatorProposalsResponseSchema.proposals. Next.js forbids aroute.tsbeside/moderate/proposals, so the proxy lives at this path. - Inputs: Bearer
sessionToken. - Returns / side effects: Open-proposal array. Throws visitor copy
Could not load moderator proposals. Please try again.on 401/403/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
ProposalsScreen,useUnreadCount.
Function: postFundingApply
- Purpose: POST
/funding/apply(Bearer) and parse{ funding }viafundingApplyResponseSchema. Rolebasisis 403. 400About me is required/About me photo is required/Location is requiredare rethrown; other failures use visitor copyCould not submit your application. Please try again. - Inputs: Bearer
sessionToken. - Returns / side effects: Updated
OwnerFunding. Throws the 400 api string, or visitor copy on 401/403/409/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
FundingApplyScreen. While applications are paused, a verified account namedjoey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejectedsees the walk and callspostFundingApply. The pause card, which does not call it, is only for a username outside those three whose status is not pending, trial, or admitted. A basis account on the open card sees You are not verified yet. and does not call it, including when the status is pending, trial, or admitted. A verified account with one of those statuses sees that copy and does not call it. WhengrantApplicationsPausedis false, the walk also calls it.
Function: fetchFundingPayoutDays
- Purpose: GET
/funding/payout-days(same-origin Bearer proxy of apiGET /funding/payout-days) and parsefundingPayoutDaysResponseSchema. Next.js forbids aroute.tsbeside/moderate/payouts, so the proxy lives at this path. Seven UTC days, oldest first, and one row per person. - Inputs: Bearer
sessionToken. - Returns / side effects:
{ days, rows }. Throws visitor copyCould not load the payout table. Please try again.on 401/403/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
FundingPayoutsScreen.
Function: fetchFundingApplications
- Purpose: GET
/funding/applications(same-origin Bearer proxy of apiGET /funding/applications) and parsefundingApplicationsResponseSchema.applications. Next.js forbids aroute.tsbeside/grants/applications, so the proxy lives at this path. - Inputs: Bearer
sessionToken. - Returns / side effects: Open-application array. Throws visitor copy
Could not load grant applications. Please try again.on 401/403/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
FundingApplicationsScreen.
Function: fetchFundingApplication
- Purpose: GET
/funding/applications/:accountId(Bearer) and parsefundingApplicationDetailSchema. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects: Account, grant, and living-room posts. Throws visitor copy
Could not load this application. Please try again.on 401/403/404/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
FundingApplicationDetailScreen.
Function: fetchDailyRoster
- Purpose: GET
/funding/daily-roster(same-origin Bearer proxy of apiGET /funding/daily-roster) and parsedailyRosterSchema. Next.js forbids aroute.tsbeside/grants/payments/commentand/grants/payments/amounts, so the proxy lives at this path. The body is{ comment, paymentsEnabled, defaultAmountUsd, recipients }with{ address, amountUsd, accountId, name }rows.addressstays in the JSON and is not rendered.defaultAmountUsdis a finite number. - Inputs: Bearer
session. - Returns / side effects: Parsed roster. Throws
funding.daily.forbiddenon apiForbidden. Throws visitor copyCould not load daily payments. Please try again.on 401, other 403, 503, other non-2xx, network failure, or a body that fails the schema. - Used by:
DailyPaymentCommentScreen,DailyPaymentAmountsScreen.
Function: saveDailyRosterComment
- Purpose: POST
/funding/daily-roster/commentwith{ comment }and parse the returned roster. - Inputs: Bearer
session, comment text. - Returns / side effects: Updated roster. Throws a
funding.daily.*catalog key. Maps apiInvalid commenttofunding.daily.invalidComment. Any other failure isfunding.daily.saveError. - Used by:
DailyPaymentCommentScreen.
Function: saveDailyRosterPayments
- Purpose: POST
/funding/daily-roster/paymentswith{ enabled }and parse the returned roster.enabledis a boolean. - Inputs: Bearer
session,enabled. - Returns / side effects: Updated roster. Throws a
funding.daily.*catalog key. Maps apiInvalid payments switchtofunding.daily.invalidSwitch. Any other failure isfunding.daily.saveError. - Used by:
DailyPaymentAmountsScreen.
Function: addDailyRosterRecipient
- Purpose: POST
/funding/daily-roster/recipientswith{ accountId, amountUsd }and parse the returned roster.amountUsdis a real number, not a numeric string. - Inputs: Bearer
session, account id, USD amount. - Returns / side effects: Updated roster. Throws a
funding.daily.*catalog key. MapsInvalid person or amount,Unknown person,Person has no Lightning address, andAddress already listed. Any other failure isfunding.daily.saveError. - Used by:
DailyPaymentAmountsScreen.
Function: updateDailyRosterRecipient
- Purpose: POST
/funding/daily-roster/recipients/updatewith{ address, amountUsd }and parse the returned roster. - Inputs: Bearer
session, stored address, new USD amount. - Returns / side effects: Updated roster. Throws a
funding.daily.*catalog key. MapsInvalid address or amountandUnknown address. Any other failure isfunding.daily.saveError. - Used by:
DailyPaymentAmountsScreen.
Function: deleteDailyRosterRecipient
- Purpose: POST
/funding/daily-roster/recipients/deletewith{ address }and parse the returned roster. - Inputs: Bearer
session, stored address. - Returns / side effects: Updated roster. Throws a
funding.daily.*catalog key. MapsUnknown address. Any other failure isfunding.daily.saveError. - Used by:
DailyPaymentAmountsScreen.
Function: postFundingAdmit
- Purpose: POST
/funding/admitwith{ accountId }(staff). Target pending or trial. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
FundingDecisionResult. Throws visitor copyCould not update this member. Please try again.on any failure. - Used by:
FundingApplicationDetailScreen.
Function: postFundingReject
- Purpose: POST
/funding/rejectwith{ accountId }(staff). The subject may re-apply. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
FundingDecisionResult. Throws visitor copyCould not update this member. Please try again.on any failure. - Used by:
FundingApplicationDetailScreen.
Function: mergeTrustChain
- Purpose: Merge a newly loaded neighborhood into the already visible Trust Chain without duplicating nodes or edges.
- Inputs:
currentgraph,incominghop fromGET /trust-chain?around=. - Returns / side effects: Combined
{ nodes, edges }. No I/O. - Used by:
TrustChainLoader.
Function: layoutTrustChain
- Purpose: Positions Trust Chain nodes without a graph library. Roots sit in one row. A person with a single next person sits to their right. Several people hanging off one person stack top to bottom (
TRUST_NODE_VGAP) by role (founder, then the moderator stack rank (initiator shares that rank), then verified; same-rank siblings keep edge order), not side by side and not as a pyramid of levels. - Inputs:
TrustChain{ nodes, edges }. - Returns / side effects:
{ nodes, edges, width, height }with pixel positions. Empty input is zero size. - Used by:
TrustChainDiagram.
Function: TrustChainDiagram
- Purpose: SVG diagram of the laid-out Trust Chain (name, role, unlabeled arrow). One next person sits to the right; several hanging off one person stack top to bottom. Drag a person to move them. A plain click loads one hop around that person; modifier-click keeps the
/members/{id}link. - Inputs:
chain: TrustChain, optionalexpandingId, optionalonExpand. - Returns / side effects: SVG with
data-testid="trust-node-{id}". Empty chain is not rendered by the parent screen. - Used by:
TrustChainScreen.
Function: TrustChainScreen
- Purpose: Localized
/trust-chainbody: title, lead, loading/error/empty/diagram, and Verified / Moderator / Initiator / Founder copy. A hop-load error with nodes already on screen keeps the diagram and shows the catalog error plus Try again above it. App theme tokens (app-fg/app-muted), not marketing display type. - Inputs:
chain,error,loading, optionalexpandingId,onExpand,onRetry. - Returns / side effects: Signed-in screen element.
- Used by:
TrustChainLoader.
Function: TrustChainLoader
- Purpose: Client loader for signed-in
/trust-chain: founder seeds first, then one hop per click, merged into the visible graph. A failed hop keeps the chain. Retry with nodes already on screen re-fetches that?around=hop (does not re-fetch seeds); the banner stays gone only if the hop succeeds. Does not fetch when the session is null. - Inputs: Session from the auth store.
- Returns / side effects: Loading, error+retry, empty, diagram, or diagram-plus-hop-error states, or
nullwithout a session. - Used by:
TrustChainPage.
Function: TrustChainPage
- Purpose: Signed-in page at
/trust-chain. Any logged-in completed account may view (not staff-only). - Inputs: none.
- Returns / side effects: Fill
AppShell(align="start") withProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGate screen="welcome"aroundTrustChainLoader. Graph HTTP is under/trust/graph(noroute.tsbeside this page). - Used by: App Router
src/app/trust-chain/page.tsx.
Function: StaffFunctions
- Purpose: Closed disclosure for moderator and founder actions. On a member card the children are the verify, propose, confirm, and appoint controls. On
/statisticsthe only child is the payout link, andStatisticsScreenmounts the disclosure only for a moderator when yesterday's person count is a number. The summary isstaff.functions. Samedetails/summaryas wallet Advanced functions. Children mount only while it is open. - Inputs:
children. - Returns / side effects:
data-testid="staff-functions".openwhile expanded. Clicking the summary toggles. Clicks do not propagate. - Used by:
MemberTrustActions,StatisticsScreen.
Function: MemberTrustActions
- Purpose: Staff-only Trust Chain controls on another member's identity card. Verify is a
ButtonLinkto/members/[accountId]/verifyand does not post from this card. Propose, Confirm, and Appoint stay one-click buttons that post from the card. The controls are children of the closedStaffFunctionsdisclosure; they are not painted until it is opened. - Inputs:
profile, optionalonUpdated. Hidden unless the signed-in account is a moderator and not the subject. - Returns / side effects: Propose / Confirm / Appoint POST then re-fetch member;
data-testid="state-members-staff-verify"when shown. The Verify link and the remaining action buttons mount only after the disclosure is opened. - Used by:
MemberProfileScreen.
Function: MemberVerifyPage
- Purpose: Next.js page for
/members/[accountId]/verify. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="profile"aroundMemberVerifyScreen. The card has no back control. There is noroute.tsbeside this page. - Inputs: Dynamic
accountId. - Returns / side effects: The stored-name check inside fill AppShell. Member HTTP stays on the existing
fetchMemberclient. - Used by: Route
/members/[accountId]/verify.
Function: MemberVerifyScreen
- Purpose: Client stored-name check. Heading Verify. Renders
nullwithout a session and does not fetch. An id that is not a UUID shows This profile could not be found. and does not fetch. A viewer below moderator sees You cannot verify this member. and does not fetch. OtherwisefetchMemberruns. A moderator who is not the subject, and a basis member with a stored name that is not only whitespace, sees that exact name as an underlined link to the member card, the question under it, then Yes and No. Yes is the only control that posts. No opens the member card and does not post. Both buttons are the only children ofSundayWritingGate; Sunday hides both. There is no Cancel. Chrome back does not post. - Inputs: Route
accountId; session and account fromuseAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session.postTrustVerify(session, profile.id, identifyingName)only from Yes, with the untrimmed stored name, thenrouter.pushto/members/${profile.id}. No onlyrouter.pushto/members/${profile.id}and does not post. A thrown write stays on this page with Could not update this member. Please try again. - Used by:
MemberVerifyPage.
Function: fetchPostStats
- Purpose: GET
/messages/statsand parse the public post totals. Notes and replies are one count. - Inputs: None.
- Returns / side effects:
PostStats. Throws visitor copy when the api is down or the body is invalid. - Used by:
StatsLoader.
Function: fetchGiftStats
- Purpose: GET
/gifts/stats(optionally?recipient=) and parse the public gift totals payload. - Inputs: Optional
recipienthandle; appended as a query param when non-empty after trim (URL-encoded). - Returns / side effects:
GiftStats. Throws visitor copy when the api is down or the body is invalid. - Used by:
StatsLoader,useLatestRateDay,PublicMessageLoader,MemberProfileScreen,ModerateScreen,StatisticsScreen.
Function: fetchAccountActivity
- Purpose: GET
/me/activitywith Bearer and parseaccountActivitySchema(given + received, house gifts + forum zaps). - Inputs:
sessionToken. - Returns / side effects:
AccountActivity. Throws visitor copyCould not load gift stats. Please try again.on non-2xx, network, or schema failure. - Used by:
useAccountTotals.
Function: fetchMemberActivity
- Purpose: GET
/forum/members/:id/activitywith Bearer and parse activity for a member card. - Inputs:
sessionToken,accountId. - Returns / side effects:
AccountActivity. 409missing_requirements→MissingRequirementsError. Other non-2xx / schema → visitor copy. - Used by:
MemberProfileLoader.
Function: fetchViewActivity
- Purpose: GET
/view-key/:viewKey/activitywith no auth and parse public activity. - Inputs:
viewKey(64 hex). - Returns / side effects:
AccountActivity. Throws visitor copy on failure; the loader catches the error, keeps the card, and the chart showsprofile.chartError. - Used by:
ViewProfileLoader.
Function: fetchMe
- Purpose: GET
/mewith the bearer session. - Inputs:
sessionToken. - Returns / side effects:
Accountornullon 401. ThrowsWrongAccountErroron 403 with the duplicate-account api string. Other non-2xx throw the generic fetch-account error. - Used by:
useHydrateSession,renewPasskey.
Function: fetchPublicForumMessages
- Purpose:
GET /forum/messages?mode=activewith no Authorization header. Same page size (20) and cursor as the signed-in feed. HTTP 401 throwsPublicForumUnauthorizedError. - Inputs: Optional
limitandcursor. - Returns / side effects:
{ messages, nextCursor }. - Used by:
ForumLoaderwhen the living room has no session.
Function: PublicForumUnauthorizedError
- Purpose: Error for a 401 from the public active page. The first page shows the existing load error. A later page sends the visitor to
/login. - Inputs: None.
- Returns / side effects: An
Errorwhose name isPublicForumUnauthorizedError. - Used by:
fetchPublicForumMessages,ForumLoader.
Function: fetchMessages
- Purpose: GET
/forum/messageswith the bearer session, parseforumListSchema, and return the newest-first feed page as{ messages, nextCursor }(including defaultedreplyCount). - Inputs:
sessionToken; optional{ mode, limit, cursor, hashtag }. Always sendslimit(default 20), sendsmodewhen supplied, sendshashtagwhen non-empty, and omits a null or empty cursor and an empty/''hashtag. - Returns / side effects:
ForumFeedPagewithnextCursor: nullwhen the response omits it. Throws visitor copy (Could not load messages. Please try again.) on failure. - Used by:
ForumLoader.
Function: fetchExternalAuthorProfile
- Purpose: GET
/public-messages/:id/external-profilewith no Authorization and parseexternalAuthorProfileSchema. - Inputs: Forum message
id. - Returns / side effects:
{ name, npub, nip05?, lud16? }, plus optionalpostCountandreplyCount, ornullon 404, any other non-OK response, network failure, JSON failure, or schema mismatch. Does not throw. - Used by:
ExternalAuthorProfile.
Function: fetchExternalAuthorPosts
- Purpose: GET
/public-messages/:id/external-postswith no Authorization. After HTTP OK, require{ messages: array },safeParseeach item withforumMessageSchema, skip invalid items. - Inputs: Forum message
id. - Returns / side effects:
ForumMessage[](empty if none survive or the list is empty). Throws visitor copy (Could not load messages. Please try again.) on non-OK including 404, network, non-JSON, or a body that is not{ messages: array }. Does not throwMissingRequirementsError. No Bearer. - Used by:
ExternalAuthorProfile.
Function: fetchExternalAuthorReplies
- Purpose: GET
/public-messages/:id/external-replieswith no Authorization. After HTTP OK, require{ messages: array },safeParseeach item withforumMessageSchema, skip invalid items. - Inputs: Forum message
id. - Returns / side effects:
ForumMessage[](empty if none survive or the list is empty). Throws visitor copy (Could not load messages. Please try again.) on non-OK including 404, network, non-JSON, or a body that is not{ messages: array }. Does not throwMissingRequirementsError. No Bearer. - Used by:
ExternalAuthorProfile.
Function: fetchPublicMessage
- Purpose: GET
/public-messages/:idwithout a session, parseforumMessageSchema, and return one public forum note for the HTML note page. OptionalsinceSatsappends?sinceSats=so the api can wait until the note has more sats (pay poll on a top-level note). OptionalsinceReceivedSatsappends?sinceReceivedSats=so the api can wait until a reply has more received sats. A non-integer, NaN, Infinity, or negative value is omitted for either query. When both are valid,sinceSatscomes first. - Inputs: Forum message
id(UUID string). OptionaloptswithsinceSatsandsinceReceivedSats(each a finite integer ≥ 0; omitted / NaN / Infinity / negatives / non-integers skip that query) andsignal(AbortSignalpassed tofetchwhen provided). - Returns / side effects:
ForumMessage, ornullon 404 or abort (AbortError/ already-aborted signal). Throws visitor copy (Could not load messages. Please try again.) on other non-ok, network, or zod failures. - Used by:
PublicMessageLoader,ForumLoader.
Function: fetchShortLink
- Purpose: GET
/links/<code>without a session. An invalid code, a non-OK response (400, 404, 409, or anything else), and an unexpected body returnnull. Network and JSON failures returnnulland do not throw. - Inputs:
codestring. Only^[0-9a-f]{8}$(case-insensitive) is fetched; other strings returnnullwithout a request. - Returns / side effects:
{ kind: 'message' | 'member', id }withidlowercased, ornull. - Used by:
ForumQuotedBody.
Function: fetchForumMessage
- Purpose: GET
/forum/messages/:idwith a bearer session, parseforumMessageSchema, and return one forum note. Staff sessions receive soft-hidden rows (deletedAt/deletedBy); others get 404. - Inputs:
sessionTokenand forum messageid(UUID string). - Returns / side effects:
ForumMessage, ornullon 404. Throws visitor copy (Could not load messages. Please try again.) on other non-ok, network, or zod failures. - Used by:
PublicMessageLoader.
Function: fetchPublicReplies
- Purpose: GET
/public-messages/:id/replieswithout a session. After HTTP OK, require{ messages: array },safeParseeach item withforumMessageSchema, skip invalid items, and return the survivors oldest-first. - Inputs: Parent forum message
id. - Returns / side effects:
ForumMessage[](empty if none survive or HTTP 200{ messages: [] }). Throws visitor copy (Could not load messages. Please try again.) on HTTP 404 (not empty), other non-ok, network, non-JSON, or a body that is not{ messages: array }. - Used by:
PublicMessageLoader.
Function: fetchPublicMessagePhoto
- Purpose: GET
/messages/:id/photowithout Authorization (index 0 and ≤0, legacy) or GET/messages/:id/photo/{index}.jpg(indices 1–9) and return raw image bytes as aBlobforURL.createObjectURLon the public note page. No Authorization. Throws visitor copy on failure. - Inputs: Forum message
id, optional zero-basedindex(default 0). - Returns / side effects:
Blob. Index 0 (and ≤0) uses the legacy photo route; indices 1–9 request{index}.jpg. Throws visitor copy (Could not load messages. Please try again.) on non-ok, empty body, or network failure — does not leak status codes. - Used by:
PublicMessageLoader.
Function: fetchReplies
- Purpose: GET
/forum/messages/:id/replieswith the bearer session. After HTTP OK, require{ messages: array },safeParseeach item withforumMessageSchema, skip invalid items, and return the survivors oldest-first. - Inputs:
sessionToken, parent messageid. - Returns / side effects:
ForumMessage[](empty if none survive). Throws visitor copy (Could not load messages. Please try again.) when the api is unavailable, the body is not JSON, or the body is not{ messages: array }. Damus authors may omitrole(schema defaults tobasis). An optionalvia: 'nostr'field is accepted (present only on replies from a Nostr user with no 21.gifts account). - Used by:
ForumLoader.
Function: fetchMessagePhoto
- Purpose: GET
/messages/:id/photo(index 0) or/messages/:id/photo/{index}.jpg(indices 1–9) with the bearer session and return the raw image bytes as aBlobforURL.createObjectURLrendering. - Inputs:
sessionToken, messageid, optional zero-basedindex(default 0). - Returns / side effects:
Blob. Throws visitor copy (Could not load messages. Please try again.) on non-ok, empty body, or network failure — does not leak status codes. - Used by:
ForumLoader,MemberProfileScreen.
Function: postMessage
- Purpose: POST
/forum/messageswith bearer +{ text, photo?, photos?, inReplyTo?, goalCurrency?, goalAmount?, place?, shopUsername? }, parseforumMessageSchema, and return the created message or reply (text and/or up to ten photos). Non-emptyphotosdual-sendsphotoas the first still plusphotos. A non-blank stringtakenAton a still is included; a blank or missing time is omitted. OptionalgoalCurrencyandgoalAmountgo together on a top-level note, never asgoalSats; omitted on replies and when either is missing.goalSatsremains a field of the read note. Optionalplaceis{ lat, lng, label }on a top-level note; omitted when unset. Replies must not send it. OptionalshopUsernameis sent only when the trimmed value without a leading@is non-blank. - Inputs:
sessionToken,inputwithtext, optional{ contentType, data, takenAt? }photo, optionalphotosarray (max 10), optionalinReplyToparent id (thread composer only), optionalgoalCurrencyandgoalAmount(together, nevergoalSats), optionalplace(ForumPlacePin, top-level only), and optionalshopUsername(leading@stripped; omitted when blank). - Returns / side effects:
ForumMessage. OmitsinReplyTo,goalCurrency/goalAmount,place, andshopUsernamefrom the JSON body when absent; omits both Ask fields on replies even if passed. On 400 or 429 uses the api error string when present; otherwise throwsCould not post your message. On 403 uses the api error string when present; otherwise throwsA reply needs a Bitcoin payment. On 404 whose api error is exactlyNo account with that usernamethrows that string; any other 404 throwsNoteDeletedError. - Used by:
ForumLoader,MemberProfileScreen.
Function: postContact
- Purpose: POST
/contact/submitwith bearer +{ text }, parsecontactSchema, and return the created message. - Inputs:
sessionToken,text. - Returns / side effects:
ContactMessage. On 400 uses the api error string when present; otherwise throwsCould not send your message. - Used by:
ContactLoader.
Function: isForumPhotoFile
- Purpose: True when a browser
Filehas mime type JPEG, PNG, or WebP for the forum attach control. - Inputs:
filefrom<input type="file">. - Returns / side effects: Boolean. No side effects.
- Used by:
prepareForumPhoto.
Function: prepareForumPhoto
- Purpose: Client-side resize/JPEG-encode a picked forum photo (max edge 1280, quality 0.8, max 1 MiB) into raw base64 plus a preview data URL. JPEG files also keep
takenAtfrom Exif before the canvas encode; PNG and WebP settakenAtnull. - Inputs:
fileaccepted byisForumPhotoFile. - Returns / side effects:
{ ok: true, photo }(photo.takenAtis a civil string or null) or{ ok: false, error: 'unsupported' | 'tooLarge' }. Revokes temporary object URLs it creates. The wide profile image is not prepared here;WideImageCropperframes that JPEG. - Used by:
ForumLoader,AboutMeSection,InboxLoader,ModeratorGroupScreen,ProfileScreen.
Function: initialBannerCrop
- Purpose: Largest 5:2 rectangle centered in a source image, used as the opening frame when a member picks a wide profile image.
- Inputs:
imageWidthandimageHeightin source pixels. - Returns / side effects:
{ x, y, width, height }, ornullwhen either dimension is not a finite number greater than 0. A panorama is full height. Every other shape, including a square and a 4:3, is full width. No network. - Used by:
WideImageCropper.
Function: panBannerCrop
- Purpose: Move a 5:2 crop window by a source-pixel delta and keep it inside the image.
- Inputs:
imageWidth,imageHeight, the currentcrop, anddx/dyin source pixels. - Returns / side effects: A new crop with the same width and height.
xandyare clamped to the image. No network. - Used by:
WideImageCropper,zoomBannerCrop.
Function: zoomBannerCrop
- Purpose: Scale a 5:2 crop around its center. A factor greater than 1 zooms in by shrinking the window.
- Inputs:
imageWidth,imageHeight, the currentcrop, andfactor. - Returns / side effects: The zoomed crop, or the same crop when the image has no fitting window. The window cannot grow past
initialBannerCropand cannot shrink below a height of 32, unless the fitting window is already shorter. Width stays height times 5/2. No network. - Used by:
WideImageCropper.
Function: outputBannerSize
- Purpose: Encoded pixel size for a chosen wide-image crop. The result is what the profile API measures.
- Inputs:
cropWidthin source pixels. - Returns / side effects:
{ width, height }. Width ismin(1280, max(640, round(cropWidth))). Height ismax(1, round(width * 2 / 5)). No network. - Used by:
encodeWideBanner.
Function: encodeWideBanner
- Purpose: Draw a 5:2 crop into a JPEG the wide-image API accepts.
- Inputs:
sourcecanvas image,imageWidth,imageHeight, and thecropwindow. - Returns / side effects:
{ ok: true, photo }withcontentType: 'image/jpeg', raw base64data, and apreviewUrldata URL. Quality tries 0.8, then 0.6, then 0.4.{ ok: false, error: 'tooLarge' }when the decoded JPEG is still over 1 MiB.'unsupported'when the crop is not finite and positive, the canvas has no 2d context, ortoDataURLis not a JPEG. Does not throw for those failures. - Used by:
WideImageCropper.
Function: WideImageCropper
- Purpose: In-app 5:2 frame for a picked wide profile image. The member drags, pinches, or scrolls the wheel, then confirms or cancels. A portrait, square, or 4:3 is framed here instead of rejected.
- Inputs:
file(JPEG, PNG, or WebP), optionalbusy,onConfirmwith{ contentType: 'image/jpeg', data },onCancel, andonError('unsupported'or'tooLarge'). - Returns / side effects: The frame (
profile.about.bannerCropHintDrag the photo to choose the wide image), labeled Use this crop, and an X IconButton whose accessible name is Cancel crop (no visible cancel sentence). Confirm callsencodeWideBannerandonConfirm. Cancel callsonCanceland uploads nothing. A file that cannot be decoded callsonError('unsupported'). No network by itself. - Used by:
ProfileScreen(empty wide-image slot; the cropper replaces Add a wide image and leaves Add a profile photo),AboutMeSection(editor, full width above the small wide-image preview).
Function: parseNumberFormat
- Purpose: Returns
valueif it is exactly one ofNUMBER_FORMATS(ch/us/de); otherwise Swissch. Case-sensitive;'CH'and'de-CH'are invalid and fall back to the default. - Inputs: Raw cookie or option
valuestring, orundefinedwhen absent. - Returns / side effects: A supported
NumberFormatStyle. Missing or unknown values becomeDEFAULT_NUMBER_FORMAT(ch). Never writes a cookie. - Used by:
getRequestNumberFormat(server cookie) and any caller that must coerce a rawnumberFormatstring.
Function: separatorsFor
- Purpose: Returns the grouping and decimal characters for one
NumberFormatStylewithoutIntl.NumberFormat. Swiss uses'+., US uses,+., German uses.+,. - Inputs:
style(ch/us/de). - Returns / side effects:
{ grouping, decimal }for that style. Exhaustive switch overNumberFormatStyle. - Used by:
formatGroupedNumberandformatUsdTick(under-10 values swap the decimal separator).
Function: formatGroupedNumber
- Purpose: Groups the integer part of
valuein threes from the right and emitsfractionDigitsdecimal digits usingseparatorsFor. Non-finite values are treated as 0. Rounding usesMath.roundatfractionDigits. - Inputs:
valuenumber,styleNumberFormatStyle,fractionDigits(0 omits the decimal part). - Returns / side effects: Grouped numeric string without a currency or ₿ prefix (for example
1'500or10'000.23). Negative values keep a leading minus. - Used by:
formatBitcoin,formatUsdDisplay,formatUsdTick,NumberFormatSwitchersample labels,DayLoader,GiftDayTable, andStatsDashboard.
Function: formatBitcoin
- Purpose: Formats a whole-sat amount as BIP-177 ₿-only display (leading ₿, grouping from
style, no fraction, no “sats” unit). - Inputs:
satsnon-negative number (APIsats/totalSats; chart mid-ticks may be fractional and are rounded); optionalstyleNumberFormatStyle(defaultch). No locale argument. - Returns / side effects: Display string such as
₿1'500or₿0. - Used by:
ForumBoard,AccountActivityChart,StatsDashboard,GiftDayTable,DayLoader.
Function: isLocalSunday
- Purpose: True when
nowMsfalls on Sunday in an IANA zone, or in the runtime zone whentimeZoneis omitted. An invalid zone returns false. Does not use one fixed zone for every visitor. - Inputs:
nowMsepoch milliseconds and optionaltimeZone. - Returns / side effects: boolean. No I/O.
- Used by: tests. The painted Sunday flag comes from
SUNDAY_BOOTSTRAP_SCRIPT.
Function: useLocalSunday
- Purpose: Reads
documentElement.dataset.localSundayafter the head script sets it. Server render, and the client before that flag, are false, so weekday tests keep their fields. - Inputs: none.
- Returns / side effects: boolean. Subscribes to attribute changes on
documentElement. - Used by:
SundayWritingGate,ModeratorGroupScreen, andPlaceField.
Function: SundayWritingGate
- Purpose: On the device's local Sunday, replaces public write controls or a forum zap control with a sentence. Otherwise renders the children. The rules in
globals.csshide the field before paint oncedata-local-sunday="1"is set. Weekday wrappers usedisplay: contents. - Inputs:
children, and optionalnotice(writeorzap). - Returns / side effects: The sentence or the children. No network.
- Used by: forum composers, profile editors, grant and trust actions, and forum pay controls.
Function: deviceTimeZoneHeader
- Purpose: Returns a
Time-Zoneheader for the runtime IANA zone so the API can apply the same Sunday rule. Empty in Node (nowindow), when the zone is blank, or whenIntlthrows. - Inputs: none.
- Returns / side effects:
{ 'Time-Zone': zone }or{}. No I/O. - Used by: public write and forum-invoice fetches in
api.ts.
Function: formatForumTime
- Purpose: Formats a forum message timestamp as medium date + short time in the runtime local timezone via
Intl.DateTimeFormat, or returns the original ISO string when the instant is invalid. - Inputs:
isostring,localeBCP 47 tag. - Returns / side effects: Display string. Uses the runtime default timezone (visitor system timezone), not UTC.
- Used by:
ForumBoard,InboxScreen,ModerateScreen,NotificationsScreen,PublicMessageLoader,FundingApplicationDetailScreen,formatForumTimeFromMs.
Function: formatForumTimeFromMs
- Purpose: Formats an epoch-ms timestamp the same way as
formatForumTime(medium date + short time in the runtime local timezone), orString(ms)when the instant is invalid. - Inputs:
msepoch milliseconds,localeBCP 47 tag. - Returns / side effects: Display string. Delegates to
formatForumTimeaftertoISOString. - Used by:
FundingStatusCard,FundingApplicationsScreen,FundingApplicationDetailScreen,MemberProfileScreen.
Function: shortResourceUrl
- Purpose: Build the absolute URL handed to a person for a note, a reply, or a member profile. A UUID becomes
origin+/l/+ the first 8 hex characters, lowercased. Any other id keepsoriginplus the long path (/messages/<id>or/members/<id>). - Inputs:
originwithout a trailing slash, resourceid, andlongPathused whenidis not a UUID. - Returns / side effects: Absolute URL string. No I/O.
- Used by:
copyMessageLinkinForumBoard, the member profile copy control, andpublicMessageOgMetadata.
Function: shortLinkPath
- Purpose: Map a
GET /links/:codeJSON body to the long app path.kind: "message"becomes/messages/<uuid>andkind: "member"becomes/members/<uuid>, with the id lowercased. Anything else, including a non-UUID id, isnull. - Inputs: Parsed JSON
body(unknown). - Returns / side effects: Path string or
null. No I/O. - Used by:
fetchShortLinkand theGET /l/[code]redirect route.
Function: splitForumMessageQuotes
- Purpose: Parse HTTP(S)
/messages/<uuid>URLs from a forum body. Returns lowercased unique first-seen ids and displayText with resolved URLs removed (all URLs when resolvedIds omitted). Does not linkify other URLs. - Inputs:
textstring, optionalresolvedIdsReadonlySet<string>. - Returns / side effects:
{ displayText, ids }. No I/O. - Used by:
ForumQuotedBody.
Function: splitShortLinks
- Purpose: Parse HTTP(S)
/l/<8 hex>URLs from a forum body using the same host and punctuation rules assplitForumMessageQuotes. Codes are lowercased, unique, and first-seen. WhenresolvedCodesis omitted, every matched short link is stripped. When it is passed, only those codes are stripped (case-insensitive)./messages/<uuid>URLs are left untouched. - Inputs:
textstring, optionalresolvedCodesReadonlySet<string>. - Returns / side effects:
{ displayText, codes }. No I/O. - Used by:
ForumQuotedBody.
Function: ForumQuotedBody
- Purpose: Remaining body text plus nested post cards for resolved
/messages/<uuid>URLs and forhttp(s)://<host>/l/<8 hex>codes thatfetchShortLinkresolves to a shown message (a member, a null lookup, the containing id, and a failed load stay in the text). Fills from knownNotes first; otherwise fetchPublicMessage (catch, never throw). 404/null leaves the URL, whichLinkedTextthen autolinks as an internal/messages/<uuid>path. Nested card is an outer frame with one empty stretched permalink (absolute inset-0, aria-labelforum.quotedNote, orforum.quotedNoteExternalwhen the quoted note hasvia === 'nostr') that covers the card, including the caption, timestamp, role pill, optional photo, and ₿ amount, and points at/messages/<id>. That permalink is not an ancestor of the caption, so Show more stays outside any anchor. Card content ispointer-events-nonewith[&_*]:pointer-events-none,[&_a]:pointer-events-auto, and[&_button]:pointer-events-auto, so a click on caption text, photo, timestamp, role pill, or amount hits the permalink, while the author profile link, Translate, Show more, and links inside the caption keep their own targets. When the quoted note hasvia === 'nostr', the name is a link to/messages/<id>/author, not a dialog.[&_[role=dialog]]:pointer-events-autoand[&_[role=dialog]_*]:pointer-events-autokeep the external-link confirm dialog and everything inside it clickable, because that dialog is a descendant of the card and is not portaled. The photoimgisdraggable={false}and is not inside the permalink. Caption rendering is unchanged (TranslatableNoteBodywhentranslateis true,plainonly whenvia === 'nostr'; whentranslateis false,ForumNoteTextwhen truncate, elseLinkedText; no nested unfurl).TranslatableNoteBodyon stripped display text only (a successful translation replaces that original after the same quote/short-link strip; optionalformatTranslatedruns after that strip). Feed remaining text and nested captions go throughForumNoteText(280-character preview, Show more only above 560, bodies autolinked) until replaced. Permalink passestruncate={false}so the original stays full (LinkedText). Callers skip this component forvia === 'nostr'rows (plain text and the External badge are handled by the caller). A nested card whose quoted note hasvia === 'nostr'shows the non-interactive External span in the role-pill slot (such notes carry no tagged role). Nested nostr captions useTranslatableNoteBodyplainonly whentranslateis true (a URL in a quoted external reply is never a link). Whentranslateis false, nested cards useForumNoteText/LinkedTextand neverTranslatableNoteBody. - Inputs: text, knownNotes, excludeId, rateDay, fiat, optional truncate (default true), optional className (default
whitespace-pre-wrap text-sm text-app-fg;text-app-btn-fgselects NoteTranslatetone="onButton"(viaTranslatableNoteBody)), optionaltranslate(default true;falserenders remaining text and nested quoted cards — includingvia === 'nostr'— throughForumNoteText/LinkedTextwithoutTranslatableNoteBody; forwarded to nestedQuotedForumNote), optionalconversationId(when set, the remainder translates as that conversation message; nested cards stay forum notes), optionalformatTranslated(applied only to the visible remainder translation, after the quote/short-link strip), optionalcontrolSlotId(forwarded toTranslatableNoteBodyfor the footer icon row), optional onActivate. - Returns / side effects: React element or null when text==='' and no resolved quotes. Unknown quote ids load via
fetchForumMessagewhen a session exists, otherwisefetchPublicMessage(catch, never throw). Optionalmentionsare passed only when the quoted author is already the member link. Each nested card has one stretched permalink to/messages/<id>covering the caption, photo, time, and amount; the author link, Translate, Show more, and links inside the caption stay outside that permalink. - Used by:
ForumBoard,PublicMessageLoader,InboxScreen.
Function: splitNoteLinks
- Purpose: Split a note body into plain-text runs and http(s) URLs. Trailing prose punctuation is not part of the URL.
javascript:/data:and scheme-less text are not links. Each URL is classified internal vs external. Internalpathcollapses extra leading slashes sohttps://21.gifts//…stays a same-origin/…href, not protocol-relative. - Inputs:
textstring, optionalcurrentOrigin(page origin; that hostname is also internal). - Returns / side effects:
NoteLinkSegment[]coveringtextin order. No I/O. - Used by:
LinkedText.
Function: isInternalAppUrl
- Purpose: True when an absolute http(s) URL is an in-app 21.gifts page (
21.gifts,www.21.gifts, or the current origin hostname).api.21.giftsand other subdomains are external. Userinfo does not change the hostname check. - Inputs:
hrefstring, optionalcurrentOrigin. - Returns / side effects: Boolean. No I/O.
- Used by:
splitNoteLinks.
Function: LinkedText
- Purpose: Render a note/About-me/inbox body with clickable http(s) URLs. Internal URLs are Next.js
Links topathname+search+hash(no warning). External URLs are<a href>thatpreventDefaulton a primary click and openExternalLinkWarning. Click and Enter/SpacestopPropagationso a forum card does not toggle; Enter/Space on an external URL opens the warning. Optionalsuffixsits in the same<p>after the runs (Show more). Optionalmentionsturn a resolved@usernameinto the member button (forum.authorProfile). A click opens/members/<accountId>. Optionalplain(default false) renderstextas a single span with no autolinking (no<a>/Link, noExternalLinkWarning), used for Nostr-zapper (via: 'nostr') rows. - Inputs:
text,classNamefor the wrapping<p>, optionallinkClassName(default underline, inherit colour), optionalcurrentOrigin, optionalsuffix, optionalplain(default false), optionalmentions({ username, accountId }[]). - Returns / side effects: Fragment:
<p>plus optional overlay. Confirm onhttps:callsopenInSystemBrowser; other http useswindow.open. - Used by:
ForumNoteText,ForumQuotedBody(whentruncateis false),AboutMeSection.
Function: ExternalAuthorProfile
- Purpose: Profile card for a forum author with no 21.gifts account. Same fields and hide rules as the member card: heading
profile.title, name with the External span, optional checked Nostr address, payment address only when it differs ignoring case, and the npub with a centered secondary IconButton copy control. It is not a dialog. Rendered byExternalAuthorPageinsidePublicMessageChrome. No image, payment, or outbound link. Copy uses the clipboard, then a hidden textarea. When bothpostCountandreplyCountare numbers, the same count buttons as a member open a read-only feed under the card (no pay, no composer, no react). If either count is absent, no buttons. - Inputs:
messageId,fallbackName. EmptyfallbackNameshowsview.unnameduntil a profile loads, and when the fetch returns null. - Returns / side effects: The card. No
onClose. Loading and a null fetch show the title, the fallback name (or Unnamed), and the External span, and omit the address sections. No overlay, portal, hint paragraph, or close control. Count buttons and the read-only feed render when both counts are numbers; a post click opens/messages/{id}; a reply click opens/messages/{parentId}; a shorter list showsprofile.activityLatest; loading usesforum.loading; failure usesforum.errorandview.retry. - Used by:
ExternalAuthorPage.
Function: ExternalAuthorPage
- Purpose: Next.js page for
/messages/[id]/author— the external author profile card for a forum note whose author has no 21.gifts account. Not a member page and not a dialog. Readsparams.idandsearchParams.name(a string or the first array entry, trimmed; blank becomes''). RendersExternalAuthorProfileinsidePublicMessageChrome. NoOnboardingGate. The note id is not validated as a UUID. - Inputs: Dynamic route params (
id) and optionalnamequery (stringorstring[]). - Returns / side effects:
ExternalAuthorProfileinsidePublicMessageChrome(unsigned wordmark href/plusLanguageSwitcher, or signed-inProfileChromeLeftplusSignedInChrome). NogenerateMetadata. - Used by: Route
/messages/[id]/author.ForumBoard(name click).QuotedForumNote(quoted name).PublicMessageLoader(PublicThreadCardname).
Function: ExternalLinkWarning
- Purpose: Confirm overlay before leaving 21.gifts for an external http(s) URL. Same overlay chrome as
IntroduceYourselfOverlay(bg-app-overlay,Card maxWidth="sm", icon-only Close, labeled Open link). The destination URL is shown as user content, not a catalog string. No Skip. - Inputs:
url,onCancel,onConfirm. - Returns / side effects: Dialog. Close/
onCanceldoes not open the URL. Open link callsonConfirm. Dialog click and keydownstopPropagationso a parent forum card does not toggle. - Used by:
LinkedText.
Function: ShopStickerOverlay
- Purpose: Shop-sticker overlay on a member profile. Same overlay chrome as
ExternalLinkWarning(role="dialog",bg-app-overlay, icon-only Close) withCard maxWidth="xl": title Shop sticker, lead Print it for a shop window. The QR code pays {handle}., a Second language combobox, a preview<img>ofbuildShopStickerSvg(alt Shop sticker preview for {handle}), a neutralSegmentedControlFile format PDF | PNG | JPG | SVG (PDF first), and a labeled Download. - Inputs:
qrValue(the profile'sopenCryptoPayQrValue),handle(giftsLightningAddress),onClose. The text language is not a prop.shopStickerLangInitialsets the first selection from the visitor's UI language (en→ English only,de→ German,es→ Spanish,fil→ Filipino), unless the pagelangquery names a sticker language. The combobox then drives the preview and the download. Choosing a language does not write the URL. Options, in order: None (English only), Spanish, German, French, Filipino, Kikamba. The closed control stays in the card flow above the preview; the list opens downward over the preview. - Returns / side effects: Dialog. Download disables itself while
shopStickerBlobruns, then saves the blob through a temporary<a download>named byshopStickerFileNameand revokes the object URL a second later; a failure showsrole="alert"Could not create the file. Please try again. until the next try. Focus moves into the dialog on open and returns to the element that opened it on close. Close callsonClose. Escape closes the open language list first and leaves the dialog up; the next Escape callsonClose. The dialog's own keydown handles Escape from inside it, and adocumentlistener (removed on unmount) closes only on Escape from outside the dialog, so one key press never closes twice even with the app root ondocument. Dialog click and keydownstopPropagation. A press outside the list closes the list only. - Used by:
MemberProfileScreen— only where the profile shows its QR (username set), including on a smartphone.
Function: visibleForumMessages
- Purpose: Client-side filter and sort of the already-loaded forum thread for the Active / No gifts yet / All / Most popular selector. Does not call the api; ranking is among the messages the loader already holds.
- Inputs:
messages(newest-first list from the api / loader merge) andmode(active|unpaid|all|popular). - Returns / side effects: A new array.
allkeeps input order including unpaid (sats === 0) notes.activekeeps paid notes (sats > 0) and unpaid notes whoseroleis at leastmoderator, newest-first. A zero-sat ask is not active.popularkeeps only paid notes, ordered by sats descending, thencreatedAtdescending, theniddescending. Never mutates the input array. - Used by:
ForumBoard,ForumLoader.
The No gifts yet mode keeps only loaded messages with exactly zero sats, including notes without a wallet, preserving input order. The board displays them newest first (same as Active/All). Active remains the default.
Function: hasUnseenForumPosts
- Purpose: True when a fetched forum list contains at least one message id that the currently loaded list does not.
nullcurrent is not unseen so the first load applies instead of showing New posts. A loaded empty list ([]) with new ids is unseen. - Inputs:
current(ForumMessage[]ornull) andfetched(newest-first GET payload). - Returns / side effects: Boolean. Compares ids only; sat changes on existing ids are not unseen.
- Used by:
ForumLoader.
Function: unpaidNewCount
- Purpose: Counts loaded zero-sat notes created after the visitor last opened No gifts yet. Pure: no I/O and does not mutate
messages. A missing or invalidseenAtis a first visit and returns0even when unpaid notes exist. - Inputs:
messages(newest-first list from the api / loader merge) andseenAt(ISO last-visit stamp, ornullwhen never opened). - Returns / side effects: How many currently loaded unpaid notes are strictly newer than
seenAt. No network. - Used by:
ForumLoader.
Function: requestForumCompose
- Purpose: Ask the welcome new-post composer to take focus and skip
IntroduceYourselfOverlayon the next SignedInChrome mount. Used by Write an introduction. - Inputs: none.
- Returns / side effects: Sets skip-introduce-once and pending-compose module flags that survive Next.js client navigations until consumed. When
windowexists, dispatchesFORUM_COMPOSE_EVENT(21gifts:forum-compose). - Used by:
IntroduceYourselfOverlay.
Function: consumePendingForumCompose
- Purpose: One-shot read of the pending-compose flag from
requestForumCompose. - Inputs: none.
- Returns / side effects:
truewhen compose focus is still pending, then clears the flag.falseon a second call.ForumBoardconsumes only after the new-post textarea is present; acomposerHiddenboard does not clear the flag. - Used by:
ForumBoard.
Function: consumeSkipIntroduceOverlay
- Purpose: One-shot read of the skip-introduce-once flag from
requestForumComposeso a remount afterrouter.push('/welcome')does not show the overlay again. - Inputs: none.
- Returns / side effects:
truewhen this mount should start with the overlay dismissed, then clears the flag. Close (X) does not set the flag. - Used by:
SignedInChrome.
Function: fiatPrefix
- Purpose: Amount prefix for a fiat code.
$and₱sit tight against the number, the same way₿does. CHF and EUR keep the code and a trailing space (CHF,EUR). - Inputs:
code(FiatCode: CHF, EUR, USD, or PHP). - Returns / side effects:
'$','₱','CHF ', or'EUR '. No grouping and no network. - Used by:
formatFiatDisplay,formatFiatTick, andformatDefinedGoalAmount.
Function: formatFiatDisplay
- Purpose: Formats an API fiat amount string for stats display using the visitor grouping style.
nullbecomes—(U+2014). USD uses$and PHP uses₱; CHF and EUR prefix the code (CHF 1'425.00). - Inputs:
amount(string | null),code(FiatCode), and optionalstyleNumberFormatStyle(defaultch). - Returns / side effects: Display string such as
$1'425.00/CHF 1'425.00. NoIntl.NumberFormat. No network. - Used by:
StatsDashboard,GiftDayTable,DayLoader.
Function: formatFiatTick
- Purpose: Formats a parsed fiat chart-axis value with grouping and a currency prefix. USD uses
$(same asformatUsdTick) and PHP uses₱; CHF and EUR keep the code (CHF 0/CHF 1.43/CHF 1'425). - Inputs:
amountnumber (layout scale only),code(FiatCode), and optionalstyleNumberFormatStyle(defaultch). - Returns / side effects: Axis label. Values under 10 keep trimmed decimals and use the style decimal separator. Does not itself map a null series to
——StatsDashboarddoes that when every selected cumulative isnull.AccountActivityChartmaps all-null CHF/EUR/PHP to—itself. - Used by:
StatsDashboardover-time fiat scale,AccountActivityChart(profile fiat scale).
Function: defaultFiatForLocale
- Purpose: Picks the locale default for preferred fiat when the
fiatcookie is absent:de→ CHF,fil→ PHP,es→ EUR,en→ USD. - Inputs:
locale(Locale). - Returns / side effects: A
FiatCode. No network. - Used by:
getRequestFiat.
Function: formatUsdDisplay
- Purpose: Formats an API USD amount string (
"1425.00") as grouped currency (wrapper aroundformatFiatDisplay(..., 'USD', style)). - Inputs:
usdstring fromGET /gifts/stats; optionalstyleNumberFormatStyle(defaultch). - Returns / side effects: Dollar string such as
$1'425.00. - Used by:
StatsDashboardKPI when USD is selected.
Function: formatUsdTick
- Purpose: Formats a parsed USD chart-axis value as a grouped dollar label.
- Inputs:
usdnumber (layout scale only); optionalstyleNumberFormatStyle(defaultch). - Returns / side effects: Label such as
$1'425. Values under 10 keep trimmed decimals and use the style decimal separator (.forch/us,,forde). - Used by:
AccountActivityChartwhen USD is selected. Stats over-time usesformatFiatTick.
Function: latestRateDay
- Purpose: Picks the last
spendOverTimeday withsats > 0, even when a currency total on that day is missing. Conversion does not use this day; it useslatestRateDayFor. - Inputs: Oldest-first series of
{ sats, usd, chf, eur, php }. - Returns / side effects: That day, or
nullwhen every day is empty. No I/O. - Used by: Tests. Callers that convert use
latestRateDayFor.
Function: latestRateDayFor
- Purpose: Picks the newest gift day that can convert the preferred currency. A newer day with gifts but a null, non-numeric, or zero total for that currency is skipped, so one incomplete gift cannot block the till.
- Inputs: Oldest-first series of
{ sats, usd, chf, eur, php }, and aFiatCode. - Returns / side effects: That day, or
nullwhen no day can convert the currency. No I/O. - Used by:
useLatestRateDayState,PublicMessageLoader,PublicMessageThread,MemberProfileScreen.
Function: useLatestRateDay
- Purpose: Latest gift-day totals for preferred-fiat conversion. Returns the
rateDayfromuseLatestRateDayState, which skips a newer day that cannot convert the preferred currency. A failed fetch or no usable day yet resolvesnull. Changing the preferred fiat reuses the fetched series. Drops the response after unmount. - Inputs: Optional
enabled(default true). When false, the fetch is skipped and the value staysnull. - Returns / side effects:
FiatRateDay | null. CallsfetchGiftStatsonce per mount while enabled. Loading and a settled missing rate are bothnull. - Used by:
ForumLoader,InboxLoader,ModeratorGroupScreen,PayLinkScreen.
Function: useLatestRateDayState
- Purpose: Latest gift-day totals for the preferred fiat, and whether that
GET /gifts/statsrequest has settled. A newer day whose total for that currency is missing or zero is skipped.settledis false while the request is in flight, so a payment screen does not treat the amount as ready. A settlednullmeans the request finished with no usable rate. Whenenabledis false, the fetch is skipped andsettledis true. Changing the preferred fiat reuses the fetched series. Drops the response after unmount. - Inputs: Optional
enabled(default true). - Returns / side effects:
{ rateDay: FiatRateDay | null, settled: boolean }. CallsfetchGiftStatsonce per mount while enabled. - Used by:
useLatestRateDay,MemberHabits,PayLinkScreen,PosTill,PosAmount.
Function: shownFiatForSats
- Purpose: The four fiat amounts shown for a sat amount the visitor is about to pay, using the same gift day as the preview.
- Inputs: Whole
satsandrateDay(FiatRateDay | null). - Returns / side effects:
{ amountUsd, amountChf, amountEur, amountPhp }, each a two-decimal string ornull. No I/O. - Used by:
ForumLoader,InboxLoader,MemberProfileScreen,PublicMessageThread.
Function: satsToFiatAmount
- Purpose: Scales whole sats into a two-decimal fiat string using one gift day's totals (
Math.roundon cents). - Inputs:
sats,day(FiatRateDay | null),code(FiatCode). - Returns / side effects:
"0.02"-style string, ornullwhen the day or that fiat is missing or the gift-day total is"0.00"(not a usable rate). - Used by:
ForumBoard,preferredFiatSuffix,InboxScreen.
Function: preferredFiatSuffix
- Purpose: Preferred-fiat suffix next to a ₿ amount (
·plus the visitor's default fiat), shared
by the forum, public thread, and inbox/moderator-group threads. Bitcoin is not the visitor's default fiat, so the suffix is required whenever a figure exists. A stored string for that currency is shown as-is and is not recomputed. A null or missing field uses the last gift-day rate. Returns null only when there is no stored string and no usable rate. Unsent previews omit stored and use that same rate.
- Inputs: Whole sats, latest
FiatRateDayornull, visitorFiatCode, number-format
style, optional stored { amountUsd?, amountChf?, amountEur?, amountPhp? } (string | null). A present string is formatted as-is (rateDay ignored). A present null or a missing field falls through to the gift-day rate.
- Returns / side effects:
ReactElement | null. No side effects. - Used by:
ForumBoard,InboxScreen,QuotedForumNote,PublicMessageLoader.
Function: ThemeProvider
- Purpose: Client provider that reads the
themecookie and OSprefers-color-scheme, exposes preference / resolved theme, and keepshtml.darkin sync after hydration (does not wipe the bootstrap class on the first paint). - Inputs: React
children. - Returns / side effects: Context value with
preference,resolved,setPreference. Writinglight/darksets the cookie (Path=/,Max-Age=31536000,SameSite=Lax,Secureon https);systemdeletes it. Listens tomatchMediawhile preference issystem. - Used by:
RootLayout(wraps the app),ThemeSwitcher,useTheme.
Function: ThemeSwitcher
- Purpose: Profile identity-card settings section: uppercase
theme.labelkicker andSegmentedControl tone="neutral"for System / Light / Dark. Always visible on the signed-in Profile card. Not page chrome, not a Menu disclosure. - Inputs: None. Reads
preference/setPreferencefromuseTheme. Catalog keystheme.label,theme.system,theme.light,theme.dark,aria.theme. - Returns / side effects: Settings row matching
PushTogglechrome. Pressing an option callssetPreference(cookie write viaThemeProvider). - Used by:
ProfileScreen.
Function: useTheme
- Purpose: Reads theme preference and setters from the nearest
ThemeProvider. - Inputs: None (React context).
- Returns / side effects:
ThemeContextValue. Throws when used outsideThemeProvider. - Used by:
ThemeSwitcherand any client chrome that needs the resolved theme.
Function: parseThemePreference
- Purpose: Parses a cookie / stored theme preference.
- Inputs: Raw cookie value, or
undefinedwhen missing. - Returns / side effects:
'light'/'dark'when valid; otherwise'system'. - Used by:
ThemeProvider,THEME_BOOTSTRAP_SCRIPT(inline equivalent).
Function: resolveTheme
- Purpose: Resolves a preference against the OS color-scheme media query.
- Inputs:
preference(system|light|dark),prefersDarkboolean. - Returns / side effects: Concrete
'light'or'dark'. - Used by:
ThemeProvider.
Function: THEME_BOOTSTRAP_SCRIPT
- Purpose: Blocking bootstrap IIFE string injected as a raw head script before paint. Reads the theme cookie and
matchMedia('(prefers-color-scheme: dark)'), toggleshtml.dark, and has no dependencies. - Inputs: None (constant string).
- Returns / side effects: Non-empty IIFE source mentioning
theme=andclassList. - Used by:
RootLayout<head>script.
Function: THEME_COOKIE
- Purpose: Cookie name for a persisted theme override (
light|dark). Absent means system. - Inputs: None (constant
'theme'). - Returns / side effects: Cookie key string.
- Used by:
ThemeProvider, theme tests.
Function: getApiUrl
- Purpose: Reads
NEXT_PUBLIC_API_URLvia the typed config accessor. - Inputs: None.
- Returns / side effects: Origin string. Throws if unset/empty (entrypoint must substitute).
- Used by:
proxyApiRequest(server-side upstream origin).
Function: getAppVersion
- Purpose: Typed accessor for the baked Menu version (
NEXT_PUBLIC_APP_VERSION). - Inputs: None.
- Returns / side effects: Decimal deploy run number string, or
dev. Throws if unset/empty. Does not go throughentrypoint.sh. Does not truncate. - Used by:
SignedInChrome.
Function: getE2eNow
- Purpose: Reads the optional Playwright clock
NEXT_PUBLIC_E2E_NOW. Production leaves it unset. - Inputs: None.
- Returns / side effects: The pinned instant, or
nullwhen unset or empty. Does not throw and does not invent a time. The head script then uses the device clock. - Used by:
RootLayoutfor thee2e-nowmeta tag.
Function: getCatalog
- Purpose: Return the message catalog for a supported UI locale without indexed-access gaps.
- Inputs:
locale(en/de/es/fil). - Returns / side effects: The
Messagesobject for that locale. Exhaustive switch overLocale. - Used by:
RootLayout,Home,/login,NotFound,MarketingFooter,HandbookPage,RulesPage,RulesSetupPage, and therenderWithLocaletest helper.
Function: getRequestLocale
- Purpose: Resolve the UI locale for the current request without writing cookies.
- Inputs: Reads the
localecookie and theAccept-Languageheader vianext/headers(both async in Next 15). - Returns / side effects: A supported locale (
en/de/es/fil). Valid cookie wins; invalid/missing cookie falls through toparseAcceptLanguage; unmatched →en. - Used by:
RootLayout,Home,/login,NotFound,MarketingFooter,HandbookPage,RulesPage, andRulesSetupPage. Lives insrc/lib/request-locale.tsso client components can import locale constants withoutnext/headers.
Function: getRequestNumberFormat
- Purpose: Resolve the visitor number-format style for the current request without writing cookies. Cookie
numberFormatwins when it isch/us/de; otherwise Swissch. - Inputs: Reads the
numberFormatcookie vianext/headers(async in Next 15). - Returns / side effects: A
NumberFormatStyle. Invalid or missing cookie →ch. Lives insrc/lib/request-number-format.tsso client components can importNUMBER_FORMATSfrom@/lib/number-formatwithout pullingnext/headersinto the browser bundle. - Used by:
RootLayout(passesinitialintoNumberFormatProvider).
Function: isAndroidUserAgent
- Purpose: Detects Android so the WoS CTA can use an Intent URL.
- Inputs:
userAgentstring. - Returns / side effects:
trueiff/Android/imatches. - Used by:
ForumBoard.
Function: isSmartphoneUserAgent
- Purpose: Detects a smartphone so the pay sheet can hide the payment QR. True for iPhone, iPod, and Android with
Mobile; false for iPad, Android tablet (noMobile), and desktop. Viewport width is irrelevant. - Inputs:
userAgentstring (navigator.userAgent). - Returns / side effects:
trueiff the UA is a smartphone. No side effects. - Used by:
ForumBoard,InboxScreen, andPayLinkScreento hide a specific invoice QR.
Function: isInAppBrowser
- Purpose: Detects Telegram and other in-app WebViews where a WebAuthn passkey ceremony cannot complete, so
/loginand/view/[viewKey]can show an escape card instead of starting WebAuthn. - Inputs: Optional
InAppBrowserHost(win); defaults toglobalThis.windowwhen present. Missing window (SSR) is treated as not in-app. - Returns / side effects:
truewhen a Telegram JS bridge is present (TelegramWebviewProxy,TelegramWebview, orTelegram.WebApp) or the UA matches a known in-app token list; otherwisefalse. No network and no DOM writes. - Used by:
LoginCardandViewProfileClaim(choose the in-app escape card after mount),usePasskeyLogin(safety net:NotAllowedErrorduring authenticate →unsupported, no register fallback),shouldOfferIosInstall/PwaInstall(hide install when in-app), and the/login//view/[viewKey]in-app handbook / e2e variants.
Function: loadHandbookDocuments
- Purpose: Read the four app handbook markdown files from disk (README, screens, functions, endpoints).
- Inputs: Optional
rootDir; defaults to<cwd>/docs/handbook. - Returns / side effects:
HandbookDocument[]in that order. Throws when the directory or a required file is missing. - Used by:
HandbookScreensPage,HandbookFunctionsPage,HandbookEndpointsPage.
Function: loadSession
- Purpose: Reads the bearer token from
localStorage. - Inputs: None.
- Returns / side effects: Token string or
null. SSR-safe. - Used by:
useHydrateSessionon mount,useUnreadCount,refreshUnreadAppBadge,NotificationsLoader.
Function: loadUnpaidSeenAt
- Purpose: Reads the persisted No gifts yet last-visit timestamp from
localStoragekey21gifts.forum-unpaid-seen. - Inputs: None.
- Returns / side effects: The stored ISO string, or
nullwhen none is stored, the value is empty/whitespace/Date.parseis not finite, storage access throws, or when running on the server (nowindow). SSR-safe. - Used by:
ForumLoaderon mount.
Function: parseAcceptLanguage
- Purpose: Negotiate a supported UI locale from an RFC 7231
Accept-Languageheader. - Inputs: Raw header string (may be empty). Splits on commas. A missing
qdefaults to 1. A bareq, empty/invalid qvalue, or duplicateqdiscards that language-range. Maps primary subtags (en/de/es/fil, andtl→fil). - Returns / side effects: Among valid mapped ranges with
q > 0, highestq, then earlier header position, thenLOCALESorder. No positive assignment →en. Pure function — no I/O. - Used by:
getRequestLocalewhen no validlocalecookie is present.
Function: parseHandbookMarkdown
- Purpose: Parse handbook markdown into headings, paragraphs, and lists with inline code, strong, links, and images.
- Inputs:
markdownstring andidPrefixfor ids and in-page hashes. - Returns / side effects:
HandbookBlock[]. Drops unsafe hrefs (.., unknown schemes). - Used by:
HandbookMarkdown.
Function: parseSupportedLocale
- Purpose: Accept a string only when it is exactly one of
en/de/es/fil. - Inputs: Raw cookie or option value, or
undefined. - Returns / side effects: That locale, or
null. Pure function — no I/O. - Used by:
getRequestLocale(cookie).
Function: resolveLightningAddress
- Purpose: GET
/lightning-address?address=on the 21.gifts api. - Inputs:
address. - Returns / side effects: Resolved LNURL-pay metadata (callback, min/max).
- Used by: Unit tests and any remaining LUD-16 resolve.
Function: saveSession
- Purpose: Writes the bearer token to
localStorage. - Inputs:
tokenstring. - Returns / side effects: void. SSR no-op.
- Used by:
useAuthStore.setAuth.
Function: saveUnpaidSeenAt
- Purpose: Persists the No gifts yet last-visit timestamp, overwriting any previous value.
- Inputs:
isoISO timestamp to store (new Date().toISOString()). - Returns / side effects: void. SSR no-op. A throwing storage write is also a no-op.
- Used by:
ForumLoaderwhen entering unpaid and while unpaid as the list refreshes.
Function: setName
- Purpose: POST
/me/name. - Inputs:
sessionToken,name, and optionalsundayWrite(enforcedefault, orsetupto omitTime-Zoneduring onboarding). - Returns / side effects: Updated
Account. - Used by:
NameForm.
Function: setLocation
- Purpose: POST
/me/locationwith JSON{ location }. Empty string is a valid clear. - Inputs:
sessionToken,location(may be empty). - Returns / side effects: Updated
Account. Throws the api error string on 400 when present, otherwise'Could not save your location'. - Used by:
LocationForm.
Function: putAboutMe
- Purpose: PUT
/me/aboutwith bearer +{ text, photo? }and return the updated account.photoomitted keeps a stored image;nullclears it;{ contentType, data }replaces it (same JPEG payload as a forum post). - Inputs:
sessionToken,text, optionalphoto({ contentType, data } | null). - Returns / side effects: Updated
AccountincludingaboutMeandaboutMeHasPhoto. ThrowsMissingRequirementsErroron 409missing_requirements;'Could not save. Please try again.'on other non-2xx. A 2xx body that failsaccountSchemathrows the schema error. - Used by:
ProfileScreen.
Function: fetchAboutMePhoto
- Purpose: GET
/me/about/photowith the bearer session and return the raw image bytes as aBlobforURL.createObjectURLrendering. - Inputs:
sessionToken. - Returns / side effects:
Blob. Throws visitor copy (Could not load. Please try again.) on non-ok, empty body, or network failure — does not leak status codes. - Used by:
ProfileScreenviaAboutMeSectionloadPhoto.
Function: fetchViewAboutMePhoto
- Purpose: GET
/view-key/:viewKey/about/photowithout Authorization and return the raw image bytes as aBlob. - Inputs:
viewKey(64 lowercase hex). Encoded in the path. - Returns / side effects:
Blob. Throws visitor copy (Could not load. Please try again.) on non-ok, empty body, or network failure. - Used by:
ViewProfileScreenviaAboutMeSectionloadPhoto.
Function: dismissForumLaws
- Purpose: POST
/me/forum-laws-dismissedto permanently dismiss the welcome-forum living-room laws hint. - Inputs:
sessionToken. - Returns / side effects: Updated
AccountwithforumLawsDismissed: true. No request body. - Used by:
ForumLoader.
Function: postNotificationLevel
- Purpose: POST
/me/notification-levelwith JSON{ level }(all|active|mentions) and Bearer session. - Inputs:
session(bearer token),level(NotificationLevel). - Returns / side effects: Updated
Account. Throws'Could not save notification level.'on a non-ok response; a 2xx body that failsaccountSchemathrows the schema error. - Used by:
PushToggle.
Function: accountNotificationLevel
- Purpose: Read
account.notificationLevel ?? 'all'so omitted API fields still mean All. - Inputs: Parsed
Account(the field may be missing). - Returns / side effects:
'all','active', or'mentions'. No network. - Used by:
PushToggle.
Function: agreeToRules
- Purpose: POST
/me/rules-agreementwith Bearer and no JSON body. - Inputs:
sessionToken. - Returns / side effects: Updated
AccountwithrulesAgreedAtset. Throws'Could not save your agreement'on a non-ok response. - Used by:
RulesSetup.
Function: setUsername
- Purpose: POST
/me/usernamewith the unique LUD-16 local-part. - Inputs:
sessionToken,username, and optionalsundayWrite(enforcedefault, orsetupto omitTime-Zoneduring onboarding). - Returns / side effects: Updated
Account. Throws'username-taken'on 409,'username-invalid'on 400,'username-request'on other failures. - Used by:
UsernameForm.
Function: giftsLightningAddress
- Purpose: Build the public
[email protected]address shown on profiles. Loopback hosts fall back to21.gifts. - Inputs:
username(nullable), optionalhostname. - Returns / side effects:
local@domainornull. No I/O. - Used by:
MemberProfileScreen,ViewProfileScreen,openCryptoPayQrValue.
Function: encodeLnurl
- Purpose: BIP-173 bech32-encode a cleartext URL with HRP
lnurland return it uppercase, for an Open CryptoPaylightningquery parameter. - Inputs:
urlstring. - Returns / side effects: Uppercase
LNURL1…. No I/O. - Used by:
openCryptoPayQrValue.
Function: openCryptoPayQrValue
- Purpose: Build the Open CryptoPay QR payload for a profile handle. Null when
giftsLightningAddressis null. - Inputs:
username(nullable), optionalhostname. - Returns / side effects:
https://<domain>/pl/?lightning=<LNURL>ornull. No I/O. The browser page for that URL is/pl. - Used by:
MemberProfileScreen,ViewProfileScreen,ShopStickerOverlay(sticker QR payload).
Function: buildShopStickerSvg
- Purpose: Printable shop-window sticker for one member as a standalone SVG document. The fixed artwork (orange band with the Bitcoin mark, ACCEPTED HERE / TINATANGGAP DITO, the English and Filipino scan text, the sari-sari shop with the 21.gifts sign) comes from
src/lib/shop-sticker-artwork.ts, whichscripts/build-shop-sticker-artwork.mjsgenerates with every glyph converted to an outline. Only three colours: orange#F99602, black, white. - Inputs:
qrValue— the member'sopenCryptoPayQrValue. Optionallang— default Filipino (English + Filipino artwork).spanish,german,french, andkikambareplace artwork indices 6–12.englishreplaces those indices with the English scan block only (no second headline). - Returns / side effects: SVG markup,
width="134.4mm",viewBox="0 0 1500 918". The QR is error correction H in byte mode, at least version 10 (57 modules; shorter payloads are padded up so the 4-module quiet zone to the band holds), with an odd-sized centre (13 of 57 modules, 15 of 61, …) cleared for the orange Open CryptoPay mark. No I/O. - Used by:
ShopStickerOverlay(preview<img>data URL),shopStickerBlob(SVG, PNG, JPG).
Function: buildShopStickerPdf
- Purpose: The same sticker as a one-page vector PDF for print, without a PDF library: the artwork paths (absolute M, L, C, Z) become PDF path operators, the QR modules become rectangles.
- Inputs:
qrValue— the member'sopenCryptoPayQrValue. Optionallang— same languages asbuildShopStickerSvg(default Filipino). - Returns / side effects: PDF 1.4 bytes (
Uint8Array), MediaBox 134.4 × 82.25 mm, no fonts, no images, exact xref offsets. No I/O. - Used by:
shopStickerBlob(formatpdf).
Function: shopStickerBlob
- Purpose: The shop-sticker file in the format the visitor picked.
- Inputs:
qrValue,format— one ofSHOP_STICKER_FORMATS(pdf,png,jpg,svg). Optionallang— same languages asbuildShopStickerSvg(default Filipino). - Returns / side effects:
Promise<Blob>:application/pdffrombuildShopStickerPdf;image/svg+xmlfrombuildShopStickerSvg; PNG / JPEG drawn from that SVG on a 3000 × 1836 canvas over white (JPEG quality 0.95). Rejects when the browser cannot load the SVG image, has no 2D canvas, or cannot encode it. The object URL used for the SVG image is always revoked. Nothing is sent to the api. - Used by:
ShopStickerOverlay(Download).
Function: shopStickerFileName
- Purpose: Download name for a member's sticker.
- Inputs:
handle(username@domainor a bare username),format. Optionallang— default Filipino, which keeps the historical unsuffixed name.english,spanish,german,french, andkikambainsert-english,-spanish,-german,-french, or-kikambabefore the extension. - Returns / side effects:
21gifts-shop-sticker-<username>[-language].<format>. The username is lowercased and reduced toa-z 0-9 . _ -(memberwhen nothing is left). No I/O. - Used by:
ShopStickerOverlay.
Function: shopStickerLangFromQuery
- Purpose: Selects the printable shop-sticker text language from a
langquery value. - Inputs:
value— rawlangquery value, or null when the parameter is absent. - Returns / side effects: Trimmed, case-insensitive.
kikamba/kam;filipino/fil; Spanishspanish/spanisch/es/espanol/español; Germangerman/deutsch/de; Frenchfrench/fr/francais/français/französisch/franzosisch; English-onlyenglish/en/none/keine. Null, blank, and unknown stayfilipino. That fallback is not the overlay default. No I/O. - Used by:
shopStickerLangFromLocation.
Function: shopStickerLangFromLocation
- Purpose: Selects the printable shop-sticker text language from the current page URL.
- Inputs: None. Reads the current
langquery viashopStickerLangFromQuery. - Returns / side effects: The language from that query (
english,spanish,german,french,filipino, orkikamba), orfilipinowhen the query is missing, unknown, orwindowis missing. No I/O. A missing query returningfilipinodoes not select the overlay; auto-open stays Kikamba-only. - Used by:
MemberProfileScreen(Kikamba still auto-opens the overlay).
Function: shopStickerLangInitial
- Purpose: First sticker language for the overlay. A known
langquery wins. Otherwise the visitor's UI language: English selects English only, Deutsch selects German, Español selects Spanish, Filipino selects Filipino. French and Kikamba are not UI languages. - Inputs:
locale— one ofen,de,es,fil. Whenwindowexists, also reads the currentlangquery. - Returns / side effects: A known alias (
kikamba/kam,fil/filipino, Spanish, German, French, or English-onlyen/none/keine) wins over the UI language. A missing, blank, or unknown query, and a missingwindow, use the UI language (en→english,de→german,es→spanish,fil→filipino). An explicit Filipino query is not treated as missing. No I/O. - Used by:
ShopStickerOverlay(first selection only).
Function: decodeLnurl
- Purpose: BIP-173 bech32-decode an
lnurlstring back to its cleartext URL. Not bech32m. - Inputs:
valuestring, either uniform case. - Returns / side effects: The URL, or
nullwhen the value is empty, mixed-case, the wrong HRP, or a bad checksum. No I/O. - Used by:
payLinkUsername.
Function: payLinkUsername
- Purpose: Read the username from an Open CryptoPay
lightningquery when it points at this site's/.well-known/lnurlp/path. - Inputs:
lightningLNURL string,pageHost(port and a leadingwww.are ignored; loopback and raw IPs expect21.gifts). - Returns / side effects: The decoded username, or
null. Does not call the network. - Used by:
PayLinkScreen.
Function: PayLinkScreen
- Purpose: Public payment card: the person's name, an exact satoshi amount, and one BOLT11 invoice.
- Inputs:
lightningquery string. - Returns / side effects:
PageChromewithProfileChromeLeft(wordmarkHomeWordmark) and a light language switcher. The arrow returns to the previous in-app view, or/welcomewhen this tab has none. Renders the shop sticker's storefront for a real pay link, and the welcome glyph only when the link is not valid. AfterGET /pay/:username, shows the name and amount form, or that open till. Continue posts the amount and then shows the active payment (locked sats, the default fiat when the gift-day rate is usable, and Pay, no amount field). Pay is the width of the invoice QR plate, centered, not the page column. Desktop shows the invoice QR. A smartphone does not (isSmartphoneUserAgent, not viewport), before or after the payment is active. A newlightningvalue clears the previous person, including an invoice that is still being created. A positive fiat amount before the gift-day request settles says that currency's rate is still loading. After it settles with no usable day, the alert names that currency. No forum and no auth gate. - Used by:
PayLinkPage.
Function: PayLinkPage
- Purpose:
/plserver page. Reads thelightningquery and rendersPayLinkScreen. - Inputs:
searchParamspromise with an optionallightningstring or array. - Returns / side effects: The payment screen. Does not 404 when the query is missing.
- Used by: The App Router at
/pl.
Function: setLightningAddress
- Purpose: POST
/me/lightning-address. - Inputs:
sessionToken,address, and optionalsundayWrite(enforcedefault, orsetupto omitTime-Zoneduring onboarding). - Returns / side effects: Updated
Account. HTTP 400 whose body isLIGHTNING_ADDRESS_NOT_ZAP_ERRORis thrown unchanged; any other 400 is rewritten to a visitor-facing save error. Other non-ok statuses throw'Could not save your Wallet of Satoshi address'. - Used by:
LightningAddressForm.
Function: translate
- Purpose: Look up a catalog key, expand
{count, plural, one {…} other {…}}(one whencountis 1, otherwise other;#in the chosen branch becomes that count), then replace{name}placeholders fromvars. - Inputs:
catalog(Messages),key(MessageKey), optionalvarsmap of string/number values. A plural header needs a finitecount. - Returns / side effects: Interpolated string. A broken plural header is left in place. Throws on a missing key, a plural header without a finite
count, or a missing{name}— no silent English fallback. - Used by: Server pages (
Home, login headings,NotFound,MarketingFooter,HandbookPage) and thethelper fromLocaleProvider/useTranslations.
Function: unlinkLightningAddress
- Purpose: DELETE
/me/lightning-address. - Inputs:
sessionToken. - Returns / side effects: Updated
Accountwith address cleared. - Used by:
LightningAddressForm.
Function: uppercaseLnurl
- Purpose: Uppercases a bech32 LNURL or BOLT11 payment request.
- Inputs:
lnurlstring. - Returns / side effects: Uppercase string.
- Used by:
walletOfSatoshiHrefandwalletOfSatoshiIntentHref(ForumBoard).
Function: useAuthStore
- Purpose: Zustand store for
session+accountpluswrongAccount. Hydration is explicit (no module-initlocalStorage). - Inputs: Hook. Methods
setAuth,setAccount,clearAuth,setWrongAccount,clearWrongAccount. - Returns / side effects: Auth state object.
clearAuthclears storage, thenbumpUnreadAppBadgeEpoch()thensetUnreadAppBadge(0), then dropssessionandaccount. It does not resetwrongAccount. - Used by:
LoginCard,OnboardingGate,NameSetup,AddressSetup,RulesSetup,WelcomeScreen,LogoutButton,useHydrateSession,usePasskeyLogin,NameForm,LightningAddressForm.
Function: useTranslations
- Purpose: Client hook returning
{ locale, t }from the nearestLocaleProvider. - Inputs: None (React context).
- Returns / side effects: Active locale and a
t(key, vars?)bound to that catalog. Throws if used outsideLocaleProvider. - Used by:
MarketingHeader,LanguageSwitcher,LoginCard,LightningAddressForm,ForumBoard,NameForm,HandbookCopyLink,NameSetup,AddressSetup,RulesSetup,WelcomeScreen,LogoutButton.
Function: useNumberFormat
- Purpose: Client hook returning
{ numberFormat, setNumberFormat }from the nearestNumberFormatProvider. Call sites that format counts or money take this hook's style, not UI locale. - Inputs: None (React context).
- Returns / side effects: Active
NumberFormatStyleand a setter that writes thenumberFormatcookie. ThrowsuseNumberFormat must be used within NumberFormatProviderwhen used outside the provider. - Used by:
NumberFormatSwitcher,ForumBoard,StatsDashboard,DayLoader,AccountActivityChart,PublicMessageLoader.
Function: walletOfSatoshiHref
- Purpose: iOS/desktop WoS deep link.
- Inputs: Bech32 LNURL or BOLT11 payment request.
- Returns / side effects:
walletofsatoshi:lightning:+ uppercase payload. - Used by:
ForumBoardwhen not Android.
Function: walletOfSatoshiIntentHref
- Purpose: Android Chrome Intent pinning the WoS package.
- Inputs: Bech32 LNURL or BOLT11 payment request.
- Returns / side effects:
intent:lightning:…#Intent;scheme=walletofsatoshi;package=com.livingroomofsatoshi.wallet;…;end. - Used by:
ForumBoardon Android.
Function: DELETE
- Purpose: Shared App Router DELETE export name.
/me/lightning-addressre-exportsproxyMeLightningAddressDelete;/me/push-subscriptionsre-exportsproxyMePushSubscriptionsDelete;/forum/messages/[id]re-exportsproxyMessagesDelete;/pos/chargere-exportsproxyPosDelete. - Inputs: Incoming
Request. For/forum/messages/[id], also async routeparamswith the message id. - Returns / side effects: Upstream api
Response. - Used by: Same-origin
unlinkLightningAddress,deletePushSubscription/disablePush, and same-origin forum moderation delete (deleteMessage).
Function: PATCH
- Purpose: Shared App Router PATCH export name.
/forum/messages/[id]/placere-exportsproxyMessagesPlacePatch./forum/messages/[id]/shop-accountre-exportsproxyMessagesShopAccountPatch./forum/messages/[id]/textre-exportsproxyMessagesTextPatch./forum/messages/[id]/photosre-exportsproxyMessagesPhotosPatch. - Inputs: Incoming
Request. For/forum/messages/[id]/place,/forum/messages/[id]/shop-account,/forum/messages/[id]/text, and/forum/messages/[id]/photos, also async routeparamswith the message id. - Returns / side effects: Upstream api
Response. - Used by: Same-origin forum staff place save (
setMessagePlace), shop-account save (setMessageShopAccount), shop-note text save (setMessageShopText), and shop-note photo save (setMessageShopPhotos).
Function: AboutPage
- Purpose: Next.js page for
/about. Three convictions, Matthew 10:8, 1 John 3:18, and a CTA into/welcome. - Inputs: None. Calls
getRequestLocale()and reads copy from the catalog viatranslate. - Returns / side effects: The about screen with a link to
/welcome. - Used by: Route
/about.
Function: LegalPage
- Purpose: Next.js page for
/legal(imprint and privacy). No published email — contact is in-app via/contact. - Inputs: None.
- Returns / side effects: The legal screen with links to
/contact. - Used by: Route
/legal.
Function: MarketingFooter
- Purpose: Footer for marketing pages: wordmark, localized section links including About, legal, living-room rules, GitHub, and a quiet Matthew 10:8 verse.
- Inputs: None. Resolves locale via
getRequestLocaleand reads copy from the catalog viatranslate. - Returns / side effects: Footer element.
- Used by:
MarketingLayout,NotFound.
Function: MarketingHeader
- Purpose: Sticky marketing header with one top-left arrow (
ProfileChromeLefttone="dark") besideHomeWordmark(tone="dark";/unsigned,/welcomewhen a session is hydrated). The arrow returns to the previous in-app view, or/welcomewhen this tab has none. The wordmark is not that control. Section nav (How / Happyland / Why / FAQ / About / Stats / Handbook, accent Log in, optionalPwaInstalltone="dark"placement="header"), always-visibleLanguageSwitcher(tone="dark"), and a mobile menu toggle. Happyland links to the existing/#happylandphoto essay from any marketing page. ThemeSwitcher and NumberFormatSwitcher are marketing-forbidden. - Inputs: None. Internal open state. Reads copy via
useTranslations. - Returns / side effects: Header element; toggles nav on small screens.
LanguageSwitcherstays visible when the hamburger is closed. Install control staysnulluntil after mount when an offer applies. - Used by:
MarketingLayout,NotFound(no extra props).
Function: MarketingLayout
- Purpose: Async dark full-page shell for
/,/about,/legal,/handbook, and/stats. - Inputs:
children. AwaitsMarketingFooter()(does not render it as a JSX child). - Returns / side effects: Wrapper div with header, page, and awaited footer.
- Used by: Marketing route group.
Function: NotFound
- Purpose: Async app-wide 404 screen with marketing chrome and localized body copy.
- Inputs: None. Calls
getRequestLocale()for body copy; awaitsMarketingFooter(). - Returns / side effects: 404 element with
MarketingHeader(the only back control is that header arrow) and awaited footer (not rendered as JSX child). No Back home button. - Used by: Next.js
not-found.tsx.
Function: POST
- Purpose: Shared App Router POST export name.
/me/namere-exportsproxyMeNamePost;/me/locationre-exportsproxyMeLocationPost;/me/forum-laws-dismissedre-exportsproxyMeForumLawsDismissedPost;/me/notification-levelre-exportsproxyMeNotificationLevelPost;/me/rules-agreementre-exportsproxyMeRulesAgreementPost;/me/lightning-addressre-exportsproxyMeLightningAddressPost;/me/push-subscriptionsre-exportsproxyMePushSubscriptionsPost;/me/wallet-backup-seenre-exportsproxyMeWalletBackupSeenPost;/me/passkey-renew/reportre-exportsproxyMePasskeyRenewReportPost;/me/passkey-renew/ackre-exportsproxyMePasskeyRenewAckPost;/auth/passkey/{register,authenticate,replace,seed}/{begin,finish}re-export the eight passkey proxy POSTs;/forum/messagesre-exportsproxyMessagesPost;/messages/[id]/invoicere-exportsproxyMessagesInvoicePost;/messages/[id]/repaymentre-exportsproxyMessagesRepaymentPost;/conversationsre-exportsproxyConversationsPost;/conversations/[id]re-exportsproxyConversationPost;/conversations/[id]/invoicere-exportsproxyConversationInvoicePost;/conversations/[id]/readre-exportsproxyConversationReadPost;/forum/notifications/read-allre-exportsproxyNotificationsReadAllPost;/forum/notifications/read-by-messagere-exportsproxyNotificationsReadByMessagePost;/forum/notifications/read-visiblere-exportsproxyNotificationsReadVisiblePost;/forum/notifications/[id]/readre-exportsproxyNotificationReadPost;/contact/submitre-exportsproxyContactPost;/translatere-exportsproxyTranslateNotePostwith JSON{ messageId, target };/conversations/[id]/messages/[messageId]/translatere-exportsproxyTranslateConversationMessagePostwith JSON{ target };/trust/verifyre-exportsproxyTrustVerifyPost;/trust/propose-moderatorre-exportsproxyTrustProposeModeratorPost;/trust/confirm-moderatorre-exportsproxyTrustConfirmModeratorPost;/trust/reject-moderatorre-exportsproxyTrustRejectModeratorPost;/trust/appoint-moderatorre-exportsproxyTrustAppointModeratorPost;/funding/applyre-exportsproxyFundingApplyPost;/funding/trialre-exportsproxyFundingTrialPost;/funding/admitre-exportsproxyFundingAdmitPost;/funding/rejectre-exportsproxyFundingRejectPost;/funding/daily-roster/commentre-exportsproxyFundingDailyRosterCommentPost;/funding/daily-roster/paymentsre-exportsproxyFundingDailyRosterPaymentsPost;/funding/daily-roster/recipientsre-exportsproxyFundingDailyRosterRecipientsPost;/funding/daily-roster/recipients/updatere-exportsproxyFundingDailyRosterRecipientsUpdatePost;/funding/daily-roster/recipients/deletere-exportsproxyFundingDailyRosterRecipientsDeletePost./pos/chargere-exportsproxyPosPost. HTML/posis the till page, not a POST proxy. HTML/messagesis the inbox page, not a POST proxy. - Inputs: Incoming
Request. - Returns / side effects: Upstream api
Responseon api proxies;/translatereturns{ translatedText, cached }or 400/404/503/502 from the 21.gifts api. - Used by: Same-origin name save, location save (
POST /me/location), forum laws dismiss, notification-level save (POST /me/notification-level), living-room rules agreement (POST /me/rules-agreement), address link, Web Push subscribe (POST /me/push-subscriptions), recovery-phrase backup-seen (POST /me/wallet-backup-seen), renew report (POST /me/passkey-renew/report), renew acknowledgement (POST /me/passkey-renew/ack), passkey begin/finish (register, authenticate, replace, and seed), forum message create (POST /forum/messages), payable-reply invoice (POST /messages/[id]/invoice), today's repayment (POST /messages/[id]/repayment), inbox open (POST /conversations) and reply (POST /conversations/[id]), inbox invoice (POST /conversations/[id]/invoice), mark-one conversation (POST /conversations/[id]/read), mark-all notifications (POST /forum/notifications/read-all), mark-by-message (POST /forum/notifications/read-by-message), mark-visible (POST /forum/notifications/read-visible), and mark-one (POST /forum/notifications/[id]/read), in-app contact (POST /contact/submit),translateNoteviaPOST /translate,translateConversationMessageviaPOST /conversations/[id]/messages/[messageId]/translate, staff Trust Chain actions (POST /trust/verify,POST /trust/propose-moderator,POST /trust/confirm-moderator,POST /trust/reject-moderator,POST /trust/appoint-moderator), grant apply (POST /funding/apply), staff funding decisions (POST /funding/trial,POST /funding/admit,POST /funding/reject), and daily-roster saves (POST /funding/daily-roster/comment,POST /funding/daily-roster/payments,POST /funding/daily-roster/recipients,POST /funding/daily-roster/recipients/update,POST /funding/daily-roster/recipients/delete).
Function: PUT
- Purpose: Shared App Router PUT export name.
/me/aboutre-exportsproxyMeAboutPut. - Inputs: Incoming
Requestwith Bearer session and JSON{ text }. - Returns / side effects: Upstream api
Response. - Used by: Same-origin About me save (
PUT /me/about/putAboutMe).
Function: proxyApiRequest
- Purpose: Forwards an App Router request to
getApiUrl()+ path. Copies query, authorization / content-type / content-length / user-agent / origin / range headers. Multipart POST/PUT/PATCH/DELETE bodies stream withduplex: 'half'whenrequest.bodyis non-null andContent-Lengthis not0; JSON and other bodies are buffered (arrayBuffer) so Node fetch does not throw. Empty POSTs omit body and duplex. Copies content-type / content-length / content-range / accept-ranges / cache-control / content-disposition from the upstream response. - Inputs:
request,apiPathbeginning with/. - Returns / side effects: Upstream
Response(status + selected headers + streamed body), or 502 JSON if fetch throws. - Used by: All same-origin api proxy route handlers.
Function: proxyTrustChainGet
- Purpose: Same-origin Bearer proxy helper for api
GET /trust-chain. Forwards the incoming Authorization header. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/trust/graph.
Function: proxyTrustProposalsGet
- Purpose: Same-origin Bearer proxy helper for api
GET /trust/proposals. Forwards the incoming Authorization header. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/trust/proposals.
Function: proxyFundingApplyPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/apply. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/apply.
Function: proxyFundingPayoutDaysGet
- Purpose: Same-origin Bearer proxy helper for api
GET /funding/payout-days. Forwards the incoming Authorization header. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/funding/payout-days.
Function: proxyFundingApplicationsGet
- Purpose: Same-origin Bearer proxy helper for api
GET /funding/applications. Forwards the incoming Authorization header. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/funding/applications.
Function: proxyFundingDailyRosterGet
- Purpose: Same-origin Bearer proxy helper for api
GET /funding/daily-roster. Forwards the incoming Authorization header. - Inputs: Incoming
Request(Bearer session). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/funding/daily-roster.
Function: proxyFundingDailyRosterCommentPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/daily-roster/commentwith JSON{ comment }. - Inputs: Incoming
Request(Bearer session and JSON body). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/daily-roster/comment.
Function: proxyFundingDailyRosterPaymentsPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/daily-roster/paymentswith JSON{ enabled }. - Inputs: Incoming
Request(Bearer session and JSON body). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/daily-roster/payments.
Function: proxyFundingDailyRosterRecipientsPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/daily-roster/recipientswith JSON{ accountId, amountUsd }. - Inputs: Incoming
Request(Bearer session and JSON body). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/daily-roster/recipients.
Function: proxyFundingDailyRosterRecipientsUpdatePost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/daily-roster/recipients/updatewith JSON{ address, amountUsd }. - Inputs: Incoming
Request(Bearer session and JSON body). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/daily-roster/recipients/update.
Function: proxyFundingDailyRosterRecipientsDeletePost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/daily-roster/recipients/deletewith JSON{ address }. - Inputs: Incoming
Request(Bearer session and JSON body). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/funding/daily-roster/recipients/delete.
Function: proxyFundingApplicationGet
- Purpose: Same-origin Bearer proxy helper for api
GET /funding/applications/:accountId. - Inputs: Incoming
Request(Bearer session) andaccountId. - Returns / side effects: Upstream
ResponseviaproxyApiRequest(id encoded). - Used by: Route GET
/funding/applications/[accountId].
Function: proxyFundingTrialPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/trial. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/funding/trial.
Function: proxyFundingAdmitPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/admit. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/funding/admit.
Function: proxyFundingRejectPost
- Purpose: Same-origin Bearer proxy helper for api
POST /funding/reject. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/funding/reject.
Function: proxyTrustVerifyPost
- Purpose: Same-origin Bearer proxy helper for api
POST /trust/verify. - Inputs: Incoming
Request(JSON{ accountId, confirmedName }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/trust/verify.
Function: proxyTrustProposeModeratorPost
- Purpose: Same-origin Bearer proxy helper for api
POST /trust/propose-moderator. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/trust/propose-moderator.
Function: proxyTrustConfirmModeratorPost
- Purpose: Same-origin Bearer proxy helper for api
POST /trust/confirm-moderator. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/trust/confirm-moderator.
Function: proxyTrustRejectModeratorPost
- Purpose: Same-origin Bearer proxy helper for api
POST /trust/reject-moderator. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/trust/reject-moderator.
Function: proxyTrustAppointModeratorPost
- Purpose: Same-origin Bearer proxy helper for api
POST /trust/appoint-moderator. - Inputs: Incoming
Request(JSON{ accountId }). - Returns / side effects: Upstream
Response. - Used by: Route POST
/trust/appoint-moderator.
Function: proxyMessagesStatsGet
- Purpose: Same-origin proxy helper for api
GET /messages/stats. - Inputs: Incoming
Request. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/messages/stats.
Function: proxyGiftsStatsGet
- Purpose: Same-origin proxy helper for api
GET /gifts/stats(forwardsrecipientquery). - Inputs: Incoming
Request(optionalrecipientsearch param). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/gifts/stats.
Function: proxyMeActivityGet
- Purpose: Same-origin proxy helper for api
GET /me/activity. - Inputs: Incoming
Request(Bearer). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/me/activity.
Function: proxyMembersActivityGet
- Purpose: Same-origin proxy helper for api
GET /members/:accountId/activity. - Inputs: Incoming
Request(Bearer) andaccountId. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/forum/members/[accountId]/activity.
Function: proxyViewActivityGet
- Purpose: Same-origin proxy helper for api
GET /view/:viewKey/activity(public). - Inputs: Incoming
RequestandviewKey. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/view-key/[viewKey]/activity.
Function: proxyLightningAddressGet
- Purpose: Proxies GET
/lightning-address. - Inputs:
Requestwithaddressquery. - Returns / side effects: Upstream
Response. - Used by: Route GET
/lightning-address.
Function: proxyMeNamePost
- Purpose: Proxies POST
/me/name. - Inputs:
Requestwith JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/name.
Function: proxyMeUsernamePost
- Purpose: Proxies POST
/me/username. - Inputs:
Requestwith JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/username.
Function: proxyMeLocationPost
- Purpose: Proxies POST
/me/location. - Inputs:
Requestwith JSON{ location }and Bearer session. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/location.
Function: proxyMeAboutPut
- Purpose: Same-origin Bearer proxy of api
PUT /me/about. - Inputs: Incoming
Requestwith Bearer session and JSON{ text, photo? }. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: App Router
PUTon/me/about.
Function: putProfilePhoto
- Purpose:
PUT /pictures/mewith{ photo }for the signed-in account.nullclears the profile photo. Does not change the wide image or the About me note. - Inputs: Session token, JPEG payload or
null. - Returns / side effects: Resolves on 2xx. Throws the save error otherwise.
- Used by:
ProfileScreenviaAboutMeSectiononSavePicture.
Function: fetchProfilePhoto
- Purpose:
GET /pictures/mebytes for the signed-in account. - Inputs: Session token.
- Returns / side effects: Blob. Throws the load error on failure or an empty body.
- Used by:
ProfileScreen(resting headerProfileImagesandAboutMeSectionloadPicture).
Function: proxyProfilePhotoGet
- Purpose: Same-origin Bearer proxy of api
GET /pictures/me. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: App Router
GETon/pictures/me.
Function: proxyProfilePhotoPut
- Purpose: Same-origin Bearer proxy of api
PUT /pictures/me. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: App Router
PUTon/pictures/me.
Function: putWideBanner
- Purpose:
PUT /banners/mewith{ photo }for the signed-in account.nullclears the wide image. Does not change the About me photo. - Inputs: Session token, JPEG payload or
null. - Returns / side effects: Resolves on 2xx. Throws the save error otherwise.
- Used by:
ProfileScreenviaAboutMeSectiononSaveBanner.
Function: fetchWideBanner
- Purpose:
GET /banners/mebytes for the signed-in account. - Inputs: Session token.
- Returns / side effects: Blob. Throws the load error on failure or an empty body.
- Used by:
ProfileScreen(resting headerProfileImagesandAboutMeSectionloadBanner).
Function: proxyWideBannerGet
- Purpose: Same-origin Bearer proxy of api
GET /banners/me. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: App Router
GETon/banners/me.
Function: proxyWideBannerPut
- Purpose: Same-origin Bearer proxy of api
PUT /banners/me. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: App Router
PUTon/banners/me.
Function: proxyMeAboutPhotoGet
- Purpose: Same-origin Bearer proxy of api
GET /me/about/photo(raw profile-note photo bytes). - Inputs: Incoming
Requestwith Bearer session. - Returns / side effects: Upstream
ResponseviaproxyApiRequestto/me/about/photo. - Used by: App Router
GETon/me/about/photo.
Function: proxyViewAboutPhotoGet
- Purpose: Same-origin public proxy of api
GET /view/:viewKey/about/photo(raw profile-note photo bytes). Browser path is/view-key/:viewKey/about/photo; upstream is/view/:viewKey/about/photo. - Inputs: Incoming
Request, plusviewKeyfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: App Router
GETon/view-key/[viewKey]/about/photo.
Function: proxyMeForumLawsDismissedPost
- Purpose: Proxies POST
/me/forum-laws-dismissed. - Inputs:
Requestwith Bearer session (no body). - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/forum-laws-dismissed.
Function: proxyMeNotificationLevelPost
- Purpose: Same-origin Bearer proxy of api POST
/me/notification-levelwith JSON{ level }. - Inputs: Incoming
Requestwith Bearer session and JSON{ level }. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/me/notification-level.
Function: proxyMeRulesAgreementPost
- Purpose: Proxies POST
/me/rules-agreement. - Inputs: Incoming
Requestwith Bearer session (no JSON body required by the client). - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/me/rules-agreement.
Function: proxyMeGet
- Purpose: Proxies GET
/me. - Inputs:
Requestwith Bearer token. - Returns / side effects: Upstream
Response. - Used by: Route GET
/me.
Function: proxyMessagesGet
- Purpose: Bearer proxy GET
/messagesto the 21.gifts api (public forum list). App route is GET/forum/messages. - Inputs: Incoming
Requestwith Bearer session. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/forum/messages.
Function: proxyMessagesHiddenGet
- Purpose: Same-origin Bearer proxy of api GET
/messages/hidden(hidden living-room notes for moderators). App route is GET/forum/messages/hidden. - Inputs: Incoming
Requestwith Bearer session. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by:
src/app/forum/messages/hidden/route.ts.
Function: proxyMessagesPost
- Purpose: Bearer proxy POST
/messagesto the 21.gifts api (create a public forum message or reply). App route is POST/forum/messages. - Inputs: Incoming
Requestwith Bearer session and JSON body. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/forum/messages.
Function: proxyMessagesRepliesGet
- Purpose: Bearer proxy GET
/messages/:id/repliesto the 21.gifts api (oldest-first replies). App route is GET/forum/messages/[id]/replies. - Inputs: Incoming
Requestwith Bearer session, plus parent messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/forum/messages/[id]/replies.
Function: proxyExternalAuthorProfileGet
- Purpose: Public proxy GET
/messages/:id/external-profileto the 21.gifts api (no auth). App path is/public-messages/[id]/external-profile. - Inputs: Incoming
Request, plus messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. Does not add a Bearer header. - Used by: Route GET
/public-messages/[id]/external-profile.
Function: proxyExternalAuthorPostsGet
- Purpose: Public proxy GET
/messages/:id/external-poststo the 21.gifts api (no auth). App path is/public-messages/[id]/external-posts. Returns the upstream response viaproxyApiRequest. Does not add a Bearer header. - Inputs: Incoming
Request, plus messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. Does not add a Bearer header. - Used by: Route GET
/public-messages/[id]/external-posts.
Function: proxyExternalAuthorRepliesGet
- Purpose: Public proxy GET
/messages/:id/external-repliesto the 21.gifts api (no auth). App path is/public-messages/[id]/external-replies. Returns the upstream response viaproxyApiRequest. Does not add a Bearer header. - Inputs: Incoming
Request, plus messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. Does not add a Bearer header. - Used by: Route GET
/public-messages/[id]/external-replies.
Function: proxyPublicMessageGet
- Purpose: Public proxy GET
/messages/:idto the 21.gifts api (one note as JSON, no auth). App path is/public-messages/[id]so/messages/[id]can serve HTML. - Inputs: Incoming
Request, plus messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/public-messages/[id].
Function: proxyShortLinkGet
- Purpose: Public proxy GET
/links/:codeto the 21.gifts api (JSON{ kind, id }, no auth). App path is/links/[code]. Forwards the code withencodeURIComponent. The visitor-facing redirect is/l/[code], not this JSON route. - Inputs: Incoming
Request, plus short-linkcodefrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest(status and JSON body, or 502 when the api is unreachable). - Used by: Route GET
/links/[code], whichfetchShortLinkcalls.
Function: proxyPublicMessageRepliesGet
- Purpose: Public proxy GET
/messages/:id/repliesto the 21.gifts api (oldest-first live replies, no auth). App path is/public-messages/[id]/repliesso/messages/[id]can serve HTML. - Inputs: Incoming
Request, plus parent messageidfrom the App Router segment. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/public-messages/[id]/replies.
Function: proxyContactPost
- Purpose: Bearer proxy POST
/contactto the 21.gifts api (create an in-app contact message). Same-origin path is/contact/submitso it does not collide with the/contactpage. - Inputs: Incoming
Requestwith Bearer session and JSON body. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/contact/submit.
Function: proxyMessagesPhotoGet
- Purpose: Same-origin proxy GET
/messages/:id/photoor/messages/:id/photo/{file}to the 21.gifts api (raw forum photo bytes). Public; no bearer required (api photo is public; proxy forwards Authorization if present but does not require it). RuntimegetApiUrl()viaproxyApiRequest(not next.config rewrites). - Inputs: Incoming
Request, messageidfrom the App Router segment, and optional indexedfilesuch as1.jpg. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/messages/[id]/photoand GET/messages/[id]/photo/[file].
Function: proxyMessagesVideoGet
- Purpose: Same-origin proxy GET
/messages/:id/video.{mp4,webm,mov}to the 21.gifts api (raw forum video bytes). Public; no bearer required. RuntimegetApiUrl()viaproxyApiRequest(not next.config rewrites). - Inputs: Incoming
Request, messageidfrom the route, andext'mp4' | 'webm' | 'mov'. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route GET
/messages/[id]/[file]whenfileisvideo.mp4|video.webm|video.mov.
Function: proxyMeLightningAddressDelete
- Purpose: Proxies DELETE
/me/lightning-address. - Inputs:
Request. - Returns / side effects: Upstream
Response. - Used by: Route DELETE
/me/lightning-address.
Function: proxyMeLightningAddressPost
- Purpose: Proxies POST
/me/lightning-address. - Inputs:
Requestwith JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/lightning-address.
Function: base64UrlToBytes
- Purpose: Decode a base64url string to bytes for WebAuthn options.
- Inputs: Base64url string (padding optional).
- Returns / side effects:
Uint8Array. No network. - Used by:
creationOptionsFromJSON,requestOptionsFromJSON.
Function: bytesToBase64Url
- Purpose: Encode bytes as unpadded base64url for WebAuthn JSON.
- Inputs:
Uint8Array. - Returns / side effects: Base64url string. No network.
- Used by:
credentialToJSON.
Function: creationOptionsFromJSON
- Purpose: Turn api creation-options JSON into
navigator.credentials.createinput, includingexcludeCredentialswhen present and clientextensions(PRFeval.firstas base64url) viaapplyClientExtensions. - Inputs: Record from
POST /auth/passkey/register/beginorPOST /auth/passkey/seed/begin. - Returns / side effects:
PublicKeyCredentialCreationOptions. Uses native parse when present. Throws if a descriptor list is present but not an array, or is non-empty but has no validpublic-keyentries (invalid type or id is skipped; all skipped → TypeError), including before native parse. - Used by:
usePasskeyLogin.register,renewPasskey.
Function: credentialToJSON
- Purpose: Serialise a
PublicKeyCredentialfor the api finish body. DropsclientExtensionResults.prfso PRF bytes never leave the tab. - Inputs: Browser credential from create/get.
- Returns / side effects: JSON record without
prfresults. Uses nativetoJSONwhen present, then stripsprf. - Used by:
usePasskeyLogin,renewPasskey.
Function: finishPasskeyAuthentication
- Purpose: POST
/auth/passkey/authenticate/finishand parse the session. - Inputs:
challengeIdand credential JSON. - Returns / side effects:
{ token, account }. ThrowsWrongAccountErroron 403 with the duplicate-account api string. A 400 body exactlyUnknown credentialthrowsUnknownCredentialError. Other non-2xx stay status fallbacks. - Used by:
usePasskeyLogin.authenticate.
Function: finishPasskeyRegistration
- Purpose: POST
/auth/passkey/register/finishand parse the session. - Inputs:
challengeIdand credential JSON. - Returns / side effects:
{ token, account }withlinkingKeynull. ThrowsWrongAccountErroron 403 with the duplicate-account api string. A 409 whose error is exactlyUsername is already in usethrowsErrorwith that string. Other non-2xx stay status fallbacks. - Used by:
usePasskeyLogin.submitNamefor a new account, andusePasskeyLogin.registeronly with a view key.
Function: proxyAuthPasskeyAuthenticateBeginPost
- Purpose: Proxies POST
/auth/passkey/authenticate/begin. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/authenticate/begin.
Function: proxyAuthPasskeyAuthenticateFinishPost
- Purpose: Proxies POST
/auth/passkey/authenticate/finish. - Inputs: Incoming
Requestwith JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/authenticate/finish.
Function: DiagnosticsListener
- Purpose: Subscribe to window
errorandunhandledrejectionand forward the error name and message to the diagnostic log. - Inputs: None. Reads
window.location.pathnamewhen the event fires, plus the event'serrororreason. - Returns / side effects: Renders null. Posts
client.unhandledthroughreportDiagnostic. Removes both listeners on unmount. - Used by: Root layout, mounted once for every page.
Function: proxyDiagnosticsPost
- Purpose: Proxies POST
/diagnosticsso the browser can store an allowlisted client event. - Inputs: Incoming
Requestwith a JSON body. - Returns / side effects: Upstream
Response(204 when accepted). Does not read or log the body itself. - Used by: Route POST
/diagnostics.
Function: reportDiagnostic
- Purpose: POST one allowlisted client diagnostic to
/diagnostics. Drops fields that fail the pattern instead of shortening them. - Inputs: Event name plus optional name, message, stage, status, path, challenge id, account id, and whether a PRF output was present.
- Returns / side effects: void. Fire-and-forget
fetchwithkeepalive. Never throws. Never sends PRF bytes, the recovery phrase, or a session token. - Used by:
DiagnosticsListener,usePasskeyLogin,renewPasskey, anduseWalletPhrase.
Function: proxyAuthPasskeyRegisterBeginPost
- Purpose: Proxies POST
/auth/passkey/register/begin. - Inputs: Incoming
Request. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/register/begin.
Function: proxyAuthPasskeyRegisterFinishPost
- Purpose: Proxies POST
/auth/passkey/register/finish. - Inputs: Incoming
Requestwith JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/register/finish.
Function: requestOptionsFromJSON
- Purpose: Turn api request-options JSON into
navigator.credentials.getinput. EmptyallowCredentialsis omitted (discoverable credentials). When discoverable, setshints: ['client-device']. Copies clientextensions(PRFeval.firstas base64url) viaapplyClientExtensions. - Inputs: Record from
POST /auth/passkey/authenticate/begin. - Returns / side effects:
PublicKeyCredentialRequestOptions. Uses native parse when present. Throws if a descriptor list is present but not an array, or is non-empty but has no validpublic-keyentries (invalid type or id is skipped; all skipped → TypeError), including before native parse. - Used by:
usePasskeyLogin.authenticate.
Function: startPasskeyAuthentication
- Purpose: POST
/auth/passkey/authenticate/beginand parse options. - Inputs: None.
- Returns / side effects:
{ challengeId, options }. Throws on non-2xx. - Used by:
usePasskeyLogin.authenticate.
Function: startPasskeyReplace
- Purpose: POST
/auth/passkey/replace/beginwith Bearer. The client helper is removed. Replace routes and proxies remain so an old client can still reach the API; the API rejects replace. Nothing in the app calls this for the phrase. - Inputs: Session token.
- Returns / side effects:
{ challengeId, options }. Throws on non-2xx. - Used by: None. The client helper is removed.
Function: finishPasskeyReplace
- Purpose: POST
/auth/passkey/replace/finishwith Bearer. Does not mint a session. The client helper is removed. Replace routes and proxies remain so an old client can still reach the API; the API rejects replace. Nothing in the app calls this for the phrase. - Inputs: Session token, challenge id, credential JSON.
- Returns / side effects: Owner
Accountfrom{ account }. Throws on non-2xx. - Used by: None. The client helper is removed.
Function: startPasskeySeed
- Purpose: POST
/auth/passkey/seed/beginwith Bearer and an empty body. - Inputs: Session token.
- Returns / side effects:
{ challengeId, options }. Throws on non-2xx, including 409. - Used by:
renewPasskey.
Function: finishPasskeySeed
- Purpose: POST
/auth/passkey/seed/finishwith Bearer and{ challengeId, credential }. - Inputs: Session token, challenge id, credential JSON.
- Returns / side effects: The owner account (
accountSchema), includingpasskeyCredentialId. A 200 body is that account, or the same account under oneaccountkey when the body has no top-levelid.accountonly holds when it is the only top-level field; a further field is not an account. No new token. Login is a different call. Throws on non-2xx or a body that is not an account. - Used by:
renewPasskey.
Function: postWalletBackupSeen
- Purpose: POST
/me/wallet-backup-seenwith Bearer. - Inputs: Session token.
- Returns / side effects: Owner
Account. Throws on non-2xx. - Used by: The helper remains.
useWalletPhrasedoes not call it.
Function: passkeyRenewDebug
- Purpose: Read public authenticator facts from a created passkey. PRF bytes, the credential id, and the attestation object are not copied.
- Inputs: The browser credential, or null when create threw, and a known
prfPresentboolean or null. - Returns / side effects: Attachment, transports, AAGUID, PRF flags, extension names, the WebAuthn flags byte, the COSE algorithm, resident key, hmac-secret, and credProtect. Missing methods and throws become null. No I/O.
- Used by:
renewPasskeybefore it reports a ceremony failure or cancel.
Function: passkeyRenewDebugFields
- Purpose: Drop null debug fields so a report that learned nothing keeps the original six-field body. Zero and false stay.
- Inputs: Facts from
passkeyRenewDebug, or null. - Returns / side effects: A partial object of only the known facts. No I/O.
- Used by:
renewPasskeywhen it builds the renew report.
Function: passkeyRenewClientCapabilities
- Purpose: Read browser capability names that are strictly true, such as
prfandhybridTransport. This describes the browser, not the passkey. - Inputs: None. Calls
PublicKeyCredential.getClientCapabilitieswhen the browser has it. - Returns / side effects: A sorted comma-separated list, capped at 24 names, or null. Objects, false, and odd keys are dropped. No I/O besides that browser call.
- Used by:
renewPasskeyon every stored failure or cancel, including when create itself threw.
Function: postPasskeyRenewReport
- Purpose: POST
/me/passkey-renew/reportwith Bearer, the six safe fields, and optional public authenticator facts. - Inputs: Session token and
{ stage, outcome, errorName, errorCode, httpStatus, message }plus, when known,authenticatorAttachment,transports,aaguid,prfEnabled,prfPresent,extensions,authenticatorFlags,publicKeyAlgorithm,residentKey,hmacSecret,credProtect, andclientCapabilities. - Returns / side effects: Owner
Account. ThrowsCould not report passkey renewon non-2xx. Never sends a phrase, PRF bytes, credential, challenge, or token in the body. - Used by:
renewPasskeyfor browser-only ceremony failures and cancels.
Function: postPasskeyRenewAck
- Purpose: POST
/me/passkey-renew/ackwith Bearer. - Inputs: Session token.
- Returns / side effects: Owner
Account. ThrowsCould not acknowledge passkey renewon non-2xx. - Used by:
PasskeyRenewNoticeOK button.
Function: renewPasskey
- Purpose: Runs the seed-passkey ceremony for a member who has no seed yet.
- Inputs: Session token.
- Returns / side effects:
okwith the account and in-memory PRF bytes,cancelled,storedwhen reload already shows a seed, orfailedwith a wallet error kind.failedincludes the owner account when that account has an open renew failure. HTTP seed failures are not reported again. A stored failure or cancel also sends the public authenticator facts and the browser capability names. Posts allowlisted seed diagnostics and never sends PRF bytes, the phrase, or the session token. Does not persist the phrase. - Used by:
useWalletPhrase.activateandPasskeyRenewNotice.
Function: PasskeyRenewNotice
- Purpose: Guided blocking renew while
walletRequiredis false: explain, confirm, device passkey prompt, then a success or failure confirmation. - Inputs: None. Reads the auth store and translations.
?visual=renew-passkeyand?visual=renew-okforce those steps for screenshots. - Returns / side effects: No dismiss control on the explanation. Continue starts the ceremony. Cancelling the device prompt returns to the explanation and is not a logged failure. Success stores the account only after OK. A failed ceremony stores the returned account and stays on the failure step only when that account has an open failure and the session is unchanged, so a remount stays there. When that account has
passkeyRenewPrfUnsupported, the failure step says this passkey cannot create a recovery phrase. An HTTP seed error that did not record a failure returns to the explanation. Failure OK stores the acknowledged account only while that same session is still current, then the dialog closes and does not start again. Null once success is confirmed, a failure was acknowledged, orpasskeyRenewClosedis true. - Used by:
AppShellwhenwalletRequired === falseandpasskeyRenewClosedis not true.
Function: FundingPausedCopy
- Purpose: Paused-applications sentence plus the statistics anchor. Renders no button and does not read a shop count; the pause is unconditional until a later change removes it.
- Inputs: None. Uses translations.
- Returns / side effects: The paused paragraph and the
https://21.gifts/statisticsanchor. - Used by:
FundingStatusCardfornone/rejected,DailyPayoutStoppedNotice, andFundingApplyScreen.
Function: DailyPayoutStoppedNotice
- Purpose: Non-dismissible info banner for signed-in members whose owner funding JSON has
dailyPayoutStoppedNotice === true. The title is Daily payout stopped. WhilegrantApplicationsPausedis true, the body isFundingPausedCopyand Apply is not offered, except usernamesjoey-rosima,vincent, andjewel-bacolbas, of any role, who still see the stopped-daily sentence and an Apply link to/grants/apply. That link is not the apply walk. When that switch is false, the body is the stopped-daily sentence and an Apply link to/grants/apply. - Inputs: None. Reads the auth store and translations.
- Returns / side effects: A
role="region"banner, ornullwhen the flag is missing, false, or funding is null. No close control, no localStorage, no extra API call. Apply is offered when applications are not paused, or when the signed-in username isjoey-rosima,vincent, orjewel-bacolbas. - Used by:
AppShell.
Function: proxyMePasskeyRenewReportPost
- Purpose: Proxies POST
/me/passkey-renew/report. - Inputs: Incoming
Requestwith Bearer. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/passkey-renew/report.
Function: proxyMePasskeyRenewAckPost
- Purpose: Proxies POST
/me/passkey-renew/ack. - Inputs: Incoming
Requestwith Bearer. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/passkey-renew/ack.
Function: proxyAuthPasskeyReplaceBeginPost
- Purpose: Proxies POST
/auth/passkey/replace/begin. - Inputs: Incoming
Requestwith Bearer. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/replace/begin.
Function: proxyAuthPasskeyReplaceFinishPost
- Purpose: Proxies POST
/auth/passkey/replace/finish. - Inputs: Incoming
Requestwith Bearer and JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/replace/finish.
Function: proxyAuthPasskeySeedBeginPost
- Purpose: Proxies POST
/auth/passkey/seed/begin. - Inputs: Incoming
Requestwith Bearer. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/seed/begin.
Function: proxyAuthPasskeySeedFinishPost
- Purpose: Proxies POST
/auth/passkey/seed/finish. - Inputs: Incoming
Requestwith Bearer and JSON body. - Returns / side effects: Upstream
Response. - Used by: Route POST
/auth/passkey/seed/finish.
Function: proxyMeWalletBackupSeenPost
- Purpose: Proxies POST
/me/wallet-backup-seen. - Inputs: Incoming
Requestwith Bearer. - Returns / side effects: Upstream
Response. - Used by: Route POST
/me/wallet-backup-seen.
Function: prfEvalFirstSalt
- Purpose: SHA-256 of UTF-8
21gifts-nostr-v1. - Inputs: None.
- Returns / side effects: 32-byte
Uint8Array. - Used by:
obtainPrfFirst,obtainPrfFirstFromGet,usePasskeyLogin.register,renewPasskey.
Function: readPrfFirst
- Purpose: Read
prf.results.firstfrom a credential. - Inputs:
PublicKeyCredential. - Returns / side effects: Bytes or
undefined. - Used by:
obtainPrfFirst,obtainPrfFirstFromGet.
Function: mnemonicFromPrfFirst
- Purpose: HKDF-SHA-256 then BIP-39 English 12 words.
- Inputs: PRF eval.first bytes.
- Returns / side effects: Space-separated mnemonic. Never sent to the api.
- Used by:
useWalletPhrase.
Function: obtainPrfFirst
- Purpose: Prefer create() PRF; else get() with allowCredentials and eval.first salt.
- Inputs: The new
PublicKeyCredential. - Returns / side effects: Bytes or
null. - Used by:
usePasskeyLogin.register,renewPasskey.
Function: obtainPrfFirstFromGet
- Purpose: get() with PRF eval.first bound to this account's current credential (
allowCredentials) to re-derive the phrase. - Inputs: Current credential id bytes (WebAuthn
rawId). - Returns / side effects: Bytes or
null. Does not contact the api. Does not pick a leftover credential after replace. - Used by:
useWalletPhrase.showPhrase.
Function: classifyWebAuthnError
- Purpose: Map WebAuthn failures to timeout / cancel / generic.
- Inputs: Unknown rejection.
- Returns / side effects: Discriminant string.
- Used by:
useWalletPhrase,renewPasskey.
Function: rememberSessionPhrase
- Purpose: Store 12 words in tab RAM.
- Inputs: Mnemonic string.
- Returns / side effects: Module-level variable. Never localStorage.
- Used by: Tests; re-exported from
useWalletPhrase. Not called from register or wallet add. Implemented intab-phrase.
Function: peekSessionPhrase
- Purpose: Read tab-RAM mnemonic.
- Inputs: None.
- Returns / side effects: String or
null. - Used by:
useWalletPhrase. Implemented intab-phrase.
Function: clearSessionPhrase
- Purpose: Drop tab-RAM mnemonic.
- Inputs: None.
- Returns / side effects: Clears the module variable.
- Used by:
useWalletPhrase.hidePhrase,clearAuth,login,authenticate. Implemented intab-phrase.
Function: resetWalletCeremonyLock
- Purpose: Drop the tab-wide wallet WebAuthn lock so a later ceremony can start.
- Inputs: None.
- Returns / side effects: Sets the module lock to idle. Tests call this between cases; production uses
finallyon activate / showPhrase. - Used by:
useWalletPhrasetests.
Function: useWalletPhrase
- Purpose: Add or show the recovery phrase.
/walletdoes not render the words./wallet/phrasedoes. There is no confirm view, no auto-reveal, and no Continue on the words. - Inputs: Auth store session and account.
- Returns / side effects: View
'activate' | 'reveal' | 'phrase', status, error, words,activate,showPhrase,hidePhrase,retry.'activate'only without a non-emptypasskeyCredentialId(Add recovery phrase:renewPasskey, thenmnemonicFromPrfFirston the in-memory PRF bytes, store the account, 12 words only in component state).'reveal'when the id is set:showPhraserunsobtainPrfFirstFromGetof that id, no create, no seed/begin.walletBackupSeenAtis not read.rememberSessionPhraseis not called. No Confirm, no Continue. - Used by:
WalletScreen,WalletPhraseScreen.
Function: WalletScreenView
- Purpose: Wallet cards, and the header Back. The visible Back is this
ProfileChromeLeftviaAppShellTopLeft, not the pageWalletChromeLeft.surface="entry"is/wallet.surface="phrase"is/wallet/phrase. - Inputs:
UseWalletPhraseResultplus optionalsurface(entrydefault, orphrase). - Returns / side effects: On
entry, heading Wallet is first, then the centered address, Open CryptoPay QR, and content-width Set an amount to/pos, then Add recovery phrase or Show recovery phrase under Advanced functions. Both recovery actions link to/wallet/phrase. No word grid and no recovery error. Onphrase, only the ceremony: Add recovery phrase, Show recovery phrase, the 12-word grid and only-backup line (no Continue), or an error with Try again. No QR. Header Back onentrycloses Advanced functions, then returns to the previous in-app view in this tab, or opens/welcomewhen this tab has none. Onphraseit hides the words, then does the same. It does not leave the site, and it does not open/walletas a parent. - Used by:
WalletScreen,WalletPhraseScreen.
Function: WalletScreen
- Purpose: Signed-in
/walletbody. Receive address above the recovery entry. - Inputs: None.
- Returns / side effects: Renders
WalletScreenViewwithuseWalletPhrase(). Does not render the 12 words. - Used by:
WalletPage.
Function: WalletPhraseScreen
- Purpose:
/wallet/phrasebody. Recovery phrase or recovery error, with no receive QR. - Inputs: None.
- Returns / side effects: Renders
WalletScreenViewsurface="phrase"withuseWalletPhrase(). - Used by:
WalletPhrasePage.
Function: WalletPhrasePage
- Purpose: Next.js page for
/wallet/phrase. - Inputs: None.
- Returns / side effects: AppShell + OnboardingGate +
WalletPhraseScreen. - Used by: Route
/wallet/phrase.
Function: WalletPage
- Purpose: Next.js page for
/wallet. - Inputs: None.
- Returns / side effects: AppShell + OnboardingGate + WalletScreen.
- Used by: Route
/wallet.
Function: startPasskeyRegistration
- Purpose: POST
/auth/passkey/register/beginand parse options. - Inputs: Optional
viewKeystring and optionalnamestring. A non-emptyviewKeyPOSTs JSON{ viewKey }withContent-Type: application/jsonand ignoresname. Otherwise a non-emptynamePOSTs JSON{ name }withContent-Type: application/json. Otherwise POSTs with no body and no Content-Type. - Returns / side effects:
{ challengeId, options }. On!ok, throws the api{ error }string when present, otherwise a status fallback. - Used by:
usePasskeyLogin.submitNamefor a new account, andusePasskeyLogin.registeronly with a view key.
Function: usePasskeyLogin
- Purpose: Client hook for passkey login.
loginauthenticates with an existing passkey. When authenticate returnsNotAllowedErrorandisInAppBrowser()is false, status becomeschoiceand registration is not started. When authenticate returnsNotAllowedErrorwhileisInAppBrowser()is true, status becomesunsupportedand register is not started. Fromchoice,authenticatenever falls through to register;register()(no view key) sets statusnameand starts neitherstartPasskeyRegistrationnorcredentials.create.submitNamenormalizes and, when valid, callsstartPasskeyRegistration(undefined, normalized)then the existing create ceremony.register(viewKey)starts the claim immediately and does not showname. User cancel (NotAllowedErrororAbortError) after a named create returns toname, notchoice. After a choice was offered, user cancel (NotAllowedErrororAbortError) on those ceremonies returns tochoice; directauthenticate/register(viewKey)fromViewProfileClaimnever sets that flag, so cancel returns toidle. Statusunknownis authenticate finishUnknown credential: the hook callssignalUnknownCredentialbest-effort with the ceremonyrpIdandcredential.idbefore showing it, and a later user-cancel returns tounknownwhile that flag is set. On iOS/iPadOS WebKit (including iPadOS desktop-site: Macintosh UA, MacIntel, maxTouchPoints > 1),credentials.get/credentials.createomit AbortSignal.cancelaborts an in-flight WebAuthn prompt and clears the choice flag.register(viewKey?)forwards an optional view key for public profile claim;retryafterregister(viewKey)resends the same key.loginnever sends a view key. FinishWrongAccountErrorclears the session, setswrongAccount, statuserrorwith that message, and does not fall through to discoverable registration.registerrequires WebAuthn PRF on create; missing PRF aborts withwallet.prfUnsupportedand does not finish. The words are discarded.login/authenticatecallclearSessionPhrase. On iPhone, iPad, or iPod below iOS 18, a named create still does not finish the account.NotAllowedErrororAbortErrorfrom create or from the extra-key read reportsclient.passkey.register.ceremonywithname,prfPresent: false, the beginchallengeId,accountIdwhenuser.iddecodes as a UUID, and messageiOS <installed> below 18 at createorat prf, then the browser text when it fits the diagnostic allowlist. A missing extra key reportsclient.passkey.register.prfwithprfPresent: false, those same ids, and messageiOS <installed> below 18 at prf.absent. Both throwlogin.iosVersioninstead of a silent cancel orwallet.prfUnsupported. On Android below 9, a named create does not finish the account.NotAllowedErrororAbortErrorfrom create or from the extra-key read reportsclient.passkey.register.ceremonywithname,prfPresent: false, the beginchallengeId,accountIdwhenuser.iddecodes as a UUID, and messageAndroid <installed> below 9 at createorat prf, then the browser text when it fits the diagnostic allowlist. A missing extra key reportsclient.passkey.register.prfwithprfPresent: false, those same ids, and messageAndroid <installed> below 9 at prf.absent. Both throwlogin.androidVersioninstead of a silent cancel orwallet.prfUnsupported. Every failed login postsclient.passkey.login.failbefore the card changes, even when no account id exists: the browsername, the allowlistedmessagewhen that text fits, and the beginchallengeIdwhen it is 64 hex. That row is written whencredentials.getthrows, when the assertion is missing, and when an in-app browser stops the ceremony first (stageloginorauthenticate, messagein-app browser). A dismissed register that is not an old-iOS or old-Android stop postsclient.passkey.register.failwith those fields, andaccountIdonly whenuser.iddecodes as a UUID. A failed login row and a dismissed register that is not an old-iOS or old-Android stop prefix the allowlisted message withiOS <installed>orAndroid <installed>when the user agent parses (iOS preferred), including versions new enough to pass the gate. Desktop and a user agent with no version token stay unprefixed. A cancelled named create stays onname; claim and paths before the name staychoice,idle, orunsupported. Begin and finish failures keep their own rows and do not need an account id. - Inputs: None (reads
useAuthStore; callsisInAppBrowseron authenticateNotAllowedError,iosPasskeyBlockandandroidPasskeyBlockon register, andiosInstalledVersionandandroidInstalledVersionwhen a failed attempt is reported). - Returns / side effects:
{ status, login, register, submitName, authenticate, retry, cancel, error, nameError }withstatusinidle | starting | error | unsupported | choice | unknown | name.erroris the lastError.messagewhenstatus === 'error', elsenull.nameErrorisinvalid,taken, ornullwhilestatus === 'name', elsenull. After a named create,retryreturns to the name form and does not start create again.retryafterregister(viewKey)sends the same key again.retryafterauthenticaterepeats authenticate. The single login button repeats login. Calls WebAuthn and the api. Unmount still aborts the controller and clears the choice flag. - Used by:
OnboardingGate,LoginCard,LogoutButton, andViewProfileClaim.
Function: fetchComposeTarget
- Purpose: GET
/messages/compose-targetso a basis account can invoice 1 sat to 21.gifts (the platform profile note) before posting or replying. - Inputs: session token.
- Returns / side effects:
{ messageId, sats }of the payable platform profile note, or throws collapsed copy. - Used by:
ForumLoader,PublicMessageThread,MemberProfileScreen.
Function: getRepayment
- Purpose: GET
/messages/:id/repaymentwith no session. The public ledger of who gave what and each bitcoin repayment. - Inputs: Credit note id.
- Returns / side effects: The ledger, or null when the response is not ok or the body does not match.
- Used by:
CreditLedger.
Function: CreditLedger
- Purpose: Show the givers and the repayment plan under a posted credit. Nothing renders until the ledger loads, and a failed read stays blank.
list="summary"(default) shows givers, the explanation, the chart, and a Repayment list link to/messages/<id>/repayment-list.list="page"shows givers, the explanation, the chart, and the day-group rows, and omits that link. - Inputs: Credit note id. Optional
list:'summary'|'page'(default'summary'). - Returns / side effects: The Given and Paid back lists, or null. Day-group rows (Tag 1, person, amount, Geplant/Fällig) render only when
list="page". The chart uses day numbers (dayIndex + 1) untilfundedAtis set or a day has nodueOn; once the credit is fully given it uses the UTC dates. A fiat chart sums only amounts in the ask currency. Fetches when the id changes. Until that read finishes, a previous note's ledger is not shown. While any row isdue, refreshes every 4 seconds. A failed or null refresh leaves the list for the same note. A response after an id change or unmount is ignored. Names stay on one line. On a narrow screen the status sits under the amount; on a wide screen it sits beside it. The list scrolls with the page. The summary link is shown once the ledger has loaded, even when there are no rows. - Used by:
ForumGoalBar(list="summary").RepaymentListPage(list="page").
Function: RepaymentListPage
- Purpose: Next.js page for
/messages/[id]/repayment-list— the repayment list for one credit note: who gave, the chart, and every day's shares. The day rows render only here. The note, the forum, and a profile show a Repayment list link instead of those rows. Chrome isPublicMessageChrome. NoOnboardingGate. No second back control. The note id is not validated as a UUID here. - Inputs: Dynamic route params (
id). LoadsGET /messages/:id/repaymentthroughgetRepayment(viaCreditLedger). No session is required to view. Chrome follows the hydrated session. - Returns / side effects:
CreditLedgerwithlist="page"insidePublicMessageChrome. NogenerateMetadata. - Used by: Route
/messages/[id]/repayment-list. The Repayment list link on a collapsedForumGoalBarand onCreditLedgersummary.
Function: proxyMessagesRepaymentGet
- Purpose: Same-origin proxy for
GET /messages/:id/repayment. - Inputs: App Router
Requestand the message id. - Returns / side effects: Forwards to the api. No session is required.
- Used by:
src/app/messages/[id]/repayment/route.ts.
Function: postRepaymentInvoice
- Purpose: POST
/messages/:id/repaymentwith no body. The author receives a BOLT11 for the next giver share. Sends the deviceTime-Zoneso a local Sunday is refused. - Inputs: session token and the credit note id.
- Returns / side effects:
{ pr, amountSats }or throws collapsed copy. 409missing_requirementsthrowsMissingRequirementsError. - Used by:
ForumLoader.
Function: proxyMessagesRepaymentPost
- Purpose: Same-origin proxy for
POST /messages/:id/repayment. - Inputs: App Router
Requestand the message id. - Returns / side effects: Forwards to the api.
- Used by:
src/app/messages/[id]/repayment/route.ts.
Function: postMessageInvoice
- Purpose: POST
/messages/:id/invoicewith{ sats },{ sats, text }when the visitor attached a reply comment, andamountUsd,amountChf,amountEur,amountPhpwhen a preview rate is on screen. Emptytextis omitted. A missing preview omits those four fields. - Inputs: session token, message id, sats, optional text, optional shown
amountUsd,amountChf,amountEur, andamountPhp(each a two-decimal string or null). The amounts are read when the request is sent, not when an earlier requirements step started. - Returns / side effects:
{ pr, amountSats }or throws collapsed copy. 409missing_requirementsthrowsMissingRequirementsError. 404 throwsNoteDeletedErrorinstead of the collapsed payment copy. - Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread.
Function: proxyMessagesComposeTargetGet
- Purpose: Same-origin proxy for
GET /messages/compose-target. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/messages/compose-target/route.ts.
Function: proxyMessagesInvoicePost
- Purpose: Same-origin proxy for
POST /messages/:id/invoice. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/messages/[id]/invoice/route.ts.
Function: MessagesPage
- Purpose: Next.js page for
/messages(signed-in PN inbox). - Inputs: None.
- Returns / side effects: Fill
AppShell(align="center") withMessagesChromeLeftinSuspensetop-left (fallbackProfileChromeLeft),SignedInChrometop-right, andOnboardingGatearoundInboxLoader. Conversation HTTP is under/conversations. - Used by: Route
/messages.
Function: InboxLoader
- Purpose: Client loader for
/messages. Session and account fromuseAuthStore; returns null without a session. Fetches the unpagedGET /conversationslist, opens?c=viaonOpenrouter.push, and posts replies. An open thread setsshowAttachtrue: JPEG/PNG/WebP stills (cap 10) viaprepareForumPhoto; photo-only send allowed. Send is disabled whileposting || preparing. Thread stills load withfetchConversationMessagePhoto. Blob URLs are revoked on unmount, on thread switch, and on session loss; drafts and preparing drop on those same events (pickGeneration). The invoice path stays text/sats only and clears still drafts. The list has no attach. The first paint of an open thread callsfetchConversation(session, openId)without a cursor for the newest 20-message page. AnIntersectionObserveronInboxScreen'snearStartRef(the eighth grouped bubble from the start, or the first when fewer than eight render) attaches only after the thread end is in view or the thread fits, uses the active page scrollport as its root when one is mounted, then fetches a setnextCursorand prepends unique older messages. The first screen is the newest message. The cursor load does not blank the thread; while stuck to the bottom, InboxScreen keeps the newest in view. Returning to the list is the chrome link (noonBackhandler). Moderators getshowFiltertrue; members see the unfiltered inbound list.?c=opens only after the inbox list loaded. For a moderator an unlisted?c=first resolvesfetchModeratorGrouponce per id (neutral Loading…, not the list, while it runs); when that id matches, the thread never opens andfetchConversationis not called. A listedmoderator_grouprow never opens either. Other roles, and a failed lookup, fall through tofetchConversation; the api rejects accounts that may not read the thread. Also callsuseLatestRateDay()and passesrateDaytoInboxScreen. While a private thread is open and the tab is visible, the newest page is fetched every 5 seconds and unseen messages are appended; a hidden tab does not poll, and a failed poll keeps the thread. - Inputs: None (reads session and account from the auth store;
useSearchParams). - Returns / side effects: React element or
nullwithout a session. CallsfetchConversations,fetchConversation,fetchModeratorGroup(roleAtLeast(role, 'moderator'), unlisted?c=only),prepareForumPhoto,fetchConversationMessagePhoto,postConversationMessage,postConversationInvoice. After a successful thread fetch, localunread: falseandunreadMessageCount: 0, then fire-and-forgetmarkConversationRead,bumpUnreadAppBadgeEpochandrefreshUnreadAppBadge(remaining inbox from the local list; a thread only opens after the list loaded, so there is no second conversations fetch). After a successful paid-gift poll orpostConversationMessage, the matching list row is alsounread: falseandunreadMessageCount: 0(including when the first thread fetch failed). When that poll fills a thread whose firstfetchConversationfailed (messages === null), it also clearsmessagesErrorand takesnextCursorfrom the poll page so older history can still prefetch. Must not fail the thread view. - Used by:
MessagesPage.
Function: groupThreadGifts
- Purpose: Pure grouping helper for an inbox/moderator-group thread. A message whose
giftFor equals a DIFFERENT message's id present in the same list is removed from the top level and appended to that parent's gifts, in original list order; a giftFor that is unknown, self-referencing, or names a message that is itself a gift leaves the message as an ordinary top-level entry with empty gifts, so no message is ever dropped. Order of top-level messages is preserved regardless of where a gift appears in the input array.
- Inputs: Oldest-first
ConversationMessage[]. - Returns / side effects:
ThreadGiftGroup[]({ message, gifts }per top-level message).
No side effects.
- Used by:
InboxScreen.
Function: InboxScreen
- Purpose: Presentational inbox: incoming threads as a conversation list, or one open thread with a 8000-character
MentionTextareaand sats amount field (showAmountfalse hides the field; the staff room has no gifts). Typing@lists people. Choosing one inserts@username. The moderator room uses this same composer. The message, attach, and send share one row; the amount is the next row (AmountEntrylayout="composer"), indented to the message when attach is shown. An open invoice hides that amount row; the pay sheet states the amount once, as bitcoin plus the default fiat from the latest gift-day rate. Without an amount the photo and send stay vertically centered on the message.showAttach(default false for callers that omit it) adds the ImagePlus control, still previews, and photo-only send; InboxLoader open threads and ModeratorGroupScreen pass true. Non-empty bodies go throughForumQuotedBodyso a pastedhttps://21.gifts/messages/<uuid>unfurls as a nested quoted-note card, using the samerateDay(a stored string as-is; a null or missing stored field uses the gift-day rate; omittingstoreduses the latest gift-day rate). A storedmentionslist turns@usernameinto a profile button for/members/<accountId>. That mark does not notify the person.photoUrls(${messageId}:${index}) render attached stills. WhenshowFilteris true (moderator), the list is filtered by the origin control (Direct / Contact / Damus; default Direct). Rows withkindmoderator_groupare never listed; the closed staff room lives on/moderate/group. Members (showFilterfalse) see inbound rows exceptmoderator_groupand no control. Each list row and the open-thread header show an origin label fromconversation.kind(Contact / Direct / Damus / Moderators chat group). Unread inbound rows usefont-semiboldnames,text-app-fglast text, a visible tabular-nums lining-nums unread-message count right of the name when the derived count is greater than zero (row.unreadMessageCount, or 1 whenunreadis true and the count is 0), andaria-labelinbox.threadUnreadwith{name}and{count}. The word Unread is not visible text. Read inbound last text is a muted preview. WhenlastFromMeis true andlastTextis non-empty, the list preview isinbox.sentPreview(You: {text}) in a filled chip; gift-only last messages (lastSats > 0, emptylastText) show the formatted amount . The list does not offer Translate. Thread remainders go throughForumQuotedBodywithconversationId(translateConversationMessage); nested forum quotes stay ontranslateNote. Thread incoming messages are full-width muted note cards;fromMemessages render as filledapp-btnbubbles on the right labelledinbox.you. Gift-only bubbles useforum.giftReply; text+sats shows the amount under the body. An open invoice shows the Lightning pay sheet. Close (X) dismisses that sheet and stays on the thread. It is not the top-left back arrow. Open-thread heading is counterpart name + origin caption (noonBack, no in-card back). Heading and incoming author names withaccountIdareinbox.authorProfilebuttons to/members/:id;fromMestaysinbox.youtext; Damus/missing id stays plain text. A staff viewer sees another staff reply as an incoming muted card with that person'sname(profile link whenaccountIdis set).fromMe/inbox.youonly when this session is the actor. Settled thread sats amounts (gift-only bubble, text+sats line, and nested gift line) show a preferred-fiat suffix viapreferredFiatSuffix: a stored string as-is, a null or missing field uses the gift-day rate, and bitcoin alone only when neither a stored string nor a usable rate exists. Unpaid invoice previews still use optionalrateDay(latest gift-day totals). A message whosegiftForpoints at another message renders viagroupThreadGiftsas a nestedrole="note"line inside the parent's<li>(name, ₿ amount, fiat suffix, time; not shown as its own top-level row) instead of a separate bubble. When supplied,nearStartRefattaches to the eighth grouped bubble from the start, or the first bubble when fewer than eight render, only after the thread end is in view or the thread fits. A scroll event attaches it only when the end is already in view. Being within 80px of the bottom sticks the view but does not by itself attach older history. Landing again on the last pin offset sticks when that offset is within 80px of the bottom. An open thread (openIdset, messages loaded) scrolls the page scrollport to the bottom so the first screen is the newest message and the composer. While stuck to the bottom (within 80px), prepends and stills keep the newest in view; scrolling up unsticks and prepends compensate scrollTop. A newest-id change re-sticks. Inside AppShell the pin waits for that scroller and does not fall back towindowwhile the node is missing (windowonly outside AppShell); the pin runs again when an invoice pay sheet opens; list view does not pin to the bottom; leaving a thread scrolls the list to the top once. - Inputs: List/thread/composer state from
InboxLoaderorModeratorGroupScreen. - Returns / side effects: React element. InboxScreen itself does not fetch;
ForumQuotedBodymay callfetchPublicMessagefor pasted/messages/<uuid>URLs. - Used by:
InboxLoader,ModeratorGroupScreen.
Function: fetchConversations
- Purpose: GET
/conversationswith Bearer and parse{ conversations, unreadCount }. Missingunreaddefaults false; missingunreadCountdefaults 0; missingunreadMessageCountdefaults 0. The api returns incoming threads, plus the member's own 21.gifts contact thread when it has a message. GET/conversationsnever lists themoderator_groupthread, even for moderators. Each row includes requiredkind:member_member|member_platform|member_damus|moderator_group, requiredlastFromMe(true when the last message was sent by this session as the actor, not when another staff member sent as the platform), requiredlastSats, optionalaccountId(counterpart),unread, andunreadMessageCount(inbound unread messages). EnvelopeunreadCountis unread thread count. - Inputs: Session token.
- Returns / side effects: Conversation list, or throws visitor copy.
- Used by:
InboxLoader,ContactLoader,useUnreadCount,NotificationsLoader,refreshUnreadAppBadge.
Function: fetchConversation
- Purpose: GET
/conversations/:idwith Bearer, always sendinglimit=20, plus optionalcursorandsinceMessageId. Parses{ messages, nextCursor? }. Each message includes requiredfromMe(true iff this session is the actor) andsats, plushasPhoto/photoCount(client default 0 when omitted). For a staff viewer, incomingnameand optionalaccountIdare the actor when the api sends them. The newest page is returned first and messages inside each page are oldest-first;nextCursoris supplied only for a full page. - Inputs: Session token, conversation id, optional
{ sinceMessageId, cursor, signal }. - Returns / side effects:
{ messages, nextCursor }, normalizing an omittednextCursortonull, or throws visitor copy. - Used by:
InboxLoader,ModeratorGroupScreen.
Function: postConversationMessage
- Purpose: POST
/conversations/:idwith{ text }and, whenphotosis non-empty,{ photo, photos }(first still duplicated asphoto, at most 10 JPEG/PNG/WebP stills). Empty text is allowed when photos are present (Moderators group and/messagesDirect/Contact/Damus). Staff replies on official threads still send as the platform account on the api, but the created message'sfromMe,name, andaccountIdfollow the actor. - Inputs: Session token, conversation id, text, optional photos array.
- Returns / side effects: Created message, or throws api/visitor copy.
- Used by:
InboxLoader,ModeratorGroupScreen.
Function: fetchConversationMessagePhoto
- Purpose: GET
/conversations/:id/messages/:messageId/photo(index 0) or/photo/{n}.jpg(indices 1–9) with Bearer. Callers use a blob URL, not a bare<img src>. Empty body throws. - Inputs: Session token, conversation id, message id, optional zero-based index (default 0).
- Returns / side effects: Photo
Blob, or throws visitor copy (Could not load messages. Please try again.). - Used by:
InboxLoader,ModeratorGroupScreen.
Function: proxyConversationMessagePhotoGet
- Purpose: Same-origin Bearer proxy of api GET
/conversations/:id/messages/:messageId/photoor/photo/:file. - Inputs: Incoming App Router
Request, conversation id, message id, optional file (1.jpg–9.jpg). - Returns / side effects: Upstream image response.
- Used by:
GETon/conversations/[id]/messages/[messageId]/photoand/photo/[file].
Function: postConversationInvoice
- Purpose: POST
/conversations/:id/invoicewith{ sats },{ sats, text }, andamountUsd,amountChf,amountEur,amountPhpwhen a preview rate is on screen. Emptytextis omitted. A missing preview omits those four fields. - Inputs: Session token, conversation id, sats, optional text, optional shown
amountUsd,amountChf,amountEur, andamountPhp(each a two-decimal string or null). - Returns / side effects:
{ pr, amountSats, messageId }, or throws api/visitor copy. - Used by:
InboxLoader.
Function: openConversation
- Purpose: POST
/conversationswith{ forumMessageId }. - Inputs: Session token and forum note/reply id.
- Returns / side effects: Conversation row, or throws on 400/404/other.
- Used by:
MemberProfileScreenMessage (profile.message).
Function: markConversationRead
- Purpose: POST
/conversations/:id/readwith Bearer. Non-ok throws; success may ignore body. - Inputs: Session token and conversation id (encoded in the path).
- Returns / side effects: void, or throws visitor copy.
- Used by:
InboxLoaderafter a successful thread fetch (fire-and-forget; failures are ignored).ModeratorGroupScreenafter a successful group+thread load (fire-and-forget; failures are ignored).
Function: proxyConversationsGet
- Purpose: Same-origin proxy for api GET
/conversations. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/conversations/route.ts.
Function: proxyConversationsPost
- Purpose: Same-origin proxy for api POST
/conversations. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/conversations/route.ts.
Function: proxyModeratorGroupGet
- Purpose: Same-origin Bearer proxy for api GET
/conversations/moderator-group(singleton closed staff room as{ conversation }). - Inputs: App Router
Request. - Returns / side effects: Forwards to the api path
/conversations/moderator-group. - Used by:
src/app/conversations/moderator-group/route.ts.
Function: proxyConversationGet
- Purpose: Same-origin proxy for api GET
/conversations/:id. - Inputs: App Router
Requestand conversation id. - Returns / side effects: Forwards to the api.
- Used by:
src/app/conversations/[id]/route.ts.
Function: proxyConversationPost
- Purpose: Same-origin proxy for api POST
/conversations/:id. - Inputs: App Router
Requestand conversation id. - Returns / side effects: Forwards to the api.
- Used by:
src/app/conversations/[id]/route.ts.
Function: proxyConversationInvoicePost
- Purpose: Same-origin proxy for api POST
/conversations/:id/invoice. - Inputs: App Router
Requestand conversation id. - Returns / side effects: Forwards to the api.
- Used by:
src/app/conversations/[id]/invoice/route.ts.
Function: proxyConversationReadPost
- Purpose: Same-origin proxy for api POST
/conversations/:id/read. App route is POST/conversations/[id]/read. - Inputs: App Router
Requestand conversation id. - Returns / side effects: Forwards to the api (id encoded).
- Used by:
src/app/conversations/[id]/read/route.ts.
Function: NotificationsPage
- Purpose: Next.js page for
/notifications(signed-in notifications for living-room posts, replies, payments, moderator appointment, and moderator proposal). - Inputs: None.
- Returns / side effects: Fill
AppShell(align="center") withProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGate screen="welcome"aroundNotificationsLoader. Notification HTTP is under/forum/notifications(noroute.tsbeside this page). - Used by: Route
/notifications.
Function: NotificationsLoader
- Purpose: Client loader for
/notifications. FetchesGET /forum/notifications(posts, replies, payments, moderator appointment, and moderator proposal). After a successful list fetch,bumpUnreadAppBadgeEpochthen set the badge to remaining inbox unread plus staff-room unread (0or1; notifications treated as 0; visiting/notificationsdoes not force badge 0 when inbox or staff-room unread remains). Repeats aftermarkAllNotificationsReadif the session is unchanged. Fetches conversations (fetchConversations) and, whenroleAtLeast(account?.role, 'moderator'), the staff room (fetchModeratorGroup) for those counts; below moderator, remaining badge is inbox unread only (staff-room contributes 0, no request). A side that fails contributes 0. Opening amoderator_proposalrow goes to/moderate/proposalswithoutmarkNotificationRead. Opening amoderator_appointedrow waits formarkNotificationReadthen goes to/welcome(still navigates if that POST fails; skips navigation if the session changed). Aforum_replyorforum_mentiongoes to/messages/{replyId}without waiting. Aforum_postorzapgoes to/messages/{parentId}without waiting. Ids are URI-encoded. - Inputs: None (session from the auth store).
- Returns / side effects: React element or
nullwithout a session. No composer. After a non-cancelled successful list fetch, marks all read fire-and-forget, thenbumpUnreadAppBadgeEpochand sets the home-screen badge to remaining inbox unread plus staff-room unread (0or1; notifications treated as 0). Repeats aftermarkAllNotificationsReadif the session is unchanged. Fetches conversations (fetchConversations) and, whenroleAtLeast(account?.role, 'moderator'), the staff room (fetchModeratorGroup) for those counts; below moderator, remaining badge is inbox unread only (staff-room contributes 0, no request). A side that fails contributes 0. Does not clear remaining inbox or staff-room unread on error, cancel, or missing session. Every fetched row is rendered, including those withreadAtset, in API order; read rows are not discarded. - Used by:
NotificationsPage.
Function: NotificationsScreen
- Purpose: Presentational notifications list of living-room posts, replies, payments, moderator appointment, and moderator proposal (actor
{name} posted/{name} replied/{name} sent bitcoin/{name} proposed a moderator;moderator_appointedtitle usesnotifications.moderatorAppointedwith no{name}placeholder; post text or Photo, except aforum_postwhosetextequalsnameomits the body (profile-note leftover); reply text or Photo reaction, zap amount as stored, moderator appointment or proposal body only whentextis non-empty — emptytextomits the body line and does not usenotifications.photoPost/photoOnly; time; unread semibold).onOpenreceives the row object. No composer, no thread view, and no filter. Unread rows (readAt === null) are a section above rows that already havereadAt, each section keeping API order; an empty section is omitted. - Inputs: List state from
NotificationsLoader(notifications,error,loading,onRetry,onOpen). - Returns / side effects: React element. No network.
- Used by:
NotificationsLoader.
Function: fetchNotifications
- Purpose: GET
/forum/notificationswith Bearer and parse{ notifications, unreadCount }. - Inputs: Session token.
- Returns / side effects:
{ notifications, unreadCount }, or throws visitor copyCould not load notifications. Please try again. - Used by:
NotificationsLoader,useUnreadCount,refreshUnreadAppBadge,ForumLoader(welcome appointment banner).
Function: markNotificationRead
- Purpose: POST
/forum/notifications/:id/readwith Bearer and optional JSON{ endpoint }when this browser has a push subscription.currentPushEndpoint()is sent only when it is a non-empty string. Parses one notification. - Inputs: Session token and notification id (encoded in the path).
- Returns / side effects: Updated notification, or throws visitor copy.
- Used by:
NotificationsLoaderon row click;ForumLoaderwhen the welcome appointment pill is clicked.
Function: markAllNotificationsRead
- Purpose: POST
/forum/notifications/read-allwith Bearer and optional JSON{ endpoint }when this browser has a push subscription. Non-ok throws. When the body has atagsarray, those local notifications are closed. A body withouttagscloses nothing. - Inputs: Session token.
- Returns / side effects: void.
- Used by:
NotificationsLoaderfire-and-forget after a successful list fetch.
Function: markNotificationsReadForMessage
- Purpose: POST
/forum/notifications/read-by-messagewith Bearer and JSON{ messageId, endpoint? }.endpointis sent only when the current push endpoint is a non-empty string. Non-ok throws visitor copyCould not mark notification as read. On success, closes local notifications for stringtags; a missingtagsfield is[]. - Inputs: Session token and forum message id.
- Returns / side effects:
{ ok: true, tags: string[] }. - Used by:
ForumLoaderwhen a note becomes expanded,NoteTranslatewhen Translate is requested for a forum note while a session is present (not for a conversation message),PublicMessageLoaderwhen a signed-in message page is ready,MemberProfileScreenwhen a profile note becomes expanded, andPublicMessageThreadwhen a thread note becomes expanded.
Function: markVisibleForumNoteRead
- Purpose: POST
/forum/notifications/read-visiblewith Bearer and JSON{ messageId, endpoint? }.endpointis sent only when the current push endpoint is a non-empty string. Non-ok throws visitor copyCould not mark notification as read. On success, closes local notifications for stringtags; a missingtagsfield is[]. - Inputs: Session token and forum message id.
- Returns / side effects:
{ ok: true, tags: string[] }. - Used by:
ForumLoaderwhen a signed-in note card is fully inside the AppShell scrollport.
Function: isForumCardFullyVisible
- Purpose: True only when a forum note card sits fully inside the AppShell scrollport within
epsilonPx(default 1). A zero-size card is false. A card taller than the root by more thanepsilonPxis false. A card 2px past the root is false. A card exactly 1px past is true. - Inputs: Card and root
DOMRectReadOnlypicks (top,bottom,left,right,width,height), and optionalepsilonPx(default 1). - Returns / side effects: Boolean. No side effects.
- Used by:
ForumLoaderwhen measuringli[data-message-id]inside the shell scroller.
Function: activeMention
- Purpose: Find the
@usernametoken under a collapsed caret in a forum composer. A mark starts at@only when the previous character is not a username character, so an email address is not a mark. The query is the lowercase text between@and the caret. A bare@is a mark with an empty query. - Inputs: Composer text and a caret index.
- Returns / side effects:
{ start, end, query }, ornullwhen the caret is outside a mark, the query is longer than 32 characters, or the query is not a username prefix. - Used by:
MentionTextarea.
Function: remapClosedMentionStarts
- Purpose: Keep a dismissed
@attached to that character across one edit. Indexes after the edit shift with the text. An@inside the replaced span is forgotten, so a different@that lands on the old index opens. - Inputs: The previous composer value, the next value, the dismissed
@indexes, and the caret in the next value, or nothing when the caret is unknown. - Returns / side effects: The same set when no dismissed
@moved, otherwise the shifted indexes. - Used by:
MentionTextarea.
Function: MentionTextarea
- Purpose: Forum text field that lists people as soon as
@is typed. The field stays mounted when the list opens. The first page is prefetched for the signed-in session. More letters narrow the list to usernames that start that way in the same update, even when a previous server page is still stored. Choosing a row inserts@usernameand closes the list. A space already after the token stays a single space. A choice that would pass the length limit is not inserted. Keys during text composition stay with the input method. The caret sits after that space even when the text was already that handle. Escape or Tab, even while the first page is still loading, or a click outside the open list, keeps each such@closed until that character is gone, and a different@does not inherit the closure when text shifts. Choosing does not remember that@as closed. The list sits under the field, and above it only when this list would not fit underneath and there is more room above. No session, or a disabled field, is a plain textarea. - Inputs: Controlled value,
onChange, disabled, wrapper and textarea classes, accessible name, and optional placeholder, max length, rows, and textarea ref. - Returns / side effects: The textarea. When a token is active and at least one person matches, a
Peoplelistbox. Inserts the chosen handle throughonChange. - Used by: The post composer and reply composer in
ForumBoard, the ask-for-money text step inForumAskWizard, the shop text step inShopAddWizard, andInboxScreen(inbox and the moderator room).
Function: searchMentionAccounts
- Purpose: Load username suggestions for an
@token. Calls same-origin GET/forum/mentions, addingqonly when the prefix is non-empty. - Inputs: Bearer session and a lowercase prefix (
""for the first page). - Returns / side effects:
{ id, username, name }[]. Throws when the response is not 200 or the body is not that list. - Used by:
MentionTextarea,ShopAccountControl, andDailyPaymentAmountsScreen.
Function: proxyForumMentionsGet
- Purpose: Same-origin proxy for api GET
/mentions. App route is GET/forum/mentions. Forwards the query string. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/forum/mentions/route.ts.
Function: proxyNotificationsGet
- Purpose: Same-origin proxy for api GET
/notifications. App route is GET/forum/notifications. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/forum/notifications/route.ts.
Function: proxyNotificationsReadAllPost
- Purpose: Same-origin proxy for api POST
/notifications/read-all. App route is POST/forum/notifications/read-all. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/forum/notifications/read-all/route.ts.
Function: proxyNotificationsReadByMessagePost
- Purpose: Same-origin proxy for api POST
/notifications/read-by-message. App route is POST/forum/notifications/read-by-message. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/forum/notifications/read-by-message/route.ts.
Function: proxyNotificationsReadVisiblePost
- Purpose: Same-origin proxy for api POST
/notifications/read-visible. App route is POST/forum/notifications/read-visible. - Inputs: App Router
Request. - Returns / side effects: Forwards to the api.
- Used by:
src/app/forum/notifications/read-visible/route.ts.
Function: proxyNotificationReadPost
- Purpose: Same-origin proxy for api POST
/notifications/:id/read. App route is POST/forum/notifications/[id]/read. - Inputs: App Router
Requestand notification id. - Returns / side effects: Forwards to the api (id encoded).
- Used by:
src/app/forum/notifications/[id]/read/route.ts.
Function: StatisticsPage
- Purpose: Next.js page for
/statistics(public measured people and shop charts, not a funding goal). Top-right is Log in without a session andSignedInChromewith one.OnboardingGate screen="welcome"allowGuest. HTML/statisticsis the page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left andOnboardingGate screen="welcome"allowGuestaroundStatisticsScreen. Noroute.tsbeside this page. Gift totals stay on public/stats. - Inputs: None.
- Returns / side effects: The statistics screen inside fill AppShell.
- Used by: Route
/statistics.
Function: ModeratePage
- Purpose: Next.js page for
/moderate(signed-in moderation hub for moderators). HTML/moderateis the hub, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGate screen="welcome"aroundModerateScreen. Hidden HTTP lives under/forum/messages/hidden; proposal HTTP under/trust/proposals; grant-application HTTP under/funding/applications(noroute.tsbeside this page); the hub itself does not fetch. - Inputs: None.
- Returns / side effects: The moderation hub inside fill AppShell.
- Used by: Route
/moderate.
Function: roleRank
- Purpose: Numeric rank of a role from an explicit rank map (not
ROLE_ORDER.indexOf), for use byroleAtLeast. - Inputs:
role— one of'basis' | 'verified' | 'moderator' | 'initiator' | 'founder'. - Returns / side effects: Integer 0 through 3 (basis 0, verified 1, moderator 2, initiator 2, founder 3). Not
ROLE_ORDER's index (initiator is not 3, founder is not 4). Pure, no side effects. - Used by:
roleAtLeast.
Function: roleAtLeast
- Purpose: Product-rule primitive for every viewer permission/visibility check: true when
rolemeets or exceedsminby numeric rank. An equal rank meets the minimum. A missing account (null/undefinedrole) is never at least any role. - Inputs:
role— live account role, ornull/undefinedwhen the account snapshot is absent;min— inclusive minimum role. - Returns / side effects: Boolean. Pure, no side effects; delegates to
roleRank. - Used by:
ModerateScreen,ModeratorGroupScreen,InboxLoader,HiddenNotesScreen,ProposalsScreen,FundingApplicationsScreen,FundingApplicationDetailScreen,FundingStatusCard,DeletePostControl,ForumLoader,MemberProfileScreen,MemberTrustActions,SignedInChrome,useUnreadCount,isReplyPaymentExempt.
Function: canEditDailyPayoutRoster
- Purpose: True only for
initiatorandfounder. This is notroleAtLeast: initiator and moderator share rank 2, and the daily payout roster stays closed to moderators.nullandundefinedare false. - Inputs:
role— live account role, ornull/undefinedwhen the account snapshot is absent. - Returns / side effects: Boolean. Pure, no side effects.
- Used by:
GrantsScreen,DailyPaymentCommentScreen,DailyPaymentAmountsScreen.
Function: isReplyPaymentExempt
- Purpose: True when the signed-in account may reply without paying: anyone at least verified is exempt. Basis, including the parent note's author, is not exempt.
- Inputs:
account—{ id, role }ornullwhen the account snapshot is missing;parentAccountId— the parent note'saccountIdif the api sent one (a missing id is not treated as exempt). - Returns / side effects: Boolean. Pure, no side effects; delegates to
roleAtLeast. - Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread.
Function: utcDayFromMs
- Purpose: UTC calendar day
YYYY-MM-DDof an instant, taken fromtoISOString(the first ten characters of the ISO timestamp). - Inputs:
ms— epoch milliseconds of that instant. - Returns / side effects: UTC day string. Pure; no I/O.
- Used by:
previousUtcDay,chartRows,StatisticsScreen,ModerateScreen.
Function: previousUtcDay
- Purpose: UTC calendar day immediately before
day, so staff screens can name yesterday against the clock. - Inputs:
day— UTCYYYY-MM-DD. - Returns / side effects: Previous UTC day string. Pure; no I/O. Delegates to
utcDayFromMs. - Used by:
StatisticsScreenandModerateScreento pick yesterday.
Function: countOnDay
- Purpose: Person count on
yesterdayfromofficialCount. First matching day wins. - Inputs:
series—spendOverTimeoldest-first;yesterday— UTC day to look up. - Returns / side effects: That day's
officialCount,0when the day is missing, ornullwhen any point omitsofficialCount. Pure; no I/O. - Used by:
StatisticsScreen.
Function: chartRows
- Purpose: Last 30 UTC days ending on
today, oldest first, with person counts fromofficialCount. - Inputs:
series—spendOverTimeoldest-first;today— UTC day of the clock. - Returns / side effects: Oldest-first
{ day, count }rows. Last write wins when a day appears twice; a missing day is 0. Pure; no I/O. - Used by:
StatisticsScreen.
Function: formatUtcDate
- Purpose: Formats a UTC calendar day as numeric day plus long month in
localefor the yesterday sentence. - Inputs:
day— UTCYYYY-MM-DD;locale— active UI locale. - Returns / side effects: Locale date string in the UTC zone. Pure; no I/O.
- Used by:
StatisticsScreenin the yesterday sentence.
Function: chartDayLabel
- Purpose: Axis tick label for a UTC day as numeric day and month in
locale. - Inputs:
day— UTCYYYY-MM-DD;locale— active UI locale. - Returns / side effects: Short numeric day-month label in the UTC zone. Pure; no I/O.
- Used by:
ShopActivityChartandPeopleCountChartfor the first, middle, and last axis labels.
Function: fetchShopActivity
- Purpose: Loads public shop-use counts from same-origin
GET /shops/activitywith no Authorization. StatisticsScreen calls it with or without a session. The body must be 30 unique contiguous UTC days, oldest first; otherwise this throws and the shop panel shows its error state. - Inputs: None.
fetch('/shops/activity')with no Authorization. - Returns / side effects:
ShopActivityDay[](day,shopCount). Network viafetch. Does not require the last day to be the browser's today. - Used by:
StatisticsScreenshop panel.
Function: proxyShopActivityGet
- Purpose: Same-origin proxy for api
GET /shops/activity. App route isexport const GET = proxyShopActivityGeton/shops/activity. No bearer is required. Forwards the incoming request unchanged. - Inputs: App Router
Request. - Returns / side effects: Upstream response from
proxyApiRequest(request, '/shops/activity'). - Used by:
src/app/shops/activity/route.ts.
Function: fetchGrantContinuation
- Purpose: Loads the signed-in grant goal from same-origin
GET /funding/goalwithAuthorization: Bearer. GrantGoalsScreen calls it only when a session exists. The body must be 7 unique contiguous UTC days, oldest first, plus a nonnegativequalifyingShops; otherwise this throws and the goals page shows its error state. Does not callfetchShopActivity. - Inputs:
sessionToken— bearer session.fetch('/funding/goal')sends that token and does not invent one. - Returns / side effects:
GrantContinuation(days,qualifyingShops). Network viafetch. Does not require the last day to be the browser's today. - Used by:
GrantGoalsScreen.
Function: proxyFundingGoalGet
- Purpose: Same-origin proxy for api
GET /funding/goal. App route isexport const GET = proxyFundingGoalGeton/funding/goal. Forwards the incoming Authorization header and does not add one. This is not the public shop-activity proxy. - Inputs: App Router
Request. - Returns / side effects: Upstream response from
proxyApiRequest(request, '/funding/goal'). - Used by:
src/app/funding/goal/route.ts.
Function: ShopActivityChart
- Purpose: Draws the shop-count SVG (
role="img") for the UTC days it is given. Scale is the max count (at least 1). Ticks are 0, that scale, and the rounded midpoint when it differs. Today's bar isfill-app-subtle; other bars arefill-app-accent. Bars only when count > 0.data-testid="shop-activity-chart-bar". No goal line. - Inputs:
rows({ day, count }[]),today(UTC day drawn lighter),locale, andariaLabel. - Returns / side effects: SVG figure. No network.
- Used by:
StatisticsScreen(30 days) andGrantGoalsScreen(the last 7 days).
Function: PeopleCountChart
- Purpose: Draws the people-count SVG (
role="img") for 30 UTC days. Scale is the max count (at least 1). Ticks are 0, that scale, and the rounded midpoint when it differs. Today's bar isfill-app-subtle; other bars arefill-app-accent. Bars only when count > 0.data-testid="people-count-chart-bar". No goal line. - Inputs:
rows({ day, count }[]),today(UTC day drawn lighter),locale, andariaLabel. - Returns / side effects: SVG figure. No network.
- Used by:
StatisticsScreen.
Function: StatisticsScreen
- Purpose: Client chart of people counted once per UTC day, with a shop-activity panel under the people chart. A signed-out visitor fetches both feeds and sees both panels. Does not return
null. Any visitor fetches both feeds and sees yesterday's person count, the measurement paragraph, and the 30-UTC-day people chart (no goal line), then the shop panel (one explainer and the 30-UTC-day shop-count chart). Each panel loads and fails on its own. The people panel is always open and there is no Tap to close. No forbidden card. WhenroleAtLeast(..., 'moderator')and yesterday's person count is a number (including 0; not loading and not the people error panel, including omittedofficialCount), closedStaffFunctionssits after the people chart and before the shop panel; its only child is the payoutButtonLink(href/moderate/payouts,variantsecondary,sizelg, labelmoderate.payouts.link). Basis and verified never renderStaffFunctions. A signed-out visitor never rendersStaffFunctions. - Inputs: Account from
useAuthStore; catalog and locale viauseTranslations. - Returns / side effects: A React element. No session still fetches. Fetches
GET /gifts/statsand also callsfetchShopActivity. Does not returnnull. - Used by:
StatisticsPage.
Function: ModerateScreen
- Purpose: Client moderation hub of staff tools. Staff (
roleAtLeast(..., 'moderator')) see a labeled GoalsButtonLink(variant="secondary"size="lg") →/grants/goals(first tool), a labeled Hidden notesButtonLink(variant="secondary"size="lg") →/moderate/hidden, a labeled Open proposalsButtonLink(variant="secondary"size="lg") →/moderate/proposalsthat showsproposalCountplusmoderate.proposals.unreadwhen greater than zero, a Moderators chat groupButtonLink→/moderate/groupthat shows the staff-room unread count plusmoderate.groupUnreadwhen unread, a labeled HandbookButtonLink(variant="secondary"size="lg") →/moderate/handbook, and a labeled Show payout per personButtonLink(variant="secondary"size="lg") →/moderate/payouts(last tool). There is no payout-goal widget. Non-staff signed-in visitors see the heading plus forbidden copy and no tools list. Does not fetch hidden notes, proposals, applications, gift stats, or the group thread itself; unread for Open proposals and Moderators chat group comes fromuseUnreadCount. Rendersnullwithout a session. No un-hide control. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations;useUnreadCount(true, { writeBadge: false })for Open proposalsproposalCountand Moderators chat group staff-room unread (network for those counts; does not write the home-screen badge). - Returns / side effects: React element or
nullwithout a session. Does not fetchGET /gifts/stats. Others see forbidden copy and do not fetch. Does not fetch hidden notes, proposals, applications, or the group thread itself. - Used by:
ModeratePage.
Function: ModerateHandbookPage
- Purpose: Next.js page for
/moderate/handbook(signed-in staff handbook). HTML/moderate/handbookis the handbook page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundModerateHandbookScreen. Hub is/moderate. - Inputs: None.
- Returns / side effects: The handbook screen inside fill AppShell.
- Used by: Route
/moderate/handbook.
Function: ModerateHandbookScreen
- Purpose: Client staff handbook of how 21.gifts works. Staff (
roleAtLeast(..., 'moderator')) see TOC Chapters and three chapters 21.gifts login (#login), Verified (#verified), Official funding program (#funding), each with a permalink andHandbookCopyLinktone="app". Non-staff signed-in visitors see the heading plus forbidden copy and no chapters. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. No fetch. On mount and hashchange, scrolls the matching chapter into view when the hash is#login,#verified, or#funding. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. No network. - Used by:
ModerateHandbookPage.
Function: HiddenNotesPage
- Purpose: Next.js page for
/moderate/hidden(signed-in hidden-notes list for moderators). HTML/moderate/hiddenis the hidden-notes page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundHiddenNotesScreen. Hidden HTTP is under/forum/messages/hidden(noroute.tsbeside this page). - Inputs: None.
- Returns / side effects: The hidden-notes screen inside fill AppShell.
- Used by: Route
/moderate/hidden.
Function: HiddenNotesScreen
- Purpose: Client list of hidden living-room notes. Staff (moderator) fetch
listHiddenMessagesand show the lead copy plus the newest-hidden-first list (a non-interactive External badge next to the name when the row has aviavalue) (or empty / loading / try-again). A 21.gifts author name is aLinkto/members/:accountId. The time is aLinkto/messages/:id. An external name stays text inside the note link. The note text sits outside that link inTranslatableNoteBody(Languages Translate when the text differs from the UI locale). Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. No un-hide control.
- Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /forum/messages/hiddenonly when the role is at least moderator. - Used by:
HiddenNotesPage.
Function: PayoutsPage
- Purpose: Next.js page for
/moderate/payouts(signed-in staff payout-per-person table). HTML/moderate/payoutsis the table, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundFundingPayoutsScreen. JSON lives under/funding/payout-daysbecause Next.js forbids aroute.tsbeside this page. Hub is/moderate. - Inputs: None.
- Returns / side effects: The payout table inside fill AppShell.
- Used by: Route
/moderate/payouts.
Function: FundingPayoutsScreen
- Purpose: Client table of daily-grant payouts for seven UTC days. Staff (moderator) fetch
fetchFundingPayoutDaysand show a lead, a four-color legend, and name plus seven cells (black not entitled, white entitled but not collected, green payout received, amber welcome gift; split green/amber when both were paid the same day). Today is the rightmost column. A row withaccountIdlinks the name to/members/{id}. Empty, Loading…, and error plus Try again are separate. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. Moderator stipends are not in this table. Welcome gifts are in the table. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/payout-daysonly when the role is at least moderator. - Used by:
PayoutsPage.
Function: ProposalsPage
- Purpose: Next.js page for
/moderate/proposals(signed-in staff confirm/reject queue). HTML/moderate/proposalsis the queue, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundProposalsScreen. Proposal HTTP lives under/trust/proposalsbecause Next.js forbids aroute.tsbeside this page. Hub is/moderate. - Inputs: None.
- Returns / side effects: The open-proposals screen inside fill AppShell.
- Used by: Route
/moderate/proposals.
Function: ProposalsScreen
- Purpose: Client confirm/reject queue of open moderator proposals. Staff (moderator) fetch
fetchTrustProposalsand show subject name, Proposed by {name}, time, Reject on every open row (postTrustReject, including a self-proposal), and Confirm as moderator (postTrustConfirm) or Waiting for another moderator to confirm. whenproposedBy.id === account.id. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. A failed confirm or reject showstrustChain.actionFailed. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /trust/proposalsonly when the role is at least moderator. Confirm postsPOST /trust/confirm-moderator; reject postsPOST /trust/reject-moderator. While either POST is in flight, Confirm and Reject are disabled and Loader2 sits on the pressed action. - Used by:
ProposalsPage.
Function: FundingApplicationsPage
- Purpose: Next.js page for
/grants/applications(signed-in staff grant-application queue). HTML/grants/applicationsis the queue, not a GET proxy./moderate/applicationsredirects there. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundFundingApplicationsScreen. Application HTTP lives under/funding/applicationsbecause Next.js forbids aroute.tsbeside this page. - Inputs: None.
- Returns / side effects: The open-applications screen inside fill AppShell.
- Used by: Route
/grants/applications./moderate/applicationsredirects here.
Function: FundingApplicationsScreen
- Purpose: Client queue of open 21 gifts grant applications. Staff (founder or moderator) fetch
fetchFundingApplicationsand show applicant name (link/grants/applications/{id}), applied time, empty / Loading… / error+Try again. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/applicationsonly when the role is founder or moderator. - Used by:
FundingApplicationsPage.
Function: FundingApplicationDetailPage
- Purpose: Next.js page for
/grants/applications/[accountId](signed-in staff grant-application review). HTML page, not a GET proxy./moderate/applications/[accountId]redirects there. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundFundingApplicationDetailScreen. Application HTTP lives under/funding/applications/:accountId. - Inputs: Dynamic
accountId. - Returns / side effects: The application-detail screen inside fill AppShell.
- Used by: Route
/grants/applications/[accountId]. The moderate path redirects here.
Function: FundingApplicationDetailScreen
- Purpose: Client staff review of one grant application. Staff fetch
fetchFundingApplicationand answer two questions against living-room posts (no replies; post text usesTranslatableNoteBody, the applicant name stays plain; each post keeps its own time; there is no application time under the name). The first isfunding.review.question.staff, with an About link tohttps://21.gifts/about. Yes showsfunding.review.truth. Yes there postspostFundingAdmit. No on either question postspostFundingRejectwhile the grant ispendingortrial. A failed decision showstrustChain.actionFailed. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. While a POST is in flight, visible decide buttons are disabled and show the Loader2 spinner. - Inputs:
accountId; session and account fromuseAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/applications/:accountIdonly when the role is founder or moderator. Decide buttons only whengrant.statusispendingortrial. Successful Admit / Reject leaves the buttons disabled and navigates to/grants/applications. - Used by:
FundingApplicationDetailPage.
Function: GrantsPage
- Purpose: Next.js page for
/grants. FillAppShellwithProfileChromeLeft,SignedInChrome, andOnboardingGate screen="profile"aroundGrantsScreen. - Inputs: None.
- Returns / side effects: The grants screen inside fill AppShell.
- Used by: Route
/grants.
Function: GrantsScreen
- Purpose: Signed-in grants page. Renders
FundingStatusCard(verification / 21 gifts grant). When the account is non-null, a secondary large GoalsButtonLink(funding.goals.link) goes to/grants/goals, under the grant card and above the staff queue. A missing account shows no link. WhencanEditDailyPayoutRosteris true (initiator or founder only), two secondary largeButtonLinks, Daily payment text to/grants/payments/commentand Daily payment amounts to/grants/payments/amounts, do not fetch the roster. A moderator does not see those links. WhenroleAtLeast(role, 'moderator'), loads open applications. A count above zero is a secondary largeButtonLinkto/grants/applicationslabeled Open application (1) when the count is one and Open applications (2) otherwise (funding.applications.openCount). A count of zero is the plain sentence No open applications., not a link. Rendersnullwithout a session. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/applicationsonly whenroleAtLeast(role, 'moderator'), which includes an initiator and a founder. Roles below moderator do not fetch. Loading and a failed load (error sentence plus Try again) do not show the applications link. The Goals link does not fetch;/grants/goalsloadsGET /funding/goal. - Used by:
GrantsPage.
Function: GrantGoalsPage
- Purpose: Next.js page for
/grants/goals. FillAppShellwithProfileChromeLeft,SignedInChrome, andOnboardingGate screen="profile"aroundGrantGoalsScreen. - Inputs: None.
- Returns / side effects: The goals screen inside fill AppShell.
- Used by: Route
/grants/goals.
Function: GrantGoalsScreen
- Purpose: Signed-in grant goal. Heading Goals, the sentence that the program continues at 10 active shops, the sentence that a shop is active with at least one transaction on 5 of the last 7 days, and the sentence that defines a transaction as a till payment created at https://21.gifts/pos (creating it counts, including later expiry or cancel). When the load succeeds, shows how many shops meet that rule, the label Shops per UTC day,
ShopActivityChartfor those 7 days, and the note that the lighter bar is today still open. Loading and error (error sentence plus Try again) keep the heading and those sentences. Rendersnullwithout a session and does not fetch then. - Inputs: Session from
useAuthStore; catalog and locale viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/goalthroughfetchGrantContinuationonly when a session exists. Does not callfetchShopActivity. - Used by:
GrantGoalsPage.
Function: DailyPaymentCommentPage
- Purpose: Next.js page for
/grants/payments/comment. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundDailyPaymentCommentScreen. Amounts are a separate page. Roster HTTP lives under/funding/daily-rosterbecause Next.js forbids aroute.tsbeside this page. - Inputs: None.
- Returns / side effects: The daily-payment comment screen inside fill AppShell.
- Used by: Route
/grants/payments/comment.
Function: DailyPaymentCommentScreen
- Purpose: Client editor for the daily payout comment only. An initiator or founder fetches
fetchDailyRosterand may save the comment. The comment is text until an icon-only pencil (funding.daily.editComment) opens it. The check (funding.daily.save) stores it and the X (funding.daily.cancel) restores the stored comment. An empty comment showsfunding.daily.commentEmpty. Words Save, Cancel, and Edit are not visible labels. A save in flight replaces the check with a spinner. This screen does not show the default-amount sentence, the payments switch, recipients, or Add. Everyone else who is signed in sees the heading plusfunding.daily.forbiddenand does not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. A failed load showsfunding.daily.errorand Try again. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/daily-rosteronly whencanEditDailyPayoutRosteris true. The comment save goes toPOST /funding/daily-roster/comment. A save failure shows afunding.daily.*catalog sentence and leaves the field open. - Used by:
DailyPaymentCommentPage.
Function: DailyPaymentAmountsPage
- Purpose: Next.js page for
/grants/payments/amounts. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundDailyPaymentAmountsScreen. The comment is a separate page. Roster HTTP lives under/funding/daily-rosterbecause Next.js forbids aroute.tsbeside this page. - Inputs: None.
- Returns / side effects: The daily-payment amounts screen inside fill AppShell.
- Used by: Route
/grants/payments/amounts.
Function: DailyPaymentAmountsScreen
- Purpose: Client editor for daily payout amounts only. An initiator or founder fetches
fetchDailyRosterand may turn payments on or off and add, update, or delete a recipient. The loaded editor showsfunding.daily.defaultNotewith{amount}fromroster.defaultAmountUsdviaformatUsdDisplay. A row shows the display name, or Unnamed when the name is null or blank. When accountId is set the name is a link to/members/{accountId}. On a recipient row the formatted USD amount, the pencil (funding.daily.editplus that label), and the trash share one line. The pencil opens aField; the check saves and the X cancels. Amounts are the stored USD figure (Field, notAmountEntry). The total usesformatUsdDisplayand the visitor grouping style. The add field is Person. Typing@opens the first page of people from the same search as a forum mention. Further letters keep only usernames that start that way. Choosing one fills@username, keeps that person selected while the field equals that token, and leaves the list open. Add posts accountId. No address is rendered. The comment is not on this screen. Everyone else who is signed in sees the heading plusfunding.daily.forbiddenand does not fetch. Rendersnullwithout a session. The page chrome owns the back; this screen renders no back control. A failed load showsfunding.daily.errorand Try again. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations; grouping viauseNumberFormat. - Returns / side effects: React element or
nullwithout a session. FetchesGET /funding/daily-rosteronly whencanEditDailyPayoutRosteris true. Saves go to the payments, add, update, and delete daily-roster POSTs. A save failure shows afunding.daily.*catalog sentence. - Used by:
DailyPaymentAmountsPage.
Function: FundingStatusCard
- Purpose: Owner grant section on
/grants, not on the profile. The title is oneh121 gifts grant at the app page ramp, with no hairline above it.basissees not-verified copy and how in-person verification works (no apply), including when the username isjoey-rosima,vincent, orjewel-bacolbas. Verified and above see funding status fromaccount.funding(missing ornulltreated asnone): whilegrantApplicationsPausedis true,FundingPausedCopyfornone/rejected(no Apply, no About link) except usernamesjoey-rosima,vincent, andjewel-bacolbas, who still see the daily-gift sentence, About link, and Apply link to/grants/apply; when that switch is false, the daily-gift sentence, About link, and Apply link to/grants/apply. Then pending, one-day trial, or admitted with Takes part in the 21.gifts funding program (since {date} whenadmittedAtis a number). Pending, trial, and admitted stay as they are for every username. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session or account.none/rejecteddo not offer Apply while applications are paused, except a verified account namedjoey-rosima,vincent, orjewel-bacolbas. - Used by:
GrantsScreen.
Function: FundingApplyPage
- Purpose: Next.js page for
/grants/apply. FillAppShellwithProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="profile"aroundFundingApplyScreen./profile/applyredirects to this path. - Inputs: None.
- Returns / side effects:
FundingApplyScreeninside fill AppShell. While applications are paused, the username is notjoey-rosima,vincent, orjewel-bacolbas, and the status is not pending, trial, or admitted: the paused card. A verified account with one of those names and statusnoneorrejected: the apply walk. Pending, trial, and admitted: that copy for every verified username. A basis account named one of those three, and a basis account whose status is pending, trial, or admitted: You are not verified yet. Any other basis account sees the paused card. - Used by: Route
/grants/apply.
Function: FundingApplyScreen
- Purpose: Signed-in grant apply screen. While
grantApplicationsPausedis true,grantApplicationStillOpenis false, and funding status is not pending, trial, or admitted, heading 21 gifts grant andFundingPausedCopyonly: no About-me steps, no questions, no POST. A missing account still shows that paused card. Pending, trial, and admitted keep their copy for every verified username. Usernamesjoey-rosima,vincent, andjewel-bacolbaswith statusnoneorrejectedopen the existing apply card: a verified account fills About me, photo, and location, then two yes/no questions; a basis account sees You are not verified yet. and does not post. When that switch is false, every signed-in account opens that same card. The page chrome owns the back; this screen renders no back control. - Inputs: Session and account from
useAuthStore(the account is read before choosing the pause card or the walk). Uses translations. - Returns / side effects: The paused card while applications are paused, the username is not one of those three, and the status is not pending, trial, or admitted. Otherwise the apply card. On that card a verified account with status none or rejected on the roster sees the walk, and a verified account with status pending, trial, or admitted sees that status. A basis account on that card sees You are not verified yet., including when the status is pending, trial, or admitted. Without a session the screen is
nullwhen the walk is open. Yes on the truth question postspostFundingApplyonly when the walk is open and the account is verified. - Used by:
FundingApplyPage.
Function: grantApplicationsPaused
- Purpose: Switch for new grant applications. Returns true while they are paused. The apply walk and the apply API stay in the code. This switch stays true for everyone;
grantApplicationStillOpenis the username exception in the three screens. - Inputs: None.
- Returns / side effects:
true. - Used by:
FundingApplyScreen,FundingStatusCard,DailyPayoutStoppedNotice.
Function: grantApplicationStillOpen
- Purpose: Whether this signed-in username skips the pause card while applications are paused. Exact, case-sensitive match against
joey-rosima,vincent, andjewel-bacolbas. It does not by itself open the apply walk: a basis account on the open card still sees You are not verified yet. Null, omitted, empty, and every other username (includingVincent) do not skip the card. - Inputs:
usernamestring, null, or undefined. - Returns / side effects:
trueonly for those three strings. - Used by:
FundingApplyScreen,FundingStatusCard,DailyPayoutStoppedNotice.
Function: aboutMeFilled
- Purpose: True when About me is a real bio (not empty or the display-name auto note). Used by the apply walk. That walk is shown while applications are paused only for a verified account named
joey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected. - Inputs:
aboutMe,name. - Returns / side effects: boolean.
- Used by:
nextFillStep,FundingApplyScreen.
Function: locationFilled
- Purpose: True when location is a non-empty trimmed string. Used by the apply walk. That walk is shown while applications are paused only for a verified account named
joey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected. - Inputs:
location. - Returns / side effects: boolean.
- Used by:
nextFillStep,FundingApplyScreen.
Function: nextFillStep
- Purpose: First missing apply fill step: about, photo, then location, or
nullwhen all three are present. Used by the apply walk. That walk is shown while applications are paused only for a verified account namedjoey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected. - Inputs: Owner
Account. - Returns / side effects:
'about' | 'photo' | 'location' | null. - Used by:
FundingApplyScreen.
Function: ModeratorGroupPage
- Purpose: Next.js page for
/moderate/group(signed-in closed moderator group thread). HTML/moderate/groupis the group page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left (the only back control: the arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none; the wordmark is not that control),SignedInChrometop-right, andOnboardingGate screen="welcome"aroundModeratorGroupScreen. Group HTTP lives under/conversations/moderator-group(noroute.tsbeside this page). - Inputs: None.
- Returns / side effects: The moderator group screen inside fill AppShell.
- Used by: Route
/moderate/group.
Function: ModeratorGroupScreen
- Purpose: Client closed staff-room thread. Moderators (
roleAtLeast(role, 'moderator')) fetchfetchModeratorGroupthen the newest 20-message page withfetchConversation(session, group.id)and reuseInboxScreenas the open thread (showFilterfalse;showAmountfalse;showAttachtrue; no in-card back; rownamereplaced by the catalogmoderate.groupLabelso the heading is always Moderators chat group). AnIntersectionObserveronInboxScreen'snearStartRef(the eighth grouped bubble from the start, or the first when fewer than eight render) attaches only after the thread end is in view or the thread fits, uses the active page scrollport as its root when one is mounted, then fetches a setnextCursorand prepends unique older messages. The first screen is the newest message. The cursor load does not blank the thread; while stuck to the bottom, InboxScreen keeps the newest in view. JPEG/PNG/WebP stills (cap 10) viaprepareForumPhoto; photo-only send allowed. Send is disabled while a pick is still preparing (posting || preparing). Thread stills load withfetchConversationMessagePhoto. Losing staff while mounted bumpspickGeneration, clears drafts/preparing, and revokes blob URLs (same cleanup as unmount); replacing the thread also revokes URLs for stills that are no longer on the message list. Also callsuseLatestRateDay()and passesrateDaytoInboxScreen. After a successful group+thread load:markConversationRead,bumpUnreadAppBadgeEpoch,refreshUnreadAppBadge(session, undefined, 0)(staff-room unread 0; inbox still fetched). Other signed-in visitors see heading Moderators chat group plusmoderate.groupForbiddenand do not fetch. Rendersnullwithout a session. The top-left arrow returns to the previous in-app view in this tab, or/welcomewhen this tab has none. One arrow. The wordmark is not that control. There is no in-card back. While the staff room is open and the tab is visible, the newest page is fetched every 5 seconds and unseen messages are appended; a hidden tab does not poll, and a failed poll keeps the thread. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /conversations/moderator-groupthenGET /conversations/:id?limit=20(optionalcursorfor older pages) only when the role is at least moderator. After a successful group+thread load:markConversationRead,bumpUnreadAppBadgeEpoch,refreshUnreadAppBadge(session, undefined, 0)(staff-room unread 0; inbox still fetched). Posts{ text, photo?, photos? }and GETs conversation photos. Losing staff (or unmount) revokes blob URLs and invalidates an in-flight pick. - Used by:
ModeratorGroupPage.
Function: fetchModeratorGroup
- Purpose: GET
/conversations/moderator-groupwith Bearer and parse{ conversation }viaconversationResponseSchema. Returns the singleton closed staff-room row (kindmoderator_group). - Inputs: Session token.
- Returns / side effects: Conversation row, or throws visitor copy.
- Used by:
ModeratorGroupScreen,InboxLoader(/messagesunlisted?c=guard for a moderator),useUnreadCount(staff unread),refreshUnreadAppBadge(whenmoderationUnreadOverrideis omitted androleAtLeast(account?.role, 'moderator')), andNotificationsLoader(remaining badge after mark-all-read, staff only).
Function: listHiddenMessages
- Purpose: GET
/forum/messages/hiddenwith Bearer and parsehiddenListSchema.messages. HTTP 401/403 throwFailed to list hidden notes: status; other failures visitor copyCould not load hidden notes. Please try again. - Inputs: Session token.
- Returns / side effects: Hidden-note array, or throws.
- Used by:
HiddenNotesScreen.
Function: HandbookScreensPage
- Purpose: Next.js page for
/handbook/screens. Loads screen-variant topics and renders the compact-cardHandbookImageViewer. English card text comes fromscreens.md. German, Spanish, and Filipino come from the screen-card locale file for the request locale. - Inputs: Request locale from
getRequestLocale(cookielocale, otherwiseAccept-Language). - Returns / side effects: The screens handbook screen inside
MarketingLayout. - Used by: Route
/handbook/screens.
Function: HandbookFunctionsPage
- Purpose: Next.js page for
/handbook/functions. Functions markdown only (id prefixfunctions). - Inputs: None.
- Returns / side effects: The functions handbook screen.
- Used by: Route
/handbook/functions.
Function: HandbookEndpointsPage
- Purpose: Next.js page for
/handbook/endpoints. Markdown only; no image switches. - Inputs: None.
- Returns / side effects: The endpoints handbook screen.
- Used by: Route
/handbook/endpoints.
Function: HandbookImageViewer
- Purpose: Nested handbook screens for the selected combo only (
makeCombo(viewport, theme)): three-level contents (chapter → screen → variant) and compactHandbookFigurecards. Left/Right arrows (and lightbox chevrons) step through every visible variant in a shared lightbox. A topic missing that combo is omitted. Global Desktop/Mobile and Light/Dark switches use the union of remaining topics and appear only when both sides exist. - Inputs:
topics(HandbookTopic[]with requireddescription). - Returns / side effects: React element or
nullwhen no topic has combos. Empty visible list still shows switches when remaining is non-empty. No network. - Used by:
HandbookScreensPage.
Function: HandbookOutline
- Purpose: Sticky three-level table of contents: chapter (first path segment), screen route, variant id. Links to
#chapter-…,#screen-…, and the figure hash. - Inputs:
chapters(HandbookOutlineChapter[]),title(already translated Contents label). - Returns / side effects: Nav labeled Contents, or
nullwhen empty. No network. - Used by:
HandbookImageViewer.
Function: HandbookSectionHeading
- Purpose: Chapter (
h2) or screen (h3) permalink heading withHandbookCopyLink. - Inputs:
level(2 or 3),id,label. - Returns / side effects: Heading row. No network.
- Used by:
HandbookImageViewer.
Function: buildHandbookOutline
- Purpose: Group topics into chapter → screen → variant, preserving catalog order. Skips empty
combos. Chapter isscreenChapter(first path segment;/stays/). - Inputs:
topics(HandbookTopic[]). - Returns / side effects:
HandbookOutlineChapter[]. No network. - Used by:
HandbookImageViewer.
Function: nextOutlineIndex
- Purpose: Next slide index for Left/Right gallery stepping. Closed gallery (
current === null): Right → 0, Left → last. Wraps. Empty list stays 0. - Inputs:
length,current(number | null),direction(1or-1). - Returns / side effects: Index. No network.
- Used by:
HandbookImageViewer.
Function: topicPath
- Purpose: Route half of a catalog topic id. An empty slice (
:variant) is/. - Inputs: Catalog topic id.
- Returns / side effects: Path string (
/when the slice is empty). No network. - Used by:
buildHandbookOutline.
Function: topicVariant
- Purpose: Variant half of a catalog topic id.
- Inputs: Catalog topic id.
- Returns / side effects: Variant string, or empty. No network.
- Used by:
buildHandbookOutline.
Function: screenChapter
- Purpose: Chapter key for a screen path (
/setup/rules→/setup;/→/). - Inputs: Screen route.
- Returns / side effects: Chapter label. No network.
- Used by:
buildHandbookOutline.
Function: pathAnchor
- Purpose: Hyphenated hash fragment for a screen path (
/→root,/setup/rules→setup-rules). Dynamic segments like[accountId]becomeaccountId. - Inputs: Route string.
- Returns / side effects: Anchor string. No network.
- Used by:
topicAnchor,buildHandbookOutline.
Function: HandbookFigure
- Purpose: Compact handbook image card: permalink label +
HandbookCopyLink, ~220px preview button that opensHandbookLightboxunlessonOpenis set, and a written description. Scrolls into view whenlocation.hashmatches#id. - Inputs:
id,label,description,src,alt, optionalonOpen(when set, the preview delegates and skips the local lightbox). - Visible UI: Label link, copy-link icon, thumbnail image inside an aria-labeled open button (no visible open-image catalog string), description paragraph, optional lightbox.
- Returns / side effects: An
<article>withid. Hash scroll on mount/hashchange. No network. - Used by:
HandbookImageViewerandHandbookMarkdown(image-only paragraphs).
Function: HandbookLightbox
- Purpose: Full-size handbook image overlay on marketing tokens (
bg-ink,border-paper/10,bg-app-overlayscrim, ghostIconButton+X). Close via X, backdrop click, or Escape. Optional previous/next chevrons whenonPrevious/onNextare set:ChevronLeftshows the previous image (handbook.previousImage) andChevronRightthe next image. Those chevrons stay in the overlay and are not the page-back arrow. Focuses the close control on open. Not a native<dialog>. - Inputs:
open,src,alt,onClose, optionalonPrevious, optionalonNext. - Returns / side effects:
role="dialog"overlay whenopen, otherwisenull. Document keydown while open. No network. - Used by:
HandbookFigure,HandbookImageViewer.
Function: topicAnchor
- Purpose: Stable DOM/hash id from a catalog topic id (
${route}:${variant}). Path/→root; other paths drop the leading/and replace remaining/with-; variant is the segment after the last:. - Inputs: Catalog topic id string (
/:default,/welcome:pay-qr,/handbook/screens:dark, …). - Returns / side effects: Anchor string (
root-default,welcome-pay-qr,handbook-screens-dark, …). No network. - Used by:
buildHandbookOutline.
Function: parseScreenVariantDescriptions
- Purpose: Parse
docs/handbook/screens.mdinto a map of catalog topic id → English description. Under each## Screen:/### Variant:, collect paragraphs (excluding image-only lines), unwrap**bold**and `code`, join with a blank line; skip empty strings. - Inputs: Raw screens handbook markdown string.
- Returns / side effects:
ReadonlyMap<string, string>keyed as<path>:<variantId>. No network. - Used by:
HandbookScreensPage(loadScreenTopics) and the screen-description tests.
Function: screenVariantDescription
- Purpose: Screen-card description for one catalog id in the request locale. English is the paragraph from
docs/handbook/screens.md. German, Spanish, and Filipino come from the screen-card locale file. A missing or blank English paragraph shows the catalog label in every locale. - Inputs: Locale (
en,de,es, orfil), catalog id (<path>:<variant>), the English paragraph when present, and the catalog label. - Returns / side effects: Locale text, unchanged English, or the catalog label. Throws when
de,es, orfilhas no non-blank text for that id. No network. No English fallback. - Used by:
HandbookScreensPage(loadScreenTopics).
Function: topicImageSrc
- Purpose: Public URL for one topic combo PNG under
/handbook-images/. - Inputs: Topic and combo id.
- Returns / side effects: Path string. No network.
- Used by:
HandbookImageViewer.
Function: comboViewport
- Purpose: Viewport half of a combo id.
- Inputs: Combo id.
- Returns / side effects:
'desktop'or'mobile'. - Used by:
HandbookImageViewer.
Function: comboTheme
- Purpose: Theme half of a combo id.
- Inputs: Combo id.
- Returns / side effects:
'light'or'dark'. - Used by:
HandbookImageViewer.
Function: makeCombo
- Purpose: Build a combo id from viewport and theme.
- Inputs: Viewport and theme.
- Returns / side effects: Combo id.
- Used by:
HandbookImageViewer.
Function: defaultCombo
- Purpose: First combo to show (
desktop-lightwhen present, else the first listed, elsenull). - Inputs: Combo id list.
- Returns / side effects: Combo id or
null. - Used by:
HandbookImageViewer.
Function: DeletePostControl
- Purpose: Inline moderator post or nested-reply deletion with confirmation, pending and error states.
- Inputs: messageId, onDeleted, optional kind (
'post'default,'reply'for nested replies); reads the current account and Bearer session. - Returns / side effects: Hidden for other roles; idle trash sits in the note footer icon row (parent) or the nested reply action row (
kind="reply"); confirming wraps to the next line viabasis-full w-fulland rendersInlineConfirm. Idle/confirm/error copy isforum.delete*for posts andforum.deleteReply*for replies. Calls deleteMessage on explicit confirmation, then onDeleted. Error keeps the post or reply and permits retry. - Used by:
ForumBoardon the parent footer and on nested reply cards.
Function: InlineConfirm
- Purpose: Bordered confirm or cancel group shared by post delete, habit archive, and habit comment delete. Callers keep their own idle button. This is only the open question.
- Inputs:
label(the question and the group's accessible name),confirmLabel,cancelLabel,onConfirm,onCancel, optionalbusy, and optionalerror. - Returns / side effects: A
role="group"with the question, an optional alert, a check (a spinner whilebusy), and an X. Both actions are disabled whilebusy. It sends no request of its own. - Used by:
DeletePostControl,MemberHabits, andHabitComments.
Function: deleteMessage
- Purpose: Send a moderator hide request for a forum message (top-level note or nested reply).
- Inputs: sessionToken and messageId.
- Returns / side effects: DELETE
/forum/messages/:id; resolves on 204 or already-missing 404, throws on other statuses or network errors. Hide/omit semantics: the API keeps the row withdeleted_atand omits it from GET. - Used by:
DeletePostControl.
Function: setMessagePlace
- Purpose: Set or clear the place pin on a forum message for a moderator session.
- Inputs: sessionToken, messageId, and
place(ForumPlacePin, ornullto clear). - Returns / side effects: PATCH
/forum/messages/:id/placewith JSON{ place }. Resolves to the public message JSON (placeomitted when cleared). ThrowsCould not save placeon a non-2xx status. - Used by:
ShopPlaceControl.
Function: setMessageShopPhotos
- Purpose: Replace the stills on a shop note for a moderator session. An empty list clears stills. A video on the note stays.
- Inputs: sessionToken, messageId, and
photos({ contentType, data, takenAt? }[], at most 10). - Returns / side effects: PATCH
/forum/messages/:id/photoswith JSON{ photos }. A blanktakenAtis omitted. Resolves to the public message JSON. ThrowsCould not save shop noteon a non-2xx status. - Used by:
ShopNoteEditControl.
Function: setMessageShopText
- Purpose: Replace the text of a shop note for a moderator session. The API keeps the shop tag.
- Inputs: sessionToken, messageId, and
text(the visible draft; the shop tag may be omitted). - Returns / side effects: PATCH
/forum/messages/:id/textwith JSON{ text }. Resolves to the public message JSON. ThrowsCould not save shop noteon a non-2xx status. - Used by:
ShopNoteEditControl.
Function: fetchShopNoteEdits
- Purpose: Load the staff edit history of one shop note, newest first.
- Inputs: sessionToken and messageId.
- Returns / side effects: GET
/forum/messages/:id/edits. Resolves to theeditsarray (text, place, or shop account, with actor id, name, and role). ThrowsCould not load edit historyon a non-2xx status. - Used by:
ShopNoteEditControlwhen the pencil panel opens.
Function: setMessageShopAccount
- Purpose: Set or clear the shop account on a forum message for a moderator session.
- Inputs: sessionToken, messageId, and
username(string, ornullto clear). - Returns / side effects: PATCH
/forum/messages/:id/shop-accountwith JSON{ username }. Resolves to the public message JSON (shopAccountomitted when cleared). ThrowsNo account with that usernameon 404, otherwiseCould not save account. - Used by:
ShopAccountControl.
Function: proxyForumMessageGet
- Purpose: Forward an authenticated GET
/messages/:idfor the app path/forum/messages/:id. Staff sessions receive soft-hidden rows. - Inputs: Incoming Request (Bearer) and
messageId. - Returns / side effects: Proxied GET
/messages/:idwith encoded id and authorization. - Used by: App Router
GETon/forum/messages/[id].
Function: proxyMessagesDelete
- Purpose: Forward a moderation DELETE to the API.
- Inputs: Incoming Request and messageId.
- Returns / side effects: Proxied DELETE
/messages/:id, with encoded id, authorization and upstream status. Upstream 204 hides the row (deleted_at); the row stays and is omitted from GET. - Used by: App Router
DELETEon/forum/messages/[id].
Function: proxyMessagesPlacePatch
- Purpose: Forward a moderator's PATCH of a message place pin to the API.
- Inputs: Incoming Request (Bearer JSON
{ place }) and messageId. - Returns / side effects: Proxied PATCH
/messages/:id/place, with encoded id, authorization and upstream status. 200 is the public message JSON; a cleared pin omitsplace. - Used by: App Router
PATCHon/forum/messages/[id]/place.
Function: proxyMessagesPhotosPatch
- Purpose: Forward a moderator's PATCH of shop-note stills to the API.
- Inputs: Incoming Request (Bearer JSON
{ photos }) and messageId. - Returns / side effects: Proxied PATCH
/messages/:id/photos, with encoded id, authorization and upstream status. 200 is the public message JSON. - Used by: App Router
PATCHon/forum/messages/[id]/photos.
Function: proxyMessagesTextPatch
- Purpose: Forward a moderator's PATCH of a shop-note body to the API.
- Inputs: Incoming Request (Bearer JSON
{ text }) and messageId. - Returns / side effects: Proxied PATCH
/messages/:id/text, with encoded id, authorization and upstream status. 200 is the public message JSON. The shop tag stays on the stored text. - Used by: App Router
PATCHon/forum/messages/[id]/text.
Function: proxyMessagesEditsGet
- Purpose: Forward a moderator's GET of shop-note edit history to the API.
- Inputs: Incoming Request (Bearer) and messageId.
- Returns / side effects: Proxied GET
/messages/:id/edits, with encoded id, authorization and upstream status. 200 is{ edits }newest first. - Used by: App Router
GETon/forum/messages/[id]/edits.
Function: proxyMessagesShopAccountPatch
- Purpose: Forward a moderator's PATCH of a message shop account to the API.
- Inputs: Incoming Request (Bearer JSON
{ username }) and messageId. - Returns / side effects: Proxied PATCH
/messages/:id/shop-account, with encoded id, authorization and upstream status. 200 is the public message JSON; a cleared account omitsshopAccount. - Used by: App Router
PATCHon/forum/messages/[id]/shop-account.
Function: detectNoteLanguage
- Purpose: Detect the language of a forum note after stripping URLs and bolt11 invoices. Scores
en/de/es/filstopwords (with extra weight for German umlauts and Spanishñ¿¡). - Inputs: Raw note
textstring. - Returns / side effects:
en/de/es/filwhen one UI locale wins,otherwhen the text is long enough but not those four, ornullwhen empty or fewer than 12 Unicode letters or digits after stripping URLs and invoices. No I/O. - Used by:
shouldOfferNoteTranslate.
Function: shouldOfferNoteTranslate
- Purpose: Decide whether to offer Translate for this note in the active UI locale.
- Inputs: Raw note
textand the active UIlocale. - Returns / side effects:
falsewhen detection isnullor equalslocale;trueforotheror a different UI locale. No I/O. - Used by:
NoteTranslate.
Function: fetchTranslateAvailable
- Purpose: Query same-origin GET
/translateand cache the shared promise. Failures and non-{ available: true }bodies resolve tofalse. - Inputs: None.
- Returns / side effects:
Promise<boolean>. One in-flight GET is reused for the module lifetime. Does not throw. - Used by:
NoteTranslateon mount.
Function: translateNote
- Purpose: POST
{ messageId, target }to same-origin/translate(api cache-first DeepL) and return the translated body. - Inputs:
translateNote(messageId, target, session?)— forum message UUID, the active UItargetlocale, and optional session Bearer. A non-empty string is sent asAuthorization: Bearer …(hidden staff permalink). Omitted, null, or empty leaves the request unsigned so public notes still work. - Returns / side effects: The
translatedTextstring. Throws when the route is non-2xx ortranslatedTextis missing, not a string, or empty after trim. - Used by:
NoteTranslateon Translate.
Function: proxyTranslateAvailableGet
- Purpose: Same-origin GET
/translate→ api GET/translate. - Inputs: Incoming
Request. - Returns / side effects: Upstream
{ available }, or 502. - Used by: App Router GET
/translate.
Function: proxyTranslateNotePost
- Purpose: Same-origin POST
/translate{ messageId, target }→ apiPOST /messages/:id/translate. - Inputs: Incoming
Requestwith JSON{ messageId, target }. - Returns / side effects:
{ translatedText, cached }or 400/404/503/502 from the 21.gifts api. - Used by: App Router POST
/translate.
Function: proxyTranslateConversationMessagePost
- Purpose: Same-origin POST
{ target }to apiPOST /conversations/:id/messages/:messageId/translate. - Inputs: Incoming
Request,conversationId, andmessageIdfrom the route segments. - Returns / side effects:
{ translatedText, cached }or 400 when the body is not JSON ortargetis not a string. Otherwise the upstream response. - Used by: App Router POST
/conversations/[id]/messages/[messageId]/translate.
Function: translateConversationMessage
- Purpose: POST
{ target }to the same-origin conversation translate route and return the stored-message translation. - Inputs:
conversationId,messageId, UItargetlocale, and the bearersession. An empty session omits Authorization. - Returns / side effects:
{ translatedText, cached }. Throws when the route is non-2xx ortranslatedTextis missing, not a string, or blank. - Used by:
NoteTranslatewhensource.kindisconversation.
Function: NoteTranslate
- Purpose: Control-only client: Translate, Show original, and Show translation are the same Languages
IconButton(aria-labelforum.translate,forum.translateShowOriginal, orforum.translateShowTranslation; no visible label). The error and the spinner stay. Does not renderForumNoteTextor the translated body — exclusive original XOR translation lives inTranslatableNoteBody. Offers the control when the note language differs from the active UI locale and GET/translatereports available. Identity issource + messageId + text + locale(nottext + localealone). Translate sits in the card footer icon row with react / pay / copy whenplacementisrow(ForumBoard supplies acontrolSlotIdtoTranslatableNoteBody, which portals this control there); otherwise under the body (unsigned cards, inbox, About me, funding, hidden notes). Failure shows Could not translate this note. Please try again. and keeps Translate. - Inputs: Required
messageId(stored forum or conversation message id) andtext(raw prose body). Optionalsource(default{ kind: 'message' }, or{ kind: 'conversation', conversationId }). Optionaltone(default|onButton, forbg-app-btnbubbles). Optionalplacement(blockunder the body, default;rowusesdisplay: contentsso the icon sits in a parent footer flex row). Parent-ownedshowingTranslation(true while the translated body is on screen).onTranslatedafter a successful POST. OptionalonTranslateRequest, called synchronously when Translate is clicked, before the request, and not from Show original / Show translation.onToggleShowingfor Show original / Show translation. Noplain,bodyClassName, oronShowingTranslation. - Returns / side effects: The control, or
nullwhen the text is blank, translation is unavailable, orshouldOfferNoteTranslateis false. CallsfetchTranslateAvailableon mount and, on click,translateNotefor a forum message ortranslateConversationMessagefor a conversation message. A change ofsource,messageId,text, or UI locale resets status and invalidates in-flight requests (identity = source + messageId + text + locale). Stops click/keydown so forum expand does not fire.onButtonpaints the control withtext-app-btn-fg. Does not mount the note body. - Used by:
TranslatableNoteBody.
Function: TranslatableNoteBody
- Purpose: Exclusive original XOR translated body in the same React commit (not a parent
useEffectafter paint). Clicking Translate marks the note read-in-full so Show more is gone immediately (while loading, on error, and after Show original). A visible translation is the full text. Idle and error keep the original. Show original restores the original and hides the translation. Identity issource + messageId + text + locale. HoldstranslatedTextandshowingTranslation; a successful POST sets both so the original unmounts in that commit. - Inputs: Required
messageId(stored forum or conversation message id) andtext. Optionalsource(default forum message, or{ kind: 'conversation', conversationId }). Optionalplain. Optionaltruncate(default true; applies only to the original body —ForumNoteTextvsLinkedText). A visible translation is always the full text viaLinkedText(no Show more), including past 280 characters. Clicking Translate sets an internal read-full flag so a truncated original expands on that click and stays expanded when Show original restores it. A new identity clears that flag. OptionalclassName(defaultwhitespace-pre-wrap text-sm text-app-fg;text-app-btn-fgselects NoteTranslatetone="onButton"). OptionalformatTranslated(applied only to the visible translation, not the original; not part of the identity key). OptionalcontrolSlotId(when set, portalsNoteTranslatewithplacement="row"into that element; if the node is missing, the control is omitted rather than stacked). - Returns / side effects: Original or translated paragraph plus the translate control, or
nullwhentextis empty. MountsNoteTranslateunder the body, or portals it intocontrolSlotIdwhen that node exists. Visible body is original XOR translation in the same commit. Idle/error keep original; Show original restores original and hides translation. - Used by:
ForumBoard(notes and replies, includingvia === 'nostr'),PublicMessageLoader,ForumQuotedBody,QuotedForumNote(any caption whentranslateis true;plainonly whenvia === 'nostr'),AboutMeSection,HiddenNotesScreen,FundingApplicationDetailScreen, andFundingApplyScreen(apply walk, shown while applications are paused only for a verified account namedjoey-rosima,vincent, orjewel-bacolbaswith statusnoneorrejected, and, when the switch is off, for a verified account with statusnoneorrejected).
Function: HappylandSection
- Purpose: Presents Father Severin's account of Happyland after How it works on the public homepage.
- Input: Receives the marketing page locale and reads all paragraphs, headings and image descriptions from the shared English, German, Spanish or Filipino catalog.
- Output: Renders an accessible section with eight full-proportion photographs in a lead image, alternating text and image groups, and a portrait row that stacks on small screens.
Function: HappylandPhoto
- Purpose: Keeps each Happyland photograph and its localized caption together in a semantic figure.
- Input: Receives approved image metadata, the active message catalog and optional layout classes.
- Output: Renders a directly served WebP with intrinsic dimensions, descriptive alternative text and a visible caption. Eager loading keeps the photographs visible in the embedded local preview.
Function: fiatDraftForSats
- Purpose: Fiat typing draft for an exact sat amount. Two decimals when they round-trip through
fiatToSats, otherwise more fraction digits so toggling back returns the same sats. Null when no digit count does. - Inputs: Whole
sats, giftdayor null, fiatcode. - Returns / side effects: A plain dot-decimal string, or null when the day or that currency cannot be used. No I/O.
- Used by:
AmountEntry,ForumLoader.
Function: fiatToSats
- Purpose: Inverse of
satsToFiatAmounton the same gift-day totals. A positive amount that rounds to 0 becomes 1 sat. - Inputs: Fiat
amount, giftdayor null, fiatcode. - Returns / side effects: Whole sats, or null when the day or that currency is missing or zero. No I/O.
- Used by:
parseAmountDraft.
Function: parseAmountDraft
- Purpose: Reads a bitcoin or fiat typing draft into whole sats. Blank is empty. Fiat allows a dot or comma and at most eight fraction digits, so a unit toggle can round-trip. A positive fiat amount whose gift-day total for that currency is missing, not finite, or zero is
no-rate. A well-formed amount on a usable total that does not become a safe sat count isinvalid, notno-rate. - Inputs:
unit(btcorfiat), rawdraft, giftdayor null, fiatcode. - Returns / side effects:
{ kind: 'empty' },{ kind: 'invalid' },{ kind: 'no-rate' }, or{ kind: 'sats', sats }. No I/O. - Used by:
AmountEntry,replySatsFromDraft,paySatsFromDraft,parseForumAskAmountInUnit,PayLinkScreen,PosAmount.
Function: replySatsFromDraft
- Purpose: Reply and inbox amount. Blank stays empty. Zero is billed as 1 sat.
- Inputs: Raw
draft, typingunit, giftdayor null, fiatcode. - Returns / side effects: Whole sats,
empty, orinvalid. No I/O. - Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread,InboxLoader.
Function: paySatsFromDraft
- Purpose: Pay-sheet amount. A blank field is 21 sats in either typing unit. Zero or a bad draft is invalid.
- Inputs: Raw
draft, typingunit, giftdayor null, fiatcode. - Returns / side effects: Whole sats, or
invalid. No I/O. - Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread.
Function: parseForumAskAmountInUnit
- Purpose: Ask amount in the active typing unit. Bitcoin uses
parseForumAskAmount. Fiat converts, then the same 1..10_000_000 range. - Inputs: Raw draft,
unit, gift day or null, fiat code. - Returns / side effects: Whole sats in range, or null. No I/O.
- Used by:
ForumAskWizard,ForumLoader.
Function: setAmountUnit
- Purpose: POST
/me/amount-unitwith JSON{ unit }(btcorfiat) and the bearer session. - Inputs: Session token and
unit. - Returns / side effects: Parsed owner
Account. Throws when the response is not ok or failsaccountSchema. - Used by:
AmountEntry.
Function: setAccountLocale
- Purpose: POSTs an account language preference to same-origin
/me/locale. - Inputs: Bearer session token, locale code, and
onlyIfUnsetboolean. - Returns / side effects: Sends JSON
{ locale, onlyIfUnset }, returns the parsed ownerAccount, and throws on an invalid or failed response. - Used by: Language controls and
AccountPreferenceSync.
Function: setAccountFiat
- Purpose: POSTs an account fiat preference to same-origin
/me/fiat. - Inputs: Bearer session token,
CHF|EUR|USD|PHP, andonlyIfUnsetboolean. - Returns / side effects: Sends JSON
{ fiat, onlyIfUnset }, returns the parsed ownerAccount, and throws on an invalid or failed response. - Used by:
FiatPreferenceSwitcherandAccountPreferenceSync.
Function: proxyMeAmountUnitPost
- Purpose: Same-origin Bearer proxy of api POST
/me/amount-unitwith JSON{ unit }. - Inputs: Incoming
Requestwith Bearer session and JSON{ unit }. - Returns / side effects: Upstream
ResponseviaproxyApiRequest. - Used by: Route POST
/me/amount-unit.
Function: proxyMeLocalePost
- Purpose: Same-origin Bearer proxy of api POST
/me/locale. - Inputs: Incoming
Requestwith JSON{ locale, onlyIfUnset }. - Returns / side effects: Returns the owner-account upstream response;
onlyIfUnset=truepreserves a stored locale. - Used by: Route POST
/me/locale.
Function: proxyMeFiatPost
- Purpose: Same-origin Bearer proxy of api POST
/me/fiat. - Inputs: Incoming
Requestwith JSON{ fiat, onlyIfUnset }. - Returns / side effects: Returns the owner-account upstream response;
onlyIfUnset=truepreserves a stored fiat value. - Used by: Route POST
/me/fiat.
Function: HabitTrackerPage
- Purpose: Next.js server page for
/habit-tracker(public habit tracker). It does not read the auth store. Top-right isHabitTrackerTopRight.OnboardingGate screen="welcome"allowGuest. HTML/habit-trackeris the page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left aroundMemberHabits. - Inputs: None.
- Returns / side effects: The habit-tracker screen inside fill AppShell.
- Used by: Route
/habit-tracker.
Function: HabitTrackerTopRight
- Purpose: Client boundary for the
/habit-trackerheader. Log in without a session andSignedInChromewith one. The page stays a server component. - Inputs: None. Reads the auth store session.
- Returns / side effects: The top-right control. No request.
- Used by:
HabitTrackerPage.
Function: MemberHabits
- Purpose: Loads
GET /habitsand writesPOST /habits. Renders every member's public habits, periods, and comments on the same note card as a living-room note. Internal notes render only for the owner. The owner rates every returned period of an open habit on a neutralSegmentedControland posts that period's key. An archived habit keeps its rows, comments, and gift, and has no rating pill, pencil, or archive control. The owner opens edit with the pencil and archives from the card while the habit is open. Any signed-in account can comment. Comments render inHabitComments. Comment and delete useSundayWritingGateand sendTime-Zone. Add sendsTime-Zoneand does not use that gate. Edit, log, and archive do not. A gift on someone else's comment uses the same Gift control,ForumPaySheet, amount rules, and Sunday zap pause as a forum reply, and sendsTime-Zone. Continue stays disabled untiluseLatestRateDayStatehas settled. The sheet does not show the raw invoice. A failed reload keeps a list that was already shown and puts the error above it. The open edit stays; a rating or a comment does not discard it. A successful edit or archive closes that habit's edit and does not close a different habit's open draft. The same action is not posted again while that reload is still failing. When a later reload settles, including one that Try again started, a rating, edit, archive, comment, deletion, or the same add can be sent again. An add is not posted again while its request or the reload that confirms it is still unresolved. Try again also closes an edit or archive whose save had already reached the server when that Try again load started. A rating or a comment does not. The archive confirmation stays open until a reload releases an archive that had already reached the server when that reload started. It stays open when that post fails or that reload fails. A reload releases only actions that had already reached the server when that reload started. A second submit does not send that same body while the first request is still waiting, including after another session's list has settled. When that request finishes after the session changed, it does not reload the list and it does not mark the body as posted for the new session. When that request fails after the session changed, or the reload it started fails after the session changed, the new session does not show that error. A request is ignored when the visit changed, including when the same account comes back: it does not reload the list, it does not mark the body as posted, and it does not show an error. A gift request that finishes after the session changed does not show its invoice or its error, and the sheet closes. A session change closes an open edit, an archive confirmation, and a comment-deletion confirmation, clears the new-habit draft and unsent comments before paint, and does not show the previous account's draft or private notes. The same gift body is not posted again while that request is still waiting. A failed request can be sent again. Try again reloads that list and does not send the action again. A different session may send it. A list load already started by a session change or Try again is not replaced by that save. A reload that a save started is not replaced by another save. The full-screen error is only when nothing has loaded. Does not log invoices, addresses, notes, or comment text. - Inputs: None. Reads the auth store session and account.
- Returns / side effects: The habit-tracker body. Posts add, edit, archive, log, comment, deleteComment, and invoice actions.
- Used by:
HabitTrackerPage.
Function: HabitComments
- Purpose: Public comments on one habit. An empty list says there are no comments yet. A signed-out visitor uses the header Log in and gets no second sign-in link. A signed-in account gets Write a comment and Post inside
SundayWritingGate. Delete is the same inline confirm as a forum note and only when the account is at least initiator, also inside that gate, and sendsTime-Zone. Send Bitcoin on someone else's comment usesForumPaySheetinside the zap gate and sendsTime-Zone. Archived habits keep this block. - Inputs: The habit, the viewer account and session, the comment draft, and the pay-sheet state owned by
MemberHabits. - Returns / side effects: The comment block. Posts nothing itself;
MemberHabitsposts comment, deleteComment, and invoice. - Used by:
MemberHabits.
Function: fetchMemberHabits
- Purpose: Load the public habit tracker from same-origin
GET /habits. - Inputs:
sessionToken— bearer token, ornullwhen signed out. A blank string sends noAuthorizationheader. - Returns / side effects: The parsed habit list. Throws
'Could not load the habit tracker. Please try again.'when the response is not OK or the body fails the schema. - Used by:
MemberHabits.
Function: postMemberHabit
- Purpose: Post a habit-tracker action to same-origin
POST /habits. - Inputs:
sessionToken— bearer token;body— JSON action;timeZone— when true, also sends the deviceTime-Zoneheader. - Returns / side effects: The JSON body when the response is OK. Throws the api
errorstring when that field is a string, otherwise'Could not save the habit tracker. Please try again.'. - Used by:
MemberHabits.