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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useAnimatedRef} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||
@@ -18,6 +18,7 @@ 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, ListRef} from '#/view/com/util/List'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -35,20 +36,37 @@ import {ListFooter} from '#/components/Lists'
|
||||
import {Loader} from '#/components/Loader'
|
||||
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 <InboxPreview count={item.count} profiles={item.profiles} />
|
||||
case 'CONVERSATION':
|
||||
return <ChatListItem convo={item.conversation} />
|
||||
}
|
||||
}
|
||||
|
||||
function keyExtractor(item: ListItem) {
|
||||
return item.type === 'INBOX' ? 'INBOX' : item.conversation.id
|
||||
}
|
||||
|
||||
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 {currentAccount} = useSession()
|
||||
const newChatControl = useDialogControl()
|
||||
const scrollElRef: ListRef = useAnimatedRef()
|
||||
const pushToConversation = route.params?.pushToConversation
|
||||
@@ -94,33 +112,63 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useListConvosQuery()
|
||||
} = useListConvosQuery({status: 'accepted'})
|
||||
|
||||
const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({
|
||||
status: 'request',
|
||||
})
|
||||
|
||||
useRefreshOnFocus(refetch)
|
||||
useRefreshOnFocus(refetchInbox)
|
||||
|
||||
const leftConvos = useLeftConvos()
|
||||
|
||||
const inboxPreviewConvos = useMemo(() => {
|
||||
const inbox =
|
||||
inboxData?.pages
|
||||
.flatMap(page => page.convos)
|
||||
.filter(
|
||||
convo =>
|
||||
!leftConvos.includes(convo.id) &&
|
||||
!convo.muted &&
|
||||
convo.unreadCount > 0,
|
||||
) ?? []
|
||||
|
||||
return inbox
|
||||
.map(x => x.members.find(y => y.did !== currentAccount?.did))
|
||||
.filter(x => !!x)
|
||||
}, [inboxData, leftConvos, currentAccount?.did])
|
||||
|
||||
const conversations = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return (
|
||||
data.pages
|
||||
.flatMap(page => page.convos)
|
||||
// filter out convos that are actively being left
|
||||
.filter(convo => !leftConvos.includes(convo.id))
|
||||
)
|
||||
const conversations = data.pages
|
||||
.flatMap(page => page.convos)
|
||||
// filter out convos that are actively being left
|
||||
.filter(convo => !leftConvos.includes(convo.id))
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'INBOX',
|
||||
count: inboxPreviewConvos.length,
|
||||
profiles: inboxPreviewConvos.slice(0, 3),
|
||||
},
|
||||
...conversations.map(
|
||||
convo => ({type: 'CONVERSATION', conversation: convo} as const),
|
||||
),
|
||||
] satisfies ListItem[]
|
||||
}
|
||||
return []
|
||||
}, [data, leftConvos])
|
||||
}, [data, leftConvos, inboxPreviewConvos])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
await Promise.all([refetch(), refetchInbox()])
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh conversations', {message: err})
|
||||
}
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
}, [refetch, refetchInbox, setIsPTRing])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
@@ -157,7 +205,8 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
return listenSoftReset(onSoftReset)
|
||||
}, [onSoftReset, isScreenFocused])
|
||||
|
||||
if (conversations.length < 1) {
|
||||
// Will always have 1 item - the inbox button
|
||||
if (conversations.length < 2) {
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Header newChatControl={newChatControl} />
|
||||
@@ -173,7 +222,7 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
<View style={[a.pt_3xl, a.align_center]}>
|
||||
<CircleInfo
|
||||
width={48}
|
||||
fill={t.atoms.border_contrast_low.borderColor}
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
/>
|
||||
<Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
|
||||
<Trans>Whoops!</Trans>
|
||||
@@ -187,13 +236,14 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
t.atoms.text_contrast_medium,
|
||||
{maxWidth: 360},
|
||||
]}>
|
||||
{cleanError(error)}
|
||||
{cleanError(error) ||
|
||||
_(msg`Failed to load conversations`)}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
label={_(msg`Reload conversations`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
variant="solid"
|
||||
onPress={() => refetch()}>
|
||||
<ButtonText>
|
||||
@@ -205,6 +255,10 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InboxPreview
|
||||
count={inboxPreviewConvos.length}
|
||||
profiles={inboxPreviewConvos}
|
||||
/>
|
||||
<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]}>
|
||||
@@ -253,8 +307,6 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderColor: 'transparent'}}
|
||||
hasNextPage={hasNextPage}
|
||||
showEndMessage={true}
|
||||
endMessageText={_(msg`No more conversations to show`)}
|
||||
/>
|
||||
}
|
||||
onEndReachedThreshold={isNative ? 1.5 : 0}
|
||||
@@ -290,7 +342,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
|
||||
<>
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Messages</Trans>
|
||||
<Trans>Chats</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
|
||||
@@ -314,7 +366,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
|
||||
<Layout.Header.MenuButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Messages</Trans>
|
||||
<Trans>Chats</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot>{settingsLink}</Layout.Header.Slot>
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
import {
|
||||
RouteProp,
|
||||
useFocusEffect,
|
||||
useNavigation,
|
||||
useRoute,
|
||||
} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {useEmail} from '#/lib/hooks/useEmail'
|
||||
@@ -172,6 +177,8 @@ function InnerReady({
|
||||
const {_} = useLingui()
|
||||
const convoState = useConvo()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {params} =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
const {needsEmailVerification} = useEmail()
|
||||
|
||||
@@ -189,6 +196,7 @@ function InnerReady({
|
||||
hasScrolled={hasScrolled}
|
||||
setHasScrolled={setHasScrolled}
|
||||
blocked={moderation?.blocked}
|
||||
hasAcceptOverride={!!params.accept}
|
||||
footer={
|
||||
<MessagesListBlockedFooter
|
||||
recipient={recipient}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -47,8 +47,12 @@ import * as bsky from '#/types/bsky'
|
||||
|
||||
export let ChatListItem = ({
|
||||
convo,
|
||||
showMenu = true,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.ReactNode => {
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -66,7 +70,9 @@ export let ChatListItem = ({
|
||||
convo={convo}
|
||||
profile={otherUser}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</ChatListItemReady>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,10 +82,14 @@ function ChatListItemReady({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
showMenu,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -252,6 +262,8 @@ function ChatListItemReady({
|
||||
leftFirst: deleteAction,
|
||||
}
|
||||
|
||||
const hasUnread = convo.unreadCount > 0 && !isDeletedAccount
|
||||
|
||||
return (
|
||||
<GestureActionView actions={actions}>
|
||||
<View
|
||||
@@ -305,7 +317,6 @@ function ChatListItemReady({
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{/* Avatar goes here */}
|
||||
<View style={{width: 52, height: 52}} />
|
||||
@@ -376,9 +387,7 @@ function ChatListItemReady({
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
convo.unreadCount > 0
|
||||
? a.font_bold
|
||||
: t.atoms.text_contrast_high,
|
||||
hasUnread ? a.font_bold : t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{lastMessage}
|
||||
@@ -389,9 +398,11 @@ function ChatListItemReady({
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</View>
|
||||
|
||||
{convo.unreadCount > 0 && (
|
||||
{hasUnread && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
@@ -412,26 +423,28 @@ function ChatListItemReady({
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
control={menuControl}
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
hideTrigger={isNative}
|
||||
blockInfo={blockInfo}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.h_full,
|
||||
a.self_end,
|
||||
a.justify_center,
|
||||
{
|
||||
right: tokens.space.lg,
|
||||
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
{showMenu && (
|
||||
<ConvoMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
control={menuControl}
|
||||
currentScreen="list"
|
||||
showMarkAsRead={convo.unreadCount > 0}
|
||||
hideTrigger={isNative}
|
||||
blockInfo={blockInfo}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.h_full,
|
||||
a.self_end,
|
||||
a.justify_center,
|
||||
{
|
||||
right: tokens.space.lg,
|
||||
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
latestReportableMessage={latestReportableMessage}
|
||||
/>
|
||||
)}
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
convoId={convo.id}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ActiveConvoStates} from '#/state/messages/convo'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
|
||||
|
||||
export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {currentAccount} = useSession()
|
||||
const leaveConvoControl = usePromptControl()
|
||||
|
||||
const onAcceptChat = useCallback(() => {
|
||||
convoState.markConvoAccepted()
|
||||
}, [convoState])
|
||||
|
||||
const otherUser = convoState.recipients.find(
|
||||
user => user.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[t.atoms.bg, a.p_lg, a.gap_md, a.align_center]}>
|
||||
{otherUser && (
|
||||
<KnownFollowers
|
||||
profile={otherUser}
|
||||
moderationOpts={moderationOpts}
|
||||
showIfEmpty
|
||||
/>
|
||||
)}
|
||||
<View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}>
|
||||
{otherUser && (
|
||||
<RejectMenu
|
||||
label={_(msg`Block or report`)}
|
||||
convo={convoState.convo}
|
||||
profile={otherUser}
|
||||
color="negative"
|
||||
size="small"
|
||||
currentScreen="conversation"
|
||||
/>
|
||||
)}
|
||||
<DeleteChatButton
|
||||
label={_(msg`Delete`)}
|
||||
convo={convoState.convo}
|
||||
color="secondary"
|
||||
size="small"
|
||||
currentScreen="conversation"
|
||||
onPress={leaveConvoControl.open}
|
||||
/>
|
||||
<LeaveConvoPrompt
|
||||
convoId={convoState.convo.id}
|
||||
control={leaveConvoControl}
|
||||
currentScreen="conversation"
|
||||
hasMessages={false}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.w_full, a.flex_row]}>
|
||||
<AcceptChatButton
|
||||
onAcceptConvo={onAcceptChat}
|
||||
convo={convoState.convo}
|
||||
color="primary"
|
||||
variant="outline"
|
||||
size="small"
|
||||
currentScreen="conversation"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {Link} from '#/components/Link'
|
||||
|
||||
export function InboxPreview({
|
||||
profiles,
|
||||
}: // count,
|
||||
{
|
||||
profiles: ChatBskyActorDefs.ProfileViewBasic[]
|
||||
count: number
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Link
|
||||
label={_(msg`Chat request inbox`)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xl,
|
||||
a.py_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
a.border_t,
|
||||
{marginTop: a.border_t.borderTopWidth * -1},
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44},
|
||||
a.rounded_0,
|
||||
]}
|
||||
to="/messages/inbox"
|
||||
color="secondary"
|
||||
variant="solid">
|
||||
<View style={[a.relative]}>
|
||||
<ButtonIcon icon={EnvelopeIcon} size="lg" />
|
||||
{profiles.length > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.z_20,
|
||||
{
|
||||
top: -4,
|
||||
right: -5,
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<ButtonText
|
||||
style={[a.flex_1, a.font_bold, a.text_left]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Chat requests</Trans>
|
||||
</ButtonText>
|
||||
<AvatarStack
|
||||
profiles={profiles}
|
||||
backgroundColor={t.atoms.bg_contrast_25.backgroundColor}
|
||||
/>
|
||||
<ButtonIcon icon={ArrowRightIcon} size="lg" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
$Typed,
|
||||
AppBskyEmbedRecord,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
@@ -31,6 +29,7 @@ import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
@@ -44,6 +43,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChatStatusInfo} from './ChatStatusInfo'
|
||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||
|
||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
@@ -85,11 +85,13 @@ export function MessagesList({
|
||||
setHasScrolled,
|
||||
blocked,
|
||||
footer,
|
||||
hasAcceptOverride,
|
||||
}: {
|
||||
hasScrolled: boolean
|
||||
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
blocked?: boolean
|
||||
footer?: React.ReactNode
|
||||
hasAcceptOverride?: boolean
|
||||
}) {
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
@@ -242,8 +244,7 @@ export function MessagesList({
|
||||
)
|
||||
|
||||
// -- Keyboard animation handling
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
const keyboardIsOpening = useSharedValue(false)
|
||||
@@ -268,28 +269,30 @@ export function MessagesList({
|
||||
onMove: e => {
|
||||
'worklet'
|
||||
keyboardHeight.set(e.height)
|
||||
if (e.height > bottomOffset) {
|
||||
if (e.height > footerHeight.get()) {
|
||||
scrollTo(flatListRef, 0, 1e7, false)
|
||||
}
|
||||
},
|
||||
onEnd: e => {
|
||||
'worklet'
|
||||
keyboardHeight.set(e.height)
|
||||
if (e.height > bottomOffset) {
|
||||
if (e.height > footerHeight.get()) {
|
||||
scrollTo(flatListRef, 0, 1e7, false)
|
||||
}
|
||||
keyboardIsOpening.set(false)
|
||||
},
|
||||
},
|
||||
[bottomOffset],
|
||||
[footerHeight],
|
||||
)
|
||||
|
||||
const animatedListStyle = useAnimatedStyle(() => ({
|
||||
marginBottom: Math.max(keyboardHeight.get(), bottomOffset),
|
||||
marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
|
||||
}))
|
||||
|
||||
const animatedStickyViewStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}],
|
||||
transform: [
|
||||
{translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
|
||||
],
|
||||
}))
|
||||
|
||||
// -- Message sending
|
||||
@@ -437,18 +440,41 @@ export function MessagesList({
|
||||
) : blocked ? (
|
||||
footer
|
||||
) : (
|
||||
<>
|
||||
{isConvoActive(convoState) &&
|
||||
!convoState.isFetchingHistory &&
|
||||
convoState.items.length === 0 && <ChatEmptyPill />}
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
</MessageInput>
|
||||
</>
|
||||
isConvoActive(convoState) &&
|
||||
!convoState.isFetchingHistory && (
|
||||
<>
|
||||
{convoState.items.length === 0 ? (
|
||||
<>
|
||||
<ChatEmptyPill />
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
openEmojiPicker={pos =>
|
||||
setEmojiPickerState({isOpen: true, pos})
|
||||
}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
</>
|
||||
) : convoState.convo.status === 'request' &&
|
||||
!hasAcceptOverride ? (
|
||||
<ChatStatusInfo convoState={convoState} />
|
||||
) : (
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
openEmojiPicker={pos =>
|
||||
setEmojiPickerState({isOpen: true, pos})
|
||||
}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
</MessageInput>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {useCallback} from 'react'
|
||||
import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useAcceptConversation} from '#/state/queries/messages/accept-conversation'
|
||||
import {precacheConvoQuery} from '#/state/queries/messages/conversation'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
export function RejectMenu({
|
||||
convo,
|
||||
profile,
|
||||
size = 'tiny',
|
||||
variant = 'outline',
|
||||
color = 'secondary',
|
||||
label,
|
||||
showDeleteConvo,
|
||||
currentScreen,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
|
||||
label?: string
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: ChatBskyActorDefs.ProfileViewBasic
|
||||
showDeleteConvo?: boolean
|
||||
currentScreen: 'list' | 'conversation'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const shadowedProfile = useProfileShadow(profile)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
|
||||
onMutate: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.dispatch(StackActions.pop())
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_('Failed to delete chat'), 'xmark')
|
||||
},
|
||||
})
|
||||
const [queueBlock] = useProfileBlockMutationQueue(shadowedProfile)
|
||||
|
||||
const onPressDelete = useCallback(() => {
|
||||
Toast.show(_('Chat deleted'), 'check')
|
||||
leaveConvo()
|
||||
}, [leaveConvo, _])
|
||||
|
||||
const onPressBlock = useCallback(() => {
|
||||
Toast.show(_('Account blocked'), 'check')
|
||||
// block and also delete convo
|
||||
queueBlock()
|
||||
leaveConvo()
|
||||
}, [queueBlock, leaveConvo, _])
|
||||
|
||||
const reportControl = useDialogControl()
|
||||
|
||||
const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage)
|
||||
? convo.lastMessage
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Reject chat request`)}>
|
||||
{({props: triggerProps}) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
{...props}
|
||||
label={triggerProps.accessibilityLabel}
|
||||
style={[a.flex_1]}
|
||||
color={color}
|
||||
variant={variant}
|
||||
size={size}>
|
||||
<ButtonText>
|
||||
{label || (
|
||||
<Trans comment="Reject a chat request, this opens a menu with options">
|
||||
Reject
|
||||
</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{showDeleteConvo && (
|
||||
<Menu.Item
|
||||
label={_(msg`Delete conversation`)}
|
||||
onPress={onPressDelete}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Delete conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item label={_(msg`Block account`)} onPress={onPressBlock}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Block account</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={PersonXIcon} />
|
||||
</Menu.Item>
|
||||
{/* note: last message will almost certainly be defined, since you can't
|
||||
delete messages for other people andit's impossible for a convo on this
|
||||
screen to have a message sent by you */}
|
||||
{lastMessage && (
|
||||
<Menu.Item
|
||||
label={_(msg`Report conversation`)}
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={FlagIcon} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
{lastMessage && (
|
||||
<ReportDialog
|
||||
currentScreen={currentScreen}
|
||||
params={{
|
||||
type: 'convoMessage',
|
||||
convoId: convo.id,
|
||||
message: lastMessage,
|
||||
}}
|
||||
control={reportControl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AcceptChatButton({
|
||||
convo,
|
||||
size = 'tiny',
|
||||
variant = 'solid',
|
||||
color = 'secondary_inverted',
|
||||
label,
|
||||
currentScreen,
|
||||
onAcceptConvo,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
|
||||
label?: string
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
onAcceptConvo?: () => void
|
||||
currentScreen: 'list' | 'conversation'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, {
|
||||
onMutate: () => {
|
||||
onAcceptConvo?.()
|
||||
if (currentScreen === 'list') {
|
||||
precacheConvoQuery(queryClient, {...convo, status: 'accepted'})
|
||||
navigation.navigate('MessagesConversation', {
|
||||
conversation: convo.id,
|
||||
accept: true,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// Should we show a toast here? They'll be on the convo screen, and it'll make
|
||||
// no difference if the request failed - when they send a message, the convo will be accepted
|
||||
// automatically. The only difference is that when they back out of the convo (without sending a message), the conversation will be rejected.
|
||||
// the list will still have this chat in it -sfn
|
||||
Toast.show(_('Failed to accept chat'), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
const onPressAccept = useCallback(() => {
|
||||
acceptConvo()
|
||||
}, [acceptConvo])
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
label={label || _(msg`Accept chat request`)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
color={color}
|
||||
style={a.flex_1}
|
||||
onPress={onPressAccept}>
|
||||
{isPending ? (
|
||||
<ButtonIcon icon={Loader} />
|
||||
) : (
|
||||
<ButtonText>
|
||||
{label || <Trans comment="Accept a chat request">Accept</Trans>}
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeleteChatButton({
|
||||
convo,
|
||||
size = 'tiny',
|
||||
variant = 'outline',
|
||||
color = 'secondary',
|
||||
label,
|
||||
currentScreen,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'children' | 'label'> & {
|
||||
label?: string
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
currentScreen: 'list' | 'conversation'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
|
||||
onMutate: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.dispatch(StackActions.pop())
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_('Failed to delete chat'), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
const onPressDelete = useCallback(() => {
|
||||
Toast.show(_('Chat deleted'), 'check')
|
||||
leaveConvo()
|
||||
}, [leaveConvo, _])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={label || _(msg`Delete chat`)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
color={color}
|
||||
style={a.flex_1}
|
||||
onPress={onPressDelete}
|
||||
{...props}>
|
||||
<ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChatListItem} from './ChatListItem'
|
||||
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
|
||||
|
||||
export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const otherUser = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherUser || !moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isDeletedAccount = otherUser.handle === 'missing.invalid'
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1]}>
|
||||
<ChatListItem convo={convo} showMenu={false}>
|
||||
<View style={[a.pt_xs, a.pb_2xs]}>
|
||||
<KnownFollowers
|
||||
profile={otherUser}
|
||||
moderationOpts={moderationOpts}
|
||||
minimal
|
||||
showIfEmpty
|
||||
/>
|
||||
</View>
|
||||
{/* spacer, since you can't nest pressables */}
|
||||
<View style={[a.pt_md, a.pb_xs, a.w_full, {opacity: 0}]} aria-hidden>
|
||||
{/* Placeholder text so that it responds to the font height */}
|
||||
<Text style={[a.text_xs, a.leading_tight, a.font_bold]}>
|
||||
<Trans comment="Accept a chat request">Accept Request</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</ChatListItem>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.pr_md,
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
{
|
||||
bottom: tokens.space.md,
|
||||
paddingLeft: tokens.space.lg + 52 + tokens.space.md,
|
||||
},
|
||||
]}>
|
||||
{!isDeletedAccount ? (
|
||||
<>
|
||||
<AcceptChatButton convo={convo} currentScreen="list" />
|
||||
<RejectMenu
|
||||
convo={convo}
|
||||
profile={otherUser}
|
||||
showDeleteConvo
|
||||
currentScreen="list"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeleteChatButton convo={convo} currentScreen="list" />
|
||||
<View style={a.flex_1} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
|
||||
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
|
||||
import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {AvatarStackWithFetch} from '#/components/AvatarStack'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
|
||||
@@ -118,7 +118,7 @@ export function SettingsScreen({}: Props) {
|
||||
{showAccounts ? (
|
||||
<SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
|
||||
) : (
|
||||
<AvatarStack
|
||||
<AvatarStackWithFetch
|
||||
profiles={accounts
|
||||
.map(acc => acc.did)
|
||||
.filter(did => did !== currentAccount?.did)
|
||||
|
||||
Reference in New Issue
Block a user