V1 Web PWA · Surface walkthrough

Profile · Telegram bridge

A per-surface walkthrough of the V1 Web PWA customer surface: layout, states, interactions, data, and cross-references.

walked
9sections8 minread

On this page

Context. surface customer · domain account · route /profile/telegram · auth signed-in · source apps/oshun/web/src/app/profile/telegram/page.tsx

Last walked. 2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data (2xx), console/page-errors, expected content, screenshot verified; live screen-reader, touch, offline, and telemetry-delivery checks pending a manual AT pass. Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; focused direct-route addendum 2026-06-25 covers live-BFF not-linked nonce render, real Telegram webhook link completion, linked render, footer targets, and anonymous proxy redirect; 2026-06-28 addendum covers 390 px mobile/no-overflow, touch-target height, reduced-motion-static route subtree, offline-after-load stability, and verifies the deep-link click has no telemetry hook; 2026-06-28 refresh addendum covers the not-linked "Refresh link" control, real server-action POST to /v1/telegram/link, BFF nonce rotation, browser-visible t.me href replacement, and 44 px refresh target; 2026-06-28 expiry addendum covers the client countdown crossing expiresAtIso, disables the stale deep-link anchor, renders the "Link expired" 44 px disabled control, keeps refresh as the recovery path, and 2026-06-29 fallback addendum removes the generic https://t.me/oshun_bot?start=link fallback and covers a real protected-route render where the forwarded account bearer is rejected by the BFF, producing an honest unavailable card with no Telegram deep link or refresh action; 2026-06-29 focused telemetry addendum verifies the active Telegram wrapper, anchor, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hooks, and the expired replacement control remains hook-free; 2026-06-30 live-status addendum covers the not-linked page staying open while the real Telegram webhook completes the nonce, authenticated /v1/telegram/link polling, in-place repaint to linked, and no full-page navigation during the transition

Purpose#

The opt-in Telegram link surface — "a side door, opt-in only." Shows whether the account is linked to a Telegram chat; if not, surfaces a time-boxed deep link (with remaining minutes) to open the bot and link; if linked, names the chat and gives the /unlink instruction. Backed by /v1/telegram/link. Telegram is one channel Lilith can reach when the phone is silenced (daily nudges and crisis check-ins).

Entry points#

  • Cross-domain footer button from /profile/persona ("Telegram")
  • "Back to profile" navigation from /profile
  • Return-trip from the Telegram bot after the deep link is used; the page can stay open and poll into the linked state after the bot confirms
  • Direct URL / bookmark — yes; auth required (proxy.ts no-session → /welcome)

Layout regions#

Async server component in <LWebShell top={<LCustomerNav active="library" />}> fetches the first /v1/telegram/link snapshot, then mounts a client state panel with a narrower maxWidth: 640 (single-column, decision-focused). One state card sits below the masthead.

  • Header: LCustomerNav (active="library")
  • Masthead: LMasthead — left Profile · telegram, right dynamic linked, not linked, or unavailable, kicker "A side door, opt-in only", italic title "The Telegram bridge."
  • Intro: LEyebrow "How this opens" + "Telegram is one place Lilith can reach if your phone is silenced…"
  • State card: accent-bordered when linked, rule-bordered otherwise — the client panel branches on data === null, then data.linked (see States); not-linked state exposes the primary Telegram deep link plus a secondary refresh control and authenticated status polling; unavailable state exposes retry copy only
  • Footer / actions: two LBtns — "← Back to profile" (ghost), "Notifications" (ghost)

States#

  • LoadingbffGet('/v1/telegram/link') blocks server render; no client skeleton
  • Linkeddata.linked === true: accent border; mono caption "Active link"; serif "Linked to {linkedChatLabel ?? 'your Telegram chat'}."; italic instruction to send the bot /unlink (takes effect immediately)
  • Not linkeddata.linked === false: rule border; mono caption "Not linked"; "Open the deep link below within {N} minutes." where N = max(0, round((expiresAtIso - now) / 60_000)); the primary deep-link button
  • Deep link expired — once the client countdown reaches expiresAtIso, the copy changes to "The link window has closed. Refresh the link and use the newest button.", the primary anchor is removed, and a disabled "Link expired" button renders beside the still-active refresh control.
  • Manual deep-link refresh — not-linked state exposes Refresh link; server action posts to /v1/telegram/link, the BFF rotates the unlinked nonce, and the route re-renders a different t.me href
  • Live linked-state refresh — while a not-linked card is open, the client island polls the authenticated /v1/telegram/link read. Once the Telegram bot webhook completes the nonce, the card repaints in place to data-profile-telegram-state="linked" / data-profile-telegram-linked="true" without requiring a reload.
  • Empty / BFF unavailablebffGet('/v1/telegram/link') returns null: the card renders data-profile-telegram-state="unavailable" / data-profile-telegram-available="false", caption "Link unavailable", retry copy, and no Telegram deep-link anchor. The old generic https://t.me/oshun_bot?start=link fallback is not shown.
  • Error (recoverable) — BFF read failure keeps the user on /profile/telegram with an honest unavailable card and a same-route "Try again" link; refresh is hidden because there is no account-scoped link to rotate.
  • Offline — first-render BFF failure degrades to the unavailable card; after a live /profile/telegram render, the SW-served/offline branch keeps the already-rendered not-linked status, expiry copy, and exact t.me nonce href stable.
  • Gated / anonymousproxy.ts redirect to /welcome before render
  • Standalone PWA / narrow shell — no route-specific standalone branch; the 390 px browser check proves the single-column shell has no horizontal overflow, no route-subtree animations, and the Open Telegram to link and Refresh link targets remain at least 44 px tall.

Interactions#

  • Open Telegram to link (LBtn primary lg, href data.deepLinkUrl; not-linked active-window state only)
    • Function: navigates to the Telegram deep link (e.g. https://t.me/oshun_bot?start=link-<nonce>); the bot confirms in chat, then the user returns to see updated state
    • Keyboard: primary focusable in the card; Enter activates; rendered as <a>
    • Screen reader: announces "Open Telegram to link, link"
    • Touch target: LBtn lg (~52 px tall) — meets ≥ 44 px
    • Mobile (≤ 640 px): card is already single-column at this width
    • Offline behavior: deep link opens the Telegram app, which requires network to complete linking; no in-app queue
    • Disabled when: the browser clock reaches or passes expiresAtIso; the anchor is removed, a disabled "Link expired" button renders in its place, and Refresh link remains the active recovery path.
    • Telemetry: none wired; E2E asserts the active wrapper, anchor, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hook. Product still needs to decide whether a future telegram_link_started event is desired.
  • Refresh link (secondary submit button, not-linked state only)
    • Function: invokes a server action that posts to the real POST /v1/telegram/link route and redirects back to /profile/telegram
    • BFF contract: GET reuses a still-valid nonce; POST rotates an unlinked nonce and returns 202 with cache-control: no-store; linked accounts stay linked
    • Browser result: the visible Open Telegram to link href changes to a new https://t.me/oshun_bot?start=link-<nonce> value
    • Touch target: custom secondary button is 52 px tall — meets ≥ 44 px
    • Offline behavior: no offline queue; if the page is already offline after load, the previously rendered link remains stable
  • /unlink instruction (linked state) — non-interactive text inside a <code>; unlinking happens in the Telegram client, not on this page
  • ← Back to profile (LBtn ghost, href /profile) → account hub
  • Notifications (LBtn ghost, href /profile/notifications) → channel prefs

Data & contracts#

  • Reads: bffGet('/v1/telegram/link')TelegramLinkResponse { generatedAt, linked, nonce, deepLinkUrl, expiresAtIso, linkedChatLabel? }
  • Unavailable read: bffGet returns null on BFF/network/auth failure; the page renders an unavailable state and does not fabricate a generic Telegram token.
  • Writes: the refresh control posts to /v1/telegram/link to rotate an unlinked nonce. Linking happens in the Telegram bot via the deep link; unlinking via the bot's /unlink command.
  • Realtime / polling: the not-linked client card polls the account-scoped /v1/telegram/link BFF read with the active browser auth token while the page remains open. A linked response immediately replaces the deep-link controls with the linked chat state; no websocket/subscription is used.
  • Caching: authenticated bffGet forwards oshun-access as a bearer and uses cache: 'no-store'; the BFF also sends cache-control: no-store
  • Auth/role check: proxy.ts protected-by-default; no-session → /welcome
  • Metadata: title: 'Profile · Telegram', description re one-tap deep link / daily nudges / crisis check-ins, alternates.canonical: '/profile/telegram'

Cross-references#

E2E coverage#

  • Backed by apps/oshun/web/e2e/profile-telegram-bridge.spec.ts — browser coverage primes a real authenticated session, mints a live /v1/telegram/link nonce, verifies the not-linked card and exact t.me deep-link href, clicks Refresh link and verifies the rendered t.me href rotates through the real web+BFF stack, advances browser time beyond a live nonce's expiresAtIso to verify the expired copy, removed anchor, disabled "Link expired" control, 44 px target, and refresh recovery, completes a live nonce through the real /telegram/webhook /start link-<nonce> path while the browser stays on the not-linked page, confirms BFF linked read-back, waits for authenticated polling to repaint the same page to the linked card and /unlink instruction without a navigation, checks the profile and notifications footer targets, verifies the 390 px layout/no-overflow/static/touch-target contract and the absence of data-telemetry-event, data-analytics-event, and data-event hooks on the active deep-link wrapper, anchor, and descendants, switches the browser offline after load to prove the rendered not-linked state stays stable, proves an anonymous visit redirects before the route renders, and poisons only the forwarded oshun-access bearer to prove a real account-scoped BFF read failure renders the unavailable state with no generic start=link href and no refresh action.
  • Backed by apps/oshun/web/src/app/profile/telegram/ProfileTelegramLinkPanel.test.tsx — component coverage asserts active countdown/link rendering and timer-driven transition to the disabled expired state while keeping refresh visible, plus authenticated polling repaint from not-linked to linked.
  • Backed by apps/oshun/bff/src/__tests__/telegram-route.test.ts — BFF route coverage asserts anonymous GET /v1/telegram/link is 401, an authenticated GET mints an unlinked nonce, a second GET reuses that valid nonce, authenticated POST rotates the nonce and returns 202 no-store, and the rotated nonce completes through /telegram/webhook.

Open questions / known gaps#

  • Trigger a refresh of the token when the user asks for it: the not-linked card now exposes Refresh link, and E2E verifies the BFF rotates the unlinked nonce and the browser-visible href changes.
  • The deep-link button is disabled when the link window has elapsed: the client countdown island removes the stale anchor at expiresAtIso, shows a disabled "Link expired" control, and keeps manual refresh available.
  • The hardcoded fallback (https://t.me/oshun_bot?start=link, 15-min expiry) is removed. BFF failure now renders an unavailable state and E2E asserts no generic start=link token is exposed.
  • No realtime / polling: closed by the client island's authenticated /v1/telegram/link polling. The user may leave /profile/telegram open after using the deep link; the linked card appears when the real webhook completes the nonce.
  • next.revalidate: 60 stale-token risk — closed by current bffGet behavior: authenticated requests use cache: 'no-store', and /v1/telegram/link returns cache-control: no-store.
  • No telemetry on the deep-link click: profile-telegram-bridge.spec.ts asserts the active deep-link wrapper, anchor, and descendants expose no data-telemetry-event, data-analytics-event, or data-event hooks. Product decision remains whether a future telegram_link_started event is desired.