import {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/hooks/useAppState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
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 {useSession} from '#/state/session'
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 {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ChatListItem} from './components/ChatListItem'
import {InboxPreview} from './components/InboxPreview'
type ListItem =
| {
type: 'INBOX'
count: number
profiles: ChatBskyActorDefs.ProfileViewBasic[]
}
| {
type: 'CONVERSATION'
conversation: ChatBskyConvoDefs.ConvoView
}
function renderItem({item}: {item: ListItem}) {
switch (item.type) {
case 'INBOX':
return
case 'CONVERSATION':
return
}
}
function keyExtractor(item: ListItem) {
return item.type === 'INBOX' ? 'INBOX' : item.conversation.id
}
type Props = NativeStackScreenProps
export function MessagesScreen(props: Props) {
const {_} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
Chat settings
}>
)
}
export function MessagesScreenInner({navigation, route}: Props) {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
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
// 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 initialNumToRender = useInitialNumToRender({minItemHeight: 80})
const [isPTRing, setIsPTRing] = useState(false)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useListConvosQuery({status: 'accepted'})
const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({
status: 'request',
})
useRefreshOnFocus(refetch)
useRefreshOnFocus(refetchInbox)
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 hasInboxConvos = inboxAllConvos?.length > 0
const inboxUnreadConvos = inboxAllConvos.filter(
convo => convo.unreadCount > 0,
)
const inboxUnreadConvoMembers = inboxUnreadConvos
.map(x => x.members.find(y => y.did !== currentAccount?.did))
.filter(x => !!x)
const conversations = useMemo(() => {
if (data?.pages) {
const conversations = data.pages
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
return [
...(hasInboxConvos
? [
{
type: 'INBOX' as const,
count: inboxUnreadConvoMembers.length,
profiles: inboxUnreadConvoMembers.slice(0, 3),
},
]
: []),
...conversations.map(
convo => ({type: 'CONVERSATION', conversation: convo}) as const,
),
] satisfies ListItem[]
}
return []
}, [data, leftConvos, hasInboxConvos, inboxUnreadConvoMembers])
const onRefresh = useCallback(async () => {
setIsPTRing(true)
try {
await Promise.all([refetch(), refetchInbox()])
} catch (err) {
logger.error('Failed to refresh conversations', {message: err})
}
setIsPTRing(false)
}, [refetch, refetchInbox, 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 onSoftReset = useCallback(async () => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: 0,
})
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh conversations', {message: err})
}
}, [scrollElRef, refetch])
const isScreenFocused = useIsFocused()
useEffect(() => {
if (!isScreenFocused) {
return
}
return listenSoftReset(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) {
return (
{!isLoading && hasInboxConvos && (
)}
{isLoading ? (
) : (
<>
{isError ? (
<>
Whoops!
{cleanError(error) ||
_(msg`Failed to load conversations`)}
>
) : (
<>
Nothing here
You have no conversations yet. Start one!
>
)}
>
)}
{!isLoading && !isError && (
)}
)
}
return (
}
onEndReachedThreshold={isNative ? 1.5 : 0}
initialNumToRender={initialNumToRender}
windowSize={11}
desktopFixedHeight
sideBorders={false}
/>
)
}
function Header({newChatControl}: {newChatControl: DialogControlProps}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const requireEmailVerification = useRequireEmailVerification()
const openChatControl = useCallback(() => {
newChatControl.open()
}, [newChatControl])
const wrappedOpenChatControl = requireEmailVerification(openChatControl, {
instructions: [
Before you can message another user, you must first verify your email.
,
],
})
const settingsLink = (
)
return (
{gtMobile ? (
<>
Chats
{settingsLink}
>
) : (
<>
Chats
{settingsLink}
>
)}
)
}