Functions
Each exported function.
Function: GET
- Purpose: Shared export name for App Router GET handlers. Healthz uses
export function GET; same-origin api proxies re-export unique functions asGET(including/forum/messages,/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],/view-key/[viewKey],/push/vapid-public,/trust/graph,/trust/proposalswhich re-exportsproxyTrustProposalsGet,/funding/applicationswhich re-exportsproxyFundingApplicationsGet, and/funding/applications/[accountId]which callsproxyFundingApplicationGet)./translatere-exportsproxyTranslateGet(availability only; no upstream call). 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 queue, not a GET proxy. HTML/moderate/applicationsis the grant-application queue, not a GET proxy. 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. - 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/translate. - Returns / side effects:
Response. Healthz is{ status: 'ok' }200;/translateis always 200{ available: boolean }; 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. - Inputs: session token,
{ text, video, poster? }. - Returns / side effects:
ForumMessage. - 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 viauseTranslations. Noembeddedprop. - Returns / side effects: Combobox + absolute popover listbox. Endonym option labels (English/Deutsch/Español/Filipino). On a new locale writes
locale=<code>; Path=/; Max-Age=31536000; SameSite=Laxand; Secureon HTTPS, thenrouter.refresh(). Same-locale click is a no-op (no cookie write, no refresh). 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
useTranslations. Catalog keyslanguage.label,aria.language. Option labels are native endonyms (not catalogized). - Returns / side effects: Settings row matching
PushTogglechrome. On a new locale writeslocale=<code>; Path=/; Max-Age=31536000; SameSite=Laxand; Secureon HTTPS, thenrouter.refresh(). Same-locale click is a no-op (no cookie write, no refresh). - 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. 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.
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
useFiatPreferenceanduseTranslations. - Returns / side effects: Settings section.
onChangepersists via the cookie. - Used by:
ProfileScreen.
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), preparing, error, or an in-app browser escape card viaInAppBrowserView(Open in browser + Copy link, no passkey ceremony). After success,OnboardingGateleaves/login. A new account is created only after Open a new account and a completed create ceremony. 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. - Inputs: Uses
usePasskeyLogin,useAuthStore,isInAppBrowser, andInAppBrowserView. - Returns / side effects: React element covering idle/choice/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:
AppShellwithHomeWordmarktop-left (/unsigned,/welcomewhen a session is hydrated) andLanguageSwitchertop-right, wrappingOnboardingGatearoundLoginCard. Signed-in visitors are sent to/setup/name,/setup/address,/setup/rules, or/welcome. - 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:
AppShellwithHomeWordmarktop-left (/unsigned,/welcomewhen a session is hydrated) andLanguageSwitchertop-right; heading, lead, Open the forumButtonLink. 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:
AppShellwithWordmarktop-left,SignedInChrometop-right, andOnboardingGatearoundAddressSetup. - 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 top-left plus icon-only chapter back after the first chapter. 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}). Wordmark and chapter-back stay inRulesSetupviaAppShellTopLeftbecause back is chapter state, not a page-level back. - 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: First post-login screen: display name form.
- 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:
AppShellwithWordmarktop-left,SignedInChrometop-right, andOnboardingGatearoundUsernameSetup. - Used by: Route
/setup/username.
Function: NameSetupPage
- Purpose: Next.js page for
/setup/name. - Inputs: None.
- Returns / side effects:
AppShellwithWordmarktop-left,SignedInChrometop-right, andOnboardingGatearoundNameSetup. - 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
/profileand/members/[accountId]). - Inputs:
screen(login/name/username/address/rules/welcome/profile) andchildren. Members usescreen="profile". - Returns / side effects: Children on the correct screen, otherwise a spinner.
router.replaceto/login,/setup/name,/setup/username,/setup/address,/setup/rules, or/welcome(nextOnboardingPathnever returns/profile). Profile and members still requirenext === '/welcome'. - Used by: Screens
/login,/setup/name,/setup/username,/setup/address,/setup/rules,/welcome,/profile,/members/[accountId],/contact,/shops,/messages,/notifications,/moderate,/moderate/hidden,/moderate/proposals,/moderate/applications,/moderate/applications/[accountId],/trust-chain.
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); User Profile with same-line given/receivedArrowUpRight/ArrowDownLeftamounts only when that side is non-zero; ScrollText Living room rules/rules; Trust Chain; Moderation (/moderate, lucideShield,nav.moderate) only whenroleAtLeast(account?.role, 'moderator')—aria-labelnav.moderateUnreadwith{ count }whenmoderationUnreadCount> 0 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). ComposesuseAccountTotals,useUnreadCount(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 is an in-treeabsolutesibling of the Menu button (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), Profile link (/profile) with same-line given/received amounts only when that side is non-zero (aria-label/titlefromprofile.given/profile.received; both-zero omits the totals cluster; loading stillforum.loading), Living room rules (/rules), Trust Chain (/trust-chain), Moderation (/moderate, lucideShield,nav.moderate) only whenroleAtLeast(account?.role, 'moderator')—aria-labelnav.moderateUnreadwith{ count }whenmoderationUnreadCount> 0 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,ProfilePage,MemberProfilePage,ContactPage,MessagesPage,NotificationsPage,ModeratePage,HiddenNotesPage,ProposalsPage,FundingApplicationsPage,FundingApplicationDetailPage,TrustChainPage,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 signed-in top-left chrome: icon-only back (44px link, ArrowLeft) plus
Wordmarkto/welcome. OptionalbackHref(default/welcome) andbackLabelKey(profile.back|inbox.back|moderate.heading, defaultprofile.back). - Inputs: Optional
backHrefandbackLabelKey; catalog viauseTranslations. - Returns / side effects: A link (
aria-labelfrombackLabelKey) and a wordmark link to/welcome. No network. - Used by:
ProfilePage,MemberProfilePage(/members/[accountId]),ContactPage,ShopsPage,MessagesPage(viaMessagesChromeLeft),MessagesChromeLeft,NotificationsPage,ModeratePage,HiddenNotesPage,ProposalsPage,FundingApplicationsPage,FundingApplicationDetailPage,ModeratorGroupPage(backHref="/moderate",moderate.heading),TrustChainPage,RulesPageChrome,PublicMessageChrome.
Function: MessagesChromeLeft
- Purpose: Client
/messageschrome that reads?c=and rendersProfileChromeLeft. List (missing or emptyc): default forum back. Non-emptyc:backHref="/messages"andbackLabelKey="inbox.back". - Inputs:
useSearchParams. - Returns / side effects:
ProfileChromeLeft. No network. - Used by:
MessagesPage.
Function: ProfileScreen
- Purpose: Signed-in profile: single
max-w-smidentity card with a compact Given/Received activity chart, About me (AboutMeSectionowner: empty prompt + Write your About me, or filled text and/or photo + edit; copy-profile-link on the card — never a forum post), name, location, and Wallet of Satoshi address forms, thenFundingStatusCard(verification / 21 gifts grant), 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. Menu icon+amount totals stay inSignedInChrome. Back + wordmark live inProfileChromeLeft. - Inputs:
useAccountTotalsfor bothreceiveOverTimeanddonateOverTime; pass both toAccountActivityChart;AboutMeSection(putAboutMetext plus optional photo,fetchAboutMePhotowhenaboutMeHasPhoto,name={account.name});NameForm,LocationForm, andLightningAddressFormfor edits;FundingStatusCard;PushToggle;LanguagePreferenceSwitcher;ThemeSwitcher;FiatPreferenceSwitcher;NumberFormatSwitcher; catalog viauseTranslations. - Returns / side effects: Heading Profile, compact chart (empty:
profile.chartEmptywith no chart FiatPicker, no SVG / no ₿|fiat scale; populated: legend + ₿ | selected fiat + SVG). The only FiatPicker on the card isFiatPreferenceSwitcher. About me, name form, location form, address form, thenFundingStatusCard, thenPushToggle(Notifications pills) under the address form, 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). Back + wordmark live inProfileChromeLeft. - Used by:
ProfilePage.
Function: AboutMeSection
- Purpose: Profile-card About me block: heading plus filled text and/or photo, 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, attach a JPEG/PNG/WebP with ImagePlus (prepareForumPhoto, no video), preview, and remove. 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. - 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, optionalonSave(text, photo?)(photoomitted keeps,nullclears, object sets). - Returns / side effects: React element or
null. Clipboard write for copy. CallsonSaveon owner save. Loads a blob URL whenhasPhotoandloadPhotoare set; revokes it on unmount. - Used by:
ProfileScreen(owner,name={account.name}),MemberProfileScreen(public,name={profile.name}),ViewProfileScreen(public,name={profile.name}).
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, and staff-room counts in parallel (
GET /forum/notifications+GET /conversations, plusGET /conversations/moderator-groupwhenroleAtLeast(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 three counts but does not callsetUnreadAppBadge(including the logout / session-null0write). - Returns / side effects:
{ unreadCount, inboxUnreadCount, moderationUnreadCount }.unreadCountis the notifications unread count.inboxUnreadCountis the number of conversation rows withunread: true.moderationUnreadCountis0or1from the staff room (unread: true→1). When badge writes are enabled (the default), the home-screen badge is notification unread + inbox unread + staff-room unread (0or1), written once all started fetches settle. A role below moderator skips the staff-room fetch and contributes0. A thrown staff-room 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 three 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: 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. Onpush, 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. No cache or offline strategy. - Inputs: Push
eventwith optional JSON payload (title,body,url,tag,unreadCount). - Returns / side effects:
event.waitUntilofshowNotificationplus optionalsetAppBadgeviaPromise.all. Install skips waiting; activate claims clients; notification click focuses or opens the payload URL. - 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: 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). 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); 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. - 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. Signed-in empty isprofile.chartEmptyalone. 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, 404 Back home, and rules nav. - Used by:
Home,MarketingHeader,DonatePage,NotFound,RulesDocument.
Function: SegmentedControl
- Purpose: Mutually exclusive option group with
tonegift(compact ₿|USD cells, optionalshellapp/dark) orneutral(full-width forum pills). Gift hit target ismin-h-11 min-w-11on mobile and desktop.shellis ignored forneutral. Neutralrole="group"is the pill; Forum!gridstill lays out the option buttons viaclassNameon the group. 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 Forum!grid). No network. - Used by:
ForumBoard(tone="neutral"),PushToggle(tone="neutral", twice: level + device),AccountActivityChart(tone="gift"),StatsDashboard(tone="gift" shell="dark").
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: 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:
ForumBoard.
Function: APP_HEIGHT_BOOTSTRAP_SCRIPT
- Purpose: Blocking bootstrap IIFE string injected as a raw head script before paint. Sets
--app-heighttomax(visualViewport.height + offsetTop, innerHeight)(fallbackinnerHeight) so first paint matches the layout canvas. It does not followvisualViewport.heightalone. Scale guard: skips the write whenvisualViewport.scaleis present and not ≈ 1, keeping the last unzoomed height (or the CSS100dvhfallback). - Inputs: None (constant string).
- Returns / side effects: Non-empty IIFE source mentioning
visualViewport,Math.max, and--app-height. - Used by:
RootLayout<head>script.
Function: AppHeightViewport
- Purpose: Minimal visual-viewport fields (
height, optionaloffsetTop, optionalscale) used to resolve--app-heightwithout depending on the DOMVisualViewporttype in tests. - Inputs:
heightin CSS pixels; optionaloffsetTop(keyboard scroll); optionalscale(omit when unknown — do not passundefined). - Returns / side effects: A structural type only — no runtime value. Callers pass
{ height }or{ height, offsetTop, scale }intoresolveAppHeight. - Used by:
resolveAppHeight;useAppHeight(livewindow.visualViewport).
Function: resolveAppHeight
- Purpose: Chooses the pixel value for
--app-height. The result ismax(innerHeight, visualViewport.height + offsetTop)so a stuck-short visual viewport cannot leave a white gap under the rounded page frame, including while the software keyboard is open. It does not shrink tovisualViewport.heightalone when a text field is focused. 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, treated as 0). - 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 max path.
Function: useAppHeight
- Purpose: After hydration, keeps the CSS custom property
--app-heightin sync soAppShellfill/flow layouts track the layout canvas. Usesmax(innerHeight, visualViewport.height + offsetTop)viaresolveAppHeight. Does not shrink tovisualViewport.heightalone when a text field is focused. Scale guard: does not update--app-heightwhenvisualViewport.scaleis present and not ≈ 1, so pinch/auto-zoom keeps the last unzoomed height. - Inputs: None (reads
window.visualViewport/innerHeightinside auseEffect). - Returns / side effects:
void. Sets--app-heightondocument.documentElementand registers window resize/orientationchange, document focusin/focusout, and visualViewport resize/scroll listeners; cleans them up on unmount. - 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 live after the blocking bootstrap script runs in<head>. - 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, frame-header chrome row, inneroverflow-y-autoscroller, footer host. Prefer this over Tailwind viewport-height utilities on app routes. Chrome (wordmark + Menu / language) is the frame’s first row ([data-app-chrome]). Card never hosts page chrome. Neverjustify-centeron<main>or the overflow scroller. - 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, 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: useAppShellScroller
- Purpose: Returns the AppShell inner
overflow-y-autoscroller 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) - 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/address pages, unsignedPublicMessageChrome/RulesPageChrome, andAppShelltop-left.
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.
Function: PublicMessageChrome
- Purpose: Client chrome wrapper for public
/messages/[id]: when a session is hydrated (ready && session !== null), mounts signed-in shell (ProfileChromeLeft+SignedInChrome); otherwise keeps unsigned chrome (Wordmark→/, lightLanguageSwitcher). - Inputs:
children(thread body fromPublicMessagePage—PublicMessageLoader). 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.
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. - 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/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·formatFiatDisplaywhen the conversion is non-null, 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(earlier stills 88% peek, last still full width,current/totalchip, dots);data-photo-indexkeeps the original still index when an earlier extra still fails to load. Labeled Translate 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 (LinkedTextplain+NoteTranslateplain) instead ofForumQuotedBody. Each unsigned card is ₿ plus optional preferred-fiat·formatFiatDisplaywhensatsToFiatAmountis non-null (useFiatPreference; cookie, otherwise locale default;fetchGiftStats/latestRateDay); otherwise ₿-only, no· —. 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 fromuseHydrateSession(Log in or Back to the forum).
- 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/messages/<uuid>) 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. - 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.
- 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'.AppShellwithHomeWordmarktop-left (/unsigned,/welcomewhen a session is hydrated) and lightLanguageSwitchertop-right; body isViewProfileLoader. - 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 with empty series. 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), 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). - Inputs:
{ profile, viewKey, received, donated }—receivedisAccountActivity['receivedOverTime']; optionaldonatedisAccountActivity['donatedOverTime'].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.
- 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). 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 }. On each fetch start (including session or Lightning Address change) totals and series reset to zeros/empty;AccountActivityChartthen showsprofile.chartEmpty(no SVG) when the series is empty. Drops stale responses when the session or address changes mid-flight; errors resolve to zeros and an empty series. - Used by:
SignedInChrome,ProfileScreen.
Function: WelcomePage
- Purpose: Next.js page for
/welcome. - Inputs: None.
- Returns / side effects: Flow
PageChrome(AppShellwrapper) withForumHomeWordmarktop-left,SignedInChrometop-right, andOnboardingGatearoundWelcomeScreen. - Used by: Route
/welcome.
Function: WelcomeScreen
- Purpose: Fourth post-login screen after name, address, and living-room rules agreement are saved. Embeds
ForumLoader(forum list + composer) below the heading. Page column ismax-w-xl(Card surface={false}) so the AppShell frame is the only page-levelrounded-3xl. - Inputs: Reads
account.namefromuseAuthStore. - Returns / side effects: Gift icon with an integrated Bitcoin symbol, Welcome, {name}, forum board. No name or address form. No donate CTA. No
LogoutButtonin the page column. - Used by: Screen
/welcome.
Function: forumTextPreview
- Purpose: Collapse a public forum note or reply body to a 280-character UTF-16
.lengthpreview (FORUM_TEXT_PREVIEW_LIMIT) for the feed/profile timeline analog. 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; optionallimit(default 280). - 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) and for translated bodies. 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 280 characters render a collapsed preview,…, and inline Show more (forum.showMore, app inline link). 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}); translations still render through this component. - Inputs:
ForumNoteTextProps—text,className, optionalplain. - 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) andNoteTranslate(translated body).
Function: ForumPhotoGallery
- Purpose: Horizontal snap gallery for a note with
photoCount > 1. Earlier stills are 88% slides (min-w-[88%] shrink-0 snap-start,gap-3) so the next photo peeks; the last still isw-full min-w-fullso it can sit flush at snap-start. Acurrent/totalchip (forum.galleryPosition) sits on the visible still. Dots under the scroller (gap-5,IconButtonsmghost,forum.galleryDot) jump to a still. Emptyphotosreturnsnull. Single still has the chip and no dots. - 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: 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, body text, andNoteTranslate, 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), a copy-linkIconButtonon every note and every reply, optional Gift on a payable reply, and optional delete sit outside that control as siblingIconButtons thatstopPropagation; 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 popularSegmentedControl tone="neutral"(unpaid segment chip whenunpaidNewCount> 0 and unpaid is not selected), list of posts (name, optional Founder / Moderator / Verified role pill on notes and replies whenroleis one of those three (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; horizontal snap gallery (ForumPhotoGallery) whenphotoCount > 1— earlier stills 88% so the next still peeks, last still full width,current/totalchip, dots of loaded blob URLs keyed${id}:${index}) then caption text below the photo, 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.showMoreon the feed; signed-in/messages/[id]passestruncate={false}so the original stays full), labeled Translate / Show original / Show translation under note and reply bodies viaNoteTranslate(not in the footer icon 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 viaForumNoteText/LinkedTextwithplain(no quoted-note unfurl, URLs stay visible text) plusNoteTranslatewithplain, instead ofForumQuotedBody, ₿ amount plus optional preferred-fiat·formatFiatDisplayonly whensatsToFiatAmountis non-null (else ₿-only, no em dash) 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/messages/<uuid>) or empty/loading/error, messenger-style composer (Add a photo or video ImagePlus left of the textarea, Post Send icon to the right, 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 500)), in-card reply composer (textarea plus Amount sats fieldid="forum-reply-amount"), and payable-reply pay sheet: 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 iOS-phone amount CTA Pay (forum.payNow) mints the invoice and keeps the amount form (it does notwindow.location.assign; walletButtonthen setswindow.location.hreftowalletofsatoshi:; no QR); Android-phone (isSmartphoneUserAgent) amount CTA stays Continue (forum.payContinue) and after mint remains on the amount form with the same walletButtonand the Android Intent href (not the invoice card); desktop and iPad (!isSmartphoneUserAgent) amount CTA stays Continue (forum.payContinue) and after mint show the invoice card with QR + the same walletButton(forum.payOpenWallet/ ariaforum.payOpenWalletAria; no custom-scheme<a>); top-left back control cancels. Gift-only replies show send ₿… (forum.giftReply) plus the same optional preferred-fiat·as notes; a reply with text and sats shows both. Clicking a role pill toggles a short explanation under that card header (one open at a time). Selector stays visible in every board state. 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), 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 andscrollIntoViews the new-post textarea and consumes pending compose only when that textarea exists (composerHiddenboards keep the flag)./messages/<uuid>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,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,onPost,onRetry,formError(empty/tooLong/request/rateLimit/unsupported/tooLarge/tooMany;ForumFormError = 'empty' | 'tooLong' | 'request' | 'rateLimit' | 'unsupported' | 'tooLarge' | 'tooMany' | null), controlledmode/onModeChange, 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[]),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 486 so the#21GiftsShopsuffix fits), optionalemptyKey(forum.emptydefault,shops.emptyon shops), plus pay sheet props (payMessageId,payDraft,payBusy,payError(amount/request/rateLimit/authorWallet),payInvoice,payWaiting, optionalrateDay(FiatRateDay | null; omit/null→ ₿-only),onPayOpen,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), 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 (₿-only when conversion is null, no em dash); text plus a gift shows that formatted amount under the text. The video-draft X still callsonClearPhoto; each photo-draft X callsonRemovePhoto(index). - Returns / side effects: React tree. Copy-link always copies
/messages/{id}for the id it was given — 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. 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; on a smartphone the amount form stays and the walletButtonsetswindow.location.hrefto the WoS href; on desktop/iPad the invoice card shows QR plus that sameButton. AFORUM_COMPOSE_EVENTfocuses the new-post composer when it is mounted; acomposerHiddenboard leaves pending compose set for a later visible board.
- Used by:
ForumLoader,MemberProfileScreen,PublicMessageThread.
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 500 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,maxLength500). 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: 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, thenForumLoaderfeed="shops". - Inputs: Catalog via
useTranslations. - Returns / side effects:
Card maxWidth="xl"surface={false}wrapping the shops forum. No network of its own. - Used by:
ShopsPage.
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. 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 optional CTAs to/contactand/welcome. - 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 / forum 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 keeps marketing-like unsigned chrome (Wordmark→/,LanguageSwitcher). - 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). Session and account fromuseAuthStore; returns null without a session. First paint fetches 20 notes of the current feed mode viafetchMessages(session, { mode, limit: 20 })on the living-room feed andfetchMessages(session, { mode, limit: 20, hashtag: '21GiftsShop' })onfeed="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 viapostMessagewith 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) 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 onfetchPublicMessagewithsinceSatswhile the pay sheet is open (no attempt cap; aborts in-flight wait on Back / clear); after a paid reply'ssatsincrease, merges that row into loadedreplies/messages(no thread refetch). 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 post withcreated.sats === 0, switches mode to All so the author sees the note (if All is already selected, loaded pages andnextCursorstay). 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 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 the parent author / verified; unpaidpostMessagewithinReplyToonly when there is text and the amount is empty, and only for those roles; everyone else invoices ≥ 1 sat; expand is ignored while a reply posts). 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/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. Empty text without photos and without a video setsempty; trimmed text longer than 500 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; 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). 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. - 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.setuponly (1:1 map; skips advancesetupwithout clearingmissing). Username 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: 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: 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 keeps the card with empty given and received series. - 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, name, location, public
[email protected](profile.giftsHeading), role pill, optional grant-reviewed tag 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 appear 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}. Replies from the parent author or a verified member mayPOST /messagesunpaid only when there is text and the amount is empty; empty text and an empty amount invoices 21 sats even for those roles; everyone else invoices ≥ 1 sat with optional text; typed0is always billed as 1 sat even for exempt. When a note omitsaccountId, the profile id is the author id. A payment 403 on unpaid post starts a 1-sat invoice. 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 feed amounts are ₿ plus optional preferred-fiat·when the conversion is non-null (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. - Inputs:
MemberProfile(includesaboutMe,aboutMeHasPhoto, andlocation) plus received and donated series; 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}. - Used by:
MemberProfileLoader.
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).
- Inputs:
value(required) andlabel(required accessible name, already translated). - Returns / side effects: React element.
- Used by:
ForumBoardonly when the UA is not a smartphone.
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. 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 }so a moderator verifies a basis member. - Inputs: Bearer
sessionToken, subjectaccountId. - Returns / side effects:
{ id, name, role }. Throws visitor copy on any failure. - Used by:
MemberTrustActions.
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: 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.
Function: postFundingApply
- Purpose: POST
/funding/apply(Bearer) and parse{ funding }viafundingApplyResponseSchema. Rolebasisis 403. - Inputs: Bearer
sessionToken. - Returns / side effects: Updated
OwnerFunding. Throws visitor copyCould not submit your application. Please try again.on 401/403/409/503, other non-2xx, network failure, or a body that fails the schema. - Used by:
FundingStatusCard.
Function: fetchFundingApplications
- Purpose: GET
/funding/applications(same-origin Bearer proxy of apiGET /funding/applications) and parsefundingApplicationsResponseSchema.applications. Next.js forbids aroute.tsbeside/moderate/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: postFundingTrial
- Purpose: POST
/funding/trialwith{ accountId }(staff). Target must be effective pending. - 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: 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 moderator, then verified; same-role 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 / 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: MemberTrustActions
- Purpose: Staff-only Verify / Propose / Confirm / Appoint controls on another member's identity card.
- Inputs:
profile, optionalonUpdated. Hidden unless the signed-in account is a moderator and not the subject. - Returns / side effects: POST then re-fetch member;
data-testid="state-members-staff-verify"when shown. - Used by:
MemberProfileScreen.
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.
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 and shows empty series. - 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.
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: 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). - Inputs: Forum message
id(UUID string). OptionaloptswithsinceSats(finite integer ≥ 0; omitted / NaN / Infinity / negatives / non-integers skip the 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: 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? }, parseforumMessageSchema, and return the created message or reply (text and/or up to ten photos). Non-emptyphotosdual-sendsphotoas the first still plusphotos. - Inputs:
sessionToken,inputwithtext, optional{ contentType, data }photo, optionalphotosarray (max 10), and optionalinReplyToparent id (thread composer only). - Returns / side effects:
ForumMessage. OmitsinReplyTofrom the JSON body when absent. 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. - 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.
- Inputs:
fileaccepted byisForumPhotoFile. - Returns / side effects:
{ ok: true, photo }or{ ok: false, error: 'unsupported' | 'tooLarge' }. Revokes temporary object URLs it creates. - Used by:
ForumLoader,AboutMeSection.
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,SignedInChrome,AccountActivityChart,StatsDashboard,GiftDayTable,DayLoader.
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: 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: ForumQuotedBody
- Purpose: Remaining body text plus nested post cards for resolved
/messages/<uuid>URLs. 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: header (name, role span pill, timestamp) and optional photo blob are a permalink link (forum.quotedNote, orforum.quotedNoteExternalwhen the quoted note hasvia === 'nostr'); caption sits beside that link (ForumNoteTextwhen truncate, elseLinkedText, no nested unfurl); ₿ amount is a second permalink link without that aria-label. NoteTranslate on stripped display text only. Feed remaining text and nested captions go throughForumNoteText(280-character Show more, bodies autolinked). 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) and renders its caption as plain text:ForumNoteText plainwhen truncate, elseLinkedText plain, followed byNoteTranslate plain, so a URL in a quoted external reply is never a link. - 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"), optional onActivate. - Returns / side effects: React element or null when text==='' and no resolved quotes. Unknown quote ids load via
fetchPublicMessage(catch, never throw). - 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). 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). - Returns / side effects: Fragment:
<p>plus optional overlay. Confirm onhttps:callsopenInSystemBrowser; other http useswindow.open. - Used by:
ForumNoteText,ForumQuotedBody(whentruncateis false),AboutMeSection.
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: 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.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: formatFiatDisplay
- Purpose: Formats an API fiat amount string for stats display using the visitor grouping style.
nullbecomes—(U+2014). USD uses a dollar symbol; CHF/EUR/PHP 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 matches
formatUsdTick; other codes areCHF 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 > 0so forum notes can scale sats into fiat from gift-day totals. - Inputs: Oldest-first series of
{ sats, usd, chf, eur, php }. - Returns / side effects: That day, or
nullwhen every day is empty. - Used by:
useLatestRateDay,PublicMessageLoader,MemberProfileScreen.
Function: useLatestRateDay
- Purpose: Latest gift-day totals for preferred-fiat conversion. Fetches
GET /gifts/stats
once on mount via fetchGiftStats and resolves latestRateDay of spendOverTime; a failed fetch or no day with a usable rate yet resolves null. Drops the response after unmount.
- Inputs: None.
- Returns / side effects:
FiatRateDay | null. CallsfetchGiftStatsonce per mount. - Used by:
ForumLoader,InboxLoader,ModeratorGroupScreen.
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,PublicMessageLoader,preferredFiatSuffix,InboxScreen.
Function: preferredFiatSuffix
- Purpose: Preferred-fiat suffix next to a ₿ amount (
·plus the visitor's fiat), shared
by the forum and inbox/moderator-group threads. null when rateDay is null or the conversion is unusable (₿-only).
- Inputs: Whole sats, latest
FiatRateDayornull, visitorFiatCode, number-format
style.
- Returns / side effects:
ReactElement | null. No side effects. - Used by:
ForumBoard,InboxScreen.
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: 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:
ForumBoardto hide the payment 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. - 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. - 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.
Function: setLightningAddress
- Purpose: POST
/me/lightning-address. - Inputs:
sessionToken,address. - 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 and replace
{name}placeholders fromvars. - Inputs:
catalog(Messages),key(MessageKey), optionalvarsmap of string/number values. - Returns / side effects: Interpolated string. Throws on a missing key or 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,SignedInChrome,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. - 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: 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
HomeWordmark(tone="dark";/unsigned,/welcomewhen a session is hydrated), section nav (How / Why / FAQ / About / Stats / Handbook, accent Log in, optionalPwaInstalltone="dark"placement="header"), always-visibleLanguageSwitcher(tone="dark"), and a mobile menu toggle. 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 a localized link home.
- Inputs: None. Calls
getRequestLocale()for body/back-link copy; awaitsMarketingFooter(). - Returns / side effects: 404 element with
MarketingHeaderand awaited footer (not rendered as JSX child). - 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;/auth/passkey/{register,authenticate}/{begin,finish}re-export the four passkey proxy POSTs;/forum/messagesre-exportsproxyMessagesPost;/messages/[id]/invoicere-exportsproxyMessagesInvoicePost;/conversationsre-exportsproxyConversationsPost;/conversations/[id]re-exportsproxyConversationPost;/conversations/[id]/invoicere-exportsproxyConversationInvoicePost;/conversations/[id]/readre-exportsproxyConversationReadPost;/forum/notifications/read-allre-exportsproxyNotificationsReadAllPost;/forum/notifications/[id]/readre-exportsproxyNotificationReadPost;/contact/submitre-exportsproxyContactPost;/translatere-exportsproxyTranslatePost;/trust/verifyre-exportsproxyTrustVerifyPost;/trust/propose-moderatorre-exportsproxyTrustProposeModeratorPost;/trust/confirm-moderatorre-exportsproxyTrustConfirmModeratorPost;/trust/appoint-moderatorre-exportsproxyTrustAppointModeratorPost;/funding/applyre-exportsproxyFundingApplyPost;/funding/trialre-exportsproxyFundingTrialPost;/funding/admitre-exportsproxyFundingAdmitPost;/funding/rejectre-exportsproxyFundingRejectPost. HTML/messagesis the inbox page, not a POST proxy. - Inputs: Incoming
Request. - Returns / side effects: Upstream api
Responseon api proxies;/translatereturns{ translatedText }or 400/502/503 JSON (LibreTranslate-compatible, not 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), passkey begin/finish, forum message create (POST /forum/messages), payable-reply invoice (POST /messages/[id]/invoice), 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) and mark-one (POST /forum/notifications/[id]/read), in-app contact (POST /contact/submit),translateNoteviaPOST /translate, staff Trust Chain actions (POST /trust/verify,POST /trust/propose-moderator,POST /trust/confirm-moderator,POST /trust/appoint-moderator), grant apply (POST /funding/apply), and staff funding decisions (POST /funding/trial,POST /funding/admit,POST /funding/reject).
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: 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: 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 }). - 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: 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: 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: 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: 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: 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. - Inputs: Record from
POST /auth/passkey/register/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.
Function: credentialToJSON
- Purpose: Serialise a
PublicKeyCredentialfor the api finish body. - Inputs: Browser credential from create/get.
- Returns / side effects: JSON record. Uses native
toJSONwhen present. - Used by:
usePasskeyLogin.
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. 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. Other non-2xx stay status fallbacks. - Used by:
usePasskeyLogin.register.
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: 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']. - 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: startPasskeyRegistration
- Purpose: POST
/auth/passkey/register/beginand parse options. - Inputs: Optional
viewKeystring. When set (non-empty), POSTs JSON{ viewKey }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.register.
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) starts create. After a choice was offered, user cancel (NotAllowedErrororAbortError) on those ceremonies returns tochoice; directauthenticate/register(viewKey)fromViewProfileClaimnever sets that flag, so cancel returns toidle. 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. - Inputs: None (reads
useAuthStore; callsisInAppBrowseron authenticateNotAllowedError). - Returns / side effects:
{ status, login, register, authenticate, retry, cancel, error }withstatusinidle | starting | error | unsupported | choice.erroris the lastError.messagewhenstatus === 'error', elsenull.retryrepeatsloginwhen the visitor used the single button. After a choice button,retryrepeats that ceremony. Calls WebAuthn and the api. Unmount still aborts the controller and clears the choice flag. - Used by:
OnboardingGate,LoginCard,LogoutButton, andViewProfileClaim.
Function: postMessageInvoice
- Purpose: POST
/messages/:id/invoicewith{ sats }or{ sats, text }when the visitor attached a reply comment. Emptytextis omitted. - Inputs: session token, message id, sats, optional text.
- Returns / side effects:
{ pr, amountSats }or throws collapsed copy. 409missing_requirementsthrowsMissingRequirementsError. - Used by:
ForumLoader,MemberProfileScreen.
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. FetchesGET /conversations, opens?c=viaonOpenrouter.push, posts replies. 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. - 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),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). 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 500-character composer and sats amount field (
showAmountfalse hides the field; the staff room has no gifts).showAttach(default false) adds the ImagePlus control, still previews, and photo-only send for the closed staff room. Non-empty bodies go throughForumQuotedBodyso a pastedhttps://21.gifts/messages/<uuid>unfurls as a nested quoted-note card.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. 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. 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. OptionalrateDayis the latest gift-day totals. Every thread sats amount (gift-only bubble, text+sats line, and nested gift line) shows a preferred-fiat suffix viapreferredFiatSuffixwhenrateDayis usable, else ₿-only. 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. An open thread (openIdset, messages loaded) scrolls the AppShell scroller to the bottom so the newest (oldest-first) messages and composer are in view; 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 and parse{ messages }. Each message includes requiredfromMe(true iff this session is the actor) andsats, plushasPhoto/photoCount(client defaults omitted fields to false / 0). For a staff viewer, incomingnameand optionalaccountIdare the actor when the api sends them. Optional{ sinceMessageId, signal }long-polls until that gift row exists. - Inputs: Session token, conversation id, optional poll opts.
- Returns / side effects: Oldest-first messages, 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). 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:
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 }or{ sats, text }. - Inputs: Session token, conversation id, sats, optional text.
- 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, and moderator appointment). - 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, and moderator appointment). 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_appointedrow waits formarkNotificationReadthen goes to/welcome(still navigates if that POST fails; skips navigation if the session changed); any other row goes to/messages/{parentId}without waiting. - 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. - Used by:
NotificationsPage.
Function: NotificationsScreen
- Purpose: Presentational notifications list of living-room posts, replies, payments, and moderator appointment (actor
{name} posted/{name} replied/{name} sent bitcoin;moderator_appointedtitle usesnotifications.moderatorAppointedwith no{name}placeholder; post text or Photo, reply text or Photo reaction, zap amount as stored, moderator appointment 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. - 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 parse 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. Non-ok throws; success may ignore body. - Inputs: Session token.
- Returns / side effects: void.
- Used by:
NotificationsLoaderfire-and-forget after a successful list fetch.
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: 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: 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 in the founder > moderator > verified > basis hierarchy, for use by
roleAtLeast. - Inputs:
role— one of'basis' | 'verified' | 'moderator' | 'founder'. - Returns / side effects: Integer 0 (basis) through 3 (founder). Pure, no side effects.
- Used by:
roleAtLeast.
Function: roleAtLeast
- Purpose: Product-rule primitive for every viewer permission/visibility check: true when
roleisminor higher in the founder > moderator > verified > basis hierarchy. 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: isReplyPaymentExempt
- Purpose: True when the signed-in account may reply without paying: anyone at least verified is exempt, and so is the parent note's author regardless of role.
- 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: ModerateScreen
- Purpose: Client moderation hub of staff tools. Staff (
roleAtLeast(..., 'moderator')) see the daily payout-goal widget (yesterday versus 100 official 21.gifts payouts; tap expands explanation plus a 30-UTC-day count chart), a labeled Hidden notesButtonLink(variant="secondary"size="lg") →/moderate/hidden, a labeled Open proposalsButtonLink(variant="secondary"size="lg") →/moderate/proposals, a labeled Open applicationsButtonLink(variant="secondary"size="lg") →/moderate/applicationswith grant-review lead, a Moderators chat groupButtonLink→/moderate/groupthat shows the staff-room unread count plusmoderate.groupUnreadwhen unread, and a labeled HandbookButtonLink(variant="secondary"size="lg") →/moderate/handbook. Non-staff signed-in visitors see the heading plus forbidden copy and no tools list. Does not fetch hidden notes, proposals, applications, or the group thread itself; unread for the Moderators chat group control comes fromuseUnreadCount. Rendersnullwithout a session. No un-hide control. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations;useUnreadCount(true, { writeBadge: false })for the Moderators chat group count (network for that count; does not write the home-screen badge). - Returns / side effects: React element or
nullwithout a session. Staff fetchGET /gifts/statsfor the goal widget; 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,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. In-card icon back to/moderate. 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,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). Each row is aLinkto/messages/:id. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. In-card icon back to/moderate. 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: ProposalsPage
- Purpose: Next.js page for
/moderate/proposals(signed-in staff confirm queue). HTML/moderate/proposalsis the queue, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left,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 queue of open moderator proposals. Staff (moderator) fetch
fetchTrustProposalsand show subject name, Proposed by {name}, time, 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. In-card icon back to/moderate. A failed confirm 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. While that POST is in flight, Confirm is disabled and shows the Loader2 spinner (same as RulesSetup busy). - Used by:
ProposalsPage.
Function: FundingApplicationsPage
- Purpose: Next.js page for
/moderate/applications(signed-in staff grant-application queue). HTML/moderate/applicationsis the queue, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left,SignedInChrometop-right, andOnboardingGate screen="welcome"aroundFundingApplicationsScreen. Application HTTP lives under/funding/applicationsbecause Next.js forbids aroute.tsbeside this page. Hub is/moderate. - Inputs: None.
- Returns / side effects: The open-applications screen inside fill AppShell.
- Used by: Route
/moderate/applications.
Function: FundingApplicationsScreen
- Purpose: Client queue of open 21 gifts grant applications. Staff (founder or moderator) fetch
fetchFundingApplicationsand show applicant name (link/moderate/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. In-card icon back to/moderate. - 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
/moderate/applications/[accountId](signed-in staff grant-application review). HTML page, not a GET proxy. FillAppShell(align="center") withProfileChromeLefttop-left,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
/moderate/applications/[accountId].
Function: FundingApplicationDetailScreen
- Purpose: Client staff review of one grant application. Staff fetch
fetchFundingApplicationand show the applicant, living-room posts, the three convictions as criteria, and status-gated Trial / Admit / Reject (postFundingTrial/postFundingAdmit/postFundingReject): Trial only whengrant.statusispending; Admit and Reject whenpendingortrial. A failed decision showstrustChain.actionFailed. Non-staff signed-in visitors see the heading plus forbidden copy and do not fetch. Rendersnullwithout a session. In-card icon back to/moderate/applications. 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. Trial only whengrant.statusispending; Admit and Reject whenpendingortrial; otherwise no decide buttons. Successful Trial / Admit / Reject leaves the buttons disabled and navigates to/moderate/applications. - Used by:
FundingApplicationDetailPage.
Function: FundingStatusCard
- Purpose: Owner profile grant section after the Lightning Address form.
basissees not-verified copy and how in-person verification works (no apply). Verified and above see funding status fromaccount.funding(missing ornulltreated asnone): not admitted + grace copy that daily gifts continue until 25 September 2026 + Apply for the 21 gifts grant plus the three conviction titles and/aboutfornone/rejected; pending; one-day trial; or admitted with Reviewed by a moderator andadmittedAt. Apply postspostFundingApplyand merges the returnedfundinginto the store account. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session or account. Apply POSTs/funding/apply. A failed apply showsfunding.applyError. - Used by:
ProfileScreen.
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 (backHref="/moderate",backLabelKey="moderate.heading"— the only back 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')) fetchfetchModeratorGroupthenfetchConversationand reuseInboxScreenas the open thread (showFilterfalse;showAmountfalse;showAttachtrue; no in-card back; rownamereplaced by the catalogmoderate.groupLabelso the heading is always Moderators chat group). 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. Back to/moderateis the page chrome (ProfileChromeLeftbackHref="/moderate"), never in the card. - Inputs: Session and account from
useAuthStore; catalog viauseTranslations. - Returns / side effects: React element or
nullwithout a session. FetchesGET /conversations/moderator-groupthenGET /conversations/:idonly 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 (with English descriptions fromscreens.mdviaparseScreenVariantDescriptions) and renders the compact-cardHandbookImageViewer. - Inputs: None.
- 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. 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).
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-full. 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: 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: 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: 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
{ text, target }to same-origin/translateand return the translated body. - Inputs: Raw forum note
textand the active UItargetlocale. - Returns / side effects: The
translatedTextstring. Throws when the route is non-2xx or omits a stringtranslatedText. - Used by:
NoteTranslateon Translate.
Function: getTranslateUpstream
- Purpose: Read optional LibreTranslate-compatible config from
TRANSLATE_URL(and optionalTRANSLATE_API_KEY). Invalid or empty URLs disable translation. - Inputs: None (process env).
- Returns / side effects:
{ url, apiKey }pointing at{TRANSLATE_URL}/translate, ornullwhen unset/invalid. Does not contact upstream. Does not throw. - Used by:
proxyTranslateGet,proxyTranslatePost.
Function: proxyTranslateGet
- Purpose: Report whether translation is configured without calling upstream. Always 200
{ available: boolean }. - Inputs: None.
- Returns / side effects: JSON
Response. InvalidTRANSLATE_URLis treated as unavailable. - Used by: App Router GET
/translate;fetchTranslateAvailableinNoteTranslate.
Function: proxyTranslatePost
- Purpose: Validate
{ text, target }and forward a LibreTranslate-compatible POST (q,source: auto,fil→tl, 500-character max, 15s timeout). Does not forward Authorization. - Inputs: Incoming
Requestwith JSON{ text, target }(en/de/es/fil). - Returns / side effects:
{ translatedText }on success; 400 invalid body, 503 not configured, 502 upstream. Does not throw. - Used by: App Router POST
/translate;translateNotefromNoteTranslate.
Function: NoteTranslate
- Purpose: Client control that offers on-demand translation when the note language differs from the active UI locale and GET
/translatereports available. Translate sits under the note body (not in the footer icon row). Success shows the translated body throughForumNoteText(the same 280-character Show more collapse) plus Show original; failure shows Could not translate this note. Please try again. and keeps Translate. Optionalplainforwards plain rendering to thatForumNoteText(autolinking is still skipped forviarows even after translation). - Inputs:
text— raw public note or reply body; optionalplain; optionaltone(defaultoronButton, forbg-app-btnbubbles). - Returns / side effects: The control, or
nullwhen the text is blank, translation is unavailable, orshouldOfferNoteTranslateis false. CallsfetchTranslateAvailableon mount andtranslateNoteon click. During render, a change oftextor UI locale resets status, clears the translated body, shows the translation slot again, and invalidates in-flight requests (identity = text + locale). Stops click/keydown so forum expand does not fire.onButtonpaints the control and translated body withtext-app-btn-fg. - Used by:
ForumBoard(notes and replies),PublicMessageLoader, andForumQuotedBody.