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:
@@ -18,6 +18,7 @@ import {
|
|||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {type SessionAccount, useSession} from '#/state/session'
|
import {type SessionAccount, useSession} from '#/state/session'
|
||||||
import {useOnboardingState} from '#/state/shell'
|
import {useOnboardingState} from '#/state/shell'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {
|
import {
|
||||||
DraftsAnnouncement,
|
DraftsAnnouncement,
|
||||||
enabled as isDraftsAnnouncementEnabled,
|
enabled as isDraftsAnnouncementEnabled,
|
||||||
@@ -28,10 +29,9 @@ import {
|
|||||||
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
|
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
|
||||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {useGeolocation} from '#/geolocation'
|
|
||||||
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
|
import {useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
activeNux: Nux | undefined
|
activeNux: Nux | undefined
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function InviteFriendsDialogInner({
|
|||||||
const isHandleValid = !!handle && handle !== 'handle.invalid'
|
const isHandleValid = !!handle && handle !== 'handle.invalid'
|
||||||
|
|
||||||
const canonicalShareUrl = isHandleValid ? getInviteShareUrl(handle) : ''
|
const canonicalShareUrl = isHandleValid ? getInviteShareUrl(handle) : ''
|
||||||
const cosmeticDisplayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
|
const displayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
|
||||||
|
|
||||||
const onShare = async () => {
|
const onShare = async () => {
|
||||||
if (!canonicalShareUrl) {
|
if (!canonicalShareUrl) {
|
||||||
@@ -225,7 +225,7 @@ export function InviteFriendsDialogInner({
|
|||||||
{color: t.palette.contrast_975, lineHeight: 19.5},
|
{color: t.palette.contrast_975, lineHeight: 19.5},
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{cosmeticDisplayUrl || ' '}
|
{displayUrl || ' '}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import {useLingui} from '@lingui/react/macro'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
|
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 * as Layout from '#/components/Layout'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -68,14 +66,6 @@ export function InviteScannerScreen() {
|
|||||||
setScannerEnabled(true)
|
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 ---------------------------------------------------------
|
// Permission states ---------------------------------------------------------
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
@@ -172,25 +162,6 @@ export function InviteScannerScreen() {
|
|||||||
]}>
|
]}>
|
||||||
<ArrowLeftIcon size="lg" fill={t.palette.white} />
|
<ArrowLeftIcon size="lg" fill={t.palette.white} />
|
||||||
</Pressable>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
|
{/* 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 {Pressable, View} from 'react-native'
|
||||||
import {Modal, Pressable, StyleSheet, View} from 'react-native'
|
|
||||||
import {LinearGradient} from 'expo-linear-gradient'
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
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 {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes'
|
import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes'
|
||||||
|
|
||||||
const PILL_LABEL_SIZE = 13.1
|
const PILL_LABEL_SIZE = 13.1
|
||||||
const SWATCH_SIZE = 14
|
const SWATCH_SIZE = 14
|
||||||
const CHEVRON_SIZE = 8
|
const CHEVRON_SIZE = 8
|
||||||
|
|
||||||
const MENU_WIDTH = 140
|
|
||||||
const MENU_ROW_HEIGHT = 44
|
|
||||||
const MENU_ITEM_SWATCH = 18
|
const MENU_ITEM_SWATCH = 18
|
||||||
const MENU_GAP_TO_TRIGGER = 8
|
|
||||||
|
|
||||||
export function ThemePicker({
|
export function ThemePicker({
|
||||||
value,
|
value,
|
||||||
@@ -27,14 +23,6 @@ export function ThemePicker({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const current = INVITE_THEMES[value]
|
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> = {
|
const labels: Record<InviteThemeKey, string> = {
|
||||||
dawn: l`Dawn`,
|
dawn: l`Dawn`,
|
||||||
@@ -43,128 +31,72 @@ export function ThemePicker({
|
|||||||
night: l`Night`,
|
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 (
|
return (
|
||||||
<>
|
<Menu.Root>
|
||||||
<Pressable
|
<Menu.Trigger
|
||||||
ref={triggerRef}
|
label={l`Pick a color theme`}
|
||||||
accessibilityRole="button"
|
hint={l`Opens a list of color themes for the QR card`}>
|
||||||
accessibilityLabel={l`Pick a color theme`}
|
{({props}) => (
|
||||||
accessibilityHint={l`Opens a list of color themes for the QR card`}
|
<Pressable
|
||||||
onPress={handleOpen}
|
{...props}
|
||||||
style={({pressed}) => ({
|
style={({pressed}) => ({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
paddingRight: 14,
|
paddingRight: 14,
|
||||||
paddingVertical: 7,
|
paddingVertical: 7,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: t.palette.contrast_50,
|
backgroundColor: t.palette.contrast_50,
|
||||||
opacity: pressed ? 0.7 : 1,
|
opacity: pressed ? 0.7 : 1,
|
||||||
})}>
|
})}>
|
||||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
|
||||||
<GradientSwatch
|
<GradientSwatch
|
||||||
from={current.light.gradientFrom}
|
from={current.light.gradientFrom}
|
||||||
to={current.light.gradientTo}
|
to={current.light.gradientTo}
|
||||||
size={SWATCH_SIZE}
|
size={SWATCH_SIZE}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: t.palette.contrast_700,
|
color: t.palette.contrast_700,
|
||||||
fontSize: PILL_LABEL_SIZE,
|
fontSize: PILL_LABEL_SIZE,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
lineHeight: PILL_LABEL_SIZE * 1.3,
|
lineHeight: PILL_LABEL_SIZE * 1.3,
|
||||||
}}>
|
}}>
|
||||||
{l`Color`}
|
{l`Color`}
|
||||||
</Text>
|
</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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
<ChevronDown
|
||||||
</Pressable>
|
width={CHEVRON_SIZE}
|
||||||
</Modal>
|
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 {Logo} from '#/view/icons/Logo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {type InviteThemeVariant} from '../themes'
|
import {type InviteThemeVariant} from '../themes'
|
||||||
|
|
||||||
@@ -238,18 +239,9 @@ function eyeColor(from: string, to: string, t: number): string {
|
|||||||
const fromRgb = hexToRgb(from)
|
const fromRgb = hexToRgb(from)
|
||||||
const toRgb = hexToRgb(to)
|
const toRgb = hexToRgb(to)
|
||||||
if (!fromRgb || !toRgb) return from
|
if (!fromRgb || !toRgb) return from
|
||||||
const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * t)
|
return rgbToHex(
|
||||||
const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * t)
|
fromRgb.r + (toRgb.r - fromRgb.r) * t,
|
||||||
const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * t)
|
fromRgb.g + (toRgb.g - fromRgb.g) * t,
|
||||||
return `#${[r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')}`
|
fromRgb.b + (toRgb.b - fromRgb.b) * t,
|
||||||
}
|
)
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,25 +35,25 @@ describe('invite URLs', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('getInviteDisplayUrl', () => {
|
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(
|
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', () => {
|
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(
|
expect(getInviteDisplayUrl('Danielle.bsky.team')).toBe(
|
||||||
'bsky.app/invite/danielle',
|
'bsky.app/profile/danielle.bsky.team',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strips a leading @ if present', () => {
|
it('strips a leading @ if present', () => {
|
||||||
expect(getInviteDisplayUrl('@alice.bsky.social')).toBe(
|
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('')
|
expect(getInviteDisplayUrl('@')).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns empty string when first segment is empty (EDGE-002)', () => {
|
it('mirrors getInviteShareUrl exactly minus the scheme', () => {
|
||||||
expect(getInviteDisplayUrl('.bsky.social')).toBe('')
|
expect(getInviteDisplayUrl('alice.bsky.social')).toBe(
|
||||||
expect(getInviteDisplayUrl('@.bsky.social')).toBe('')
|
getInviteShareUrl('alice.bsky.social').replace(/^https:\/\//, ''),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* URL helpers for the Invite Friends share sheet (APP-2142).
|
* URL helpers for the Invite Friends share sheet (APP-2142).
|
||||||
*
|
*
|
||||||
* We display a cosmetic `bsky.app/invite/{shortHandle}` string to match the
|
* Every action (QR payload, share sheet, clipboard) and the displayed label
|
||||||
* design, but every actual action (QR payload, share sheet, clipboard) uses
|
* all derive from the same canonical `https://bsky.app/profile/{handle}` URL,
|
||||||
* the canonical `https://bsky.app/profile/{handle}` URL because no
|
* so what the user reads matches exactly what they copy/share. The displayed
|
||||||
* `bsky.app/invite/...` route exists yet.
|
* 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 {
|
function stripLeadingAt(handle: string): string {
|
||||||
@@ -18,11 +21,11 @@ export function getInviteShareUrl(handle: string): string {
|
|||||||
return `https://bsky.app/profile/${bare}`
|
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 {
|
export function getInviteDisplayUrl(handle: string): string {
|
||||||
const bare = stripLeadingAt(handle)
|
return getInviteShareUrl(handle).replace(/^https:\/\//, '')
|
||||||
if (!bare) return ''
|
|
||||||
const short = bare.split('.')[0].toLowerCase()
|
|
||||||
if (!short) return ''
|
|
||||||
return `bsky.app/invite/${short}`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import * as Prompt from '#/components/Prompt'
|
|||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_IOS} from '#/env'
|
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||||
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import {useActorStatus} from '#/features/liveNow'
|
import {useActorStatus} from '#/features/liveNow'
|
||||||
import {GermButton} from '../components/GermButton'
|
import {GermButton} from '../components/GermButton'
|
||||||
@@ -350,20 +350,24 @@ export function HeaderStandardButtons({
|
|||||||
<Trans>Edit Profile</Trans>
|
<Trans>Edit Profile</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* Invite friends is a native-only share sheet (the dialog is a
|
||||||
testID="profileHeaderShareButton"
|
no-op on web), so gate the entry point to avoid a dead button. */}
|
||||||
size="small"
|
{IS_NATIVE && (
|
||||||
color="secondary"
|
<Button
|
||||||
shape="round"
|
testID="profileHeaderShareButton"
|
||||||
onPress={() => {
|
size="small"
|
||||||
playHaptic('Light')
|
color="secondary"
|
||||||
inviteFriendsControl.open()
|
shape="round"
|
||||||
}}
|
onPress={() => {
|
||||||
label={_(msg`Invite friends`)}>
|
playHaptic('Light')
|
||||||
<ButtonIcon icon={ArrowShareRight} />
|
inviteFriendsControl.open()
|
||||||
</Button>
|
}}
|
||||||
|
label={_(msg`Invite friends`)}>
|
||||||
|
<ButtonIcon icon={ArrowShareRight} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<EditProfileDialog profile={profile} control={editProfileControl} />
|
<EditProfileDialog profile={profile} control={editProfileControl} />
|
||||||
<InviteFriendsDialog control={inviteFriendsControl} />
|
{IS_NATIVE && <InviteFriendsDialog control={inviteFriendsControl} />}
|
||||||
</>
|
</>
|
||||||
) : profile.viewer?.blocking ? (
|
) : profile.viewer?.blocking ? (
|
||||||
profile.viewer?.blockingByList ? null : (
|
profile.viewer?.blockingByList ? null : (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {useDialogControl} from '#/components/Dialog'
|
|||||||
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
|
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
|
||||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_NATIVE} from '#/env'
|
||||||
import {
|
import {
|
||||||
FollowersPromoBanner,
|
FollowersPromoBanner,
|
||||||
InviteFriendsDialog,
|
InviteFriendsDialog,
|
||||||
@@ -167,7 +168,10 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||||||
const inviteFriendsControl = useDialogControl()
|
const inviteFriendsControl = useDialogControl()
|
||||||
const [followersPromoDismissed, setFollowersPromoDismissed] =
|
const [followersPromoDismissed, setFollowersPromoDismissed] =
|
||||||
useFollowersPromoDismissed()
|
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 =
|
const showFollowersPromo =
|
||||||
|
IS_NATIVE &&
|
||||||
isMe &&
|
isMe &&
|
||||||
!followersPromoDismissed &&
|
!followersPromoDismissed &&
|
||||||
followers.length < 1 &&
|
followers.length < 1 &&
|
||||||
@@ -206,7 +210,9 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||||||
onPress: () => navigation.goBack(),
|
onPress: () => navigation.goBack(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<InviteFriendsDialog control={inviteFriendsControl} />
|
{showFollowersPromo && (
|
||||||
|
<InviteFriendsDialog control={inviteFriendsControl} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user