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 {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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:\/\//, ''),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 : (
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user