Files
bsky-social-app/src/screens/Messages/List/index.tsx
T
Samuel Newman cfb8a3160e Priority notifications (#4798)
* new settings screen

* bring back the spinner

* add experimental language

* fix typo, change leading

* integrate priority notifications API

* update package

* use refetch instead of invalidateQueries

* fix read-after-write issue by polling for update

* add spinner for initial load

* rm onmutate, it's overcomplicated

* set error state eagerly

* Change language in description

Co-authored-by: Hailey <me@haileyok.com>

* prettier

* add `Toggle.Platform`

* extract out mutation hook + error state

* rm useless cache mutation

* disambiguate isError and isPending

* rm unused isError

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
2024-07-24 20:09:20 +01:00

346 lines
11 KiB
TypeScript

import React, {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/hooks/useAppState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {MessagesTabNavigatorParams} 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 {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {List} from '#/view/com/util/List'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChatListItem} from './ChatListItem'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
return <ChatListItem convo={item} />
}
function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
return item.id
}
export function MessagesScreen({navigation, route}: Props) {
const {_} = useLingui()
const t = useTheme()
const newChatControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const pushToConversation = route.params?.pushToConversation
// Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
// this tab. We should immediately push to the conversation after pressing the notification.
// After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if
// the conversation is the same as before
useEffect(() => {
if (pushToConversation) {
navigation.navigate('MessagesConversation', {
conversation: pushToConversation,
})
navigation.setParams({pushToConversation: undefined})
}
}, [navigation, pushToConversation])
// 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 renderButton = useCallback(() => {
return (
<Link
to="/messages/settings"
label={_(msg`Chat settings`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
style={[a.justify_center]}>
<SettingsSlider size="md" style={[t.atoms.text_contrast_medium]} />
</Link>
)
}, [_, t])
const initialNumToRender = useInitialNumToRender(80)
const [isPTRing, setIsPTRing] = useState(false)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useListConvosQuery()
useRefreshOnFocus(refetch)
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
}
return []
}, [data])
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])
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesSettings')
}, [navigation])
if (conversations.length < 1) {
return (
<View style={a.flex_1}>
<MessagesNUX />
<CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}>
{gtMobile ? (
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
) : (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
{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]}>
<CircleInfo
width={48}
fill={t.atoms.border_contrast_low.borderColor}
/>
<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)}
</Text>
<Button
label={_(msg`Reload conversations`)}
size="medium"
color="secondary"
variant="solid"
onPress={() => refetch()}>
<ButtonText>Retry</ButtonText>
<ButtonIcon icon={Retry} position="right" />
</Button>
</View>
</>
) : (
<>
<View style={[a.pt_3xl, a.align_center]}>
<Message width={48} fill={t.palette.primary_500} />
<Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_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>
</>
)}
</>
)}
</CenteredView>
{!isLoading && !isError && (
<NewChat onNewChat={onNewChat} control={newChatControl} />
)}
</View>
)
}
return (
<View style={a.flex_1}>
<MessagesNUX />
{!gtMobile && (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
<NewChat onNewChat={onNewChat} control={newChatControl} />
<List
data={conversations}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
ListHeaderComponent={
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderColor: 'transparent'}}
hasNextPage={hasNextPage}
showEndMessage={true}
endMessageText={_(msg`No more conversations to show`)}
/>
}
onEndReachedThreshold={isNative ? 1.5 : 0}
initialNumToRender={initialNumToRender}
windowSize={11}
// @ts-ignore our .web version only -sfn
desktopFixedHeight
/>
</View>
)
}
function DesktopHeader({
newChatControl,
onNavigateToSettings,
}: {
newChatControl: DialogControlProps
onNavigateToSettings: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
if (!gtMobile) {
return null
}
return (
<View
style={[
t.atoms.bg,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.px_lg,
a.pr_md,
a.py_sm,
a.border_b,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Messages</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Button
label={_(msg`Message settings`)}
color="secondary"
size="small"
variant="ghost"
shape="square"
onPress={onNavigateToSettings}>
<SettingsSlider size="md" style={[t.atoms.text_contrast_medium]} />
</Button>
{gtTablet && (
<Button
label={_(msg`New chat`)}
color="primary"
size="small"
variant="solid"
onPress={newChatControl.open}>
<ButtonIcon icon={Plus} position="left" />
<ButtonText>
<Trans>New chat</Trans>
</ButtonText>
</Button>
)}
</View>
</View>
)
}