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