add split view layout to chat

This commit is contained in:
Samuel Newman
2026-04-15 15:46:27 +03:00
parent e52b7df39b
commit f5a3487566
12 changed files with 410 additions and 253 deletions
+24 -20
View File
@@ -143,6 +143,7 @@ import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -564,26 +565,28 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => TopicScreen} getComponent={() => TopicScreen}
options={{title: title(msg`Topic`)}} options={{title: title(msg`Topic`)}}
/> />
<Stack.Screen <Stack.Group screenLayout={renderMessagesSplitViewLayout}>
name="MessagesConversation" <Stack.Screen
getComponent={() => MessagesConversationScreen} name="MessagesConversation"
options={{title: title(msg`Chat`), requireAuth: true}} getComponent={() => MessagesConversationScreen}
/> options={{title: title(msg`Chat`), requireAuth: true}}
<Stack.Screen />
name="MessagesConversationSettings" <Stack.Screen
getComponent={() => MessagesConversationSettingsScreen} name="MessagesConversationSettings"
options={{title: title(msg`Group chat settings`), requireAuth: true}} getComponent={() => MessagesConversationSettingsScreen}
/> options={{title: title(msg`Group chat settings`), requireAuth: true}}
<Stack.Screen />
name="MessagesSettings" <Stack.Screen
getComponent={() => MessagesSettingsScreen} name="MessagesSettings"
options={{title: title(msg`Chat settings`), requireAuth: true}} getComponent={() => MessagesSettingsScreen}
/> options={{title: title(msg`Chat settings`), requireAuth: true}}
<Stack.Screen />
name="MessagesInbox" <Stack.Screen
getComponent={() => MessagesInboxScreen} name="MessagesInbox"
options={{title: title(msg`Chat request inbox`), requireAuth: true}} getComponent={() => MessagesInboxScreen}
/> options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
</Stack.Group>
<Stack.Screen <Stack.Screen
name="NotificationsActivityList" name="NotificationsActivityList"
getComponent={() => NotificationsActivityListScreen} getComponent={() => NotificationsActivityListScreen}
@@ -832,6 +835,7 @@ const FlatNavigator = ({
name="Messages" name="Messages"
getComponent={() => MessagesScreen} getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}} options={{title: title(msg`Messages`), requireAuth: true}}
layout={renderMessagesSplitViewLayout}
/> />
<Flat.Screen <Flat.Screen
name="Start" name="Start"
+14 -6
View File
@@ -7,6 +7,7 @@ import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
import { import {
atoms as a, atoms as a,
platform, platform,
@@ -46,6 +47,7 @@ export function Outer({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {isWithinOffsetView} = useContext(ScrollbarOffsetContext) const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
const {centerColumnOffset} = useLayoutBreakpoints() const {centerColumnOffset} = useLayoutBreakpoints()
const {isWithinSplitView} = useIsWithinSplitView()
return ( return (
<View <View
@@ -64,12 +66,13 @@ export function Outer({
}), }),
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
gtMobile && [a.mx_auto, {maxWidth: 600}], gtMobile && [a.mx_auto, {maxWidth: 600}],
!isWithinOffsetView && { !isWithinOffsetView &&
transform: [ !isWithinSplitView && {
{translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0}, transform: [
{translateX: web(SCROLLBAR_OFFSET) ?? 0}, {translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0},
], {translateX: web(SCROLLBAR_OFFSET) ?? 0},
}, ],
},
]}> ]}>
{children} {children}
</View> </View>
@@ -108,6 +111,7 @@ export function Slot({children}: {children?: React.ReactNode}) {
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isWithinRightPanel} = useIsWithinSplitView()
const onPressBack = useCallback( const onPressBack = useCallback(
(evt: GestureResponderEvent) => { (evt: GestureResponderEvent) => {
@@ -122,6 +126,10 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
[onPress, navigation], [onPress, navigation],
) )
if (isWithinRightPanel) {
return null
}
return ( return (
<Slot> <Slot>
<Button <Button
+19 -15
View File
@@ -18,6 +18,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEnableMinimalShellModeForScreen} from '#/state/shell' import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
import { import {
atoms as a, atoms as a,
useBreakpoints, useBreakpoints,
@@ -49,12 +50,13 @@ export const Screen = memo(function Screen({
...props ...props
}: ScreenProps) { }: ScreenProps) {
const {top} = useSafeAreaInsets() const {top} = useSafeAreaInsets()
const {isWithinSplitView} = useIsWithinSplitView()
useEnableMinimalShellModeForScreen({enabled: minimalShell}) useEnableMinimalShellModeForScreen({enabled: minimalShell})
return ( return (
<> <>
{IS_WEB && <WebCenterBorders />} {IS_WEB && !isWithinSplitView && <WebCenterBorders />}
<View <View
style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]} style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]}
{...props} {...props}
@@ -174,28 +176,30 @@ export const Center = memo(function LayoutCenter({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {centerColumnOffset} = useLayoutBreakpoints() const {centerColumnOffset} = useLayoutBreakpoints()
const {isWithinDialog} = useDialogContext() const {isWithinDialog} = useDialogContext()
const {isWithinSplitView} = useIsWithinSplitView()
const ctx = useMemo(() => ({isWithinOffsetView: true}), []) const ctx = useMemo(() => ({isWithinOffsetView: true}), [])
return ( return (
<View <View
style={[ style={[
a.w_full, a.w_full,
a.mx_auto, !isWithinSplitView && a.mx_auto,
gtMobile && { gtMobile && {
maxWidth: 600, maxWidth: 600,
}, },
!isWithinOffsetView && { !isWithinOffsetView &&
transform: [ !isWithinSplitView && {
{ transform: [
translateX: {
centerColumnOffset && translateX:
!ignoreTabletLayoutOffset && centerColumnOffset &&
!isWithinDialog !ignoreTabletLayoutOffset &&
? CENTER_COLUMN_OFFSET !isWithinDialog
: 0, ? CENTER_COLUMN_OFFSET
}, : 0,
{translateX: web(SCROLLBAR_OFFSET) ?? 0}, },
], {translateX: web(SCROLLBAR_OFFSET) ?? 0},
}, ],
},
style, style,
]} ]}
{...props}> {...props}>
+13 -8
View File
@@ -6,6 +6,7 @@ import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
@@ -25,6 +26,7 @@ export function NewChat({
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const {isWithinSplitView} = useIsWithinSplitView()
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
@@ -99,14 +101,17 @@ export function NewChat({
return ( return (
<> <>
<FAB {/* in split view, header button is always available, so no need for FAB */}
testID="newChatFAB" {!isWithinSplitView && (
onPress={wrappedOnPress} <FAB
icon={<NewChatIcon size="lg" fill={t.palette.white} />} testID="newChatFAB"
accessibilityRole="button" onPress={wrappedOnPress}
accessibilityLabel={l`New chat`} icon={<NewChatIcon size="lg" fill={t.palette.white} />}
accessibilityHint="" accessibilityRole="button"
/> accessibilityLabel={l`New chat`}
accessibilityHint=""
/>
)}
<Dialog.Outer <Dialog.Outer
control={control} control={control}
testID="newChatDialog" testID="newChatDialog"
+1 -1
View File
@@ -139,7 +139,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Notifications: undefined Notifications: undefined
MyProfileTab: undefined MyProfileTab: undefined
MessagesTab: undefined MessagesTab: undefined
Messages: {animation?: 'push' | 'pop'} Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
} }
// NOTE // NOTE
+174 -170
View File
@@ -2,9 +2,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -19,9 +17,10 @@ import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation' import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -41,14 +40,16 @@ import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {ChatListItem} from './components/ChatListItem' import {ChatListItem} from './components/ChatListItem'
import {InboxRequests} from './components/InboxRequests' import {InboxRequests} from './components/InboxRequests'
import {useIsWithinSplitView} from './components/splitView/context'
type ListItem = { type ListItem = {
type: 'CONVERSATION' type: 'CONVERSATION'
conversation: ChatBskyConvoDefs.ConvoView conversation: ChatBskyConvoDefs.ConvoView
selected: boolean
} }
function renderItem({item}: {item: ListItem}) { function renderItem({item}: {item: ListItem}) {
return <ChatListItem convo={item.conversation} /> return <ChatListItem convo={item.conversation} selected={item.selected} />
} }
function keyExtractor(item: ListItem) { function keyExtractor(item: ListItem) {
@@ -58,19 +59,19 @@ function keyExtractor(item: ListItem) {
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen(props: Props) { export function MessagesScreen(props: Props) {
const {_} = useLingui() const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy() const aaCopy = useAgeAssuranceCopy()
const aa = useAgeAssurance() const aa = useAgeAssurance()
return ( return (
<AgeRestrictedScreen <AgeRestrictedScreen
screenTitle={_(msg`Chats`)} screenTitle={l`Chats`}
infoText={aaCopy.chatsInfoText} infoText={aaCopy.chatsInfoText}
rightHeaderSlot={ rightHeaderSlot={
aa.flags.chatDisabled ? null : ( aa.flags.chatDisabled ? null : (
<Link <Link
to="/messages/settings" to="/messages/settings"
label={_(msg`Chat settings`)} label={l`Chat settings`}
size="small" size="small"
color="secondary"> color="secondary">
<ButtonText> <ButtonText>
@@ -85,10 +86,9 @@ export function MessagesScreen(props: Props) {
} }
export function MessagesScreenInner({navigation, route}: Props) { export function MessagesScreenInner({navigation, route}: Props) {
const {_} = useLingui() const {isWithinSplitView} = useIsWithinSplitView()
const t = useTheme() const {t: l} = useLingui()
const newChatControl = useDialogControl() const newChatControl = useDialogControl()
const scrollElRef: ListRef = useAnimatedRef()
const pushToConversation = route.params?.pushToConversation const pushToConversation = route.params?.pushToConversation
// 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
@@ -120,6 +120,48 @@ export function MessagesScreenInner({navigation, route}: Props) {
}, [messagesBus, isActive]), }, [messagesBus, isActive]),
) )
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
if (isWithinSplitView) {
return (
<>
<EmptyState
message={l`Start a conversation`}
icon={MessageIcon}
iconSize="3xl"
button={{
label: l`New chat`,
text: l`New chat`,
onPress: newChatControl.open,
size: 'small',
color: 'secondary_inverted',
}}
style={[a.h_full, a.justify_center, a.pb_5xl]}
/>
<NewChat onNewChat={onNewChat} control={newChatControl} />
</>
)
}
return (
<Layout.Screen testID="messagesScreen">
<Header newChatControl={newChatControl} />
<ChatList />
<NewChat onNewChat={onNewChat} control={newChatControl} />
</Layout.Screen>
)
}
export function ChatList({selectedChat}: {selectedChat?: string}) {
const t = useTheme()
const {t: l} = useLingui()
const scrollElRef: ListRef = useAnimatedRef()
const {isWithinSplitView} = useIsWithinSplitView()
const initialNumToRender = useInitialNumToRender({minItemHeight: 80}) const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
@@ -134,11 +176,7 @@ export function MessagesScreenInner({navigation, route}: Props) {
refetch, refetch,
} = useListConvosQuery({status: 'accepted'}) } = useListConvosQuery({status: 'accepted'})
const { const {refetch: refetchInbox} = useListConvosQuery({
data: inboxData,
refetch: refetchInbox,
hasNextPage: hasMoreRequests,
} = useListConvosQuery({
status: 'request', status: 'request',
}) })
@@ -147,16 +185,6 @@ export function MessagesScreenInner({navigation, route}: Props) {
const leftConvos = useLeftConvos() const leftConvos = useLeftConvos()
const inboxAllConvos =
inboxData?.pages
.flatMap(page => page.convos)
.filter(
convo =>
!leftConvos.includes(convo.id) &&
!convo.muted &&
convo.members.every(member => member.handle !== 'missing.invalid'),
) ?? []
const conversations = useMemo(() => { const conversations = useMemo(() => {
if (data?.pages) { if (data?.pages) {
const conversations = data.pages const conversations = data.pages
@@ -164,18 +192,17 @@ export function MessagesScreenInner({navigation, route}: Props) {
// filter out convos that are actively being left // filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id)) .filter(convo => !leftConvos.includes(convo.id))
return [ return conversations.map(
...conversations.map( convo =>
convo => ({
({ type: 'CONVERSATION',
type: 'CONVERSATION', conversation: convo,
conversation: convo, selected: convo.id === selectedChat,
}) as const, }) as const,
), ) satisfies ListItem[]
] satisfies ListItem[]
} }
return [] return []
}, [data, leftConvos]) }, [data, leftConvos, selectedChat])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
@@ -196,12 +223,6 @@ export function MessagesScreenInner({navigation, route}: Props) {
} }
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
const onSoftReset = useCallback(async () => { const onSoftReset = useCallback(async () => {
scrollElRef.current?.scrollToOffset({ scrollElRef.current?.scrollToOffset({
animated: IS_NATIVE, animated: IS_NATIVE,
@@ -222,141 +243,122 @@ export function MessagesScreenInner({navigation, route}: Props) {
return listenSoftReset(() => void onSoftReset()) return listenSoftReset(() => void onSoftReset())
}, [onSoftReset, isScreenFocused]) }, [onSoftReset, isScreenFocused])
// NOTE(APiligrim) if (conversations.length === 0) {
// Show empty state only if there are no conversations at all
const activeConversations = conversations.filter(
item => item.type === 'CONVERSATION',
)
if (activeConversations.length === 0) {
return ( return (
<Layout.Screen> <Layout.Center>
<Header {isLoading ? (
newChatControl={newChatControl} <ChatListLoadingPlaceholder />
requestsCount={inboxAllConvos.length} ) : (
hasMoreRequests={hasMoreRequests} <>
/> {isError ? (
<Layout.Center> <>
{isLoading ? ( <View style={[a.pt_3xl, a.align_center]}>
<ChatListLoadingPlaceholder /> <CircleInfoIcon
) : ( width={48}
<> fill={t.atoms.text_contrast_low.color}
{isError ? ( />
<> <Text
<View style={[a.pt_3xl, a.align_center]}> style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}>
<CircleInfoIcon <Trans>Whoops!</Trans>
width={48} </Text>
fill={t.atoms.text_contrast_low.color} <Text
/> style={[
<Text a.text_md,
style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}> a.pb_xl,
<Trans>Whoops!</Trans> a.text_center,
</Text> a.leading_snug,
<Text t.atoms.text_contrast_medium,
style={[ {maxWidth: 360},
a.text_md, ]}>
a.pb_xl, {cleanError(error) || l`Failed to load conversations`}
a.text_center, </Text>
a.leading_snug,
t.atoms.text_contrast_medium,
{maxWidth: 360},
]}>
{cleanError(error) ||
_(msg`Failed to load conversations`)}
</Text>
<Button <Button
label={_(msg`Reload conversations`)} label={l`Reload conversations`}
size="small" size="small"
color="secondary_inverted" color="secondary_inverted"
variant="solid" onPress={() => void refetch()}>
onPress={() => void refetch()}> <ButtonText>
<ButtonText> <Trans>Retry</Trans>
<Trans>Retry</Trans> </ButtonText>
</ButtonText> <ButtonIcon icon={RetryIcon} />
<ButtonIcon icon={RetryIcon} position="right" /> </Button>
</Button> </View>
</View> </>
</> ) : isWithinSplitView ? (
) : ( <EmptyState
<> message={l`Your conversations will appear here`}
<View style={[a.pt_3xl, a.align_center]}> icon={MessageIcon}
<MessageIcon width={48} fill={t.palette.primary_500} /> />
<Text ) : (
style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_semi_bold]}> <EmptyState
<Trans>Nothing here</Trans> message={l`No chats yet`}
</Text> icon={MessageIcon}
<Text iconSize="3xl"
style={[ />
a.text_md, )}
a.pb_xl, </>
a.text_center,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>You have no conversations yet. Start one!</Trans>
</Text>
</View>
</>
)}
</>
)}
</Layout.Center>
{!isLoading && !isError && (
<NewChat onNewChat={onNewChat} control={newChatControl} />
)} )}
</Layout.Screen> </Layout.Center>
) )
} }
return ( return (
<Layout.Screen testID="messagesScreen"> <List
<Header ref={scrollElRef}
newChatControl={newChatControl} data={conversations}
requestsCount={inboxAllConvos.length} renderItem={renderItem}
hasMoreRequests={hasMoreRequests} keyExtractor={keyExtractor}
/> refreshing={isPTRing}
<NewChat onNewChat={onNewChat} control={newChatControl} /> onRefresh={() => void onRefresh()}
<List onEndReached={() => void onEndReached()}
ref={scrollElRef} ListFooterComponent={
data={conversations} <ListFooter
renderItem={renderItem} isFetchingNextPage={isFetchingNextPage}
keyExtractor={keyExtractor} error={cleanError(error)}
refreshing={isPTRing} onRetry={fetchNextPage}
onRefresh={() => void onRefresh()} style={{borderColor: 'transparent'}}
onEndReached={() => void onEndReached()} hasNextPage={hasNextPage}
ListFooterComponent={ />
<ListFooter }
isFetchingNextPage={isFetchingNextPage} onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
error={cleanError(error)} initialNumToRender={initialNumToRender}
onRetry={fetchNextPage} windowSize={11}
style={{borderColor: 'transparent'}} desktopFixedHeight
hasNextPage={hasNextPage} sideBorders={false}
/> disableFullWindowScroll={isWithinSplitView}
} style={
onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} isWithinSplitView && [
initialNumToRender={initialNumToRender} a.w_full,
windowSize={11} web({
desktopFixedHeight scrollbarWidth: 'thin',
sideBorders={false} scrollbarColor: `${t.palette.contrast_100} transparent`,
/> }),
</Layout.Screen> ]
}
/>
) )
} }
function Header({ export function Header({newChatControl}: {newChatControl: DialogControlProps}) {
newChatControl, const {t: l} = useLingui()
requestsCount,
hasMoreRequests,
}: {
newChatControl: DialogControlProps
requestsCount: number
hasMoreRequests: boolean
}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const leftConvos = useLeftConvos()
const {data: inboxData, hasNextPage: hasMoreRequests} = useListConvosQuery({
status: 'request',
})
const inboxAllConvos =
inboxData?.pages
.flatMap(page => page.convos)
.filter(
convo =>
!leftConvos.includes(convo.id) &&
!convo.muted &&
convo.members.every(member => member.handle !== 'missing.invalid'),
) ?? []
const openChatControl = useCallback(() => { const openChatControl = useCallback(() => {
newChatControl.open() newChatControl.open()
@@ -370,13 +372,16 @@ function Header({
}) })
const requestsLink = ( const requestsLink = (
<InboxRequests count={requestsCount} more={hasMoreRequests} /> <InboxRequests
count={inboxAllConvos.length}
more={hasMoreRequests ?? false}
/>
) )
const settingsLink = ( const settingsLink = (
<Link <Link
to="/messages/settings" to="/messages/settings"
label={_(msg`Chat settings`)} label={l`Chat settings`}
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
@@ -400,12 +405,11 @@ function Header({
{requestsLink} {requestsLink}
{settingsLink} {settingsLink}
<Button <Button
label={_(msg`New chat`)} label={l`New chat`}
color="primary" color="primary"
size="small" size="small"
variant="solid"
onPress={wrappedOpenChatControl}> onPress={wrappedOpenChatControl}>
<ButtonIcon icon={PlusIcon} position="left" /> <ButtonIcon icon={PlusIcon} />
<ButtonText> <ButtonText>
<Trans>New chat</Trans> <Trans>New chat</Trans>
</ButtonText> </ButtonText>
+22 -18
View File
@@ -44,6 +44,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {RequestListItem} from './components/RequestListItem' import {RequestListItem} from './components/RequestListItem'
import {useIsWithinSplitView} from './components/splitView/context'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
@@ -127,6 +128,7 @@ function RequestList({
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isWithinSplitView} = useIsWithinSplitView()
// Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future)
// but only when the screen is active // but only when the screen is active
@@ -217,7 +219,7 @@ function RequestList({
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={RetryIcon} position="right" /> <ButtonIcon icon={RetryIcon} />
</Button> </Button>
</View> </View>
</> </>
@@ -243,23 +245,25 @@ function RequestList({
You don't have any chat requests at the moment. You don't have any chat requests at the moment.
</Trans> </Trans>
</Text> </Text>
<Button {!isWithinSplitView && (
variant="solid" <Button
color="secondary" variant="solid"
size="small" color="secondary"
label={l`Go back`} size="small"
onPress={() => { label={l`Go back`}
if (navigation.canGoBack()) { onPress={() => {
navigation.goBack() if (navigation.canGoBack()) {
} else { navigation.goBack()
navigation.navigate('Messages', {animation: 'pop'}) } else {
} navigation.navigate('Messages', {animation: 'pop'})
}}> }
<ButtonIcon icon={ArrowLeftIcon} /> }}>
<ButtonText> <ButtonIcon icon={ArrowLeftIcon} />
<Trans>Back to Chats</Trans> <ButtonText>
</ButtonText> <Trans>Back to Chats</Trans>
</Button> </ButtonText>
</Button>
)}
</View> </View>
</> </>
)} )}
@@ -57,10 +57,12 @@ export const ChatListItemPortal = createPortalGroup()
export function ChatListItem({ export function ChatListItem({
convo: convoView, convo: convoView,
showMenu = true, showMenu = true,
selected = false,
children, children,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean showMenu?: boolean
selected?: boolean
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -78,7 +80,8 @@ export function ChatListItem({
<DirectChatItem <DirectChatItem
convo={convo} convo={convo}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
showMenu={showMenu}> showMenu={showMenu}
selected={selected}>
{children} {children}
</DirectChatItem> </DirectChatItem>
) )
@@ -88,7 +91,8 @@ export function ChatListItem({
<GroupChatItem <GroupChatItem
convo={convo} convo={convo}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
showMenu={showMenu}> showMenu={showMenu}
selected={selected}>
{children} {children}
</GroupChatItem> </GroupChatItem>
) )
@@ -103,11 +107,13 @@ function DirectChatItem({
convo, convo,
moderationOpts, moderationOpts,
showMenu, showMenu,
selected,
children, children,
}: { }: {
convo: Extract<ConvoWithDetails, {kind: 'direct'}> convo: Extract<ConvoWithDetails, {kind: 'direct'}>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
showMenu?: boolean showMenu?: boolean
selected?: boolean
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -145,6 +151,7 @@ function DirectChatItem({
: l`This conversation is with a deleted or a deactivated account. Press for options` : l`This conversation is with a deleted or a deactivated account. Press for options`
} }
showMenu={showMenu} showMenu={showMenu}
selected={selected}
isDeletedAccount={isDeletedAccount} isDeletedAccount={isDeletedAccount}
isBlockedAccount={moderation.blocked} isBlockedAccount={moderation.blocked}
showProfileBadges showProfileBadges
@@ -164,11 +171,13 @@ function GroupChatItem({
convo, convo,
moderationOpts, moderationOpts,
showMenu, showMenu,
selected,
children, children,
}: { }: {
convo: Extract<ConvoWithDetails, {kind: 'group'}> convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
showMenu?: boolean showMenu?: boolean
selected?: boolean
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -193,6 +202,7 @@ function GroupChatItem({
isBlockedAccount={false} isBlockedAccount={false}
isDeletedAccount={false} isDeletedAccount={false}
showProfileBadges={false} showProfileBadges={false}
selected={selected}
showMenu={showMenu}> showMenu={showMenu}>
{children} {children}
</BaseChatItem> </BaseChatItem>
@@ -210,6 +220,7 @@ function BaseChatItem({
primaryProfile, primaryProfile,
primaryProfileModeration, primaryProfileModeration,
showMenu, showMenu,
selected,
showProfileBadges, showProfileBadges,
postAlerts, postAlerts,
children, children,
@@ -224,6 +235,7 @@ function BaseChatItem({
primaryProfile?: Shadow<bsky.profile.AnyProfileView> primaryProfile?: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration?: ModerationDecision primaryProfileModeration?: ModerationDecision
showMenu?: boolean showMenu?: boolean
selected?: boolean
showProfileBadges: boolean showProfileBadges: boolean
postAlerts?: React.ReactNode postAlerts?: React.ReactNode
children?: React.ReactNode children?: React.ReactNode
@@ -457,7 +469,8 @@ function BaseChatItem({
a.px_lg, a.px_lg,
a.py_md, a.py_md,
a.gap_md, a.gap_md,
(hovered || pressed || focused) && t.atoms.bg_contrast_25, selected && t.atoms.bg_contrast_25,
(hovered || pressed || focused) && t.atoms.bg_contrast_50,
]}> ]}>
{/* Avatar goes here */} {/* Avatar goes here */}
<View style={{width: 52, height: 52}} /> <View style={{width: 52, height: 52}} />
@@ -0,0 +1,85 @@
import {View} from 'react-native'
import {type ScreenLayoutArgs, useIsFocused} from '@react-navigation/native'
import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {type AllNavigatorParams, type NavigationProp} from '#/lib/routes/types'
import {atoms as a, useLayoutBreakpoints, useTheme, web} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {SCROLLBAR_OFFSET} from '#/components/Layout'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_WEB} from '#/env'
import {ChatList, Header as ChatListHeader} from '../../ChatList'
import {SplitViewProvider} from './context'
const LEFT_NAV_MINIMAL_WIDTH = 86
const RIGHT_NAV_WIDTH = 330 + 28
type LayoutProps = ScreenLayoutArgs<
AllNavigatorParams,
'MessagesConversation',
{},
NavigationProp
>
export function renderMessagesSplitViewLayout(props: LayoutProps) {
return <MessagesSplitViewLayout {...props} />
}
function MessagesSplitViewLayout({children, navigation, route}: LayoutProps) {
const {rightNavVisible, centerColumnOffset} = useLayoutBreakpoints()
const newChatControl = useDialogControl()
const t = useTheme()
const aa = useAgeAssurance()
const isFocused = useIsFocused()
if (!IS_WEB || !rightNavVisible || aa.state.access !== aa.Access.Full) {
return children
}
const onNewChat = (conversation: string) =>
navigation.navigate('MessagesConversation', {conversation})
const selectedChat =
route.name === 'MessagesConversation'
? route?.params?.conversation
: undefined
return (
<View
style={[
a.flex_1,
a.flex_row,
a.mx_auto,
{maxWidth: centerColumnOffset ? 900 : 950},
{
transform: [
{
translateX: centerColumnOffset
? LEFT_NAV_MINIMAL_WIDTH / 2
: RIGHT_NAV_WIDTH / 2,
},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{isFocused && <RemoveScrollBar />}
<SplitViewProvider side="left">
<View
style={[
a.border_l,
t.atoms.border_contrast_low,
{width: centerColumnOffset ? 300 : 350},
]}>
<ChatListHeader newChatControl={newChatControl} />
<ChatList selectedChat={selectedChat} />
<NewChat onNewChat={onNewChat} control={newChatControl} />
</View>
</SplitViewProvider>
<SplitViewProvider side="right">
<View style={[a.border_x, t.atoms.border_contrast_low, {width: 600}]}>
{children}
</View>
</SplitViewProvider>
</View>
)
}
@@ -0,0 +1,31 @@
import {createContext, useContext, useMemo} from 'react'
const SplitViewContext = createContext<{
isWithinSplitView: boolean
isWithinLeftPanel: boolean
isWithinRightPanel: boolean
}>({
isWithinSplitView: false,
isWithinLeftPanel: false,
isWithinRightPanel: false,
})
export function SplitViewProvider({
children,
side,
}: {
children: React.ReactNode
side: 'left' | 'right'
}) {
const value = useMemo(
() => ({
isWithinSplitView: true,
isWithinLeftPanel: side === 'left',
isWithinRightPanel: side === 'right',
}),
[side],
)
return <SplitViewContext value={value}>{children}</SplitViewContext>
}
export const useIsWithinSplitView = () => useContext(SplitViewContext)
+9 -11
View File
@@ -2,8 +2,6 @@ import {isValidElement} from 'react'
import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native' import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'
import {View} from 'react-native' import {View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, type ButtonProps, ButtonText} from '#/components/Button' import {Button, type ButtonProps, ButtonText} from '#/components/Button'
import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
@@ -24,23 +22,25 @@ export function EmptyState({
button, button,
}: { }: {
testID?: string testID?: string
icon?: React.ComponentType<any> | React.ReactElement icon?: React.ComponentType<any> | React.ReactElement | null
iconSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' iconSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
message: string message: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle> textStyle?: StyleProp<TextStyle>
button?: EmptyStateButtonProps button?: EmptyStateButtonProps
}) { }) {
const pal = usePalette('default')
const {isTabletOrDesktop} = useWebMediaQueries()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile, gtTablet} = useBreakpoints()
const placeholderIcon = ( const placeholderIcon = (
<EditIcon size="2xl" fill={t.atoms.text_contrast_medium.color} /> <EditIcon size="2xl" fill={t.atoms.text_contrast_medium.color} />
) )
const renderIcon = () => { const renderIcon = () => {
if (icon === null) {
return null
}
if (!icon) { if (!icon) {
return placeholderIcon return placeholderIcon
} }
@@ -79,16 +79,14 @@ export function EmptyState({
{height: 64, width: 64}, {height: 64, width: 64},
isValidElement(icon) isValidElement(icon)
? a.bg_transparent ? a.bg_transparent
: [isTabletOrDesktop && {marginTop: 50}], : [gtTablet && {marginTop: 50}],
]}> ]}>
{renderIcon()} {renderIcon()}
</View> </View>
<Text <Text
style={[ style={[
{ t.atoms.text_contrast_high,
color: pal.colors.textLight, {maxWidth: gtMobile ? '40%' : '60%'},
maxWidth: gtMobile ? '40%' : '60%',
},
a.pt_xs, a.pt_xs,
a.font_medium, a.font_medium,
a.text_md, a.text_md,
+2 -1
View File
@@ -49,6 +49,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
const kawaii = useKawaiiMode() const kawaii = useKawaiiMode()
const gutters = useGutters(['base', 0, 'base', 'wide']) const gutters = useGutters(['base', 0, 'base', 'wide'])
const isSearchScreen = routeName === 'Search' const isSearchScreen = routeName === 'Search'
const isMessagesRelatedScreen = routeName.startsWith('Messages')
const webqueryParams = useWebQueryParams() const webqueryParams = useWebQueryParams()
const searchQuery = webqueryParams?.q const searchQuery = webqueryParams?.q
const showExploreScreenDuplicatedContent = const showExploreScreenDuplicatedContent =
@@ -56,7 +57,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
const {rightNavVisible, centerColumnOffset, leftNavMinimal} = const {rightNavVisible, centerColumnOffset, leftNavMinimal} =
useLayoutBreakpoints() useLayoutBreakpoints()
if (!rightNavVisible) { if (!rightNavVisible || isMessagesRelatedScreen) {
return null return null
} }