DMs inbox (#7778)
* improve error screen * add chat request prompt * mock up inbox * bigger button * use two-button layout * get inbox working somewhat * fix type errors * fetch both pages for badge * don't include read convos in preview * in-chat ui for non-accepted convos (part 1) * add chatstatusinfo * fix status info not disappearing * get chat status info working * change min item height * move files around * add updated sdk * improve badge behaviour * mock up mark all as read * update sdk to 0.14.4 * hide chat status info if initiating convo * fix unread count for deleted accounts * add toasts after rejection * add prompt to delete * adjust badge on desktop * requests -> chat requests * fix height flicker * add mark as read button to header * add mark all as read APIs * separate avatarstack into two components (#7845) * fix messages being hidden behind chatstatusinfo * show inbox preview on empty state * fix empty state again * Use new convo availability API (#7812) * [Inbox] Accept button on convo screen (#7795) * accept button on convo screen * fix types * fix type error * improve spacing * [DMs] Implement new log types (#7835) * optimise badge state * add read message log * add isLogAcceptConvo * mute/unmute convo logs * use setqueriesdata * always show label on button * optimistically update badge * change incorrect unread count change * Update src/screens/Messages/Inbox.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestListItem.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix race condition with accepting convo * fix back button on web * filter left convos from badge * update atproto to fix CI * Add accept override external to convo (#7891) * Add accept override external to convo * rm log --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -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 |
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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]})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -264,13 +264,7 @@ let UserAvatar = ({
|
|||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MediaInsetBorder
|
<MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
|
||||||
style={[
|
|
||||||
{
|
|
||||||
borderRadius: aviStyle.borderRadius,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{alert}
|
{alert}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user