Invite friends: fix web dead buttons/links, harden scanner, dedupe utils

Addresses code-review findings on the invite-friends feature:
- Gate the profile-header share button and followers promo banner to
  native; the invite dialog is a no-op on web, so these were dead buttons.
- Show the real canonical profile URL in the invite-link field so the
  displayed text matches what Copy/Share/QR actually use.
- Add a web fallback for the QR scanner screen (route is registered on
  all platforms) and remove the unimplemented gallery picker button.
- Reuse shared hexToRgb/rgbToHex from alf/util/colorGeneration in ThemedQrCard.
- Rebuild ThemePicker on the shared Menu component instead of a hand-rolled
  Modal dropdown.
- Fix an import-sort lint error in dialogs/nuxs/index.
This commit is contained in:
vineyardbovines
2026-06-01 12:43:22 -04:00
parent 5b4d7fa175
commit 7e6198edb2
10 changed files with 170 additions and 216 deletions
+2 -2
View File
@@ -18,6 +18,7 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import * as Dialog from '#/components/Dialog'
import {
DraftsAnnouncement,
enabled as isDraftsAnnouncementEnabled,
@@ -28,10 +29,9 @@ import {
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import * as Dialog from '#/components/Dialog'
import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useGeolocation} from '#/geolocation'
type Context = {
activeNux: Nux | undefined
@@ -52,7 +52,7 @@ export function InviteFriendsDialogInner({
const isHandleValid = !!handle && handle !== 'handle.invalid'
const canonicalShareUrl = isHandleValid ? getInviteShareUrl(handle) : ''
const cosmeticDisplayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
const displayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
const onShare = async () => {
if (!canonicalShareUrl) {
@@ -225,7 +225,7 @@ export function InviteFriendsDialogInner({
{color: t.palette.contrast_975, lineHeight: 19.5},
]}
numberOfLines={1}>
{cosmeticDisplayUrl || ' '}
{displayUrl || ' '}
</Text>
<Pressable
accessibilityRole="button"
@@ -10,12 +10,10 @@ import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
@@ -68,14 +66,6 @@ export function InviteScannerScreen() {
setScannerEnabled(true)
}, [])
const onPressGallery = useCallback(() => {
// TODO: implement gallery image picker + QR decode. expo-camera only
// scans live; decoding from a gallery image requires an additional
// library (e.g. @react-native-vision-camera + vision-camera-code-scanner,
// or a JS-side decoder like jsqr running on a pixel array).
logger.warn('InviteScanner: gallery picker not implemented yet')
}, [])
// Permission states ---------------------------------------------------------
if (!permission) {
@@ -172,25 +162,6 @@ export function InviteScannerScreen() {
]}>
<ArrowLeftIcon size="lg" fill={t.palette.white} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Pick from gallery`}
accessibilityHint={l`Choose a QR code image from your photo library`}
onPress={onPressGallery}
hitSlop={12}
style={({pressed}) => [
{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
},
]}>
<ImageIcon width={16} height={16} fill={t.palette.white} />
</Pressable>
</View>
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
@@ -0,0 +1,45 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
/**
* Web fallback - QR scanning relies on the device camera and is native-only by
* design. The route is registered on all platforms, so a web user reaching
* /invite/scan via a direct URL gets this message instead of the native
* camera UI.
*/
export function InviteScannerScreen() {
const {t: l} = useLingui()
const t = useTheme()
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>{l`Scan QR Code`}</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<View
style={[a.flex_1, a.align_center, a.justify_center, a.p_xl, a.gap_md]}>
<InfoIcon size="xl" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_xl, a.font_bold, a.text_center]}>
{l`Not available on this platform`}
</Text>
<Text
style={[
a.text_md,
a.text_center,
t.atoms.text_contrast_medium,
{maxWidth: 320},
]}>
{l`Please use the Bluesky mobile app to scan a QR code.`}
</Text>
</View>
</Layout.Screen>
)
}
@@ -1,21 +1,17 @@
import {useRef, useState} from 'react'
import {Modal, Pressable, StyleSheet, View} from 'react-native'
import {Pressable, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes'
const PILL_LABEL_SIZE = 13.1
const SWATCH_SIZE = 14
const CHEVRON_SIZE = 8
const MENU_WIDTH = 140
const MENU_ROW_HEIGHT = 44
const MENU_ITEM_SWATCH = 18
const MENU_GAP_TO_TRIGGER = 8
export function ThemePicker({
value,
@@ -27,14 +23,6 @@ export function ThemePicker({
const t = useTheme()
const {t: l} = useLingui()
const current = INVITE_THEMES[value]
const triggerRef = useRef<View>(null)
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState<{
x: number
y: number
w: number
h: number
} | null>(null)
const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`,
@@ -43,128 +31,72 @@ export function ThemePicker({
night: l`Night`,
}
const handleOpen = () => {
triggerRef.current?.measureInWindow((x, y, w, h) => {
setAnchor({x, y, w, h})
setOpen(true)
})
}
const handleSelect = (key: InviteThemeKey) => {
onChange(key)
setOpen(false)
}
return (
<>
<Pressable
ref={triggerRef}
accessibilityRole="button"
accessibilityLabel={l`Pick a color theme`}
accessibilityHint={l`Opens a list of color themes for the QR card`}
onPress={handleOpen}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 12,
paddingRight: 14,
paddingVertical: 7,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
opacity: pressed ? 0.7 : 1,
})}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<GradientSwatch
from={current.light.gradientFrom}
to={current.light.gradientTo}
size={SWATCH_SIZE}
/>
<Text
style={{
color: t.palette.contrast_700,
fontSize: PILL_LABEL_SIZE,
fontWeight: '500',
lineHeight: PILL_LABEL_SIZE * 1.3,
}}>
{l`Color`}
</Text>
</View>
<ChevronDown
width={CHEVRON_SIZE}
height={CHEVRON_SIZE}
fill={t.palette.contrast_700}
/>
</Pressable>
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => setOpen(false)}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Close color picker`}
accessibilityHint={l`Dismisses the color picker`}
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}>
{anchor ? (
<View
accessibilityRole="menu"
style={{
position: 'absolute',
top: anchor.y + anchor.h + MENU_GAP_TO_TRIGGER,
left: anchor.x + anchor.w / 2 - MENU_WIDTH / 2,
width: MENU_WIDTH,
backgroundColor: t.palette.white,
borderRadius: 16,
paddingVertical: 8,
shadowColor: t.palette.black,
shadowOpacity: 0.18,
shadowRadius: 24,
shadowOffset: {width: 0, height: 8},
elevation: 8,
}}>
{INVITE_THEME_KEYS.map(key => {
const theme = INVITE_THEMES[key]
return (
<Pressable
key={key}
accessibilityRole="menuitem"
accessibilityLabel={labels[key]}
accessibilityHint={l`Apply this color theme to the QR card`}
onPress={() => handleSelect(key)}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: MENU_ROW_HEIGHT,
paddingHorizontal: 16,
backgroundColor: pressed
? t.palette.contrast_50
: 'transparent',
})}>
<GradientSwatch
from={theme.light.gradientFrom}
to={theme.light.gradientTo}
size={MENU_ITEM_SWATCH}
/>
<Text
style={[
a.text_md,
a.font_medium,
{color: t.palette.contrast_975, lineHeight: 19.5},
]}>
{labels[key]}
</Text>
</Pressable>
)
})}
<Menu.Root>
<Menu.Trigger
label={l`Pick a color theme`}
hint={l`Opens a list of color themes for the QR card`}>
{({props}) => (
<Pressable
{...props}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 12,
paddingRight: 14,
paddingVertical: 7,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
opacity: pressed ? 0.7 : 1,
})}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<GradientSwatch
from={current.light.gradientFrom}
to={current.light.gradientTo}
size={SWATCH_SIZE}
/>
<Text
style={{
color: t.palette.contrast_700,
fontSize: PILL_LABEL_SIZE,
fontWeight: '500',
lineHeight: PILL_LABEL_SIZE * 1.3,
}}>
{l`Color`}
</Text>
</View>
) : null}
</Pressable>
</Modal>
</>
<ChevronDown
width={CHEVRON_SIZE}
height={CHEVRON_SIZE}
fill={t.palette.contrast_700}
/>
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{INVITE_THEME_KEYS.map(key => {
const theme = INVITE_THEMES[key]
return (
<Menu.Item
key={key}
label={labels[key]}
onPress={() => onChange(key)}>
<GradientSwatch
from={theme.light.gradientFrom}
to={theme.light.gradientTo}
size={MENU_ITEM_SWATCH}
/>
<Menu.ItemText>{labels[key]}</Menu.ItemText>
<Menu.ItemRadio selected={key === value} />
</Menu.Item>
)
})}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
@@ -8,6 +8,7 @@ import {LinearGradient} from 'expo-linear-gradient'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration'
import {Text} from '#/components/Typography'
import {type InviteThemeVariant} from '../themes'
@@ -238,18 +239,9 @@ function eyeColor(from: string, to: string, t: number): string {
const fromRgb = hexToRgb(from)
const toRgb = hexToRgb(to)
if (!fromRgb || !toRgb) return from
const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * t)
const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * t)
const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * t)
return `#${[r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')}`
}
function hexToRgb(hex: string): {r: number; g: number; b: number} | null {
const match = hex.replace('#', '').match(/^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i)
if (!match) return null
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
}
return rgbToHex(
fromRgb.r + (toRgb.r - fromRgb.r) * t,
fromRgb.g + (toRgb.g - fromRgb.g) * t,
fromRgb.b + (toRgb.b - fromRgb.b) * t,
)
}
+10 -9
View File
@@ -35,25 +35,25 @@ describe('invite URLs', () => {
})
describe('getInviteDisplayUrl', () => {
it('returns a cosmetic bsky.app/invite/<short> string', () => {
it('returns the scheme-stripped canonical profile URL', () => {
expect(getInviteDisplayUrl('danielle.bsky.team')).toBe(
'bsky.app/invite/danielle',
'bsky.app/profile/danielle.bsky.team',
)
})
it('uses the bare handle when there is no dot', () => {
expect(getInviteDisplayUrl('alice')).toBe('bsky.app/invite/alice')
expect(getInviteDisplayUrl('alice')).toBe('bsky.app/profile/alice')
})
it('lowercases the short segment', () => {
it('lowercases the handle', () => {
expect(getInviteDisplayUrl('Danielle.bsky.team')).toBe(
'bsky.app/invite/danielle',
'bsky.app/profile/danielle.bsky.team',
)
})
it('strips a leading @ if present', () => {
expect(getInviteDisplayUrl('@alice.bsky.social')).toBe(
'bsky.app/invite/alice',
'bsky.app/profile/alice.bsky.social',
)
})
@@ -62,9 +62,10 @@ describe('invite URLs', () => {
expect(getInviteDisplayUrl('@')).toBe('')
})
it('returns empty string when first segment is empty (EDGE-002)', () => {
expect(getInviteDisplayUrl('.bsky.social')).toBe('')
expect(getInviteDisplayUrl('@.bsky.social')).toBe('')
it('mirrors getInviteShareUrl exactly minus the scheme', () => {
expect(getInviteDisplayUrl('alice.bsky.social')).toBe(
getInviteShareUrl('alice.bsky.social').replace(/^https:\/\//, ''),
)
})
})
})
+13 -10
View File
@@ -1,10 +1,13 @@
/**
* URL helpers for the Invite Friends share sheet (APP-2142).
*
* We display a cosmetic `bsky.app/invite/{shortHandle}` string to match the
* design, but every actual action (QR payload, share sheet, clipboard) uses
* the canonical `https://bsky.app/profile/{handle}` URL because no
* `bsky.app/invite/...` route exists yet.
* Every action (QR payload, share sheet, clipboard) and the displayed label
* all derive from the same canonical `https://bsky.app/profile/{handle}` URL,
* so what the user reads matches exactly what they copy/share. The displayed
* label simply drops the `https://` scheme for readability.
*
* Kept as a dependency-free leaf module (no #/lib/strings/url-helpers import)
* so its unit tests stay fast and isolated from the heavy @atproto/api graph.
*/
function stripLeadingAt(handle: string): string {
@@ -18,11 +21,11 @@ export function getInviteShareUrl(handle: string): string {
return `https://bsky.app/profile/${bare}`
}
/** Cosmetic label shown in the "Invite link" field. Not a functional URL. */
/**
* Human-readable label shown in the "Invite link" field. This is the same
* canonical URL as getInviteShareUrl with the `https://` scheme stripped, so
* the displayed text always resolves and matches what Copy/Share use.
*/
export function getInviteDisplayUrl(handle: string): string {
const bare = stripLeadingAt(handle)
if (!bare) return ''
const short = bare.split('.')[0].toLowerCase()
if (!short) return ''
return `bsky.app/invite/${short}`
return getInviteShareUrl(handle).replace(/^https:\/\//, '')
}
@@ -39,7 +39,7 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
@@ -350,20 +350,24 @@ export function HeaderStandardButtons({
<Trans>Edit Profile</Trans>
</ButtonText>
</Button>
<Button
testID="profileHeaderShareButton"
size="small"
color="secondary"
shape="round"
onPress={() => {
playHaptic('Light')
inviteFriendsControl.open()
}}
label={_(msg`Invite friends`)}>
<ButtonIcon icon={ArrowShareRight} />
</Button>
{/* Invite friends is a native-only share sheet (the dialog is a
no-op on web), so gate the entry point to avoid a dead button. */}
{IS_NATIVE && (
<Button
testID="profileHeaderShareButton"
size="small"
color="secondary"
shape="round"
onPress={() => {
playHaptic('Light')
inviteFriendsControl.open()
}}
label={_(msg`Invite friends`)}>
<ButtonIcon icon={ArrowShareRight} />
</Button>
)}
<EditProfileDialog profile={profile} control={editProfileControl} />
<InviteFriendsDialog control={inviteFriendsControl} />
{IS_NATIVE && <InviteFriendsDialog control={inviteFriendsControl} />}
</>
) : profile.viewer?.blocking ? (
profile.viewer?.blockingByList ? null : (
+7 -1
View File
@@ -14,6 +14,7 @@ import {useDialogControl} from '#/components/Dialog'
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {
FollowersPromoBanner,
InviteFriendsDialog,
@@ -167,7 +168,10 @@ export function ProfileFollowers({name}: {name: string}) {
const inviteFriendsControl = useDialogControl()
const [followersPromoDismissed, setFollowersPromoDismissed] =
useFollowersPromoDismissed()
// Native-only: the invite-friends sheet is a no-op on web, so the banner
// would open nothing. Gate it to avoid a dead promo on web.
const showFollowersPromo =
IS_NATIVE &&
isMe &&
!followersPromoDismissed &&
followers.length < 1 &&
@@ -206,7 +210,9 @@ export function ProfileFollowers({name}: {name: string}) {
onPress: () => navigation.goBack(),
}}
/>
<InviteFriendsDialog control={inviteFriendsControl} />
{showFollowersPromo && (
<InviteFriendsDialog control={inviteFriendsControl} />
)}
</>
)
}