diff --git a/assets/images/groupchats_announcement_dark.webp b/assets/images/groupchats_announcement_dark.webp new file mode 100644 index 0000000000..5bbc64ebfc Binary files /dev/null and b/assets/images/groupchats_announcement_dark.webp differ diff --git a/assets/images/groupchats_announcement_dim.webp b/assets/images/groupchats_announcement_dim.webp new file mode 100644 index 0000000000..d418be4de0 Binary files /dev/null and b/assets/images/groupchats_announcement_dim.webp differ diff --git a/assets/images/groupchats_announcement_light.webp b/assets/images/groupchats_announcement_light.webp new file mode 100644 index 0000000000..32014850ad Binary files /dev/null and b/assets/images/groupchats_announcement_light.webp differ diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index af724a4ab2..bd23e566ad 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) { export const ScrollableInner = forwardRef( function ScrollableInner( - {children, contentContainerStyle, header, style, ...props}, + {children, contentContainerStyle, header, footer, style, ...props}, ref, ) { const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} = @@ -248,42 +248,45 @@ export const ScrollableInner = forwardRef( } return ( - - {header} - {children} - + <> + + {header} + {children} + + {footer} + ) }, ) @@ -350,9 +353,11 @@ export const InnerFlatList = forwardRef< export function FlatListFooter({ children, onLayout, + border = true, }: { children: React.ReactNode onLayout?: (event: LayoutChangeEvent) => void + border?: boolean }) { const t = useTheme() const {bottom} = useSafeAreaInsets() @@ -373,7 +378,7 @@ export function FlatListFooter({ a.bottom_0, a.w_full, a.z_10, - a.border_t, + border && a.border_t, t.atoms.bg, t.atoms.border_contrast_low, a.px_lg, diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 9666e9a7a4..37c893be1a 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -170,6 +170,7 @@ export function Inner({ accessibilityLabelledBy, accessibilityDescribedBy, header, + footer, contentContainerStyle, }: DialogInnerProps) { const t = useTheme() @@ -216,6 +217,7 @@ export function Inner({ {children} + {footer} @@ -266,9 +268,11 @@ export const InnerFlatList = forwardRef< export function FlatListFooter({ children, onLayout, + border = true, }: { children: React.ReactNode onLayout?: (event: LayoutChangeEvent) => void + border?: boolean }) { const t = useTheme() @@ -281,7 +285,7 @@ export function FlatListFooter({ a.w_full, a.z_10, t.atoms.bg, - a.border_t, + border && a.border_t, t.atoms.border_contrast_low, a.px_lg, a.py_md, diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 865083d501..79c40942de 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -79,14 +79,18 @@ export type DialogInnerProps = accessibilityLabelledBy: A11yProps['aria-labelledby'] accessibilityDescribedBy: string keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] + showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator'] contentContainerStyle?: StyleProp header?: React.ReactNode + footer?: React.ReactNode }> | DialogInnerPropsBase<{ label: string accessibilityLabelledBy?: undefined accessibilityDescribedBy?: undefined keyboardDismissMode?: ScrollViewProps['keyboardDismissMode'] + showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator'] contentContainerStyle?: StyleProp header?: React.ReactNode + footer?: React.ReactNode }> diff --git a/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx b/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx new file mode 100644 index 0000000000..0535b3478f --- /dev/null +++ b/src/components/dialogs/nuxs/GroupChatsAnnouncement.tsx @@ -0,0 +1,241 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Image} from 'expo-image' +import {type ThemeName} from '@bsky.app/alf' +import {Trans, useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import { + atoms as a, + native, + platform, + type TextStyleProp, + useTheme, + web, +} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' +import {Shield_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' +import {Text} from '#/components/Typography' +import {IS_E2E, IS_WEB} from '#/env' +import {createIsEnabledCheck, isExistingUserAsOf} from './utils' + +// Gate: only show to existing users (created before 2026-06-11), not E2E +export const enabled = createIsEnabledCheck(props => { + return ( + !IS_E2E && + isExistingUserAsOf( + '2026-06-11T00:00:00.000Z', + props.currentProfile.createdAt, + ) + ) +}) + +function getHero(theme: ThemeName) { + switch (theme) { + case 'light': + return require('../../../../assets/images/groupchats_announcement_light.webp') + case 'dark': + return require('../../../../assets/images/groupchats_announcement_dark.webp') + case 'dim': + return require('../../../../assets/images/groupchats_announcement_dim.webp') + } +} + +export function GroupChatsAnnouncement() { + const t = useTheme() + const {t: l} = useLingui() + const navigation = useNavigation() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + const {bottom} = useSafeAreaInsets() + + // Measure the footer so the scrollable content can pad itself out from + // underneath it (the footer is absolutely positioned). + const [footerHeight, setFooterHeight] = useState( + platform({ + native: 124 + bottom, + web: 128, + default: 0, + }), + ) + + Dialog.useAutoOpen(control) + + const onClose = useCallback(() => { + nuxDialogs.dismissActiveNux() + }, [nuxDialogs]) + + const onPressStartGroupChat = useCallback(() => { + control.close(() => { + if (IS_WEB) { + navigation.navigate('Messages', {pushToNewGroupChat: true}) + } else { + // On native, Messages is nested inside MessagesTab. + // @ts-expect-error nested navigators aren't typed -sfn + navigation.navigate('MessagesTab', { + screen: 'Messages', + params: {pushToNewGroupChat: true}, + }) + } + }) + }, [control, navigation]) + + return ( + + + setFooterHeight(evt.nativeEvent.layout.height)} + border={false}> + + + + + + }> + + {l({ + + + + + + + New + + + + Group Chats + + + Take the conversation private. + + + + + Add people with a link} + descriptionText={ + Post it to Bluesky or share anywhere. + } + /> + Up to 50 people} + descriptionText={ + Bring up to 50 friends together in one chat. + } + /> + You’re in control} + descriptionText={ + + Mute, leave, or remove people anytime. It’s your chat. + + } + /> + + + + + ) +} + +function Feature({ + icon: Icon, + titleText, + descriptionText, + style, +}: { + icon: React.ComponentType + titleText: React.ReactNode + descriptionText: React.ReactNode +} & TextStyleProp) { + const t = useTheme() + return ( + + + + {titleText} + + {descriptionText} + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 41daa8b9f0..2fa8780d95 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -19,9 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile' import {type SessionAccount, useSession} from '#/state/session' import {useOnboardingState} from '#/state/shell' import { - DraftsAnnouncement, - enabled as isDraftsAnnouncementEnabled, -} from '#/components/dialogs/nuxs/DraftsAnnouncement' + enabled as isGroupChatsAnnouncementEnabled, + GroupChatsAnnouncement, +} from '#/components/dialogs/nuxs/GroupChatsAnnouncement' import { enabled as isInviteFriendsAnnouncementEnabled, InviteFriendsAnnouncement, @@ -41,8 +41,8 @@ const queuedNuxs: { enabled?: (props: EnabledCheckProps) => boolean }[] = [ { - id: Nux.DraftsAnnouncement, - enabled: isDraftsAnnouncementEnabled, + id: Nux.GroupChatsAnnouncement, + enabled: isGroupChatsAnnouncementEnabled, }, { id: Nux.InviteFriendsAnnouncement, @@ -194,7 +194,7 @@ function Inner({ return ( {/*For example, activeNux === Nux.NeueTypography && */} - {activeNux === Nux.DraftsAnnouncement && } + {activeNux === Nux.GroupChatsAnnouncement && } {/* Mounted unconditionally: it gates the announcement on `activeNux` internally, so it can keep the invite-friends dialog mounted across diff --git a/src/components/dialogs/nuxs/snoozing.ts b/src/components/dialogs/nuxs/snoozing.ts index 91effd0501..94389d312b 100644 --- a/src/components/dialogs/nuxs/snoozing.ts +++ b/src/components/dialogs/nuxs/snoozing.ts @@ -1,6 +1,9 @@ -import {simpleAreDatesEqual} from '#/lib/strings/time' +import {IS_DEV} from '#/env' import {device} from '#/storage' +const PROD_SNOOZE_SECONDS = 3 * 60 * 60 // 3 hours +const SNOOZE_SECONDS = IS_DEV ? 10 : PROD_SNOOZE_SECONDS + export function snooze() { device.set(['lastNuxDialog'], new Date().toISOString()) } @@ -14,9 +17,5 @@ export function isSnoozed() { if (!lastNuxDialog) return false const last = new Date(lastNuxDialog) const now = new Date() - // already snoozed today - if (simpleAreDatesEqual(last, now)) { - return true - } - return false + return now.getTime() - last.getTime() < SNOOZE_SECONDS * 1000 } diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index 0c3adc450b..ed4b9bc825 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -211,10 +211,12 @@ export function InitiateChatFlow({ title, onSelectChat, onSelectGroupChat, + startInGroupChat = false, }: { title: string onSelectChat: (did: string) => void onSelectGroupChat: (dids: string[], groupName: string) => void + startInGroupChat?: boolean }) { const t = useTheme() const {t: l} = useLingui() @@ -243,8 +245,8 @@ export function InitiateChatFlow({ }, dispatch, ] = useReducer(reducer, { - chatState: ChatState.NEW_CHAT, - screenTitle: title, + chatState: startInGroupChat ? ChatState.NEW_GROUP_CHAT : ChatState.NEW_CHAT, + screenTitle: startInGroupChat ? l`New group chat` : title, groupChatDids: [], groupChatProfiles: [], groupName: '', diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index c24e080340..74ab6b4c0d 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -23,9 +23,13 @@ import {useAnalytics} from '#/analytics' export function NewChat({ control, onNewChat, + startInGroupChat = false, + onClose, }: { control: Dialog.DialogControlProps onNewChat: (chatId: string) => void + startInGroupChat?: boolean + onClose?: () => void }) { const t = useTheme() const {t: l} = useLingui() @@ -169,13 +173,18 @@ export function NewChat({ + nativeOptions={{fullHeight: true}} + onClose={onClose}> {isGroupChatEnabled ? ( ) : ( { + setStartNewChatInGroupChat(false) newChatControl.open() }, [newChatControl]) @@ -149,6 +157,50 @@ export function MessagesScreenInner({navigation, route}: Props) { ], }) + // Deep link into the group-chat creation step of the new-chat dialog. Mirrors + // the `pushToConversation` pattern: open the dialog (respecting the same + // email-verification gating as the normal new-chat button) starting directly + // in group mode, then clear the param so it can fire again later. + const openGroupChatControl = useCallback(() => { + setStartNewChatInGroupChat(true) + newChatControl.open() + }, [newChatControl]) + const wrappedOpenGroupChatControl = requireEmailVerification( + openGroupChatControl, + { + instructions: [ + + Before you can message another user, you must first verify your email. + , + ], + }, + ) + // Stable reference to the (otherwise per-render) opener so the effect below + // doesn't list it as a dependency - if it did, clearing the param would + // re-run the effect and its cleanup would cancel the pending open. + const openGroupChat = useNonReactiveCallback(wrappedOpenGroupChatControl) + // Deep link into the group-chat creation step of the new-chat dialog. The + // dialog control isn't attached synchronously when navigating onto this + // screen, so defer the open by a tick. We clear the param *after* opening + // (inside the timeout) so clearing doesn't cancel the pending open. + useEffect(() => { + if (!pushToNewGroupChat) return + const timeout = setTimeout(() => { + openGroupChat() + if (IS_WEB) { + // `navigation.setParams({pushToNewGroupChat: undefined})` serializes the + // literal string "undefined" into the query on web (see router build()), + // so strip the param with the history API instead. + const url = new URL(window.location.href) + url.searchParams.delete('pushToNewGroupChat') + history.replaceState(null, '', url.pathname + url.search + url.hash) + } else { + navigation.setParams({pushToNewGroupChat: undefined}) + } + }, 100) + return () => clearTimeout(timeout) + }, [navigation, pushToNewGroupChat, openGroupChat]) + if (isWithinSplitView) { return ( <> @@ -172,7 +224,12 @@ export function MessagesScreenInner({navigation, route}: Props) { } style={[a.h_full, a.justify_center, a.pb_5xl]} /> - + setStartNewChatInGroupChat(false)} + /> ) } @@ -181,7 +238,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
- + setStartNewChatInGroupChat(false)} + /> ) } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 7e55b9f50a..d9323acf87 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -15,6 +15,7 @@ export enum Nux { LiveNowBetaDialog = 'LiveNowBetaDialog', LiveNowBetaNudge = 'LiveNowBetaNudge', DraftsAnnouncement = 'DraftsAnnouncement', + GroupChatsAnnouncement = 'GroupChatsAnnouncement', InviteFriendsAnnouncement = 'InviteFriendsAnnouncement', /* @@ -78,6 +79,10 @@ export type AppNux = BaseNux< id: Nux.DraftsAnnouncement data: undefined } + | { + id: Nux.GroupChatsAnnouncement + data: undefined + } | { id: Nux.InviteFriendsAnnouncement data: undefined @@ -98,5 +103,6 @@ export const NuxSchemas: Record | undefined> = { [Nux.LiveNowBetaDialog]: undefined, [Nux.LiveNowBetaNudge]: undefined, [Nux.DraftsAnnouncement]: undefined, + [Nux.GroupChatsAnnouncement]: undefined, [Nux.InviteFriendsAnnouncement]: undefined, }