[Chat] Split view layout for web (#10258)

This commit is contained in:
Samuel Newman
2026-05-15 20:46:53 +01:00
committed by GitHub
parent 5b7bc56a12
commit 0eb382da03
35 changed files with 849 additions and 588 deletions
@@ -51,16 +51,19 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {useIsWithinSplitView} from './splitView/context'
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 +81,8 @@ export function ChatListItem({
<DirectChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}>
showMenu={showMenu}
selected={selected}>
{children}
</DirectChatItem>
)
@@ -88,7 +92,8 @@ export function ChatListItem({
<GroupChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}>
showMenu={showMenu}
selected={selected}>
{children}
</GroupChatItem>
)
@@ -103,15 +108,18 @@ 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()
const profile = useProfileShadow(convo.primaryMember)
const {isWithinSplitView} = useIsWithinSplitView()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -129,7 +137,7 @@ function DirectChatItem({
avatar={
<PreviewableUserAvatar
profile={profile}
size={52}
size={isWithinSplitView ? 48 : 52}
moderation={moderation.ui('avatar')}
/>
}
@@ -145,15 +153,18 @@ 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
postAlerts={
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
isWithinSplitView ? null : (
<PostAlerts
modui={moderation.ui('contentList')}
size="sm"
style={[a.pb_2xs, a.max_w_full, a.overflow_hidden]}
/>
)
}>
{children}
</BaseChatItem>
@@ -164,15 +175,18 @@ 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()
const groupOwner = useMaybeProfileShadow(convo.primaryMember)
const {isWithinSplitView} = useIsWithinSplitView()
const moderation = useMemo(
() =>
@@ -185,7 +199,12 @@ function GroupChatItem({
return (
<BaseChatItem
convo={convo}
avatar={<AvatarBubbles profiles={convo.members} size={52} />}
avatar={
<AvatarBubbles
profiles={convo.members}
size={isWithinSplitView ? 48 : 52}
/>
}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
@@ -193,6 +212,7 @@ function GroupChatItem({
isBlockedAccount={false}
isDeletedAccount={false}
showProfileBadges={false}
selected={selected}
showMenu={showMenu}>
{children}
</BaseChatItem>
@@ -210,6 +230,7 @@ function BaseChatItem({
primaryProfile,
primaryProfileModeration,
showMenu,
selected,
showProfileBadges,
postAlerts,
children,
@@ -224,6 +245,7 @@ function BaseChatItem({
primaryProfile?: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration?: ModerationDecision
showMenu?: boolean
selected?: boolean
showProfileBadges: boolean
postAlerts?: React.ReactNode
children?: React.ReactNode
@@ -236,6 +258,7 @@ function BaseChatItem({
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
const {isWithinSplitView} = useIsWithinSplitView()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
@@ -407,6 +430,8 @@ function BaseChatItem({
leftFirst: deleteAction,
}
const avatarSize = isWithinSplitView ? 48 : 52
return (
<ChatListItemPortal.Provider>
<GestureActionView actions={actions}>
@@ -416,7 +441,7 @@ function BaseChatItem({
// @ts-expect-error web only
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.relative, t.atoms.bg]}>
style={[a.relative, t.atoms.bg, isWithinSplitView && a.mx_sm]}>
<View
style={[
a.z_10,
@@ -457,13 +482,15 @@ function BaseChatItem({
a.px_lg,
a.py_md,
a.gap_md,
isWithinSplitView && a.rounded_sm,
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
selected && t.atoms.bg_contrast_50,
]}>
{/* Avatar goes here */}
<View style={{width: 52, height: 52}} />
<View style={{width: avatarSize, height: avatarSize}} />
<View
style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
style={[a.flex_1, a.justify_center, web({paddingRight: 40})]}>
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
<View style={[a.flex_shrink]}>
<Text
@@ -483,7 +510,7 @@ function BaseChatItem({
{showProfileBadges && primaryProfile && (
<ProfileBadges
profile={primaryProfile}
size="md"
size="sm"
style={[a.pl_xs, a.self_center]}
/>
)}
@@ -526,15 +553,14 @@ function BaseChatItem({
{subtitle && (
<Text
numberOfLines={1}
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.pb_xs,
]}>
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}
emoji>
{subtitle}
</Text>
)}
{postAlerts}
<View style={[a.flex_row, a.align_center]}>
{LastMessageIcon && (
<LastMessageIcon
@@ -557,8 +583,6 @@ function BaseChatItem({
</Text>
</View>
{postAlerts}
{children}
</View>
@@ -3,15 +3,29 @@ import {useLingui} from '@lingui/react/macro'
import {UNREAD_LIMIT} from '#/state/queries/messages/list-conversations'
import {atoms as a} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
import {Link} from '#/components/Link'
export function InboxRequests({count, more}: {count: number; more: boolean}) {
export function InboxRequests({
count,
more,
variant,
}: {
count: number
more: boolean
variant?: 'ghost' | 'solid'
}) {
const {t: l} = useLingui()
if (count < 1) return null
const unread = count > 0
const label =
count >= UNREAD_LIMIT && more
const label = !unread
? l({
message: `Requests`,
comment: 'Incoming message requests',
})
: count >= UNREAD_LIMIT && more
? l({
message: `${count}+ requests`,
comment: 'Displayed when the number of requests is greater than 20',
@@ -21,12 +35,43 @@ export function InboxRequests({count, more}: {count: number; more: boolean}) {
other: '# requests',
})
return (
<InlineLinkText
label={label}
to="/messages/inbox"
style={[a.text_md, a.font_medium]}>
{label}
</InlineLinkText>
)
switch (variant) {
case 'ghost': {
return (
<Link
label={label}
to="/messages/inbox"
size="small"
variant={unread ? 'solid' : 'ghost'}
color={unread ? 'primary_subtle' : 'secondary'}
shape={unread ? 'default' : 'round'}
style={[a.justify_center, unread && [a.gap_sm, a.pl_lg, a.pr_md]]}>
<ButtonIcon icon={InboxIcon} size="lg" />
{unread && (
<ButtonText style={[a.text_md, a.font_bold]}>
{count >= UNREAD_LIMIT && more
? l({
message: `${count}+`,
comment:
'Displayed when the number of requests is greater than 20',
})
: count}
</ButtonText>
)}
</Link>
)
}
case 'solid': {
return (
<Link
label={label}
to="/messages/inbox"
color={unread ? 'primary_subtle' : 'secondary'}
size="small">
<ButtonIcon icon={InboxIcon} />
<ButtonText>{label}</ButtonText>
</Link>
)
}
}
}
@@ -321,8 +321,8 @@ export function ComposerContainer({children}: {children: React.ReactNode}) {
a.pl_lg,
a.pb_lg,
// prevent overlap with the scrollbar, which looks ugly
a.pr_xs, // xs + md = lg
{width: `calc(100% - ${tokens.space.md}px)` as '100%'},
a.pr_sm, // sm + sm = lg
{width: `calc(100% - ${tokens.space.sm}px)` as '100%'},
],
})}
key={t.name} // android does not update when you change the colors. sigh.
@@ -0,0 +1,128 @@
import {View} from 'react-native'
import {type ScreenLayoutArgs, useIsFocused} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type FlatNavigatorParams} from '#/lib/routes/types'
import {type NativeStackNavigationOptionsWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
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 {LockScroll} from '#/components/LockScroll'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_WEB} from '#/env'
import {ChatList, Header as ChatListHeader} from '../../ChatList'
import {SplitViewProvider} from './context'
const CENTER_COLUMN_WIDTH = 600
const LEFT_NAV_FULL_WIDTH = 245
const LEFT_NAV_MINIMAL_WIDTH = 86
const RIGHT_NAV_FULL_WIDTH = 330
const RIGHT_NAV_MINIMAL_WIDTH = 280
type MessageScreens =
| 'Messages'
| 'MessagesConversation'
| 'MessagesConversationSettings'
| 'MessagesInbox'
| 'MessagesSettings'
type LayoutProps = ScreenLayoutArgs<
FlatNavigatorParams,
MessageScreens,
NativeStackNavigationOptionsWithAuth,
NativeStackNavigationProp<
FlatNavigatorParams,
MessageScreens,
string | undefined
>
>
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.name === 'MessagesConversationSettings') &&
route.params &&
'conversation' in route.params
? route.params.conversation
: undefined
const rightNavWidth = centerColumnOffset
? RIGHT_NAV_MINIMAL_WIDTH
: RIGHT_NAV_FULL_WIDTH
const leftNavWidth = centerColumnOffset
? LEFT_NAV_MINIMAL_WIDTH
: LEFT_NAV_FULL_WIDTH - LEFT_NAV_MINIMAL_WIDTH
// slight reduce width for smaller breakpoint
const centerColumnWidth = centerColumnOffset
? CENTER_COLUMN_WIDTH - 50
: CENTER_COLUMN_WIDTH
// nasty magic numbers here, sorry :(
const offset = centerColumnOffset
? LEFT_NAV_MINIMAL_WIDTH - 34
: LEFT_NAV_MINIMAL_WIDTH + 5
const containerWidth = leftNavWidth + centerColumnWidth + rightNavWidth
return (
<View
style={[
a.flex_1,
a.flex_row,
a.mx_auto,
{maxWidth: containerWidth},
{
transform: [
{translateX: offset},
{translateX: web(SCROLLBAR_OFFSET) ?? 0},
],
},
]}>
{isFocused && <LockScroll />}
<SplitViewProvider side="left">
<View
style={[
a.border_l,
t.atoms.border_contrast_low,
{width: containerWidth - centerColumnWidth},
]}>
<ChatListHeader newChatControl={newChatControl} />
<ChatList
newChatControl={newChatControl}
selectedChat={selectedChat}
/>
<NewChat onNewChat={onNewChat} control={newChatControl} />
</View>
</SplitViewProvider>
<SplitViewProvider side="right">
<View
style={[
a.border_x,
t.atoms.border_contrast_low,
{width: centerColumnWidth},
]}>
{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)