Show a banner when there are incoming chat requests (#10498)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {type LayoutChangeEvent, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
ScrollEdgeEffect,
|
||||
ScrollEdgeEffectProvider,
|
||||
@@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
||||
import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read'
|
||||
import {useSession} from '#/state/session'
|
||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
@@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env'
|
||||
import {ChatDisabled} from './components/ChatDisabled'
|
||||
import {ChatEnded} from './components/ChatEnded'
|
||||
import {ChatLocked} from './components/ChatLocked'
|
||||
import {RequestStatus} from './components/RequestStatus'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -180,6 +182,12 @@ function InnerReady({
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const emailDialogControl = useEmailDialogControl()
|
||||
|
||||
const unreadRequestCount =
|
||||
convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind)
|
||||
? (convo.view.kind.unreadJoinRequestCount ?? 0)
|
||||
: 0
|
||||
const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id)
|
||||
|
||||
/**
|
||||
* Must be non-reactive, otherwise the update to open the global dialog will
|
||||
* cause a re-render loop.
|
||||
@@ -264,8 +272,25 @@ function InnerReady({
|
||||
{header}
|
||||
</ScrollEdgeEffect>
|
||||
) : (
|
||||
header
|
||||
<View onLayout={onHeaderLayout}>{header}</View>
|
||||
)}
|
||||
|
||||
{isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? (
|
||||
<RequestStatus
|
||||
top={headerHeight}
|
||||
count={unreadRequestCount}
|
||||
onDismiss={() => {
|
||||
markJoinRequestsRead()
|
||||
}}
|
||||
onPress={() => {
|
||||
markJoinRequestsRead()
|
||||
navigation.navigate('MessagesJoinRequests', {
|
||||
conversation: convo.view.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isActive && (
|
||||
<MessagesList
|
||||
hasScrolled={hasScrolled}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
precacheConvoQuery,
|
||||
useMarkAsReadMutation,
|
||||
} from '#/state/queries/messages/conversation'
|
||||
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
@@ -214,15 +215,15 @@ function GroupChatItem({
|
||||
primaryProfileModeration={moderation}
|
||||
isBlockedAccount={false}
|
||||
isDeletedAccount={false}
|
||||
subtitle={
|
||||
convo.details.joinRequestCount
|
||||
? convo.details.joinRequestCount > 20
|
||||
requestInfo={
|
||||
convo.details.unreadJoinRequestCount
|
||||
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
|
||||
? l({
|
||||
message: '20+ new join requests',
|
||||
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||
context:
|
||||
'Displayed when there are more than 20 requests to join a group chat',
|
||||
})
|
||||
: plural(convo.details.joinRequestCount, {
|
||||
: plural(convo.details.unreadJoinRequestCount, {
|
||||
one: '# new join request',
|
||||
other: '# new join requests',
|
||||
})
|
||||
@@ -241,6 +242,7 @@ function BaseChatItem({
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
requestInfo,
|
||||
accessibilityHint,
|
||||
isDeletedAccount,
|
||||
isBlockedAccount,
|
||||
@@ -256,6 +258,7 @@ function BaseChatItem({
|
||||
avatar: React.ReactNode
|
||||
title: string
|
||||
subtitle?: string
|
||||
requestInfo?: string
|
||||
accessibilityHint: string
|
||||
isDeletedAccount: boolean
|
||||
isBlockedAccount: boolean
|
||||
@@ -280,8 +283,10 @@ function BaseChatItem({
|
||||
const playHaptic = useHaptics()
|
||||
const queryClient = useQueryClient()
|
||||
const hasUnread =
|
||||
convo.view.unreadCount > 0 &&
|
||||
!isDeletedAccount &&
|
||||
(convo.view.unreadCount > 0 ||
|
||||
(convo.kind === 'group' &&
|
||||
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
|
||||
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
||||
|
||||
const blockInfo = useMemo(() => {
|
||||
@@ -607,6 +612,19 @@ function BaseChatItem({
|
||||
|
||||
{postAlerts}
|
||||
|
||||
{requestInfo && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
a.pb_2xs,
|
||||
]}
|
||||
emoji>
|
||||
{requestInfo}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
{LastMessageIcon && (
|
||||
<LastMessageIcon
|
||||
@@ -623,8 +641,6 @@ function BaseChatItem({
|
||||
emoji
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||
isDimStyle && t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {GlassView} from '#/components/GlassView'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS} from '#/env'
|
||||
|
||||
export function RequestStatus({
|
||||
top,
|
||||
count,
|
||||
onDismiss,
|
||||
onPress,
|
||||
}: {
|
||||
top: number
|
||||
count: number
|
||||
onDismiss: () => void
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200).delay(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_50,
|
||||
{
|
||||
top: top + (IS_LIQUID_GLASS ? tokens.space.sm : tokens.space.xl),
|
||||
left: tokens.space.xl,
|
||||
right: tokens.space.xl,
|
||||
},
|
||||
]}>
|
||||
<GlassView
|
||||
style={[a.flex_1, a.rounded_full, a.flex_row, a.align_center]}
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
tintColor={t.palette.primary_50}
|
||||
fallbackStyle={{
|
||||
backgroundColor: t.palette.primary_50,
|
||||
borderWidth: 1,
|
||||
borderColor: t.palette.primary_100,
|
||||
}}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`View incoming requests`}
|
||||
accessibilityHint={l`View incoming requests to join this group chat`}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.p_lg]}
|
||||
onPress={onPress}>
|
||||
<EnvelopeIcon size="md" fill={t.palette.primary_500} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.ml_sm,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
{count > JOIN_REQUESTS_THRESHOLD
|
||||
? l({
|
||||
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||
comment:
|
||||
'Displayed when the number of requests is greater than 20',
|
||||
})
|
||||
: plural(count, {
|
||||
one: '# new join request',
|
||||
other: '# new join requests',
|
||||
})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Close banner`}
|
||||
accessibilityHint={l`Close the incoming requests banner`}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={onDismiss}
|
||||
style={[a.p_lg]}>
|
||||
<CloseIcon size="md" fill={t.palette.primary_500} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {STALE} from '..'
|
||||
|
||||
export const JOIN_REQUESTS_THRESHOLD = 20
|
||||
|
||||
const listJoinRequestsQueryKeyRoot = 'list-join-requests'
|
||||
|
||||
export const createListJoinRequestsQueryKey = (args: {convoId: string}) =>
|
||||
@@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({
|
||||
queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const {data} = await agent.chat.bsky.group.listJoinRequests(
|
||||
{convoId: convoId!, cursor: pageParam, limit: 20},
|
||||
{convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {RQKEY as CONVO_KEY} from './conversation'
|
||||
import {
|
||||
type ConvoListQueryData,
|
||||
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
|
||||
} from './list-conversations'
|
||||
|
||||
export function useMarkJoinRequestsRead(convoId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!convoId) throw new Error('No convoId provided')
|
||||
await agent.chat.bsky.group.updateJoinRequestsRead(
|
||||
{convoId},
|
||||
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||
)
|
||||
},
|
||||
onMutate: () => {
|
||||
if (!convoId) return
|
||||
|
||||
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||
CONVO_KEY(convoId),
|
||||
)
|
||||
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView | undefined>(
|
||||
CONVO_KEY(convoId),
|
||||
old => {
|
||||
if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old
|
||||
return {
|
||||
...old,
|
||||
kind: {...old.kind, unreadJoinRequestCount: 0},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const prevListEntries = queryClient.getQueriesData<ConvoListQueryData>({
|
||||
queryKey: [CONVO_LIST_ROOT_KEY],
|
||||
})
|
||||
queryClient.setQueriesData<ConvoListQueryData>(
|
||||
{queryKey: [CONVO_LIST_ROOT_KEY]},
|
||||
old => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map(page => ({
|
||||
...page,
|
||||
convos: page.convos.map(convo => {
|
||||
if (
|
||||
convo.id !== convoId ||
|
||||
!ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||
) {
|
||||
return convo
|
||||
}
|
||||
return {
|
||||
...convo,
|
||||
kind: {...convo.kind, unreadJoinRequestCount: 0},
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {prevConvo, prevListEntries}
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
logger.error('Failed to mark join requests as read', {safeMessage: error})
|
||||
if (!convoId) return
|
||||
if (context?.prevConvo) {
|
||||
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
|
||||
}
|
||||
for (const [key, data] of context?.prevListEntries ?? []) {
|
||||
queryClient.setQueryData(key, data)
|
||||
}
|
||||
void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
|
||||
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user