Files
bsky-social-app/src/screens/Messages/Inbox.tsx
T
Samuel Newman c995eb2f2f DMs inbox (#7778)
* improve error screen

* add chat request prompt

* mock up inbox

* bigger button

* use two-button layout

* get inbox working somewhat

* fix type errors

* fetch both pages for badge

* don't include read convos in preview

* in-chat ui for non-accepted convos (part 1)

* add chatstatusinfo

* fix status info not disappearing

* get chat status info working

* change min item height

* move files around

* add updated sdk

* improve badge behaviour

* mock up mark all as read

* update sdk to 0.14.4

* hide chat status info if initiating convo

* fix unread count for deleted accounts

* add toasts after rejection

* add prompt to delete

* adjust badge on desktop

* requests -> chat requests

* fix height flicker

* add mark as read button to header

* add mark all as read APIs

* separate avatarstack into two components (#7845)

* fix messages being hidden behind chatstatusinfo

* show inbox preview on empty state

* fix empty state again

* Use new convo availability API (#7812)

* [Inbox] Accept button on convo screen (#7795)

* accept button on convo screen

* fix types

* fix type error

* improve spacing

* [DMs] Implement new log types (#7835)

* optimise badge state

* add read message log

* add isLogAcceptConvo

* mute/unmute convo logs

* use setqueriesdata

* always show label on button

* optimistically update badge

* change incorrect unread count change

* Update src/screens/Messages/Inbox.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestListItem.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix race condition with accepting convo

* fix back button on web

* filter left convos from badge

* update atproto to fix CI

* Add accept override external to convo (#7891)

* Add accept override external to convo

* rm log

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2025-03-04 05:54:19 -08:00

333 lines
10 KiB
TypeScript

import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useAppState} from '#/lib/hooks/useAppState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {
CommonNavigatorParams,
NativeStackScreenProps,
NavigationProp,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
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 {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {FAB} from '#/view/com/util/fab/FAB'
import {List} from '#/view/com/util/List'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {RequestListItem} from './components/RequestListItem'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
export function MessagesInboxScreen({}: Props) {
const {gtTablet} = useBreakpoints()
const listConvosQuery = useListConvosQuery({status: 'request'})
const {data} = listConvosQuery
const leftConvos = useLeftConvos()
const conversations = useMemo(() => {
if (data?.pages) {
const convos = data.pages
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
return convos
}
return []
}, [data, leftConvos])
const hasUnreadConvos = useMemo(() => {
return conversations.some(conversation => conversation.unreadCount > 0)
}, [conversations])
return (
<Layout.Screen testID="messagesInboxScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
<Layout.Header.TitleText>
<Trans>Chat requests</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
{hasUnreadConvos && gtTablet ? (
<MarkAsReadHeaderButton />
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<RequestList
listConvosQuery={listConvosQuery}
conversations={conversations}
hasUnreadConvos={hasUnreadConvos}
/>
</Layout.Screen>
)
}
function RequestList({
listConvosQuery,
conversations,
hasUnreadConvos,
}: {
listConvosQuery: UseInfiniteQueryResult<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
Error
>
conversations: ChatBskyConvoDefs.ConvoView[]
hasUnreadConvos: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
// 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
const messagesBus = useMessagesEventBus()
const state = useAppState()
const isActive = state === 'active'
useFocusEffect(
useCallback(() => {
if (isActive) {
const unsub = messagesBus.requestPollInterval(
MESSAGE_SCREEN_POLL_INTERVAL,
)
return () => unsub()
}
}, [messagesBus, isActive]),
)
const initialNumToRender = useInitialNumToRender({minItemHeight: 130})
const [isPTRing, setIsPTRing] = useState(false)
const {
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = listConvosQuery
useRefreshOnFocus(refetch)
const onRefresh = useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh conversations', {message: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more conversations', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
if (conversations.length < 1) {
return (
<Layout.Center>
{isLoading ? (
<View style={[a.align_center, a.pt_3xl, web({paddingTop: '10vh'})]}>
<Loader size="xl" />
</View>
) : (
<>
{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_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>
<Button
label={_(msg`Reload conversations`)}
size="small"
color="secondary_inverted"
variant="solid"
onPress={() => 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_bold]}>
<Trans comment="Title message shown in chat requests inbox when it's empty">
Inbox zero!
</Trans>
</Text>
<Text
style={[
a.text_md,
a.pb_xl,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
You don't have any chat requests at the moment.
</Trans>
</Text>
<Button
variant="solid"
color="secondary"
size="small"
label={_(msg`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>
</>
)}
</>
)}
</Layout.Center>
)
}
return (
<>
<List
data={conversations}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderColor: 'transparent'}}
hasNextPage={hasNextPage}
/>
}
onEndReachedThreshold={isNative ? 1.5 : 0}
initialNumToRender={initialNumToRender}
windowSize={11}
desktopFixedHeight
sideBorders={false}
/>
{hasUnreadConvos && <MarkAllReadFAB />}
</>
)
}
function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
return item.id
}
function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
return <RequestListItem convo={item} />
}
function MarkAllReadFAB() {
const {_} = useLingui()
const t = useTheme()
const {mutate: markAllRead} = useUpdateAllRead('request', {
onMutate: () => {
Toast.show(_(msg`Marked all as read`), 'check')
},
onError: () => {
Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
},
})
return (
<FAB
testID="markAllAsReadFAB"
onPress={() => markAllRead()}
icon={<CheckIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`Mark all as read`)}
accessibilityHint=""
/>
)
}
function MarkAsReadHeaderButton() {
const {_} = useLingui()
const {mutate: markAllRead} = useUpdateAllRead('request', {
onMutate: () => {
Toast.show(_(msg`Marked all as read`), 'check')
},
onError: () => {
Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
},
})
return (
<Button
label={_(msg`Mark all as read`)}
size="small"
color="secondary"
variant="solid"
onPress={() => markAllRead()}>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Mark all as read</Trans>
</ButtonText>
</Button>
)
}