Skip to content

feat(invite): invite/refer-a-friend mobile client - #679

Open
islandbitcoin wants to merge 8 commits into
mainfrom
feat/invite
Open

feat(invite): invite/refer-a-friend mobile client#679
islandbitcoin wants to merge 8 commits into
mainfrom
feat/invite

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Invite / refer-a-friend — mobile client

The mobile half of the invite/refer feature, freshly grafted onto current main (RN 0.77). Pairs with the backend lnflash/flash#462 and the ops page lnflash/frappe-flash-admin#57.

What's here

  • Invite flow: InviteFriend screen (Contacts / WhatsApp / Email methods), InviteFriendSuccess, a contact picker, and email/phone inputs.
  • Deep-link redemption: HandleInviteDeepLink + InviteDeepLinkHandler — parses flash://invite?token=…, stores the token, previews the invite, and redeems on onboarding (welcome.tsx), routing new users into registration with the invite context.
  • Wiring: GraphQL ops (createInvite, redeemInvite, invitePreview), generated types, i18n strings, navigation (routes + deep-link skip), a Settings entry, and a Home-screen QuickStart entry.

How it was built

This branch was re-grafted onto current main rather than merged from the ~8-month-old original branch (which was pinned to RN 0.72). The graft kept npubByUsername (the old branch's diff wrongly deleted it), hand-grafted the generated GraphQL types (the backend invite schema isn't in the published schema yet), and added the native bits (contacts permission, a separate flash://invite Android intent that leaves the widget deep-link untouched).

Tests

  • 18 green: InviteFriend (5), InviteFriendSuccess (3), HandleInviteDeepLink/redeemPendingInvite + deep-link parsing (10).
  • yarn jest app/screens/invite-friend/
  • Full tsc --noEmit clean across the app.

Notes for review

  • Gated on the backend: the GraphQL ops call schema types that ship in flash#462 — the feature only works end-to-end once that's merged + deployed.
  • iOS build not verified here (no Xcode/bench in the build environment); pod install ran clean and regenerated Podfile.lock. react-native-permissions ^4.1.5 may want a v5 bump for RN 0.77's New Architecture at build time (JS API is stable v4→v5, so it doesn't affect the graft).
  • Deliberately skipped: the 4 phone-auth pre-fill deltas from the old branch — they alter a shared auth-hook signature main has evolved and are only UX polish (deep-link redemption works without them; the route params degrade gracefully).

Activation dependency (out of repo)

The backend sends invite links as https://getflash.io/invite?token=…. The app registers only the flash://invite scheme — HTTPS links require either a getflash.io/invite landing page that redirects to flash://invite?token=… (recommended, no app change) or App Links / Universal Links (assetlinks.json + AASA served on getflash.io). Until one exists, HTTPS invite links open the browser, not the app.

Post-review hardening (Simon review, addressed)

  • Launch URL is processed exactly once (no token resurrection or spurious alerts on auth flips).
  • Token parsing is format-agnostic ([^&#\s]+ + decode) — the backend owns validation.
  • The pending token is stored only for unauthenticated sessions, as {token, storedAt} with a 24h TTL — no cross-account leak.
  • The stored token is cleared only on success or terminal errors; transient failures keep it for retry. In-flight guard prevents double redemption.
  • Dead X-Invite-Recipient header, dead getStarted invite branch, and unused invite route params removed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg

Dread and others added 4 commits July 28, 2026 13:07
Re-integrates the invite-a-friend feature (previously on the stale RN-0.72
feat/new-invite-friend branch) onto current main (RN 0.77) as a fresh graft
rather than a conflict-ridden rebase.

New files (verbatim): invite-friend screens (InviteFriend, InviteFriendSuccess,
HandleInviteDeepLink, InviteDeepLinkHandler), contact-picker, Email/PhoneNumber
inputs, settings entry, assets, and the screen test.

Grafted onto main's current versions:
- navigation: register InviteFriend/InviteFriendSuccess routes + param types;
  skip invite URLs in the container so InviteDeepLinkHandler owns them.
- graphql: createInvite/redeemInvite mutations + invitePreview query; hand-grafted
  the generated types/hooks (codegen can't run — backend invite schema lives only
  on the unmerged backend feat/invite branch). Kept npubByUsername (the old branch
  diff wrongly deleted it).
- i18n: HomeScreen.invite{Title,Desc}, SettingsScreen.inviteFriend, InviteFriend.*
  in en + raw en.json + i18n-types.
- app.tsx: wrap RootStack in InviteDeepLinkHandler (onto main's new provider tree,
  without the branch's dropped NostrGroupChatProvider).
- QuickStart: invite quick-action, merged with main's disabled/pending item logic.
- welcome/get-started: pending-invite redemption + invite-token registration routing.
- contact-picker: dropped phantom i18n keys (never existed; literal fallbacks kept),
  typed options as AlertButton[], Text type h3->h2.

Native: added react-native-contacts + react-native-permissions (Contacts only) to
Podfile/Info.plist/AndroidManifest/package.json. pod install succeeded and
regenerated Podfile.lock.

Deliberately SKIPPED: the branch's phone-auth pre-fill changes (phone-login/
registration-input, request-phone-code-*). They alter the useRequestPhoneCodeLogin
signature and touch critical shared auth code main has evolved. The feature works
without them — the deep-link handler stores the token and welcome.tsx redeems it;
pre-fill is only a registration UX nicety, deferred pending an auth-flow build.

Verified: full tsc typecheck clean (0 app errors); invite screen test 5/5 pass;
pod install clean. NOT verified here: iOS Xcode build/link, and end-to-end behavior
(gated on the backend feat/invite schema being deployed). react-native-permissions
pinned at ^4.1.5 may want a v5 bump for RN 0.77 New Arch at build time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
Expand invite coverage beyond the InviteFriend screen: InviteFriendSuccess
(contact interpolation, fallback, Done->popToTop) and HandleInviteDeepLink
(redeemPendingInvite success/no-token/duplicate/error paths, useInviteDeepLink
token parsing + store->preview->navigate). 18/18 green (5 existing + 13 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
… lint)

The grafted invite feature was never CI'd on this branch (RN 0.77):

- codegen: add invite types + Mutation/Query fields to the local
  public-schema.graphql so dev:codegen succeeds; regenerate generated.ts/gql
  canonically (replaces the earlier hand-graft).
- i18n: run update-translations (regenerates i18n-types.ts) and propagate the
  12 invite keys into all 23 locales (English fallback) to clear translation-drift.
- QuickStart: fix raster-asset detection (typeof !== 'function' — the old
  'typeof === number' check crashed under jest's PNG stub); make quick-start
  spec load the 'en' locale so it matches real titles order-independently.
- lint: format the never-linted invite files, drop unused vars, type
  redeemInviteMutation as RedeemInviteMutationFn, tidy InviteFriendSuccess styles.

Gates: jest 222 pass / 0 fail, tsc:check clean, translation-drift clean,
lint-changed 0 errors. Native Android/iOS build checks still need a device build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
lint-changed (react-native/no-inline-styles) flagged the graft's spacer
<View style={{ height: 20 }} />. Move it to styles.spacer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

🎤 Simon Review — mobile client

Adversarial senior review.

Verdict: NEEDS WORK

The InviteFriend create-invite screen and redeemPendingInvite are solid and well-tested. But the deep-link entry and redemption layer — the part that actually onboards a referred user — has a cluster of real correctness bugs. It's gated on the backend so nothing's on fire, but it is below the bar as-is.

Blocking

  1. app/screens/invite-friend/HandleInviteDeepLink.tsx:41 — invite links are https://getflash.io/invite?… per the code's own comment, but nothing registers HTTPS links with either OS. Android adds only a flash://invite scheme filter; iOS adds no associated-domains/AASA. Custom flash:// schemes aren't tappable in WhatsApp/SMS/email — the primary delivery channels — so a real invite opens Safari, not the app, and redemption never starts. Confirm what the backend notification emits: if https, this is a hard blocker (register Android App Links + iOS Universal Links); if genuinely flash://, delete the misleading https claim.
  2. HandleInviteDeepLink.tsx:19-35 — the deep-link effect keys on isAuthed and re-runs Linking.getInitialURL(), which keeps returning the launch URL for the whole process, re-storing the just-redeemed token and firing a wrong "new users only" alert at the user who just onboarded. Process the initial URL once (ref guard), and don't setItem the token in the pre-auth branch.

Should fix

  1. HandleInviteDeepLink.tsx:56 — invite token is stored globally in AsyncStorage before the auth check and never namespaced/expired, so it leaks across accounts. An already-authed user taps an invite → token stored, never cleared → next login of a different account on that device redeems it, crediting the referral to the wrong person. Namespace + expire the token; never store it for an already-authed session.
  2. HandleInviteDeepLink.tsx:45 — token regex is ([a-f0-9]{40}), unanchored. It matches the backend's current 20-byte hex token, but any change (UUID, uppercase, base64url) silently no-matches (dead-end, no alert), and a longer token is truncated to 40 chars and sent anyway. Capture generically ([^&#\s]+) and let the backend validate.
  3. HandleInviteDeepLink.tsx:148 — token removeItem'd before checking success, so a transient/server error in errors[] burns a valid invite forever. Only clear on success or known-terminal errors.
  4. app/screens/get-started-screen/get-started-screen.tsx:82-96 — the invite-token registration branch is dead code (nothing navigates to getStarted with those params; redemption actually runs via AsyncStorage + Welcome). Delete it or wire it; as-is it's misleading and untested.

Notes

  • Deep-link tests cover redeemPendingInvite well but skip the risky useInviteDeepLink paths (malformed token, authed-user branch, invitePreview.isValid===false, phone-flow nav) — exactly where the bugs above live.
  • X-Invite-Recipient: true (:63) is client-spoofable; confirm the backend doesn't widen invitePreview.contact based on it.

🤖 Simon-style adversarial review generated with Claude Code. Findings are advisory — verify before acting.

Dread and others added 4 commits July 31, 2026 15:08
- Launch URL processed exactly once (useRef guard): no re-storing an
  already-redeemed token or spurious 'new users only' alerts on auth flips.
- Token extraction is format-agnostic ([^&#\s]+ + decodeURIComponent);
  the backend owns token validation.
- Pending token stored only for unauthenticated sessions, as {token, storedAt}
  with a 24h TTL matching backend invite expiry — closes the cross-account
  redemption leak.
- Stored token cleared only on success or terminal errors (expired/used/
  invalid/own); transient failures keep it for retry. Module-level in-flight
  guard collapses concurrent redemptions to one mutation.
- Removed the backend-ignored X-Invite-Recipient header, the dead getStarted
  invite branch, and the unused invite route-param extensions.
- Comment now states the real link model: app registers flash:// only; the
  backend's https://getflash.io/invite links need the landing redirect or
  App/Universal Links (out-of-repo activation dependency).

Tests: HandleInviteDeepLink 10->21 scenarios. Full suite 233/233; tsc,
lint-changed, translation-drift clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
…preview-error triage

- Declare READ_CONTACTS in the Android manifest: the Contacts tab (InviteFriend
  default) was permanently dead on Android — request() returns BLOCKED for an
  undeclared permission and Settings offers no toggle. iOS half already existed.
- invitePreview errors are now triaged: network errors keep the stored token
  and ask to retry; GraphQL errors (the backend throws for malformed/unknown
  tokens) clear the token and surface an invalid-invitation alert — no more
  doomed tokens lingering for 24h with repeat alerts.
- decodeURIComponent runs inside try with raw fallback (malformed %-encoding
  no longer escapes the async handler as an unhandled rejection).
- Dropped phantom nav params (declared undefined, consumed by nothing) and
  their as-never casts; redemption rides AsyncStorage as designed.
- TERMINAL_REDEEM_ERRORS gains the resolver's two other permanent errors
  ('for new users only', 'a different phone number').
- Contact picker awaits Contacts.getAll() so the loading state is real.

Tests: HandleInviteDeepLink 20 -> 24 scenarios. Full suite 237/237; tsc,
lint-changed clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
…light guard

'Unparseable'->'Unparsable' (typos CI); eslint-disable with rationale on the
finally reset of redeemInFlight (guard is set synchronously before any await).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
…atches

The backend's one-reward-per-invitee and revoked-invite fixes introduced two
new permanent error strings; recognize both ('already redeemed an invitation'
clears silently, 'no longer valid' clears with notice) so doomed tokens are
not kept for 24h of futile retries. Alert suppression generalized to a
SILENT_REDEEM_ERRORS list; test fixture wording aligned with the resolver's
actual prose. +2 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7z7o9J18BWbUnYJcemMtg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant