* 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:
Samuel Newman
2025-03-04 13:54:19 +00:00
committed by GitHub
parent 5c14f69566
commit c995eb2f2f
40 changed files with 1804 additions and 344 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 454 B

+6
View File
@@ -69,6 +69,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
@@ -411,6 +412,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen} getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}} options={{title: title(msg`Chat settings`), requireAuth: true}}
/> />
<Stack.Screen
name="MessagesInbox"
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="NotificationSettings" name="NotificationSettings"
getComponent={() => NotificationSettingsScreen} getComponent={() => NotificationSettingsScreen}
+15
View File
@@ -31,6 +31,18 @@ export const atoms = {
right: 0, right: 0,
bottom: 0, bottom: 0,
}, },
top_0: {
top: 0,
},
right_0: {
right: 0,
},
bottom_0: {
bottom: 0,
},
left_0: {
left: 0,
},
z_10: { z_10: {
zIndex: 10, zIndex: 10,
}, },
@@ -93,6 +105,9 @@ export const atoms = {
/* /*
* Border radius * Border radius
*/ */
rounded_0: {
borderRadius: 0,
},
rounded_2xs: { rounded_2xs: {
borderRadius: tokens.borderRadius._2xs, borderRadius: tokens.borderRadius._2xs,
}, },
+41 -11
View File
@@ -1,37 +1,37 @@
import {View} from 'react-native' import {View} from 'react-native'
import {moderateProfile} from '@atproto/api' import {moderateProfile} from '@atproto/api'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile' import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky'
export function AvatarStack({ export function AvatarStack({
profiles, profiles,
size = 26, size = 26,
numPending,
backgroundColor,
}: { }: {
profiles: string[] profiles: bsky.profile.AnyProfileView[]
size?: number size?: number
numPending?: number
backgroundColor?: string
}) { }) {
const halfSize = size / 2 const halfSize = size / 2
const {data, error} = useProfilesQuery({handles: profiles})
const t = useTheme() const t = useTheme()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
if (error) { const isPending = (numPending && profiles.length === 0) || !moderationOpts
console.error(error)
return null
}
const isPending = !data || !moderationOpts
const items = isPending const items = isPending
? Array.from({length: profiles.length}).map((_, i) => ({ ? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
key: i, key: i,
profile: null, profile: null,
moderation: null, moderation: null,
})) }))
: data.profiles.map(item => ({ : profiles.map(item => ({
key: item.did, key: item.did,
profile: item, profile: item,
moderation: moderateProfile(item, moderationOpts), moderation: moderateProfile(item, moderationOpts),
@@ -56,7 +56,7 @@ export function AvatarStack({
height: size, height: size,
left: i * -halfSize, left: i * -halfSize,
borderWidth: 1, borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor, borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999, borderRadius: 999,
zIndex: 3 - i, zIndex: 3 - i,
}, },
@@ -74,3 +74,33 @@ export function AvatarStack({
</View> </View>
) )
} }
export function AvatarStackWithFetch({
profiles,
size,
backgroundColor,
}: {
profiles: string[]
size?: number
backgroundColor?: string
}) {
const {data, error} = useProfilesQuery({handles: profiles})
if (error) {
if (error.name !== 'AbortError') {
logger.error('Error fetching profiles for AvatarStack', {
safeMessage: error,
})
}
return null
}
return (
<AvatarStack
numPending={profiles.length}
profiles={data?.profiles || []}
size={size}
backgroundColor={backgroundColor}
/>
)
}
+20 -9
View File
@@ -33,11 +33,13 @@ export function KnownFollowers({
moderationOpts, moderationOpts,
onLinkPress, onLinkPress,
minimal, minimal,
showIfEmpty,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean minimal?: boolean
showIfEmpty?: boolean
}) { }) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(), new Map(),
@@ -64,11 +66,12 @@ export function KnownFollowers({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
onLinkPress={onLinkPress} onLinkPress={onLinkPress}
minimal={minimal} minimal={minimal}
showIfEmpty={showIfEmpty}
/> />
) )
} }
return null return <EmptyFallback show={showIfEmpty} />
} }
function KnownFollowersInner({ function KnownFollowersInner({
@@ -77,22 +80,19 @@ function KnownFollowersInner({
cachedKnownFollowers, cachedKnownFollowers,
onLinkPress, onLinkPress,
minimal, minimal,
showIfEmpty,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean minimal?: boolean
showIfEmpty?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const textStyle = [ const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
a.flex_1,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
const moderation = moderateProfile(f, moderationOpts) const moderation = moderateProfile(f, moderationOpts)
@@ -115,7 +115,7 @@ function KnownFollowersInner({
* We check above too, but here for clarity and a reminder to _check for * We check above too, but here for clarity and a reminder to _check for
* valid indices_ * valid indices_
*/ */
if (slice.length === 0) return null if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
@@ -127,7 +127,6 @@ function KnownFollowersInner({
onPress={onLinkPress} onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')} to={makeProfileLink(profile, 'known-followers')}
style={[ style={[
a.flex_1,
a.flex_row, a.flex_row,
minimal ? a.gap_sm : a.gap_md, minimal ? a.gap_sm : a.gap_md,
a.align_center, a.align_center,
@@ -243,3 +242,15 @@ function KnownFollowersInner({
</Link> </Link>
) )
} }
function EmptyFallback({show}: {show?: boolean}) {
const t = useTheme()
if (!show) return null
return (
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Not followed by anyone you're following</Trans>
</Text>
)
}
+5 -1
View File
@@ -13,10 +13,12 @@ export function LeaveConvoPrompt({
control, control,
convoId, convoId,
currentScreen, currentScreen,
hasMessages = true,
}: { }: {
control: DialogOuterProps['control'] control: DialogOuterProps['control']
convoId: string convoId: string
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
hasMessages?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -39,7 +41,9 @@ export function LeaveConvoPrompt({
control={control} control={control}
title={_(msg`Leave conversation`)} title={_(msg`Leave conversation`)}
description={_( description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, hasMessages
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
: msg`Are you sure you want to leave this conversation?`,
)} )}
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
+34 -13
View File
@@ -8,13 +8,15 @@ import {useNavigation} from '@react-navigation/native'
import {useEmail} from '#/lib/hooks/useEmail' import {useEmail} from '#/lib/hooks/useEmail'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {useDialogControl} from '../Dialog'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
export function MessageProfileButton({ export function MessageProfileButton({
profile, profile,
@@ -27,10 +29,19 @@ export function MessageProfileButton({
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl() const verifyEmailControl = useDialogControl()
const {data: convo, isPending} = useMaybeConvoForUser(profile.did) const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
},
onError: () => {
Toast.show(_(msg`Failed to create conversation`))
},
})
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
if (!convo?.id) { if (!convoAvailability?.canChat) {
return return
} }
@@ -39,15 +50,25 @@ export function MessageProfileButton({
return return
} }
if (convo && !convo.lastMessage) { if (convoAvailability.convo) {
logEvent('chat:create', {logContext: 'ProfileHeader'})
}
logEvent('chat:open', {logContext: 'ProfileHeader'}) logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id,
})
} else {
logEvent('chat:create', {logContext: 'ProfileHeader'})
initiateConvo([profile.did])
}
}, [
needsEmailVerification,
verifyEmailControl,
navigation,
profile.did,
initiateConvo,
convoAvailability,
])
navigation.navigate('MessagesConversation', {conversation: convo.id}) if (!convoAvailability) {
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
if (isPending) {
// show pending state based on declaration // show pending state based on declaration
if (canBeMessaged(profile)) { if (canBeMessaged(profile)) {
return ( return (
@@ -69,7 +90,7 @@ export function MessageProfileButton({
} }
} }
if (convo) { if (convoAvailability.canChat) {
return ( return (
<> <>
<Button <Button
+3 -3
View File
@@ -53,10 +53,10 @@ export let MessagesListHeader = ({
}, [moderation]) }, [moderation])
const onPressBack = useCallback(() => { const onPressBack = useCallback(() => {
if (isWeb) { if (navigation.canGoBack()) {
navigation.replace('Messages', {})
} else {
navigation.goBack() navigation.goBack()
} else {
navigation.navigate('Messages', {})
} }
}, [navigation]) }, [navigation])
+16 -9
View File
@@ -311,6 +311,19 @@ function DoneStep({
}, },
}) })
let btnText = _(msg`Done`)
let toastMsg: string | undefined
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
toastMsg = _(msg`Conversation deleted`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
toastMsg = _(msg`Conversation deleted`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
toastMsg = _(msg`User blocked`)
}
const onPressPrimaryAction = () => { const onPressPrimaryAction = () => {
control.close(() => { control.close(() => {
if (actions.includes('block')) { if (actions.includes('block')) {
@@ -319,16 +332,10 @@ function DoneStep({
if (actions.includes('leave')) { if (actions.includes('leave')) {
leaveConvo() leaveConvo()
} }
}) if (toastMsg) {
Toast.show(toastMsg, 'check')
} }
})
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
} }
return ( return (
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z',
})
+2 -1
View File
@@ -56,8 +56,9 @@ export type CommonNavigatorParams = {
Search: {q?: string} Search: {q?: string}
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Topic: {topic: string} Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string} MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesSettings: undefined MessagesSettings: undefined
MessagesInbox: undefined
NotificationSettings: undefined NotificationSettings: undefined
Feeds: undefined Feeds: undefined
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
+1
View File
@@ -59,6 +59,7 @@ export const router = new Router({
// DMs // DMs
Messages: '/messages', Messages: '/messages',
MessagesSettings: '/messages/settings', MessagesSettings: '/messages/settings',
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation', MessagesConversation: '/messages/:conversation',
// starter packs // starter packs
Start: '/start/:name/:rkey', Start: '/start/:name/:rkey',
+78 -26
View File
@@ -1,7 +1,7 @@
import {useCallback, useEffect, useMemo, useState} from 'react' import {useCallback, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto/api' import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect, useIsFocused} from '@react-navigation/native' 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 {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation' import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useSession} from '#/state/session'
import {List, ListRef} from '#/view/com/util/List' import {List, ListRef} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -35,20 +36,37 @@ import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ChatListItem} from './components/ChatListItem' 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'> 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) { export function MessagesScreen({navigation, route}: Props) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession()
const newChatControl = useDialogControl() const newChatControl = useDialogControl()
const scrollElRef: ListRef = useAnimatedRef() const scrollElRef: ListRef = useAnimatedRef()
const pushToConversation = route.params?.pushToConversation const pushToConversation = route.params?.pushToConversation
@@ -94,33 +112,63 @@ export function MessagesScreen({navigation, route}: Props) {
isError, isError,
error, error,
refetch, refetch,
} = useListConvosQuery() } = useListConvosQuery({status: 'accepted'})
const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({
status: 'request',
})
useRefreshOnFocus(refetch) useRefreshOnFocus(refetch)
useRefreshOnFocus(refetchInbox)
const leftConvos = useLeftConvos() 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(() => { const conversations = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return ( const conversations = data.pages
data.pages
.flatMap(page => page.convos) .flatMap(page => page.convos)
// filter out convos that are actively being left // filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id)) .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 [] return []
}, [data, leftConvos]) }, [data, leftConvos, inboxPreviewConvos])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
try { try {
await refetch() await Promise.all([refetch(), refetchInbox()])
} catch (err) { } catch (err) {
logger.error('Failed to refresh conversations', {message: err}) logger.error('Failed to refresh conversations', {message: err})
} }
setIsPTRing(false) setIsPTRing(false)
}, [refetch, setIsPTRing]) }, [refetch, refetchInbox, setIsPTRing])
const onEndReached = useCallback(async () => { const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return if (isFetchingNextPage || !hasNextPage || isError) return
@@ -157,7 +205,8 @@ export function MessagesScreen({navigation, route}: Props) {
return listenSoftReset(onSoftReset) return listenSoftReset(onSoftReset)
}, [onSoftReset, isScreenFocused]) }, [onSoftReset, isScreenFocused])
if (conversations.length < 1) { // Will always have 1 item - the inbox button
if (conversations.length < 2) {
return ( return (
<Layout.Screen> <Layout.Screen>
<Header newChatControl={newChatControl} /> <Header newChatControl={newChatControl} />
@@ -173,7 +222,7 @@ export function MessagesScreen({navigation, route}: Props) {
<View style={[a.pt_3xl, a.align_center]}> <View style={[a.pt_3xl, a.align_center]}>
<CircleInfo <CircleInfo
width={48} 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]}> <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
<Trans>Whoops!</Trans> <Trans>Whoops!</Trans>
@@ -187,13 +236,14 @@ export function MessagesScreen({navigation, route}: Props) {
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
{maxWidth: 360}, {maxWidth: 360},
]}> ]}>
{cleanError(error)} {cleanError(error) ||
_(msg`Failed to load conversations`)}
</Text> </Text>
<Button <Button
label={_(msg`Reload conversations`)} label={_(msg`Reload conversations`)}
size="large" size="small"
color="secondary" color="secondary_inverted"
variant="solid" variant="solid"
onPress={() => refetch()}> onPress={() => refetch()}>
<ButtonText> <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]}> <View style={[a.pt_3xl, a.align_center]}>
<Message width={48} fill={t.palette.primary_500} /> <Message width={48} fill={t.palette.primary_500} />
<Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> <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} onRetry={fetchNextPage}
style={{borderColor: 'transparent'}} style={{borderColor: 'transparent'}}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
showEndMessage={true}
endMessageText={_(msg`No more conversations to show`)}
/> />
} }
onEndReachedThreshold={isNative ? 1.5 : 0} onEndReachedThreshold={isNative ? 1.5 : 0}
@@ -290,7 +342,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
<> <>
<Layout.Header.Content> <Layout.Header.Content>
<Layout.Header.TitleText> <Layout.Header.TitleText>
<Trans>Messages</Trans> <Trans>Chats</Trans>
</Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content> </Layout.Header.Content>
@@ -314,7 +366,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
<Layout.Header.MenuButton /> <Layout.Header.MenuButton />
<Layout.Header.Content> <Layout.Header.Content>
<Layout.Header.TitleText> <Layout.Header.TitleText>
<Trans>Messages</Trans> <Trans>Chats</Trans>
</Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content> </Layout.Header.Content>
<Layout.Header.Slot>{settingsLink}</Layout.Header.Slot> <Layout.Header.Slot>{settingsLink}</Layout.Header.Slot>
+9 -1
View File
@@ -7,7 +7,12 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useEmail} from '#/lib/hooks/useEmail' import {useEmail} from '#/lib/hooks/useEmail'
@@ -172,6 +177,8 @@ function InnerReady({
const {_} = useLingui() const {_} = useLingui()
const convoState = useConvo() const convoState = useConvo()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {params} =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const verifyEmailControl = useDialogControl() const verifyEmailControl = useDialogControl()
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
@@ -189,6 +196,7 @@ function InnerReady({
hasScrolled={hasScrolled} hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled} setHasScrolled={setHasScrolled}
blocked={moderation?.blocked} blocked={moderation?.blocked}
hasAcceptOverride={!!params.accept}
footer={ footer={
<MessagesListBlockedFooter <MessagesListBlockedFooter
recipient={recipient} recipient={recipient}
+332
View File
@@ -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 = ({ export let ChatListItem = ({
convo, convo,
showMenu = true,
children,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean
children?: React.ReactNode
}): React.ReactNode => { }): React.ReactNode => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -66,7 +70,9 @@ export let ChatListItem = ({
convo={convo} convo={convo}
profile={otherUser} profile={otherUser}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> showMenu={showMenu}>
{children}
</ChatListItemReady>
) )
} }
@@ -76,10 +82,14 @@ function ChatListItemReady({
convo, convo,
profile: profileUnshadowed, profile: profileUnshadowed,
moderationOpts, moderationOpts,
showMenu,
children,
}: { }: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -252,6 +262,8 @@ function ChatListItemReady({
leftFirst: deleteAction, leftFirst: deleteAction,
} }
const hasUnread = convo.unreadCount > 0 && !isDeletedAccount
return ( return (
<GestureActionView actions={actions}> <GestureActionView actions={actions}>
<View <View
@@ -305,7 +317,6 @@ function ChatListItemReady({
a.py_md, a.py_md,
a.gap_md, a.gap_md,
(hovered || pressed || focused) && t.atoms.bg_contrast_25, (hovered || pressed || focused) && t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}> ]}>
{/* Avatar goes here */} {/* Avatar goes here */}
<View style={{width: 52, height: 52}} /> <View style={{width: 52, height: 52}} />
@@ -376,9 +387,7 @@ function ChatListItemReady({
style={[ style={[
a.text_sm, a.text_sm,
a.leading_snug, a.leading_snug,
convo.unreadCount > 0 hasUnread ? a.font_bold : t.atoms.text_contrast_high,
? a.font_bold
: t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium, isDimStyle && t.atoms.text_contrast_medium,
]}> ]}>
{lastMessage} {lastMessage}
@@ -389,9 +398,11 @@ function ChatListItemReady({
size="lg" size="lg"
style={[a.pt_xs]} style={[a.pt_xs]}
/> />
{children}
</View> </View>
{convo.unreadCount > 0 && ( {hasUnread && (
<View <View
style={[ style={[
a.absolute, a.absolute,
@@ -412,6 +423,7 @@ function ChatListItemReady({
)} )}
</Link> </Link>
{showMenu && (
<ConvoMenu <ConvoMenu
convo={convo} convo={convo}
profile={profile} profile={profile}
@@ -432,6 +444,7 @@ function ChatListItemReady({
]} ]}
latestReportableMessage={latestReportableMessage} latestReportableMessage={latestReportableMessage}
/> />
)}
<LeaveConvoPrompt <LeaveConvoPrompt
control={leaveConvoControl} control={leaveConvoControl}
convoId={convo.id} 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, useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import { import {
$Typed, $Typed,
AppBskyEmbedRecord, AppBskyEmbedRecord,
@@ -17,7 +16,6 @@ import {
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import { import {
@@ -31,6 +29,7 @@ import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerState, EmojiPickerState,
@@ -44,6 +43,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
function MaybeLoader({isLoading}: {isLoading: boolean}) { function MaybeLoader({isLoading}: {isLoading: boolean}) {
@@ -85,11 +85,13 @@ export function MessagesList({
setHasScrolled, setHasScrolled,
blocked, blocked,
footer, footer,
hasAcceptOverride,
}: { }: {
hasScrolled: boolean hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
blocked?: boolean blocked?: boolean
footer?: React.ReactNode footer?: React.ReactNode
hasAcceptOverride?: boolean
}) { }) {
const convoState = useConvoActive() const convoState = useConvoActive()
const agent = useAgent() const agent = useAgent()
@@ -242,8 +244,7 @@ export function MessagesList({
) )
// -- Keyboard animation handling // -- Keyboard animation handling
const {bottom: bottomInset} = useSafeAreaInsets() const {footerHeight} = useShellLayout()
const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
const keyboardHeight = useSharedValue(0) const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false) const keyboardIsOpening = useSharedValue(false)
@@ -268,28 +269,30 @@ export function MessagesList({
onMove: e => { onMove: e => {
'worklet' 'worklet'
keyboardHeight.set(e.height) keyboardHeight.set(e.height)
if (e.height > bottomOffset) { if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false) scrollTo(flatListRef, 0, 1e7, false)
} }
}, },
onEnd: e => { onEnd: e => {
'worklet' 'worklet'
keyboardHeight.set(e.height) keyboardHeight.set(e.height)
if (e.height > bottomOffset) { if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false) scrollTo(flatListRef, 0, 1e7, false)
} }
keyboardIsOpening.set(false) keyboardIsOpening.set(false)
}, },
}, },
[bottomOffset], [footerHeight],
) )
const animatedListStyle = useAnimatedStyle(() => ({ const animatedListStyle = useAnimatedStyle(() => ({
marginBottom: Math.max(keyboardHeight.get(), bottomOffset), marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
})) }))
const animatedStickyViewStyle = useAnimatedStyle(() => ({ const animatedStickyViewStyle = useAnimatedStyle(() => ({
transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}], transform: [
{translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
],
})) }))
// -- Message sending // -- Message sending
@@ -437,18 +440,41 @@ export function MessagesList({
) : blocked ? ( ) : blocked ? (
footer footer
) : ( ) : (
isConvoActive(convoState) &&
!convoState.isFetchingHistory && (
<> <>
{isConvoActive(convoState) && {convoState.items.length === 0 ? (
!convoState.isFetchingHistory && <>
convoState.items.length === 0 && <ChatEmptyPill />} <ChatEmptyPill />
<MessageInput <MessageInput
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
hasEmbed={!!embedUri} hasEmbed={!!embedUri}
setEmbed={setEmbed} setEmbed={setEmbed}
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}> openEmojiPicker={pos =>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> setEmojiPickerState({isOpen: true, pos})
}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageInput> </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> </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>
)
}
+2 -2
View File
@@ -28,7 +28,7 @@ import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, tokens, useTheme} from '#/alf' import {atoms as a, tokens, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack' import {AvatarStackWithFetch} from '#/components/AvatarStack'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
@@ -118,7 +118,7 @@ export function SettingsScreen({}: Props) {
{showAccounts ? ( {showAccounts ? (
<SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
) : ( ) : (
<AvatarStack <AvatarStackWithFetch
profiles={accounts profiles={accounts
.map(acc => acc.did) .map(acc => acc.did)
.filter(did => did !== currentAccount?.did) .filter(did => did !== currentAccount?.did)
+21
View File
@@ -105,6 +105,7 @@ export class Convo {
this.ingestFirehose = this.ingestFirehose.bind(this) this.ingestFirehose = this.ingestFirehose.bind(this)
this.onFirehoseConnect = this.onFirehoseConnect.bind(this) this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
this.onFirehoseError = this.onFirehoseError.bind(this) this.onFirehoseError = this.onFirehoseError.bind(this)
this.markConvoAccepted = this.markConvoAccepted.bind(this)
} }
private commit() { private commit() {
@@ -145,6 +146,7 @@ export class Convo {
deleteMessage: undefined, deleteMessage: undefined,
sendMessage: undefined, sendMessage: undefined,
fetchMessageHistory: undefined, fetchMessageHistory: undefined,
markConvoAccepted: undefined,
} }
} }
case ConvoStatus.Disabled: case ConvoStatus.Disabled:
@@ -162,6 +164,7 @@ export class Convo {
deleteMessage: this.deleteMessage, deleteMessage: this.deleteMessage,
sendMessage: this.sendMessage, sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory, fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
} }
} }
case ConvoStatus.Error: { case ConvoStatus.Error: {
@@ -176,6 +179,7 @@ export class Convo {
deleteMessage: undefined, deleteMessage: undefined,
sendMessage: undefined, sendMessage: undefined,
fetchMessageHistory: undefined, fetchMessageHistory: undefined,
markConvoAccepted: undefined,
} }
} }
default: { default: {
@@ -190,6 +194,7 @@ export class Convo {
deleteMessage: undefined, deleteMessage: undefined,
sendMessage: undefined, sendMessage: undefined,
fetchMessageHistory: undefined, fetchMessageHistory: undefined,
markConvoAccepted: undefined,
} }
} }
} }
@@ -780,6 +785,12 @@ export class Convo {
id: tempId, id: tempId,
message, message,
}) })
if (this.convo?.status === 'request') {
this.convo = {
...this.convo,
status: 'accepted',
}
}
this.commit() this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
@@ -787,6 +798,16 @@ export class Convo {
} }
} }
markConvoAccepted() {
if (this.convo) {
this.convo = {
...this.convo,
status: 'accepted',
}
}
this.commit()
}
async processPendingMessages() { async processPendingMessages() {
logger.debug( logger.debug(
`Convo: processing messages (${this.pendingMessages.size} remaining)`, `Convo: processing messages (${this.pendingMessages.size} remaining)`,
+2 -2
View File
@@ -19,7 +19,7 @@ import {
RQKEY as getConvoKey, RQKEY as getConvoKey,
useMarkAsReadMutation, useMarkAsReadMutation,
} from '#/state/queries/messages/conversation' } from '#/state/queries/messages/conversation'
import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations' import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@@ -104,7 +104,7 @@ export function ConvoProvider({
}) })
} }
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ListConvosQueryKey, queryKey: [ListConvosQueryKeyRoot],
}) })
} }
} }
+8
View File
@@ -141,6 +141,7 @@ type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'], message: ChatBskyConvoSendMessage.InputSchema['message'],
) => void ) => void
type FetchMessageHistory = () => Promise<void> type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
export type ConvoStateUninitialized = { export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized status: ConvoStatus.Uninitialized
@@ -153,6 +154,7 @@ export type ConvoStateUninitialized = {
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
} }
export type ConvoStateInitializing = { export type ConvoStateInitializing = {
status: ConvoStatus.Initializing status: ConvoStatus.Initializing
@@ -165,6 +167,7 @@ export type ConvoStateInitializing = {
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
} }
export type ConvoStateReady = { export type ConvoStateReady = {
status: ConvoStatus.Ready status: ConvoStatus.Ready
@@ -177,6 +180,7 @@ export type ConvoStateReady = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
} }
export type ConvoStateBackgrounded = { export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded status: ConvoStatus.Backgrounded
@@ -189,6 +193,7 @@ export type ConvoStateBackgrounded = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
} }
export type ConvoStateSuspended = { export type ConvoStateSuspended = {
status: ConvoStatus.Suspended status: ConvoStatus.Suspended
@@ -201,6 +206,7 @@ export type ConvoStateSuspended = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
} }
export type ConvoStateError = { export type ConvoStateError = {
status: ConvoStatus.Error status: ConvoStatus.Error
@@ -213,6 +219,7 @@ export type ConvoStateError = {
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
} }
export type ConvoStateDisabled = { export type ConvoStateDisabled = {
status: ConvoStatus.Disabled status: ConvoStatus.Disabled
@@ -225,6 +232,7 @@ export type ConvoStateDisabled = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
} }
export type ConvoState = export type ConvoState =
| ConvoStateUninitialized | ConvoStateUninitialized
+11 -7
View File
@@ -7,18 +7,22 @@ import {
ConvoStatus, ConvoStatus,
} from './types' } from './types'
/**
* States where the convo is ready to be used - either ready, or backgrounded/suspended
* and ready to be resumed
*/
export type ActiveConvoStates =
| ConvoStateReady
| ConvoStateBackgrounded
| ConvoStateSuspended
| ConvoStateDisabled
/** /**
* Checks if a `Convo` has a `status` that is "active", meaning the chat is * Checks if a `Convo` has a `status` that is "active", meaning the chat is
* loaded and ready to be used, or its in a suspended or background state, and * loaded and ready to be used, or its in a suspended or background state, and
* ready for resumption. * ready for resumption.
*/ */
export function isConvoActive( export function isConvoActive(convo: ConvoState): convo is ActiveConvoStates {
convo: ConvoState,
): convo is
| ConvoStateReady
| ConvoStateBackgrounded
| ConvoStateSuspended
| ConvoStateDisabled {
return ( return (
convo.status === ConvoStatus.Ready || convo.status === ConvoStatus.Ready ||
convo.status === ConvoStatus.Backgrounded || convo.status === ConvoStatus.Backgrounded ||
@@ -0,0 +1,135 @@
import {ChatBskyConvoAcceptConvo, ChatBskyConvoListConvos} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {DM_SERVICE_HEADERS} from './const'
import {
RQKEY as CONVO_LIST_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
} from './list-conversations'
export function useAcceptConversation(
convoId: string,
{
onSuccess,
onMutate,
onError,
}: {
onMutate?: () => void
onSuccess?: (data: ChatBskyConvoAcceptConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
const {data} = await agent.chat.bsky.convo.acceptConvo(
{convoId},
{headers: DM_SERVICE_HEADERS},
)
return data
},
onMutate: () => {
let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = []
let convoBeingAccepted:
| ChatBskyConvoListConvos.OutputSchema['convos'][number]
| undefined
queryClient.setQueryData(
CONVO_LIST_KEY('request'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
prevInboxPages = old.pages
return {
...old,
pages: old.pages.map(page => {
const found = page.convos.find(convo => convo.id === convoId)
if (found) {
convoBeingAccepted = found
return {
...page,
convos: page.convos.filter(convo => convo.id !== convoId),
}
}
return page
}),
}
},
)
queryClient.setQueryData(
CONVO_LIST_KEY('accepted'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
prevAcceptedPages = old.pages
if (convoBeingAccepted) {
return {
...old,
pages: [
{
...old.pages[0],
convos: [
{
...convoBeingAccepted,
status: 'accepted',
},
...old.pages[0].convos,
],
},
...old.pages.slice(1),
],
}
} else {
return old
}
},
)
onMutate?.()
return {prevAcceptedPages, prevInboxPages}
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data)
},
onError: (error, _, context) => {
logger.error(error)
queryClient.setQueryData(
CONVO_LIST_KEY('accepted'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
return {
...old,
pages: context?.prevAcceptedPages || old.pages,
}
},
)
queryClient.setQueryData(
CONVO_LIST_KEY('request'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
return {
...old,
pages: context?.prevInboxPages || old.pages,
}
},
)
queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
onError?.(error)
},
})
}
+6 -3
View File
@@ -13,7 +13,7 @@ import {useAgent} from '#/state/session'
import { import {
ConvoListQueryData, ConvoListQueryData,
getConvoFromQueryData, getConvoFromQueryData,
RQKEY as LIST_CONVOS_KEY, RQKEY_ROOT as LIST_CONVOS_KEY,
} from './list-conversations' } from './list-conversations'
const RQKEY_ROOT = 'convo' const RQKEY_ROOT = 'convo'
@@ -76,7 +76,9 @@ export function useMarkAsReadMutation() {
onSuccess(_, {convoId}) { onSuccess(_, {convoId}) {
if (!convoId) return if (!convoId) return
queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => { queryClient.setQueriesData(
{queryKey: [LIST_CONVOS_KEY]},
(old?: ConvoListQueryData) => {
if (!old) return old if (!old) return old
const existingConvo = getConvoFromQueryData(convoId, old) const existingConvo = getConvoFromQueryData(convoId, old)
@@ -103,7 +105,8 @@ export function useMarkAsReadMutation() {
// If we somehow marked a convo as read that doesn't exist in the // If we somehow marked a convo as read that doesn't exist in the
// list, then we don't need to do anything. // list, then we don't need to do anything.
} }
}) },
)
}, },
}) })
} }
@@ -0,0 +1,25 @@
import {useQuery} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session'
import {STALE} from '..'
const RQKEY_ROOT = 'convo-availability'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useGetConvoAvailabilityQuery(did: string) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const {data} = await agent.chat.bsky.convo.getConvoAvailability(
{members: [did]},
{headers: DM_SERVICE_HEADERS},
)
return data
},
staleTime: STALE.INFINITY,
})
}
@@ -1,14 +1,10 @@
import {ChatBskyConvoGetConvoForMembers} from '@atproto/api' import {ChatBskyConvoGetConvoForMembers} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {STALE} from '..' import {precacheConvoQuery} from './conversation'
import {RQKEY as CONVO_KEY} from './conversation'
const RQKEY_ROOT = 'convo-for-user'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useGetConvoForMembers({ export function useGetConvoForMembers({
onSuccess, onSuccess,
@@ -22,7 +18,7 @@ export function useGetConvoForMembers({
return useMutation({ return useMutation({
mutationFn: async (members: string[]) => { mutationFn: async (members: string[]) => {
const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( const {data} = await agent.chat.bsky.convo.getConvoForMembers(
{members: members}, {members: members},
{headers: DM_SERVICE_HEADERS}, {headers: DM_SERVICE_HEADERS},
) )
@@ -30,7 +26,7 @@ export function useGetConvoForMembers({
return data return data
}, },
onSuccess: data => { onSuccess: data => {
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) precacheConvoQuery(queryClient, data.convo)
onSuccess?.(data) onSuccess?.(data)
}, },
onError: error => { onError: error => {
@@ -39,26 +35,3 @@ export function useGetConvoForMembers({
}, },
}) })
} }
/**
* Gets the conversation ID for a given DID. Returns null if it's not possible to message them.
*/
export function useMaybeConvoForUser(did: string) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const convo = await agent.api.chat.bsky.convo
.getConvoForMembers({members: [did]}, {headers: DM_SERVICE_HEADERS})
.catch(() => ({success: null}))
if (convo.success) {
return convo.data.convo
} else {
return null
}
},
staleTime: STALE.INFINITY,
})
}
@@ -1,3 +1,4 @@
import {useMemo} from 'react'
import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api' import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api'
import { import {
useMutation, useMutation,
@@ -8,7 +9,7 @@ import {
import {logger} from '#/logger' import {logger} from '#/logger'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations' import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
const RQKEY_ROOT = 'leave-convo' const RQKEY_ROOT = 'leave-convo'
export function RQKEY(convoId: string | undefined) { export function RQKEY(convoId: string | undefined) {
@@ -35,7 +36,7 @@ export function useLeaveConvo(
mutationFn: async () => { mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided') if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.api.chat.bsky.convo.leaveConvo( const {data} = await agent.chat.bsky.convo.leaveConvo(
{convoId}, {convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
) )
@@ -45,7 +46,7 @@ export function useLeaveConvo(
onMutate: () => { onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
queryClient.setQueryData( queryClient.setQueryData(
CONVO_LIST_KEY, [CONVO_LIST_KEY],
(old?: { (old?: {
pageParams: Array<string | undefined> pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema> pages: Array<ChatBskyConvoListConvos.OutputSchema>
@@ -67,13 +68,13 @@ export function useLeaveConvo(
return {prevPages} return {prevPages}
}, },
onSuccess: data => { onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
logger.error(error) logger.error(error)
queryClient.setQueryData( queryClient.setQueryData(
CONVO_LIST_KEY, [CONVO_LIST_KEY],
(old?: { (old?: {
pageParams: Array<string | undefined> pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema> pages: Array<ChatBskyConvoListConvos.OutputSchema>
@@ -85,7 +86,7 @@ export function useLeaveConvo(
} }
}, },
) )
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onError?.(error) onError?.(error)
}, },
}) })
@@ -105,5 +106,8 @@ export function useLeftConvos() {
filters: {mutationKey: [RQKEY_ROOT], status: 'success'}, filters: {mutationKey: [RQKEY_ROOT], status: 'success'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined, select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
}) })
return [...pending, ...success].filter(id => id !== undefined) return useMemo(
() => [...pending, ...success].filter(id => id !== undefined),
[pending, success],
)
} }
+246 -75
View File
@@ -9,6 +9,7 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
ChatBskyConvoListConvos, ChatBskyConvoListConvos,
moderateProfile, moderateProfile,
ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import { import {
InfiniteData, InfiniteData,
@@ -23,26 +24,39 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useLeftConvos} from './leave-conversation'
export const RQKEY = ['convo-list'] export const RQKEY_ROOT = 'convo-list'
export const RQKEY = (
status: 'accepted' | 'request' | 'all',
readState: 'all' | 'unread' = 'all',
) => [RQKEY_ROOT, status, readState]
type RQPageParam = string | undefined type RQPageParam = string | undefined
export function useListConvosQuery({ export function useListConvosQuery({
enabled, enabled,
status,
readState = 'all',
}: { }: {
enabled?: boolean enabled?: boolean
status?: 'request' | 'accepted'
readState?: 'all' | 'unread'
} = {}) { } = {}) {
const agent = useAgent() const agent = useAgent()
return useInfiniteQuery({ return useInfiniteQuery({
enabled, enabled,
queryKey: RQKEY, queryKey: RQKEY(status ?? 'all', readState),
queryFn: async ({pageParam}) => { queryFn: async ({pageParam}) => {
const {data} = await agent.api.chat.bsky.convo.listConvos( const {data} = await agent.chat.bsky.convo.listConvos(
{cursor: pageParam, limit: 20}, {
limit: 20,
cursor: pageParam,
readState: readState === 'unread' ? 'unread' : undefined,
status,
},
{headers: DM_SERVICE_HEADERS}, {headers: DM_SERVICE_HEADERS},
) )
return data return data
}, },
initialPageParam: undefined as RQPageParam, initialPageParam: undefined as RQPageParam,
@@ -50,9 +64,10 @@ export function useListConvosQuery({
}) })
} }
const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>( const ListConvosContext = createContext<{
null, accepted: ChatBskyConvoDefs.ConvoView[]
) request: ChatBskyConvoDefs.ConvoView[]
} | null>(null)
export function useListConvos() { export function useListConvos() {
const ctx = useContext(ListConvosContext) const ctx = useContext(ListConvosContext)
@@ -62,12 +77,13 @@ export function useListConvos() {
return ctx return ctx
} }
const empty = {accepted: [], request: []}
export function ListConvosProvider({children}: {children: React.ReactNode}) { export function ListConvosProvider({children}: {children: React.ReactNode}) {
const {hasSession} = useSession() const {hasSession} = useSession()
if (!hasSession) { if (!hasSession) {
return ( return (
<ListConvosContext.Provider value={[]}> <ListConvosContext.Provider value={empty}>
{children} {children}
</ListConvosContext.Provider> </ListConvosContext.Provider>
) )
@@ -81,20 +97,23 @@ export function ListConvosProviderInner({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const {refetch, data} = useListConvosQuery() const {refetch, data} = useListConvosQuery({readState: 'unread'})
const messagesBus = useMessagesEventBus() const messagesBus = useMessagesEventBus()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const leftConvos = useLeftConvos()
const debouncedRefetch = useMemo( const debouncedRefetch = useMemo(() => {
() => const refetchAndInvalidate = () => {
throttle(refetch, 500, { refetch()
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
}
return throttle(refetchAndInvalidate, 500, {
leading: true, leading: true,
trailing: true, trailing: true,
}), })
[refetch], }, [refetch, queryClient])
)
useEffect(() => { useEffect(() => {
const unsub = messagesBus.on( const unsub = messagesBus.on(
@@ -105,16 +124,21 @@ export function ListConvosProviderInner({
if (ChatBskyConvoDefs.isLogBeginConvo(log)) { if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
debouncedRefetch() debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => queryClient.setQueriesData(
optimisticDelete(log.convoId, old), {queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
) )
} else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => { optimisticUpdate(log.convoId, old, convo => {
if ( if (
(ChatBskyConvoDefs.isDeletedMessageView(log.message) || (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
ChatBskyConvoDefs.isMessageView(log.message)) && ChatBskyConvoDefs.isMessageView(log.message)) &&
(ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) || (ChatBskyConvoDefs.isDeletedMessageView(
convo.lastMessage,
) ||
ChatBskyConvoDefs.isMessageView(convo.lastMessage)) ChatBskyConvoDefs.isMessageView(convo.lastMessage))
) { ) {
return log.message.id === convo.lastMessage.id return log.message.id === convo.lastMessage.id
@@ -133,41 +157,50 @@ export function ListConvosProviderInner({
// Store in a new var to avoid TS errors due to closures. // Store in a new var to avoid TS errors due to closures.
const logRef: ChatBskyConvoDefs.LogCreateMessage = log const logRef: ChatBskyConvoDefs.LogCreateMessage = log
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { // Get all matching queries
if (!old) return old const queries = queryClient.getQueriesData<ConvoListQueryData>({
queryKey: [RQKEY_ROOT],
})
function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { // Check if convo exists in any query
let unreadCount = convo.unreadCount let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
if (convo.id !== currentConvoId) { for (const [_key, query] of queries) {
if ( if (!query) continue
ChatBskyConvoDefs.isMessageView(logRef.message) || const convo = getConvoFromQueryData(logRef.convoId, query)
ChatBskyConvoDefs.isDeletedMessageView(logRef.message) if (convo) {
) { foundConvo = convo
if (logRef.message.sender.did !== currentAccount?.did) { break
unreadCount++
} }
} }
} else {
unreadCount = 0
}
return { if (!foundConvo) {
...convo, // Convo not found, trigger refetch
debouncedRefetch()
return
}
// Update the convo
const updatedConvo = {
...foundConvo,
rev: logRef.rev, rev: logRef.rev,
lastMessage: logRef.message, lastMessage: logRef.message,
unreadCount, unreadCount:
} foundConvo.id !== currentConvoId
? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
logRef.message.sender.did !== currentAccount?.did
? foundConvo.unreadCount + 1
: foundConvo.unreadCount
: 0,
} }
function filterConvoFromPage( function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) {
convo: ChatBskyConvoDefs.ConvoView[],
) {
return convo.filter(c => c.id !== logRef.convoId) return convo.filter(c => c.id !== logRef.convoId)
} }
const existingConvo = getConvoFromQueryData(logRef.convoId, old) // Update all matching queries
function updateFn(old?: ConvoListQueryData) {
if (existingConvo) { if (!old) return old
return { return {
...old, ...old,
pages: old.pages.map((page, i) => { pages: old.pages.map((page, i) => {
@@ -175,7 +208,7 @@ export function ListConvosProviderInner({
return { return {
...page, ...page,
convos: [ convos: [
updateConvo(existingConvo), updatedConvo,
...filterConvoFromPage(page.convos), ...filterConvoFromPage(page.convos),
], ],
} }
@@ -186,15 +219,103 @@ export function ListConvosProviderInner({
} }
}), }),
} }
} else {
/**
* We received a message from an conversation old enough that
* it doesn't exist in the query cache, meaning we need to
* refetch and bump the old convo to the top.
*/
debouncedRefetch()
} }
}) // always update the unread one
queryClient.setQueriesData(
{queryKey: RQKEY('all', 'unread')},
(old?: ConvoListQueryData) =>
old
? updateFn(old)
: ({
pageParams: [undefined],
pages: [{convos: [updatedConvo], cursor: undefined}],
} satisfies ConvoListQueryData),
)
// update the other ones based on status of the incoming message
if (updatedConvo.status === 'accepted') {
queryClient.setQueriesData(
{queryKey: RQKEY('accepted')},
updateFn,
)
} else if (updatedConvo.status === 'request') {
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
}
} else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
const logRef: ChatBskyConvoDefs.LogReadMessage = log
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(logRef.convoId, old, convo => ({
...convo,
unreadCount: 0,
rev: logRef.rev,
})),
)
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
const logRef: ChatBskyConvoDefs.LogAcceptConvo = log
const requests = queryClient.getQueryData<ConvoListQueryData>(
RQKEY('request'),
)
if (!requests) {
debouncedRefetch()
return
}
const acceptedConvo = getConvoFromQueryData(log.convoId, requests)
if (!acceptedConvo) {
debouncedRefetch()
return
}
queryClient.setQueryData(
RQKEY('request'),
(old?: ConvoListQueryData) =>
optimisticDelete(logRef.convoId, old),
)
queryClient.setQueriesData(
{queryKey: RQKEY('accepted')},
(old?: ConvoListQueryData) => {
if (!old) {
debouncedRefetch()
return old
}
return {
...old,
pages: old.pages.map((page, i) => {
if (i === 0) {
return {
...page,
convos: [
{...acceptedConvo, status: 'accepted'},
...page.convos,
],
}
}
return page
}),
}
},
)
} else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
const logRef: ChatBskyConvoDefs.LogMuteConvo = log
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(logRef.convoId, old, convo => ({
...convo,
muted: true,
rev: logRef.rev,
})),
)
} else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(logRef.convoId, old, convo => ({
...convo,
muted: false,
rev: logRef.rev,
})),
)
} }
} }
}, },
@@ -208,15 +329,21 @@ export function ListConvosProviderInner({
}, [ }, [
messagesBus, messagesBus,
currentConvoId, currentConvoId,
refetch,
queryClient, queryClient,
currentAccount?.did, currentAccount?.did,
debouncedRefetch, debouncedRefetch,
]) ])
const ctx = useMemo(() => { const ctx = useMemo(() => {
return data?.pages.flatMap(page => page.convos) ?? [] const convos =
}, [data]) data?.pages
.flatMap(page => page.convos)
.filter(convo => !leftConvos.includes(convo.id)) ?? []
return {
accepted: convos.filter(conv => conv.status === 'accepted'),
request: convos.filter(conv => conv.status === 'request'),
}
}, [data, leftConvos])
return ( return (
<ListConvosContext.Provider value={ctx}> <ListConvosContext.Provider value={ctx}>
@@ -228,16 +355,62 @@ export function ListConvosProviderInner({
export function useUnreadMessageCount() { export function useUnreadMessageCount() {
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const convos = useListConvos() const {accepted, request} = useListConvos()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const count = useMemo(() => { return useMemo<{
count: number
numUnread?: string
hasNew: boolean
}>(() => {
const acceptedCount = calculateCount(
accepted,
currentAccount?.did,
currentConvoId,
moderationOpts,
)
const requestCount = calculateCount(
request,
currentAccount?.did,
currentConvoId,
moderationOpts,
)
if (acceptedCount > 0) {
const total = acceptedCount + Math.min(requestCount, 1)
return {
count: total,
numUnread: total > 10 ? '10+' : String(total),
// only needed when numUnread is undefined
hasNew: false,
}
} else if (requestCount > 0) {
return {
count: 1,
numUnread: undefined,
hasNew: true,
}
} else {
return {
count: 0,
numUnread: undefined,
hasNew: false,
}
}
}, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts])
}
function calculateCount(
convos: ChatBskyConvoDefs.ConvoView[],
currentAccountDid: string | undefined,
currentConvoId: string | undefined,
moderationOpts: ModerationOpts | undefined,
) {
return ( return (
convos convos
.filter(convo => convo.id !== currentConvoId) .filter(convo => convo.id !== currentConvoId)
.reduce((acc, convo) => { .reduce((acc, convo) => {
const otherMember = convo.members.find( const otherMember = convo.members.find(
member => member.did !== currentAccount?.did, member => member.did !== currentAccountDid,
) )
if (!otherMember || !moderationOpts) return acc if (!otherMember || !moderationOpts) return acc
@@ -246,20 +419,12 @@ export function useUnreadMessageCount() {
const shouldIgnore = const shouldIgnore =
convo.muted || convo.muted ||
moderation.blocked || moderation.blocked ||
otherMember.did === 'missing.invalid' otherMember.handle === 'missing.invalid'
const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
return acc + unreadCount return acc + unreadCount
}, 0) ?? 0 }, 0) ?? 0
) )
}, [convos, currentAccount?.did, currentConvoId, moderationOpts])
return useMemo(() => {
return {
count,
numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined,
}
}, [count])
} }
export type ConvoListQueryData = { export type ConvoListQueryData = {
@@ -272,12 +437,16 @@ export function useOnMarkAsRead() {
return useCallback( return useCallback(
(chatId: string) => { (chatId: string) => {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) => {
if (!old) return old
return optimisticUpdate(chatId, old, convo => ({ return optimisticUpdate(chatId, old, convo => ({
...convo, ...convo,
unreadCount: 0, unreadCount: 0,
})) }))
}) },
)
}, },
[queryClient], [queryClient],
) )
@@ -285,10 +454,12 @@ export function useOnMarkAsRead() {
function optimisticUpdate( function optimisticUpdate(
chatId: string, chatId: string,
old: ConvoListQueryData, old?: ConvoListQueryData,
updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, updateFn?: (
convo: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView,
) { ) {
if (!old) return old if (!old || !updateFn) return old
return { return {
...old, ...old,
@@ -301,7 +472,7 @@ function optimisticUpdate(
} }
} }
function optimisticDelete(chatId: string, old: ConvoListQueryData) { function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
if (!old) return old if (!old) return old
return { return {
@@ -331,7 +502,7 @@ export function* findAllProfilesInQueryData(
const queryDatas = queryClient.getQueriesData< const queryDatas = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema> InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({ >({
queryKey: RQKEY, queryKey: [RQKEY_ROOT],
}) })
for (const [_queryKey, queryData] of queryDatas) { for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) { if (!queryData?.pages) {
@@ -8,7 +8,7 @@ import {InfiniteData, useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations' import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
export function useMuteConvo( export function useMuteConvo(
convoId: string | undefined, convoId: string | undefined,
@@ -53,7 +53,7 @@ export function useMuteConvo(
) )
queryClient.setQueryData< queryClient.setQueryData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema> InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>(CONVO_LIST_KEY, prev => { >([CONVO_LIST_KEY], prev => {
if (!prev?.pages) return if (!prev?.pages) return
return { return {
...prev, ...prev,
@@ -0,0 +1,105 @@
import {ChatBskyConvoListConvos} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
export function useUpdateAllRead(
status: 'accepted' | 'request',
{
onSuccess,
onMutate,
onError,
}: {
onMutate?: () => void
onSuccess?: () => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
const {data} = await agent.chat.bsky.convo.updateAllRead(
{status},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
queryClient.setQueryData(
CONVO_LIST_KEY(status),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
prevPages = old.pages
return {
...old,
pages: old.pages.map(page => {
return {
...page,
convos: page.convos.map(convo => {
return {
...convo,
unreadCount: 0,
}
}),
}
}),
}
},
)
// remove unread convos from the badge query
queryClient.setQueryData(
CONVO_LIST_KEY('all', 'unread'),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => {
return {
...page,
convos: page.convos.filter(convo => convo.status !== status),
}
}),
}
},
)
onMutate?.()
return {prevPages}
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
onSuccess?.()
},
onError: (error, _, context) => {
logger.error(error)
queryClient.setQueryData(
CONVO_LIST_KEY(status),
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
if (!old) return old
return {
...old,
pages: context?.prevPages || old.pages,
}
},
)
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')})
onError?.(error)
},
})
}
+2 -2
View File
@@ -37,7 +37,7 @@ import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '../shell/progress-guide' } from '../shell/progress-guide'
import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-conversations' import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
@@ -456,7 +456,7 @@ export function useProfileBlockMutationQueue(
updateProfileShadow(queryClient, did, { updateProfileShadow(queryClient, did, {
blockingUri: finalBlockingUri, blockingUri: finalBlockingUri,
}) })
queryClient.invalidateQueries({queryKey: RQKEY_LIST_CONVOS}) queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
}, },
}) })
+1 -7
View File
@@ -264,13 +264,7 @@ let UserAvatar = ({
onLoad={onLoad} onLoad={onLoad}
/> />
)} )}
<MediaInsetBorder <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
style={[
{
borderRadius: aviStyle.borderRadius,
},
]}
/>
{alert} {alert}
</View> </View>
) : ( ) : (
+1
View File
@@ -199,6 +199,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
} }
onPress={onPressMessages} onPress={onPressMessages}
notificationCount={numUnreadMessages.numUnread} notificationCount={numUnreadMessages.numUnread}
hasNew={numUnreadMessages.hasNew}
accessible={true} accessible={true}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityLabel={_(msg`Chat`)} accessibilityLabel={_(msg`Chat`)}
@@ -14,7 +14,6 @@ export const styles = StyleSheet.create({
paddingRight: 10, paddingRight: 10,
}, },
bottomBarWeb: { bottomBarWeb: {
// @ts-ignore web-only
position: 'fixed', position: 'fixed',
}, },
ctrl: { ctrl: {
@@ -46,7 +45,7 @@ export const styles = StyleSheet.create({
}, },
hasNewBadge: { hasNewBadge: {
position: 'absolute', position: 'absolute',
left: '52%', left: '54%',
marginLeft: 4, marginLeft: 4,
top: 10, top: 10,
width: 8, width: 8,
+2 -5
View File
@@ -112,11 +112,8 @@ export function BottomBarWeb() {
<NavItem <NavItem
routeName="Messages" routeName="Messages"
href="/messages" href="/messages"
notificationCount={ notificationCount={unreadMessageCount.numUnread}
unreadMessageCount.count > 0 hasNew={unreadMessageCount.hasNew}>
? unreadMessageCount.numUnread
: undefined
}>
{({isActive}) => { {({isActive}) => {
const Icon = isActive ? MessageFilled : Message const Icon = isActive ? MessageFilled : Message
return ( return (
+5 -4
View File
@@ -423,12 +423,12 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
backgroundColor: t.palette.primary_500, backgroundColor: t.palette.primary_500,
width: 8, width: 8,
height: 8, height: 8,
right: -1, right: -2,
top: -3, top: -4,
}, },
leftNavMinimal && { leftNavMinimal && {
right: 6, right: 4,
top: 4, top: 2,
}, },
]} ]}
/> />
@@ -520,6 +520,7 @@ function ChatNavItem() {
<NavItem <NavItem
href="/messages" href="/messages"
count={numUnreadMessages.numUnread} count={numUnreadMessages.numUnread}
hasNew={numUnreadMessages.hasNew}
icon={ icon={
<Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} /> <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} />
} }