diff --git a/assets/images/find_friends_illustration_small.webp b/assets/images/find_friends_illustration_small.webp new file mode 100644 index 0000000000..af7cd083f2 Binary files /dev/null and b/assets/images/find_friends_illustration_small.webp differ diff --git a/package.json b/package.json index 970c3fd170..ef97b9d71b 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@atproto/api": "^0.18.6", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", - "@bsky.app/alf": "^0.1.5", + "@bsky.app/alf": "^0.1.6", "@bsky.app/react-native-mmkv": "2.12.5", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 762223fc35..6e4256c1e9 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -38,7 +38,7 @@ export function Outer({ }: { children: React.ReactNode noBottomBorder?: boolean - headerRef?: React.MutableRefObject + headerRef?: React.RefObject sticky?: boolean }) { const t = useTheme() @@ -183,7 +183,7 @@ export function TitleText({ + + + + + + + Import contacts to find your friends + + + + + + + + ) +} +function useInternalState() { + const {nux} = useNux(Nux.FindContactsDismissibleBanner) + const {mutate: save, variables} = useSaveNux() + const hidden = !!variables + + const visible = useMemo(() => { + if (isWeb) return false + if (hidden) return false + if (nux && nux.completed) return false + return true + }, [hidden, nux]) + + const close = () => { + save({ + id: Nux.FindContactsDismissibleBanner, + completed: true, + data: undefined, + }) + } + + return {visible, close} +} diff --git a/src/components/contacts/SyncContactsFlow.web.tsx b/src/components/contacts/SyncContactsFlow.web.tsx new file mode 100644 index 0000000000..d40435f6fa --- /dev/null +++ b/src/components/contacts/SyncContactsFlow.web.tsx @@ -0,0 +1,3 @@ +export function SyncContactsFlow() { + throw new Error('SyncContactsFlow is not available on web') +} diff --git a/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx new file mode 100644 index 0000000000..3666578898 --- /dev/null +++ b/src/components/dialogs/nuxs/FindContactsAnnouncement.tsx @@ -0,0 +1,114 @@ +import {useCallback} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {LinearGradient} from 'expo-linear-gradient' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isWeb} from '#/platform/detection' +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {Text} from '#/components/Typography' +import {navigate} from '#/Navigation' + +export function FindContactsAnnouncement() { + const t = useTheme() + const {_} = useLingui() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + + Dialog.useAutoOpen(control) + + const onClose = useCallback(() => { + nuxDialogs.dismissActiveNux() + }, [nuxDialogs]) + + return ( + + + + + + + + {_( + + + + + + Find your friends + + + + Bluesky is more fun with friends! Import your contacts to see + who’s already here. + + + + + + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 4076708fc0..63e11a7f4b 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -1,8 +1,16 @@ -import React from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' import {type AppBskyActorDefs} from '@atproto/api' import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {isNative} from '#/platform/detection' import {STALE} from '#/state/queries' import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' import { @@ -12,11 +20,11 @@ import { import {useProfileQuery} from '#/state/queries/profile' import {type SessionAccount, useSession} from '#/state/session' import {useOnboardingState} from '#/state/shell' -import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement' +import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' /* * NUXs */ -import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' +import {FindContactsAnnouncement} from './FindContactsAnnouncement' import {isExistingUserAsOf} from './utils' type Context = { @@ -34,24 +42,24 @@ const queuedNuxs: { }) => boolean }[] = [ { - id: Nux.BookmarksAnnouncement, + id: Nux.FindContactsAnnouncement, enabled: ({currentProfile}) => { - return isExistingUserAsOf( - '2025-09-08T00:00:00.000Z', - currentProfile.createdAt, + return ( + isNative && + isExistingUserAsOf('2025-12-16T00:00:00.000Z', currentProfile.createdAt) ) }, }, ] -const Context = React.createContext({ +const Context = createContext({ activeNux: undefined, dismissActiveNux: () => {}, }) Context.displayName = 'NuxDialogContext' export function useNuxDialogContext() { - return React.useContext(Context) + return useContext(Context) } export function NuxDialogs() { @@ -92,19 +100,19 @@ function Inner({ }) { const gate = useGate() const {nuxs} = useNuxs() - const [snoozed, setSnoozed] = React.useState(() => { + const [snoozed, setSnoozed] = useState(() => { return isSnoozed() }) - const [activeNux, setActiveNux] = React.useState() + const [activeNux, setActiveNux] = useState() const {mutateAsync: saveNux} = useSaveNux() const {mutate: resetNuxs} = useResetNuxs() - const snoozeNuxDialog = React.useCallback(() => { + const snoozeNuxDialog = useCallback(() => { snooze() setSnoozed(true) }, [setSnoozed]) - const dismissActiveNux = React.useCallback(() => { + const dismissActiveNux = useCallback(() => { if (!activeNux) return setActiveNux(undefined) }, [activeNux, setActiveNux]) @@ -118,7 +126,7 @@ function Inner({ } } - React.useEffect(() => { + useEffect(() => { if (snoozed) return // comment this out to test if (!nuxs) return @@ -170,7 +178,7 @@ function Inner({ preferences, ]) - const ctx = React.useMemo(() => { + const ctx = useMemo(() => { return { activeNux, dismissActiveNux, @@ -180,7 +188,9 @@ function Inner({ return ( {/*For example, activeNux === Nux.NeueTypography && */} - {activeNux === Nux.BookmarksAnnouncement && } + {activeNux === Nux.FindContactsAnnouncement && ( + + )} ) } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 1656494471..6c03c936a6 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -10,6 +10,8 @@ export enum Nux { AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', BookmarksAnnouncement = 'BookmarksAnnouncement', + FindContactsAnnouncement = 'FindContactsAnnouncement', + FindContactsDismissibleBanner = 'FindContactsDismissibleBanner', /* * Blocking announcements. New IDs are required for each new announcement. @@ -52,6 +54,14 @@ export type AppNux = BaseNux< id: Nux.BookmarksAnnouncement data: undefined } + | { + id: Nux.FindContactsAnnouncement + data: undefined + } + | { + id: Nux.FindContactsDismissibleBanner + data: undefined + } > export const NuxSchemas: Record | undefined> = { @@ -63,4 +73,6 @@ export const NuxSchemas: Record | undefined> = { [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, [Nux.PolicyUpdate202508]: undefined, [Nux.BookmarksAnnouncement]: undefined, + [Nux.FindContactsAnnouncement]: undefined, + [Nux.FindContactsDismissibleBanner]: undefined, } diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 243f3705b5..44d4aeaee8 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -12,6 +12,7 @@ import {isWeb} from '#/platform/detection' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' +import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX' import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {List} from '../util/List' @@ -208,6 +209,7 @@ export function ProfileFollows({name}: {name: string}) { onEndReached={onEndReached} onEndReachedThreshold={4} onItemSeen={onItemSeen} + ListHeaderComponent={} ListFooterComponent={