feat(invites): viral share hub with hero card + one-tap social share#860
feat(invites): viral share hub with hero card + one-tap social share#860jwalin-shah wants to merge 1 commit into
Conversation
…ite nudge Turn invite/referral flows into proper viral loops so users actually share: - Hero ViralInviteCard with gradient mesh — screenshottable, personalized, tap-to-copy code + short URL. - SocialShareRow with platform-native intents (X, Telegram, WhatsApp, LinkedIn, Reddit, Email), native share sheet fallback, and copy button. - InviteProgressBar gamifies the "share your 5 codes" goal. - Invites page redesigned around the hero + one-tap share; Referral Rewards section uses the same hero + share row (unified UX). - Home gains a gradient-wrapped "Give OpenHuman to a friend" nudge that links into the full share flow. - Welcome swaps generic copy for a tighter value prop + social-proof row. - New share utils: buildInviteUrl, buildShareUrl, shareOn, tryNativeShare, copyToClipboard (with execCommand fallback for locked-down webviews). - New VITE_SHARE_BASE_URL config (default https://openhuman.ai) for the public invite links that land friends on the web entry point. - Unit tests cover URL builders, every platform intent, clipboard fallback path, and native share AbortError handling. https://claude.ai/code/session_0158dPfhqG9hQ1vkb4AoVkb3
📝 WalkthroughWalkthroughThe PR introduces a modularized sharing and referral invite system, replacing bespoke component logic with reusable utilities ( Changes
Sequence DiagramsequenceDiagram
actor User
participant ViralInviteCard as ViralInviteCard<br/>(UI)
participant SocialShareRow as SocialShareRow<br/>(UI)
participant Utilities as Share Utilities<br/>(shareOn, copyToClipboard,<br/>tryNativeShare)
participant Browser/OS as Browser/OS APIs<br/>(clipboard, navigator.share)
User->>ViralInviteCard: Click "Copy code"
ViralInviteCard->>Utilities: copyToClipboard(code)
Utilities->>Browser/OS: navigator.clipboard.writeText()
Browser/OS-->>Utilities: success/failure
Utilities-->>ViralInviteCard: boolean result
ViralInviteCard->>ViralInviteCard: Update button to "Copied!"
ViralInviteCard->>User: Show feedback
User->>SocialShareRow: Click platform button
SocialShareRow->>Utilities: shareOn(platform, text, url)
Utilities->>Utilities: buildShareUrl() → platform-specific URL
Utilities->>Browser/OS: window.open(shareUrl)
Browser/OS-->>User: Open platform in browser
User->>SocialShareRow: Click "More" (native share)
SocialShareRow->>Utilities: tryNativeShare({title, text, url})
Utilities->>Browser/OS: navigator.share(payload)
alt Native share available & user completes
Browser/OS-->>Utilities: resolved
Utilities-->>SocialShareRow: true
else User cancels (AbortError) or unavailable
Browser/OS-->>Utilities: AbortError / undefined
Utilities->>Utilities: Fallback: copyToClipboard(url)
Utilities-->>SocialShareRow: true/false
end
SocialShareRow->>SocialShareRow: Update button state
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
Suggested Reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (9)
app/src/pages/Home.tsx (1)
275-307: Duplicate invite CTA on Home.This new gradient nudge navigates to
/invites, and the existing "Invite a friend" row inside the "Next steps" list (lines 356–377) already does the same thing — both are visible on the first paint. Consider removing one to avoid two CTAs to the same destination on a single viewport. Given the PR description calls out calm-UI fit as an open question, dropping the bigger gradient card (or replacing the Next-steps row with it) would keep the surface calmer.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/pages/Home.tsx` around lines 275 - 307, There are two visible CTAs that both navigate to '/invites': the new gradient invite card (the button with onClick={() => navigate('/invites')} and the "Give OpenHuman to a friend" content) and the existing "Invite a friend" row inside the Next steps list; remove or consolidate one to avoid duplicate CTAs on first paint. Either delete the whole gradient button block (the <button ...> wrapper with the "Give OpenHuman to a friend" content) or replace the Next steps "Invite a friend" list item with this new gradient card so only one invite CTA remains; ensure navigation still uses navigate('/invites') and style/aria-label are preserved if you keep the gradient version.app/src/components/share/ViralInviteCard.tsx (2)
36-42: Cancel the reset timer on unmount / re-trigger.If the user navigates away within 1.8s of copying, the timer still fires and calls
setCopiedTargeton an unmounted component. React 19 swallows the warning but the leaked timer remains. Also, rapid double-clicks stack timeouts that race each other. Tracking the handle in a ref and clearing on unmount fixes both.♻️ Suggested change
-import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ - const [copiedTarget, setCopiedTarget] = useState<'code' | 'url' | null>(null); + const [copiedTarget, setCopiedTarget] = useState<'code' | 'url' | null>(null); + const resetTimerRef = useRef<number | null>(null); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current); + }, + [] + ); @@ - const copy = useCallback(async (target: 'code' | 'url', text: string) => { - const ok = await copyToClipboard(text); - if (ok) { - setCopiedTarget(target); - setTimeout(() => setCopiedTarget(null), 1800); - } - }, []); + const copy = useCallback(async (target: 'code' | 'url', text: string) => { + const ok = await copyToClipboard(text); + if (!ok) return; + setCopiedTarget(target); + if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current); + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopiedTarget(null); + }, 1800); + }, []);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/components/share/ViralInviteCard.tsx` around lines 36 - 42, The copy callback sets a 1.8s timeout but never clears it, causing timers to fire after unmount or stack on rapid re-triggers; update the copy function (useCallback named copy in ViralInviteCard) to store the timeout id in a ref (e.g., copyTimeoutRef), clear any existing timeout before creating a new one, and clear the ref on unmount (useEffect cleanup) so setCopiedTarget is never called after the component unmounts and multiple clicks don't stack timers.
87-103: Disable code-copy button whencodeis empty.When
codeis empty, the UI shows—but the button still firescopyToClipboard("")and reports success on most browsers — confusing for the user. Cheap guard:- <button - type="button" - onClick={() => void copy('code', code)} + <button + type="button" + onClick={() => void copy('code', code)} + disabled={!code} className="group flex items-center justify-between rounded-2xl border border-white/15 bg-white/10 px-4 py-3 text-left backdrop-blur-md transition-colors hover:bg-white/15" aria-label="Copy invite code">(Same idea applies to the link button if
urlis ever empty.)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/components/share/ViralInviteCard.tsx` around lines 87 - 103, The copy button currently calls copy('code', code) even when code is empty; update the ViralInviteCard button so it is disabled when code is falsy: guard the onClick to no-op (or remove) when !code, add disabled and aria-disabled attributes, and adjust the className to reflect disabled styling; ensure the copiedTarget logic only sets "Copied!" when a real code was copied. Apply the same pattern to the link button that calls copy('link', url) if url can be empty.app/src/pages/Welcome.tsx (1)
65-76: Decorative "social proof" with no actual proof.The four colored circles aren't avatars and "Trusted by builders worldwide" isn't backed by anything visible (a count, real avatars, logos). On the pre-auth landing page, this risks reading as a manufactured trust signal. Consider either (a) replacing with a concrete signal you can stand behind (real user count, named partners, GitHub stars, press logos) or (b) dropping it to keep the page calm — the PR description itself flags calm-UI fit as an open question.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/pages/Welcome.tsx` around lines 65 - 76, The decorative "social proof" block in Welcome.tsx (the div with className "flex items-center justify-center gap-2 mb-6" that contains the colored avatar spans and the "Trusted by builders worldwide" text) should be replaced or removed: either swap the four colored span placeholders with a concrete, verifiable signal (e.g., a real users count element, partner/logo images, or GitHub stars component and update the accompanying text accordingly) or remove the entire div to avoid a manufactured trust signal; update any related accessibility text or aria-labels for the new assets and ensure styling/layout (the inner "flex -space-x-1.5" group and the text span with className "text-[11px] font-medium text-stone-500") remain consistent with the page’s calm-UI design.app/src/components/share/InviteProgressBar.tsx (1)
36-46: Optional: associate the visible label with the progressbar for screen readers.The
role="progressbar"element has no accessible name —"Your invite streak"is a sibling, not linked. Addingaria-labelledby(oraria-label) makes the bar meaningful in a11y tools.♻️ Suggested change
- <div className="flex items-center justify-between text-[11px] font-medium uppercase tracking-wider text-stone-400"> - <span>Your invite streak</span> + <div className="flex items-center justify-between text-[11px] font-medium uppercase tracking-wider text-stone-400"> + <span id="invite-progress-label">Your invite streak</span> <span> {clamped}/{cap} </span> </div> <div className="h-2.5 w-full rounded-full bg-stone-100" role="progressbar" + aria-labelledby="invite-progress-label" aria-valuemin={0} aria-valuemax={cap} aria-valuenow={clamped}>Note: if multiple
InviteProgressBarinstances ever render on the same page, swap the static id foruseId()to keep ids unique.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/components/share/InviteProgressBar.tsx` around lines 36 - 46, The progressbar DIV that has role="progressbar" in InviteProgressBar has no accessible name; link it to the visible label by adding aria-labelledby pointing at that label's id (or add aria-label) and ensure the label element has a unique id; generate a unique id using React's useId() inside InviteProgressBar and apply it to the label (e.g., the "Your invite streak" text) and to the progressbar's aria-labelledby so screen readers associate the two (if you prefer a single static label fallback, use aria-label but prefer aria-labelledby with useId for multiple instances).app/src/utils/share.ts (2)
105-125:AbortError → trueconflates "shared" with "cancelled" for callers.Returning
trueon user cancellation is convenient for the "should I show a copy fallback?" question but loses information for analytics and UX.SocialShareRow.handleNativeShare(Line 178-182) reportsonShare('native')for both genuine shares and cancellations, so any share-funnel metric built on this will be inflated. Consider returning a discriminated result instead:type NativeShareResult = 'shared' | 'cancelled' | 'unsupported' | 'failed';…and let callers decide whether cancellation should suppress the copy fallback (yes) and whether it should fire analytics (no). This is the same concern raised on
SocialShareRow.tsx; flagging it here as the root cause.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/utils/share.ts` around lines 105 - 125, Change tryNativeShare to return a discriminated result type instead of boolean: define type NativeShareResult = 'shared' | 'cancelled' | 'unsupported' | 'failed' and change the function signature to Promise<NativeShareResult>; if navigator or navigator.share is unavailable return 'unsupported', on successful await return 'shared', if caught error has name 'AbortError' return 'cancelled', otherwise log the error and return 'failed'; update callers like SocialShareRow.handleNativeShare to branch on these string results so cancellation can be treated differently from a real share or a failure.
31-33: Hardcoded English share copy.
defaultInviteMessagewill surface this exact string into every platform intent regardless of the user's locale. Once i18n lands (or if it's already in place via another util), this should route through the translation layer. Not a blocker for the prototype.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/utils/share.ts` around lines 31 - 33, The share message is hardcoded in defaultInviteMessage — route it through the app's i18n/translation layer instead. Replace the plain template in defaultInviteMessage(code, url) with a call to the translation utility (e.g., t('invite.message', { code, url }) or i18n.translate('invite.message', { code, url })) so the string is localized and uses interpolation keys; update translation resource files to include the corresponding message key for each locale.app/src/utils/__tests__/share.test.ts (2)
86-109: Missing happy-path coverage fortryNativeShare.The current tests only cover (a)
navigator.sharemissing and (b)AbortError. There's no assertion thattryNativeSharereturnstrueand forwards the payload tonavigator.shareon a normal resolve, nor that other rejections (e.g.NotAllowedError) returnfalse. These are the cases theSocialShareRowfallback chain actually depends on.🧪 Suggested additional cases
+ it('forwards payload and returns true on success', async () => { + const share = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'share', { value: share, configurable: true }); + const payload = { title: 't', text: 'm', url: 'x' }; + await expect(tryNativeShare(payload)).resolves.toBe(true); + expect(share).toHaveBeenCalledWith(payload); + }); + + it('returns false on non-Abort rejection', async () => { + const share = vi.fn().mockRejectedValue(new Error('boom')); + Object.defineProperty(navigator, 'share', { value: share, configurable: true }); + await expect(tryNativeShare({ url: 'x' })).resolves.toBe(false); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/utils/__tests__/share.test.ts` around lines 86 - 109, Add two more tests for tryNativeShare: one "returns true and forwards payload on successful share" that stubs navigator.share with a vi.fn().mockResolvedValue(undefined), calls tryNativeShare with a payload (e.g., { url: 'x', text: 'y' }), asserts it resolves to true and that the mock was called with the exact payload; and one "returns false for non-AbortError rejections" that stubs navigator.share with vi.fn().mockRejectedValue(err) where err.name = 'NotAllowedError' (or another non-'AbortError'), asserts tryNativeShare resolves to false and that the mock was called. Use Object.defineProperty to set navigator.share and restore the original as in existing tests.
26-55: Coverage gap onbuildShareUrlanddefaultInviteMessage.Half of the
SharePlatformunion —sms— is untested inbuildShareUrl, anddefaultInviteMessage(also exported fromshare.ts) has no tests at all. Given these strings are user-visible and feed share intents, regressions (e.g. accidentally swappingtext/urlquery params) won't be caught by the current suite. Adding a single parametrizedit.eachwould cover all eight platforms cheaply.🧪 Suggested addition
+ it.each([ + ['linkedin', 'https://www.linkedin.com/sharing/share-offsite/?url='], + ['facebook', 'https://www.facebook.com/sharer/sharer.php?u='], + ['reddit', 'https://www.reddit.com/submit?'], + ['sms', 'sms:?&body='], + ] as const)('builds %s share url', (platform, prefix) => { + const result = buildShareUrl(platform, text, url); + expect(result.startsWith(prefix)).toBe(true); + expect(result).toContain(encodeURIComponent(url)); + }); + + it('defaultInviteMessage embeds code and url', () => { + const msg = defaultInviteMessage('ABC123', 'https://openhuman.ai/i/ABC123'); + expect(msg).toContain('ABC123'); + expect(msg).toContain('https://openhuman.ai/i/ABC123'); + });As per coding guidelines: "Ship unit tests and coverage for behavior you are adding or changing before building additional features on top".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/utils/__tests__/share.test.ts` around lines 26 - 55, Tests for buildShareUrl and defaultInviteMessage are incomplete: add parameterized tests covering the remaining SharePlatform values ('linkedin', 'facebook', 'reddit', 'sms') and assert their expected URL patterns/encoded params, and add tests for defaultInviteMessage to ensure it returns the expected invite string; update app/src/utils/__tests__/share.test.ts to use it.each with rows for all eight platforms and expected substrings (referencing buildShareUrl and SharePlatform) and add a separate test asserting defaultInviteMessage's exact value or required substrings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/components/rewards/ReferralRewardsSection.tsx`:
- Around line 92-99: The share URL currently falls back to the raw referralCode
in the useMemo that computes shareUrl (using stats?.referralLink?.trim() ||
stats?.referralCode?.trim()), which breaks downstream ViralInviteCard.url and
SocialShareRow.url; change the fallback to construct a proper URL via
buildInviteUrl(...) instead. Locate the useMemo that defines shareUrl and
replace the fallback to stats?.referralCode?.trim() with
buildInviteUrl(stats?.referralCode?.trim() || ''), and update any other
identical fallback useMemo instances (the similar blocks around the other
occurrences referenced) so defaultInviteMessage and components receive a full
URL rather than a bare code. Ensure you import or reference buildInviteUrl where
needed and preserve trimming/empty-string guards.
In `@app/src/components/share/SocialShareRow.tsx`:
- Around line 242-247: The copy button in SocialShareRow contains a redundant
ternary for sizing; replace the duplicated conditional in the className on the
button (the expression that currently returns 'h-9 px-3 text-xs font-medium' for
both dense branches) with a single class string to collapse the ternary, and
while editing confirm whether the copy button should mimic the other platform
buttons' dense behavior (i.e., become icon-only with classes like 'h-9 w-9') and
implement that change on the same button if intended; locate the button by
referencing SocialShareRow, the onClick handler handleCopy, and the copied/dense
props to make the update.
- Around line 176-191: The native-share flow conflates user-cancel with success
and fires onShare('native') even on AbortError; update tryNativeShare to return
a tri-state (e.g. 'shared' | 'cancelled' | 'unsupported') and change
handleNativeShare to only call onShare('native') when the result === 'shared'
while falling back to handleCopy for 'unsupported' and doing nothing for
'cancelled'. Also wrap the await shareOn(...) call inside handlePlatform in a
try/catch so that if shareOn (or openUrl) rejects you still call or safely skip
onShare(platform) and avoid unhandled promise rejections; reference functions:
tryNativeShare, handleNativeShare, handleCopy, shareOn, handlePlatform, and
onShare.
- Around line 1-27: The PlatformMeta interface uses the type-only
React.ReactNode; change this to a type import to follow guidelines: add "import
type { ReactNode } from 'react'" at the top and update the PlatformMeta.icon
declaration to use ReactNode (remove the React. prefix). Locate the PlatformMeta
interface in SocialShareRow.tsx and replace the type usage accordingly so only a
type-only import is used.
In `@app/src/pages/Invites.tsx`:
- Around line 143-178: availableCode currently falls back to codes[0], which can
be fully redeemed and causes the hero (ViralInviteCard / SocialShareRow) to
render incorrectly; change availableCode to return undefined when no code has
currentUses < maxUses (i.e. remove the fallback to codes[0]) so heroCode becomes
undefined in that case, and ensure heroUrl and heroMessage derive from heroCode
so the hero conditional (heroCode ? …) naturally prevents rendering the invite
card and share row when all codes are maxed out.
In `@app/src/utils/share.ts`:
- Around line 24-28: The buildInviteUrl function silently returns SHARE_BASE_URL
when code is empty/whitespace; change buildInviteUrl (exported function
buildInviteUrl) to return an empty string (or throw) when trimmed is falsy
instead of returning SHARE_BASE_URL, so callers can detect "no code" (e.g.,
heroCode = availableCode?.code ?? '') and avoid rendering the share affordance;
update any call sites that currently assume a URL (particularly the component
that renders the share row) to gate rendering on a non-empty return from
buildInviteUrl.
- Around line 18-28: The JSDoc for buildInviteUrl is inaccurate: it claims the
invite is a ?invite=CODE query and mentions openhuman:// deep links, but the
function (buildInviteUrl) actually returns a path-style URL using SHARE_BASE_URL
+ '/i/' + encodeURIComponent(trimmed). Update the JSDoc to describe the actual
behavior (trims the code, URL-encodes it, and returns SHARE_BASE_URL/i/CODE) and
remove the misleading deep-link claim (or, if you prefer to implement
deep-linking, change buildInviteUrl to also return the deep link or provide a
separate function). Ensure the doc references buildInviteUrl and SHARE_BASE_URL
and accurately documents trimming and encoding.
- Line 51: The sms share URI currently built in the sms property (sms: (text,
url) => `sms:?&body=...`) uses the `?&` separator which breaks body parsing on
iOS; update the sms builder in the sms function to use
`sms:?body=${encodeURIComponent(`${text} ${url}`)}` (remove the extra `&`) for
cross-platform compatibility, or alternatively implement simple UA detection
inside the same sms function to return `sms:;body=...` for iOS user agents and
`sms:?body=...` for others if you need to handle edge cases.
---
Nitpick comments:
In `@app/src/components/share/InviteProgressBar.tsx`:
- Around line 36-46: The progressbar DIV that has role="progressbar" in
InviteProgressBar has no accessible name; link it to the visible label by adding
aria-labelledby pointing at that label's id (or add aria-label) and ensure the
label element has a unique id; generate a unique id using React's useId() inside
InviteProgressBar and apply it to the label (e.g., the "Your invite streak"
text) and to the progressbar's aria-labelledby so screen readers associate the
two (if you prefer a single static label fallback, use aria-label but prefer
aria-labelledby with useId for multiple instances).
In `@app/src/components/share/ViralInviteCard.tsx`:
- Around line 36-42: The copy callback sets a 1.8s timeout but never clears it,
causing timers to fire after unmount or stack on rapid re-triggers; update the
copy function (useCallback named copy in ViralInviteCard) to store the timeout
id in a ref (e.g., copyTimeoutRef), clear any existing timeout before creating a
new one, and clear the ref on unmount (useEffect cleanup) so setCopiedTarget is
never called after the component unmounts and multiple clicks don't stack
timers.
- Around line 87-103: The copy button currently calls copy('code', code) even
when code is empty; update the ViralInviteCard button so it is disabled when
code is falsy: guard the onClick to no-op (or remove) when !code, add disabled
and aria-disabled attributes, and adjust the className to reflect disabled
styling; ensure the copiedTarget logic only sets "Copied!" when a real code was
copied. Apply the same pattern to the link button that calls copy('link', url)
if url can be empty.
In `@app/src/pages/Home.tsx`:
- Around line 275-307: There are two visible CTAs that both navigate to
'/invites': the new gradient invite card (the button with onClick={() =>
navigate('/invites')} and the "Give OpenHuman to a friend" content) and the
existing "Invite a friend" row inside the Next steps list; remove or consolidate
one to avoid duplicate CTAs on first paint. Either delete the whole gradient
button block (the <button ...> wrapper with the "Give OpenHuman to a friend"
content) or replace the Next steps "Invite a friend" list item with this new
gradient card so only one invite CTA remains; ensure navigation still uses
navigate('/invites') and style/aria-label are preserved if you keep the gradient
version.
In `@app/src/pages/Welcome.tsx`:
- Around line 65-76: The decorative "social proof" block in Welcome.tsx (the div
with className "flex items-center justify-center gap-2 mb-6" that contains the
colored avatar spans and the "Trusted by builders worldwide" text) should be
replaced or removed: either swap the four colored span placeholders with a
concrete, verifiable signal (e.g., a real users count element, partner/logo
images, or GitHub stars component and update the accompanying text accordingly)
or remove the entire div to avoid a manufactured trust signal; update any
related accessibility text or aria-labels for the new assets and ensure
styling/layout (the inner "flex -space-x-1.5" group and the text span with
className "text-[11px] font-medium text-stone-500") remain consistent with the
page’s calm-UI design.
In `@app/src/utils/__tests__/share.test.ts`:
- Around line 86-109: Add two more tests for tryNativeShare: one "returns true
and forwards payload on successful share" that stubs navigator.share with a
vi.fn().mockResolvedValue(undefined), calls tryNativeShare with a payload (e.g.,
{ url: 'x', text: 'y' }), asserts it resolves to true and that the mock was
called with the exact payload; and one "returns false for non-AbortError
rejections" that stubs navigator.share with vi.fn().mockRejectedValue(err) where
err.name = 'NotAllowedError' (or another non-'AbortError'), asserts
tryNativeShare resolves to false and that the mock was called. Use
Object.defineProperty to set navigator.share and restore the original as in
existing tests.
- Around line 26-55: Tests for buildShareUrl and defaultInviteMessage are
incomplete: add parameterized tests covering the remaining SharePlatform values
('linkedin', 'facebook', 'reddit', 'sms') and assert their expected URL
patterns/encoded params, and add tests for defaultInviteMessage to ensure it
returns the expected invite string; update app/src/utils/__tests__/share.test.ts
to use it.each with rows for all eight platforms and expected substrings
(referencing buildShareUrl and SharePlatform) and add a separate test asserting
defaultInviteMessage's exact value or required substrings.
In `@app/src/utils/share.ts`:
- Around line 105-125: Change tryNativeShare to return a discriminated result
type instead of boolean: define type NativeShareResult = 'shared' | 'cancelled'
| 'unsupported' | 'failed' and change the function signature to
Promise<NativeShareResult>; if navigator or navigator.share is unavailable
return 'unsupported', on successful await return 'shared', if caught error has
name 'AbortError' return 'cancelled', otherwise log the error and return
'failed'; update callers like SocialShareRow.handleNativeShare to branch on
these string results so cancellation can be treated differently from a real
share or a failure.
- Around line 31-33: The share message is hardcoded in defaultInviteMessage —
route it through the app's i18n/translation layer instead. Replace the plain
template in defaultInviteMessage(code, url) with a call to the translation
utility (e.g., t('invite.message', { code, url }) or
i18n.translate('invite.message', { code, url })) so the string is localized and
uses interpolation keys; update translation resource files to include the
corresponding message key for each locale.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 29ae2616-7194-4fc0-981d-025a58c634cb
📒 Files selected for processing (11)
app/src/components/rewards/ReferralRewardsSection.tsxapp/src/components/share/InviteProgressBar.tsxapp/src/components/share/SocialShareRow.tsxapp/src/components/share/ViralInviteCard.tsxapp/src/pages/Home.tsxapp/src/pages/Invites.tsxapp/src/pages/Welcome.tsxapp/src/test/setup.tsapp/src/utils/__tests__/share.test.tsapp/src/utils/config.tsapp/src/utils/share.ts
| const shareUrl = useMemo( | ||
| () => stats?.referralLink?.trim() || stats?.referralCode?.trim() || '', | ||
| [stats?.referralLink, stats?.referralCode] | ||
| ); | ||
| const shareMessage = useMemo( | ||
| () => defaultInviteMessage(stats?.referralCode?.trim() || 'OPENHUMAN', shareUrl), | ||
| [stats?.referralCode, shareUrl] | ||
| ); |
There was a problem hiding this comment.
Bug: shareUrl falls back to a bare referral code, breaking social share intents.
When the backend doesn't populate stats.referralLink, shareUrl becomes the raw referralCode (e.g. "ABC123") and is then handed to ViralInviteCard.url and SocialShareRow.url. SocialShareRow interpolates it into x.com/intent/tweet?url=…, wa.me/?text=…, mailto bodies, etc., producing broken/non-clickable share targets. Invites.tsx already uses buildInviteUrl(code) for the same flow — ReferralRewardsSection should do the same so the two surfaces stay consistent.
🐛 Proposed fix
-import { defaultInviteMessage } from '../../utils/share';
+import { buildInviteUrl, defaultInviteMessage } from '../../utils/share';
@@
- const shareUrl = useMemo(
- () => stats?.referralLink?.trim() || stats?.referralCode?.trim() || '',
- [stats?.referralLink, stats?.referralCode]
- );
+ const shareUrl = useMemo(() => {
+ const link = stats?.referralLink?.trim();
+ if (link) return link;
+ const code = stats?.referralCode?.trim();
+ return code ? buildInviteUrl(code) : '';
+ }, [stats?.referralLink, stats?.referralCode]);
const shareMessage = useMemo(
() => defaultInviteMessage(stats?.referralCode?.trim() || 'OPENHUMAN', shareUrl),
[stats?.referralCode, shareUrl]
);
@@
- <ViralInviteCard
- code={stats.referralCode}
- url={shareUrl || stats.referralCode}
+ <ViralInviteCard
+ code={stats.referralCode}
+ url={shareUrl}
@@
- <SocialShareRow
- url={shareUrl || stats.referralCode}
+ <SocialShareRow
+ url={shareUrl}
message={shareMessage}
variant="spacious"
/>Also applies to: 141-153, 177-181
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/rewards/ReferralRewardsSection.tsx` around lines 92 - 99,
The share URL currently falls back to the raw referralCode in the useMemo that
computes shareUrl (using stats?.referralLink?.trim() ||
stats?.referralCode?.trim()), which breaks downstream ViralInviteCard.url and
SocialShareRow.url; change the fallback to construct a proper URL via
buildInviteUrl(...) instead. Locate the useMemo that defines shareUrl and
replace the fallback to stats?.referralCode?.trim() with
buildInviteUrl(stats?.referralCode?.trim() || ''), and update any other
identical fallback useMemo instances (the similar blocks around the other
occurrences referenced) so defaultInviteMessage and components receive a full
URL rather than a bare code. Ensure you import or reference buildInviteUrl where
needed and preserve trimming/empty-string guards.
| import debugFactory from 'debug'; | ||
| import { useCallback, useState } from 'react'; | ||
|
|
||
| import { copyToClipboard, shareOn, type SharePlatform, tryNativeShare } from '../../utils/share'; | ||
|
|
||
| const log = debugFactory('share:row'); | ||
|
|
||
| interface SocialShareRowProps { | ||
| /** Share URL (typically the invite or referral link). */ | ||
| url: string; | ||
| /** Text blurb to prefill each social intent. */ | ||
| message: string; | ||
| /** Dense (icon-only) vs spacious (icon + label) rendering. */ | ||
| variant?: 'dense' | 'spacious'; | ||
| /** Platforms to render, in the order shown. Defaults to the full set. */ | ||
| platforms?: SharePlatform[]; | ||
| /** Called after a share intent opens (analytics hook). */ | ||
| onShare?: (platform: SharePlatform | 'native' | 'copy') => void; | ||
| } | ||
|
|
||
| interface PlatformMeta { | ||
| id: SharePlatform; | ||
| label: string; | ||
| bg: string; | ||
| hover: string; | ||
| icon: React.ReactNode; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm React isn't otherwise pulled into scope via tsconfig jsxImportSource or a global types augmentation.
fd -e json -i tsconfig | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'
rg -nP "jsx(ImportSource|Factory)" --type=json
rg -nP "^\s*import\s+\*\s+as\s+React" app/src/components/share/SocialShareRow.tsxRepository: tinyhumansai/openhuman
Length of output: 1729
🏁 Script executed:
cat -n app/src/components/share/SocialShareRow.tsx | head -30Repository: tinyhumansai/openhuman
Length of output: 1203
🏁 Script executed:
cd app && npx tsc --noEmit 2>&1 | grep -A 2 -B 2 "SocialShareRow\|React.ReactNode" | head -20Repository: tinyhumansai/openhuman
Length of output: 48
🏁 Script executed:
cd app && npx tsc --noEmit 2>&1 | head -30Repository: tinyhumansai/openhuman
Length of output: 307
🏁 Script executed:
cat app/package.json | grep -A 2 '"react"'
rg "declare\s+global|declare\s+namespace\s+React" app/src
fd -e d.ts | xargs grep -l "namespace React" 2>/dev/null | head -5Repository: tinyhumansai/openhuman
Length of output: 229
🏁 Script executed:
cat app/src/vite-env.d.ts
echo "---"
cat app/src/types/global.d.tsRepository: tinyhumansai/openhuman
Length of output: 1018
Use import type for the ReactNode type reference.
While the code currently passes typecheck, the type-only usage of React.ReactNode on line 26 should follow the guideline of importing type-only imports explicitly. Import ReactNode directly and use it without the React. prefix.
Suggested change
-import { useCallback, useState } from 'react';
+import { useCallback, useState } from 'react';
+import type { ReactNode } from 'react';Then update line 26:
- icon: React.ReactNode;
+ icon: ReactNode;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/share/SocialShareRow.tsx` around lines 1 - 27, The
PlatformMeta interface uses the type-only React.ReactNode; change this to a type
import to follow guidelines: add "import type { ReactNode } from 'react'" at the
top and update the PlatformMeta.icon declaration to use ReactNode (remove the
React. prefix). Locate the PlatformMeta interface in SocialShareRow.tsx and
replace the type usage accordingly so only a type-only import is used.
| const handleNativeShare = useCallback(async () => { | ||
| const ok = await tryNativeShare({ title: 'OpenHuman', text: message, url }); | ||
| if (ok) { | ||
| onShare?.('native'); | ||
| } else { | ||
| await handleCopy(); | ||
| } | ||
| }, [message, url, onShare, handleCopy]); | ||
|
|
||
| const handlePlatform = useCallback( | ||
| async (platform: SharePlatform) => { | ||
| await shareOn(platform, message, url); | ||
| onShare?.(platform); | ||
| }, | ||
| [message, url, onShare] | ||
| ); |
There was a problem hiding this comment.
onShare('native') fires even when the user cancels the native share sheet.
tryNativeShare deliberately returns true on AbortError (see app/src/utils/share.ts Lines 117-121, also asserted in the test suite). That collapses two distinct outcomes — successful share vs. user cancellation — into a single 'native' analytics event, which will inflate share-success metrics. If onShare is intended for analytics, consider returning a tri-state from tryNativeShare (e.g. 'shared' | 'cancelled' | 'unsupported') and only firing onShare('native') on the 'shared' branch.
Additionally, shareOn (Line 187) is awaited but its rejection isn't caught; if openUrl throws, the onShare(platform) call below never runs and the rejection becomes an unhandled promise rejection through the void handlePlatform(id) wrapper. A small try/catch around it would harden the UX.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/share/SocialShareRow.tsx` around lines 176 - 191, The
native-share flow conflates user-cancel with success and fires onShare('native')
even on AbortError; update tryNativeShare to return a tri-state (e.g. 'shared' |
'cancelled' | 'unsupported') and change handleNativeShare to only call
onShare('native') when the result === 'shared' while falling back to handleCopy
for 'unsupported' and doing nothing for 'cancelled'. Also wrap the await
shareOn(...) call inside handlePlatform in a try/catch so that if shareOn (or
openUrl) rejects you still call or safely skip onShare(platform) and avoid
unhandled promise rejections; reference functions: tryNativeShare,
handleNativeShare, handleCopy, shareOn, handlePlatform, and onShare.
| <button | ||
| type="button" | ||
| onClick={() => void handleCopy()} | ||
| className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${ | ||
| copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800' | ||
| } ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}> |
There was a problem hiding this comment.
Redundant ternary on copy-button sizing.
Both branches of dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium' are identical — collapse to a single class string. Also worth verifying whether the copy button was intended to also become icon-only in dense mode (the platform buttons do shrink to h-9 w-9 when dense).
♻️ Proposed simplification
- className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${
- copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
- } ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}>
+ className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors ${
+ copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
+ }`}>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| type="button" | |
| onClick={() => void handleCopy()} | |
| className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${ | |
| copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800' | |
| } ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}> | |
| <button | |
| type="button" | |
| onClick={() => void handleCopy()} | |
| className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors ${ | |
| copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800' | |
| }`}> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/components/share/SocialShareRow.tsx` around lines 242 - 247, The copy
button in SocialShareRow contains a redundant ternary for sizing; replace the
duplicated conditional in the className on the button (the expression that
currently returns 'h-9 px-3 text-xs font-medium' for both dense branches) with a
single class string to collapse the ternary, and while editing confirm whether
the copy button should mimic the other platform buttons' dense behavior (i.e.,
become icon-only with classes like 'h-9 w-9') and implement that change on the
same button if intended; locate the button by referencing SocialShareRow, the
onClick handler handleCopy, and the copied/dense props to make the update.
| const availableCode = useMemo( | ||
| () => codes.find(c => c.currentUses < c.maxUses) ?? codes[0], | ||
| [codes] | ||
| ); | ||
|
|
||
| const convertedCount = useMemo( | ||
| () => codes.filter(c => c.currentUses >= c.maxUses).length, | ||
| [codes] | ||
| ); | ||
|
|
||
| const heroCode = availableCode?.code ?? ''; | ||
| const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]); | ||
| const heroMessage = useMemo( | ||
| () => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl), | ||
| [heroCode, heroUrl] | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="min-h-full flex items-center justify-center p-4 pt-6"> | ||
| <div className="max-w-md w-full space-y-4"> | ||
| <div> | ||
| <div className="space-y-4"> | ||
| {/* Redeem Section — shown only if user hasn't redeemed yet */} | ||
| {!hasBeenInvited && ( | ||
| <div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up"> | ||
| <h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2> | ||
| <p className="text-xs opacity-70 mb-4"> | ||
| Got a code from a friend? Enter it below to unlock free credits. | ||
| </p> | ||
| <div className="flex gap-2"> | ||
| <input | ||
| type="text" | ||
| value={redeemInput} | ||
| onChange={e => setRedeemInput(e.target.value.toUpperCase())} | ||
| onKeyDown={e => e.key === 'Enter' && handleRedeem()} | ||
| placeholder="Enter code" | ||
| className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" | ||
| disabled={redeemStatus === 'loading'} | ||
| /> | ||
| <button | ||
| onClick={handleRedeem} | ||
| disabled={redeemStatus === 'loading' || !redeemInput.trim()} | ||
| className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"> | ||
| {redeemStatus === 'loading' ? '...' : 'Redeem'} | ||
| </button> | ||
| </div> | ||
| {redeemStatus === 'success' && ( | ||
| <p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p> | ||
| )} | ||
| {redeemStatus === 'error' && redeemError && ( | ||
| <p className="text-coral-500 text-xs mt-2">{redeemError}</p> | ||
| )} | ||
| </div> | ||
| <div className="min-h-full p-4 pt-6 pb-10"> | ||
| <div className="mx-auto max-w-xl space-y-4"> | ||
| {/* Hero — shareable invite card */} | ||
| {heroCode ? ( | ||
| <div className="animate-fade-up space-y-3"> | ||
| <ViralInviteCard | ||
| code={heroCode} | ||
| url={heroUrl} | ||
| firstName={user?.firstName ?? undefined} | ||
| /> | ||
| <div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft"> | ||
| <p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400"> | ||
| Share with one tap | ||
| </p> | ||
| <SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" /> | ||
| </div> | ||
| </div> | ||
| ) : null} |
There was a problem hiding this comment.
Hero card can present a fully-redeemed code when no available code exists.
availableCode falls back to codes[0] when no code has remaining capacity, so the hero card + SocialShareRow will render and let the user share a code that no friend can redeem. Consider gating the hero on a truly-available code; if all are used up, showing the progress section ("you maxed out") alone reads better.
♻️ Suggested change
- const availableCode = useMemo(
- () => codes.find(c => c.currentUses < c.maxUses) ?? codes[0],
- [codes]
- );
+ const availableCode = useMemo(
+ () => codes.find(c => c.currentUses < c.maxUses),
+ [codes]
+ );heroCode ? … will then naturally hide the hero when there's nothing shareable left.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const availableCode = useMemo( | |
| () => codes.find(c => c.currentUses < c.maxUses) ?? codes[0], | |
| [codes] | |
| ); | |
| const convertedCount = useMemo( | |
| () => codes.filter(c => c.currentUses >= c.maxUses).length, | |
| [codes] | |
| ); | |
| const heroCode = availableCode?.code ?? ''; | |
| const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]); | |
| const heroMessage = useMemo( | |
| () => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl), | |
| [heroCode, heroUrl] | |
| ); | |
| return ( | |
| <div className="min-h-full flex items-center justify-center p-4 pt-6"> | |
| <div className="max-w-md w-full space-y-4"> | |
| <div> | |
| <div className="space-y-4"> | |
| {/* Redeem Section — shown only if user hasn't redeemed yet */} | |
| {!hasBeenInvited && ( | |
| <div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up"> | |
| <h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2> | |
| <p className="text-xs opacity-70 mb-4"> | |
| Got a code from a friend? Enter it below to unlock free credits. | |
| </p> | |
| <div className="flex gap-2"> | |
| <input | |
| type="text" | |
| value={redeemInput} | |
| onChange={e => setRedeemInput(e.target.value.toUpperCase())} | |
| onKeyDown={e => e.key === 'Enter' && handleRedeem()} | |
| placeholder="Enter code" | |
| className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" | |
| disabled={redeemStatus === 'loading'} | |
| /> | |
| <button | |
| onClick={handleRedeem} | |
| disabled={redeemStatus === 'loading' || !redeemInput.trim()} | |
| className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"> | |
| {redeemStatus === 'loading' ? '...' : 'Redeem'} | |
| </button> | |
| </div> | |
| {redeemStatus === 'success' && ( | |
| <p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p> | |
| )} | |
| {redeemStatus === 'error' && redeemError && ( | |
| <p className="text-coral-500 text-xs mt-2">{redeemError}</p> | |
| )} | |
| </div> | |
| <div className="min-h-full p-4 pt-6 pb-10"> | |
| <div className="mx-auto max-w-xl space-y-4"> | |
| {/* Hero — shareable invite card */} | |
| {heroCode ? ( | |
| <div className="animate-fade-up space-y-3"> | |
| <ViralInviteCard | |
| code={heroCode} | |
| url={heroUrl} | |
| firstName={user?.firstName ?? undefined} | |
| /> | |
| <div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft"> | |
| <p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400"> | |
| Share with one tap | |
| </p> | |
| <SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" /> | |
| </div> | |
| </div> | |
| ) : null} | |
| const availableCode = useMemo( | |
| () => codes.find(c => c.currentUses < c.maxUses), | |
| [codes] | |
| ); | |
| const convertedCount = useMemo( | |
| () => codes.filter(c => c.currentUses >= c.maxUses).length, | |
| [codes] | |
| ); | |
| const heroCode = availableCode?.code ?? ''; | |
| const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]); | |
| const heroMessage = useMemo( | |
| () => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl), | |
| [heroCode, heroUrl] | |
| ); | |
| return ( | |
| <div className="min-h-full p-4 pt-6 pb-10"> | |
| <div className="mx-auto max-w-xl space-y-4"> | |
| {/* Hero — shareable invite card */} | |
| {heroCode ? ( | |
| <div className="animate-fade-up space-y-3"> | |
| <ViralInviteCard | |
| code={heroCode} | |
| url={heroUrl} | |
| firstName={user?.firstName ?? undefined} | |
| /> | |
| <div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft"> | |
| <p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400"> | |
| Share with one tap | |
| </p> | |
| <SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" /> | |
| </div> | |
| </div> | |
| ) : null} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/pages/Invites.tsx` around lines 143 - 178, availableCode currently
falls back to codes[0], which can be fully redeemed and causes the hero
(ViralInviteCard / SocialShareRow) to render incorrectly; change availableCode
to return undefined when no code has currentUses < maxUses (i.e. remove the
fallback to codes[0]) so heroCode becomes undefined in that case, and ensure
heroUrl and heroMessage derive from heroCode so the hero conditional (heroCode ?
…) naturally prevents rendering the invite card and share row when all codes are
maxed out.
| /** | ||
| * Build a public invite URL that friends can click from anywhere. | ||
| * | ||
| * Routes to the web onboarding flow with `?invite=CODE`; the desktop/mobile | ||
| * app also handles `openhuman://invite/CODE` for deep linking. | ||
| */ | ||
| export function buildInviteUrl(code: string): string { | ||
| const trimmed = code.trim(); | ||
| if (!trimmed) return SHARE_BASE_URL; | ||
| return `${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)}`; | ||
| } |
There was a problem hiding this comment.
JSDoc doesn't match the implementation.
The doc comment says the URL routes to ?invite=CODE and that the app handles openhuman://invite/CODE deep links, but buildInviteUrl actually produces ${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)} — a path segment, not a query param, and there is no deep-link handling here. Either fix the doc or extend the implementation to match the documented contract (the deep-link claim in particular could be confusing for downstream consumers).
📝 Suggested doc rewrite
/**
* Build a public invite URL that friends can click from anywhere.
*
- * Routes to the web onboarding flow with `?invite=CODE`; the desktop/mobile
- * app also handles `openhuman://invite/CODE` for deep linking.
+ * Produces `${SHARE_BASE_URL}/i/<code>` where the web onboarding flow picks
+ * up the trailing path segment as the invite code.
*/🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/utils/share.ts` around lines 18 - 28, The JSDoc for buildInviteUrl is
inaccurate: it claims the invite is a ?invite=CODE query and mentions
openhuman:// deep links, but the function (buildInviteUrl) actually returns a
path-style URL using SHARE_BASE_URL + '/i/' + encodeURIComponent(trimmed).
Update the JSDoc to describe the actual behavior (trims the code, URL-encodes
it, and returns SHARE_BASE_URL/i/CODE) and remove the misleading deep-link claim
(or, if you prefer to implement deep-linking, change buildInviteUrl to also
return the deep link or provide a separate function). Ensure the doc references
buildInviteUrl and SHARE_BASE_URL and accurately documents trimming and
encoding.
| export function buildInviteUrl(code: string): string { | ||
| const trimmed = code.trim(); | ||
| if (!trimmed) return SHARE_BASE_URL; | ||
| return `${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)}`; | ||
| } |
There was a problem hiding this comment.
Empty/whitespace code silently degrades to the bare base URL.
When code is empty or whitespace, buildInviteUrl returns SHARE_BASE_URL with no path. In app/src/pages/Invites.tsx Line 153 (heroCode = availableCode?.code ?? ''), this means a user with no available invite codes still sees a "share" affordance whose URL is just the homepage — they'll happily share a non-attributable link. Consider returning '' (or throwing) and having callers gate the share UI on a non-empty code, so the share row doesn't render at all in the no-code case.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/utils/share.ts` around lines 24 - 28, The buildInviteUrl function
silently returns SHARE_BASE_URL when code is empty/whitespace; change
buildInviteUrl (exported function buildInviteUrl) to return an empty string (or
throw) when trimmed is falsy instead of returning SHARE_BASE_URL, so callers can
detect "no code" (e.g., heroCode = availableCode?.code ?? '') and avoid
rendering the share affordance; update any call sites that currently assume a
URL (particularly the component that renders the share row) to gate rendering on
a non-empty return from buildInviteUrl.
| `mailto:?subject=${encodeURIComponent('Try OpenHuman with me')}&body=${encodeURIComponent( | ||
| `${text}\n\n${url}` | ||
| )}`, | ||
| sms: (text, url) => `sms:?&body=${encodeURIComponent(`${text} ${url}`)}`, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
What is the cross-platform compatible URI scheme for SMS share links with a prefilled body on iOS Safari and Android Chrome in 2026?
💡 Result:
There is no single cross-platform compatible URI scheme for SMS share links with a prefilled body that works identically on both iOS Safari and Android Chrome without detection or JavaScript. Official Apple documentation states that the sms: scheme does not support message body text, only phone numbers. However, in practice, undocumented extensions allow prefill on both platforms using platform-specific syntax. For a prefilled body with no recipient (share link use case): - Android Chrome: sms://?&body=message (or sms:?body=message) - iOS Safari: sms://?&body=message The most commonly cited cross-compatible format from developer testing (up to iOS 18/iOS 26 and Android 13 in 2025 reports) is <a href="sms://;?&body=Hello%20World">, which opens the SMS composer with prefilled body and prompts for recipient on both. To make it truly cross-platform, use JavaScript to detect the platform via user agent and construct the URL dynamically: var ua = navigator.userAgent.toLowerCase; var separator = (ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1) ? ';' : '?'; // or '&' for newer iOS var url = 'sms:' + separator + 'body=' + encodeURIComponent(message); location.href = url; Or with // for better Chrome Android support: sms:// + separator + 'body='... Note: Prefill behavior can vary by OS version, default SMS app (e.g., Google Messages vs Samsung), and whether the recipient is in contacts. Always URL-encode the body. No changes noted for 2026; RCS uses similar sms: with body= but is not standard SMS.
Citations:
- 1: https://stackoverflow.com/questions/6480462/how-to-pre-populate-the-sms-body-text-via-an-html-link
- 2: https://stackoverflow.com/questions/60515815/is-it-possible-to-use-an-sms-url-to-pre-fill-a-message-with-a-body-but-no-recip
- 3: https://www.voyagesms.com
- 4: http://weblog.west-wind.com/posts/50927.aspx
- 5: https://weblog.west-wind.com/posts/2013/Oct/09/Prefilling-an-SMS-on-Mobile-Devices-with-the-sms-Uri-Scheme
- 6: https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/SMSLinks/SMSLinks.html
- 7: https://developers.google.com/business-communications/rcs-business-messaging/guides/build/deeplinks
- 8: https://stackoverflow.com/questions/39529099/trying-to-send-sms-in-ios-10-is-sms-protocol-broken
🏁 Script executed:
cat -n app/src/utils/share.ts | head -60Repository: tinyhumansai/openhuman
Length of output: 2869
🏁 Script executed:
cat -n app/src/utils/share.ts | tail -20Repository: tinyhumansai/openhuman
Length of output: 685
🏁 Script executed:
rg -i "iphone|ipad|ios|android|userAgent|platform" app/src/utils/share.ts -A 3 -B 3Repository: tinyhumansai/openhuman
Length of output: 1474
🏁 Script executed:
rg -i "sms" app/src/ --type ts --type tsx -B 3 -A 3Repository: tinyhumansai/openhuman
Length of output: 94
🏁 Script executed:
rg -i "sms" app/src/ -t ts -t tsx -B 3 -A 3Repository: tinyhumansai/openhuman
Length of output: 94
🏁 Script executed:
rg -i "sms" app/src/ -B 3 -A 3Repository: tinyhumansai/openhuman
Length of output: 1351
🏁 Script executed:
grep -r "navigator.userAgent\|iphone\|ipad\|android" app/src/utils/ 2>/dev/nullRepository: tinyhumansai/openhuman
Length of output: 48
🏁 Script executed:
rg "navigator.userAgent\|window.location\|isTauri\|platform" app/src/utils/ --type ts -B 2 -A 2Repository: tinyhumansai/openhuman
Length of output: 48
🏁 Script executed:
grep -r "iOS\|Android\|Safari\|Chrome" app/src/ 2>/dev/null | head -20Repository: tinyhumansai/openhuman
Length of output: 1152
Update SMS URI scheme for iOS compatibility.
The current format sms:?&body=... works on Android but fails on iOS — the ?& separator causes the body parameter to be ignored in iOS Safari, leaving users with an empty SMS draft. Change to sms:?body=... (removing the &) for better cross-platform compatibility, or implement UA detection to use sms:;body=... on iOS and sms:?body=... on Android if broader testing reveals the simpler format still has edge cases.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/utils/share.ts` at line 51, The sms share URI currently built in the
sms property (sms: (text, url) => `sms:?&body=...`) uses the `?&` separator
which breaks body parsing on iOS; update the sms builder in the sms function to
use `sms:?body=${encodeURIComponent(`${text} ${url}`)}` (remove the extra `&`)
for cross-platform compatibility, or alternatively implement simple UA detection
inside the same sms function to return `sms:;body=...` for iOS user agents and
`sms:?body=...` for others if you need to handle edge cases.
Opening as Draft — this is a product direction call, not a mechanical change.
What it does
+974 / −198 lines across 11 files.
Open questions
Close this if the direction doesn't fit — worst case we learned what shape an invite revamp would take.
Summary by CodeRabbit
New Features
Tests