diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 3e9aac4ce5..3413f19dfb 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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 diff --git a/src/features/inviteFriends/InviteFriendsDialogInner.tsx b/src/features/inviteFriends/InviteFriendsDialogInner.tsx index 4a57bb3a00..2a22235705 100644 --- a/src/features/inviteFriends/InviteFriendsDialogInner.tsx +++ b/src/features/inviteFriends/InviteFriendsDialogInner.tsx @@ -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 || ' '} { - // 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() { ]}> - [ - { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: 'rgba(255, 255, 255, 0.25)', - alignItems: 'center', - justifyContent: 'center', - opacity: pressed ? 0.6 : 1, - }, - ]}> - - {/* Error overlay shown when scanned QR isn't a valid profile URL */} diff --git a/src/features/inviteFriends/InviteScannerScreen.web.tsx b/src/features/inviteFriends/InviteScannerScreen.web.tsx new file mode 100644 index 0000000000..fae7ec728c --- /dev/null +++ b/src/features/inviteFriends/InviteScannerScreen.web.tsx @@ -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 ( + + + + + {l`Scan QR Code`} + + + + + + + {l`Not available on this platform`} + + + {l`Please use the Bluesky mobile app to scan a QR code.`} + + + + ) +} diff --git a/src/features/inviteFriends/components/ThemePicker.tsx b/src/features/inviteFriends/components/ThemePicker.tsx index 305424d579..179c80d815 100644 --- a/src/features/inviteFriends/components/ThemePicker.tsx +++ b/src/features/inviteFriends/components/ThemePicker.tsx @@ -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(null) - const [open, setOpen] = useState(false) - const [anchor, setAnchor] = useState<{ - x: number - y: number - w: number - h: number - } | null>(null) const labels: Record = { 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 ( - <> - ({ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingLeft: 12, - paddingRight: 14, - paddingVertical: 7, - borderRadius: 16, - backgroundColor: t.palette.contrast_50, - opacity: pressed ? 0.7 : 1, - })}> - - - - {l`Color`} - - - - - - setOpen(false)}> - setOpen(false)}> - {anchor ? ( - - {INVITE_THEME_KEYS.map(key => { - const theme = INVITE_THEMES[key] - return ( - handleSelect(key)} - style={({pressed}) => ({ - flexDirection: 'row', - alignItems: 'center', - gap: 6, - height: MENU_ROW_HEIGHT, - paddingHorizontal: 16, - backgroundColor: pressed - ? t.palette.contrast_50 - : 'transparent', - })}> - - - {labels[key]} - - - ) - })} + + + {({props}) => ( + ({ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingLeft: 12, + paddingRight: 14, + paddingVertical: 7, + borderRadius: 16, + backgroundColor: t.palette.contrast_50, + opacity: pressed ? 0.7 : 1, + })}> + + + + {l`Color`} + - ) : null} - - - + + + )} + + + + + {INVITE_THEME_KEYS.map(key => { + const theme = INVITE_THEMES[key] + return ( + onChange(key)}> + + {labels[key]} + + + ) + })} + + + ) } diff --git a/src/features/inviteFriends/components/ThemedQrCard.tsx b/src/features/inviteFriends/components/ThemedQrCard.tsx index 6b6e61c70b..874f45ccf8 100644 --- a/src/features/inviteFriends/components/ThemedQrCard.tsx +++ b/src/features/inviteFriends/components/ThemedQrCard.tsx @@ -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, + ) } diff --git a/src/features/inviteFriends/urls.test.ts b/src/features/inviteFriends/urls.test.ts index 0819ff5f6e..95e07dc6e3 100644 --- a/src/features/inviteFriends/urls.test.ts +++ b/src/features/inviteFriends/urls.test.ts @@ -35,25 +35,25 @@ describe('invite URLs', () => { }) describe('getInviteDisplayUrl', () => { - it('returns a cosmetic bsky.app/invite/ 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:\/\//, ''), + ) }) }) }) diff --git a/src/features/inviteFriends/urls.ts b/src/features/inviteFriends/urls.ts index 8d2628cbb9..1b5c68d965 100644 --- a/src/features/inviteFriends/urls.ts +++ b/src/features/inviteFriends/urls.ts @@ -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:\/\//, '') } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 90b3ebabe8..2164c7c499 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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({ Edit Profile - + {/* 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 && ( + + )} - + {IS_NATIVE && } ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : ( diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 71d72d7b25..5d61c521b2 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -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(), }} /> - + {showFollowersPromo && ( + + )} ) }