[Chat] NUX (#10848)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
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<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{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,
|
||||
|
||||
@@ -170,6 +170,7 @@ export function Inner({
|
||||
accessibilityLabelledBy,
|
||||
accessibilityDescribedBy,
|
||||
header,
|
||||
footer,
|
||||
contentContainerStyle,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
@@ -216,6 +217,7 @@ export function Inner({
|
||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
{footer}
|
||||
</DismissableLayer.DismissableLayer>
|
||||
</View>
|
||||
</FocusScope.FocusScope>
|
||||
@@ -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,
|
||||
|
||||
@@ -79,14 +79,18 @@ export type DialogInnerProps =
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
header?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
header?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}>
|
||||
|
||||
@@ -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<NavigationProp>()
|
||||
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 (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
showsVerticalScrollIndicator={false}
|
||||
label={l`Introducing group chats`}
|
||||
// Fill the full-height sheet so the absolute footer pins to the bottom.
|
||||
style={[
|
||||
native(a.h_full),
|
||||
web([{maxWidth: 440}, a.overflow_hidden, {borderRadius: 32}]),
|
||||
]}
|
||||
contentContainerStyle={[
|
||||
native(a.p_0),
|
||||
web(a.p_md),
|
||||
{paddingBottom: footerHeight},
|
||||
]}
|
||||
footer={
|
||||
<Dialog.FlatListFooter
|
||||
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
|
||||
border={false}>
|
||||
<View
|
||||
style={[
|
||||
a.gap_md,
|
||||
native(a.px_lg),
|
||||
web([a.px_lg, a.pb_2xl, a.pt_xl]),
|
||||
]}>
|
||||
<Button
|
||||
label={l`Got it`}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Got it</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={l`Start a group chat`}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={onPressStartGroupChat}>
|
||||
<ButtonText>
|
||||
<Trans>Start a group chat</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.FlatListFooter>
|
||||
}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
platform({
|
||||
web: [a.pt_xl, a.px_4xl],
|
||||
native: [a.pt_xl, a.pb_md, a.px_sm],
|
||||
}),
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={getHero(t.name)}
|
||||
style={[a.w_full, {aspectRatio: 343 / 230}]}
|
||||
alt={l({
|
||||
message: `Four message bubbles representing a group chat. First message: "Did you hear the news? Bluesky has group chats now!" Second message: "omg, no way" Third message: "Wow, 50 people in one chat!" Fourth message: "You can send invite links too!"`,
|
||||
comment:
|
||||
'This is alt text for a marketing image which transcribes English text that appears in the image',
|
||||
})}
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.px_xl, a.pt_2xl, a.gap_2xl]}>
|
||||
<View style={[a.align_center, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, {left: -6}]}>
|
||||
<SparkleIcon fill={t.palette.primary_500} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
<Trans>New</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
{fontSize: IS_WEB ? 32 : 36},
|
||||
]}>
|
||||
<Trans>Group Chats</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.text_center]}>
|
||||
<Trans>Take the conversation private.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_xl, a.pt_sm, a.pb_md]}>
|
||||
<Feature
|
||||
icon={ChainLinkIcon}
|
||||
titleText={<Trans>Add people with a link</Trans>}
|
||||
descriptionText={
|
||||
<Trans>Post it to Bluesky or share anywhere.</Trans>
|
||||
}
|
||||
/>
|
||||
<Feature
|
||||
icon={GroupIcon}
|
||||
titleText={<Trans>Up to 50 people</Trans>}
|
||||
descriptionText={
|
||||
<Trans>Bring up to 50 friends together in one chat.</Trans>
|
||||
}
|
||||
/>
|
||||
<Feature
|
||||
icon={ShieldIcon}
|
||||
titleText={<Trans>You’re in control</Trans>}
|
||||
descriptionText={
|
||||
<Trans>
|
||||
Mute, leave, or remove people anytime. It’s your chat.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Feature({
|
||||
icon: Icon,
|
||||
titleText,
|
||||
descriptionText,
|
||||
style,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
titleText: React.ReactNode
|
||||
descriptionText: React.ReactNode
|
||||
} & TextStyleProp) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, style]}>
|
||||
<Icon size="md" style={[t.atoms.text]} />
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold]}>{titleText}</Text>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{descriptionText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
||||
{activeNux === Nux.GroupChatsAnnouncement && <GroupChatsAnnouncement />}
|
||||
{/*
|
||||
Mounted unconditionally: it gates the announcement on `activeNux`
|
||||
internally, so it can keep the invite-friends dialog mounted across
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
nativeOptions={{fullHeight: true}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
{isGroupChatEnabled ? (
|
||||
<InitiateChatFlow
|
||||
// remount when the entry mode changes so the flow re-seeds its
|
||||
// initial step (the children stay mounted across open/close)
|
||||
key={startInGroupChat ? 'group' : 'default'}
|
||||
title={l`New chat`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectGroupChat={onCreateGroupChat}
|
||||
startInGroupChat={startInGroupChat}
|
||||
/>
|
||||
) : (
|
||||
<SearchablePeopleList
|
||||
|
||||
+15
-3
@@ -110,7 +110,11 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||
Messages: {
|
||||
pushToConversation?: string
|
||||
pushToNewGroupChat?: boolean
|
||||
animation?: 'push' | 'pop'
|
||||
}
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -118,7 +122,11 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||
Messages: {
|
||||
pushToConversation?: string
|
||||
pushToNewGroupChat?: boolean
|
||||
animation?: 'push' | 'pop'
|
||||
}
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -131,7 +139,11 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
MessagesTab: undefined
|
||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||
Messages: {
|
||||
pushToConversation?: string
|
||||
pushToNewGroupChat?: boolean
|
||||
animation?: 'push' | 'pop'
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -8,6 +8,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {useAppState} from '#/lib/appState'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
@@ -41,7 +42,7 @@ import {Link} from '#/components/Link'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {ChatDisabled} from './components/ChatDisabled'
|
||||
import {ChatListItem} from './components/ChatListItem'
|
||||
import {InboxRequests} from './components/InboxRequests'
|
||||
@@ -100,6 +101,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
|
||||
const newChatControl = useDialogControl()
|
||||
const {data: chatStatus} = useChatActorStatusQuery()
|
||||
const pushToConversation = route.params?.pushToConversation
|
||||
const pushToNewGroupChat = route.params?.pushToNewGroupChat
|
||||
// Tracks whether the next new-chat dialog open should start directly on the
|
||||
// group-chat creation step. Set when deep-linked via `pushToNewGroupChat`,
|
||||
// and reset to `false` whenever the dialog is opened through the normal FAB/
|
||||
// button path so it never gets stuck in group mode.
|
||||
const [startNewChatInGroupChat, setStartNewChatInGroupChat] = useState(false)
|
||||
|
||||
// Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
|
||||
// this tab. We should immediately push to the conversation after pressing the notification.
|
||||
@@ -137,6 +144,7 @@ export function MessagesScreenInner({navigation, route}: Props) {
|
||||
)
|
||||
|
||||
const openChatControl = useCallback(() => {
|
||||
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: [
|
||||
<Trans key="new-group-chat">
|
||||
Before you can message another user, you must first verify your email.
|
||||
</Trans>,
|
||||
],
|
||||
},
|
||||
)
|
||||
// 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]}
|
||||
/>
|
||||
<NewChat onNewChat={onNewChat} control={newChatControl} />
|
||||
<NewChat
|
||||
onNewChat={onNewChat}
|
||||
control={newChatControl}
|
||||
startInGroupChat={startNewChatInGroupChat}
|
||||
onClose={() => setStartNewChatInGroupChat(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -181,7 +238,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
|
||||
<Layout.Screen testID="messagesScreen">
|
||||
<Header newChatControl={newChatControl} chatStatus={chatStatus} />
|
||||
<ChatList newChatControl={newChatControl} chatStatus={chatStatus} />
|
||||
<NewChat onNewChat={onNewChat} control={newChatControl} />
|
||||
<NewChat
|
||||
onNewChat={onNewChat}
|
||||
control={newChatControl}
|
||||
startInGroupChat={startNewChatInGroupChat}
|
||||
onClose={() => setStartNewChatInGroupChat(false)}
|
||||
/>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.LiveNowBetaDialog]: undefined,
|
||||
[Nux.LiveNowBetaNudge]: undefined,
|
||||
[Nux.DraftsAnnouncement]: undefined,
|
||||
[Nux.GroupChatsAnnouncement]: undefined,
|
||||
[Nux.InviteFriendsAnnouncement]: undefined,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user