[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>(
|
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||||
function ScrollableInner(
|
function ScrollableInner(
|
||||||
{children, contentContainerStyle, header, style, ...props},
|
{children, contentContainerStyle, header, footer, style, ...props},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||||
@@ -248,42 +248,45 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<>
|
||||||
style={[isHeightConstrained && a.flex_1, style]}
|
<ScrollView
|
||||||
contentContainerStyle={[
|
style={[isHeightConstrained && a.flex_1, style]}
|
||||||
a.pt_2xl,
|
contentContainerStyle={[
|
||||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
a.pt_2xl,
|
||||||
platform({
|
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||||
ios: a.pb_2xl,
|
platform({
|
||||||
android: {
|
ios: a.pb_2xl,
|
||||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
android: {
|
||||||
},
|
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||||
}),
|
},
|
||||||
contentContainerStyle,
|
}),
|
||||||
]}
|
contentContainerStyle,
|
||||||
ref={ref}
|
]}
|
||||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
ref={ref}
|
||||||
contentInsetAdjustmentBehavior={
|
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
contentInsetAdjustmentBehavior={
|
||||||
}
|
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
}
|
||||||
{...props}
|
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||||
bounces={isAtMaxSnapPoint}
|
{...props}
|
||||||
scrollEventThrottle={50}
|
bounces={isAtMaxSnapPoint}
|
||||||
// set drag state based on scroll on android.
|
scrollEventThrottle={50}
|
||||||
// we want to detect if it's at the top or not, so watch
|
// set drag state based on scroll on android.
|
||||||
// scrollEndDrag and momentumScrollEnd as well
|
// we want to detect if it's at the top or not, so watch
|
||||||
onScroll={android(onScroll)}
|
// scrollEndDrag and momentumScrollEnd as well
|
||||||
onScrollEndDrag={android(onScroll)}
|
onScroll={android(onScroll)}
|
||||||
onMomentumScrollEnd={android(onScroll)}
|
onScrollEndDrag={android(onScroll)}
|
||||||
keyboardShouldPersistTaps="handled"
|
onMomentumScrollEnd={android(onScroll)}
|
||||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
keyboardShouldPersistTaps="handled"
|
||||||
// on Android. fine to disable for now, because we don't have any
|
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||||
// dialogs that use this that actually scroll -sfn
|
// on Android. fine to disable for now, because we don't have any
|
||||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
// dialogs that use this that actually scroll -sfn
|
||||||
{header}
|
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||||
{children}
|
{header}
|
||||||
</ScrollView>
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -350,9 +353,11 @@ export const InnerFlatList = forwardRef<
|
|||||||
export function FlatListFooter({
|
export function FlatListFooter({
|
||||||
children,
|
children,
|
||||||
onLayout,
|
onLayout,
|
||||||
|
border = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onLayout?: (event: LayoutChangeEvent) => void
|
onLayout?: (event: LayoutChangeEvent) => void
|
||||||
|
border?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
@@ -373,7 +378,7 @@ export function FlatListFooter({
|
|||||||
a.bottom_0,
|
a.bottom_0,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.z_10,
|
a.z_10,
|
||||||
a.border_t,
|
border && a.border_t,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export function Inner({
|
|||||||
accessibilityLabelledBy,
|
accessibilityLabelledBy,
|
||||||
accessibilityDescribedBy,
|
accessibilityDescribedBy,
|
||||||
header,
|
header,
|
||||||
|
footer,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
}: DialogInnerProps) {
|
}: DialogInnerProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -216,6 +217,7 @@ export function Inner({
|
|||||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
{footer}
|
||||||
</DismissableLayer.DismissableLayer>
|
</DismissableLayer.DismissableLayer>
|
||||||
</View>
|
</View>
|
||||||
</FocusScope.FocusScope>
|
</FocusScope.FocusScope>
|
||||||
@@ -266,9 +268,11 @@ export const InnerFlatList = forwardRef<
|
|||||||
export function FlatListFooter({
|
export function FlatListFooter({
|
||||||
children,
|
children,
|
||||||
onLayout,
|
onLayout,
|
||||||
|
border = true,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onLayout?: (event: LayoutChangeEvent) => void
|
onLayout?: (event: LayoutChangeEvent) => void
|
||||||
|
border?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -281,7 +285,7 @@ export function FlatListFooter({
|
|||||||
a.w_full,
|
a.w_full,
|
||||||
a.z_10,
|
a.z_10,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
a.border_t,
|
border && a.border_t,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
|
|||||||
@@ -79,14 +79,18 @@ export type DialogInnerProps =
|
|||||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||||
accessibilityDescribedBy: string
|
accessibilityDescribedBy: string
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
header?: React.ReactNode
|
header?: React.ReactNode
|
||||||
|
footer?: React.ReactNode
|
||||||
}>
|
}>
|
||||||
| DialogInnerPropsBase<{
|
| DialogInnerPropsBase<{
|
||||||
label: string
|
label: string
|
||||||
accessibilityLabelledBy?: undefined
|
accessibilityLabelledBy?: undefined
|
||||||
accessibilityDescribedBy?: undefined
|
accessibilityDescribedBy?: undefined
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
header?: React.ReactNode
|
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 {type SessionAccount, useSession} from '#/state/session'
|
||||||
import {useOnboardingState} from '#/state/shell'
|
import {useOnboardingState} from '#/state/shell'
|
||||||
import {
|
import {
|
||||||
DraftsAnnouncement,
|
enabled as isGroupChatsAnnouncementEnabled,
|
||||||
enabled as isDraftsAnnouncementEnabled,
|
GroupChatsAnnouncement,
|
||||||
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
|
} from '#/components/dialogs/nuxs/GroupChatsAnnouncement'
|
||||||
import {
|
import {
|
||||||
enabled as isInviteFriendsAnnouncementEnabled,
|
enabled as isInviteFriendsAnnouncementEnabled,
|
||||||
InviteFriendsAnnouncement,
|
InviteFriendsAnnouncement,
|
||||||
@@ -41,8 +41,8 @@ const queuedNuxs: {
|
|||||||
enabled?: (props: EnabledCheckProps) => boolean
|
enabled?: (props: EnabledCheckProps) => boolean
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
id: Nux.DraftsAnnouncement,
|
id: Nux.GroupChatsAnnouncement,
|
||||||
enabled: isDraftsAnnouncementEnabled,
|
enabled: isGroupChatsAnnouncementEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Nux.InviteFriendsAnnouncement,
|
id: Nux.InviteFriendsAnnouncement,
|
||||||
@@ -194,7 +194,7 @@ function Inner({
|
|||||||
return (
|
return (
|
||||||
<Context.Provider value={ctx}>
|
<Context.Provider value={ctx}>
|
||||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||||
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
{activeNux === Nux.GroupChatsAnnouncement && <GroupChatsAnnouncement />}
|
||||||
{/*
|
{/*
|
||||||
Mounted unconditionally: it gates the announcement on `activeNux`
|
Mounted unconditionally: it gates the announcement on `activeNux`
|
||||||
internally, so it can keep the invite-friends dialog mounted across
|
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'
|
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() {
|
export function snooze() {
|
||||||
device.set(['lastNuxDialog'], new Date().toISOString())
|
device.set(['lastNuxDialog'], new Date().toISOString())
|
||||||
}
|
}
|
||||||
@@ -14,9 +17,5 @@ export function isSnoozed() {
|
|||||||
if (!lastNuxDialog) return false
|
if (!lastNuxDialog) return false
|
||||||
const last = new Date(lastNuxDialog)
|
const last = new Date(lastNuxDialog)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
// already snoozed today
|
return now.getTime() - last.getTime() < SNOOZE_SECONDS * 1000
|
||||||
if (simpleAreDatesEqual(last, now)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,10 +211,12 @@ export function InitiateChatFlow({
|
|||||||
title,
|
title,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
onSelectGroupChat,
|
onSelectGroupChat,
|
||||||
|
startInGroupChat = false,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
onSelectChat: (did: string) => void
|
onSelectChat: (did: string) => void
|
||||||
onSelectGroupChat: (dids: string[], groupName: string) => void
|
onSelectGroupChat: (dids: string[], groupName: string) => void
|
||||||
|
startInGroupChat?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
@@ -243,8 +245,8 @@ export function InitiateChatFlow({
|
|||||||
},
|
},
|
||||||
dispatch,
|
dispatch,
|
||||||
] = useReducer(reducer, {
|
] = useReducer(reducer, {
|
||||||
chatState: ChatState.NEW_CHAT,
|
chatState: startInGroupChat ? ChatState.NEW_GROUP_CHAT : ChatState.NEW_CHAT,
|
||||||
screenTitle: title,
|
screenTitle: startInGroupChat ? l`New group chat` : title,
|
||||||
groupChatDids: [],
|
groupChatDids: [],
|
||||||
groupChatProfiles: [],
|
groupChatProfiles: [],
|
||||||
groupName: '',
|
groupName: '',
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ import {useAnalytics} from '#/analytics'
|
|||||||
export function NewChat({
|
export function NewChat({
|
||||||
control,
|
control,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
|
startInGroupChat = false,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
onNewChat: (chatId: string) => void
|
onNewChat: (chatId: string) => void
|
||||||
|
startInGroupChat?: boolean
|
||||||
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
@@ -169,13 +173,18 @@ export function NewChat({
|
|||||||
<Dialog.Outer
|
<Dialog.Outer
|
||||||
control={control}
|
control={control}
|
||||||
testID="newChatDialog"
|
testID="newChatDialog"
|
||||||
nativeOptions={{fullHeight: true}}>
|
nativeOptions={{fullHeight: true}}
|
||||||
|
onClose={onClose}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
{isGroupChatEnabled ? (
|
{isGroupChatEnabled ? (
|
||||||
<InitiateChatFlow
|
<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`}
|
title={l`New chat`}
|
||||||
onSelectChat={onCreateChat}
|
onSelectChat={onCreateChat}
|
||||||
onSelectGroupChat={onCreateGroupChat}
|
onSelectGroupChat={onCreateGroupChat}
|
||||||
|
startInGroupChat={startInGroupChat}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SearchablePeopleList
|
<SearchablePeopleList
|
||||||
|
|||||||
+15
-3
@@ -110,7 +110,11 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
Messages: {
|
||||||
|
pushToConversation?: string
|
||||||
|
pushToNewGroupChat?: boolean
|
||||||
|
animation?: 'push' | 'pop'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||||
@@ -118,7 +122,11 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
|||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
Messages: {
|
||||||
|
pushToConversation?: string
|
||||||
|
pushToNewGroupChat?: boolean
|
||||||
|
animation?: 'push' | 'pop'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
@@ -131,7 +139,11 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
|||||||
Notifications: undefined
|
Notifications: undefined
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
MessagesTab: undefined
|
MessagesTab: undefined
|
||||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
Messages: {
|
||||||
|
pushToConversation?: string
|
||||||
|
pushToNewGroupChat?: boolean
|
||||||
|
animation?: 'push' | 'pop'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
|||||||
|
|
||||||
import {useAppState} from '#/lib/appState'
|
import {useAppState} from '#/lib/appState'
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
@@ -41,7 +42,7 @@ import {Link} from '#/components/Link'
|
|||||||
import {ListFooter} from '#/components/Lists'
|
import {ListFooter} from '#/components/Lists'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {ChatDisabled} from './components/ChatDisabled'
|
import {ChatDisabled} from './components/ChatDisabled'
|
||||||
import {ChatListItem} from './components/ChatListItem'
|
import {ChatListItem} from './components/ChatListItem'
|
||||||
import {InboxRequests} from './components/InboxRequests'
|
import {InboxRequests} from './components/InboxRequests'
|
||||||
@@ -100,6 +101,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
|
|||||||
const newChatControl = useDialogControl()
|
const newChatControl = useDialogControl()
|
||||||
const {data: chatStatus} = useChatActorStatusQuery()
|
const {data: chatStatus} = useChatActorStatusQuery()
|
||||||
const pushToConversation = route.params?.pushToConversation
|
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
|
// 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.
|
// 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(() => {
|
const openChatControl = useCallback(() => {
|
||||||
|
setStartNewChatInGroupChat(false)
|
||||||
newChatControl.open()
|
newChatControl.open()
|
||||||
}, [newChatControl])
|
}, [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) {
|
if (isWithinSplitView) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -172,7 +224,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
|
|||||||
}
|
}
|
||||||
style={[a.h_full, a.justify_center, a.pb_5xl]}
|
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">
|
<Layout.Screen testID="messagesScreen">
|
||||||
<Header newChatControl={newChatControl} chatStatus={chatStatus} />
|
<Header newChatControl={newChatControl} chatStatus={chatStatus} />
|
||||||
<ChatList 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>
|
</Layout.Screen>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export enum Nux {
|
|||||||
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
||||||
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
||||||
DraftsAnnouncement = 'DraftsAnnouncement',
|
DraftsAnnouncement = 'DraftsAnnouncement',
|
||||||
|
GroupChatsAnnouncement = 'GroupChatsAnnouncement',
|
||||||
InviteFriendsAnnouncement = 'InviteFriendsAnnouncement',
|
InviteFriendsAnnouncement = 'InviteFriendsAnnouncement',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -78,6 +79,10 @@ export type AppNux = BaseNux<
|
|||||||
id: Nux.DraftsAnnouncement
|
id: Nux.DraftsAnnouncement
|
||||||
data: undefined
|
data: undefined
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: Nux.GroupChatsAnnouncement
|
||||||
|
data: undefined
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: Nux.InviteFriendsAnnouncement
|
id: Nux.InviteFriendsAnnouncement
|
||||||
data: undefined
|
data: undefined
|
||||||
@@ -98,5 +103,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
|||||||
[Nux.LiveNowBetaDialog]: undefined,
|
[Nux.LiveNowBetaDialog]: undefined,
|
||||||
[Nux.LiveNowBetaNudge]: undefined,
|
[Nux.LiveNowBetaNudge]: undefined,
|
||||||
[Nux.DraftsAnnouncement]: undefined,
|
[Nux.DraftsAnnouncement]: undefined,
|
||||||
|
[Nux.GroupChatsAnnouncement]: undefined,
|
||||||
[Nux.InviteFriendsAnnouncement]: undefined,
|
[Nux.InviteFriendsAnnouncement]: undefined,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user