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 {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {type LayoutChangeEvent, View} from 'react-native'
|
import {type LayoutChangeEvent, View} from 'react-native'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {moderateProfile} from '@atproto/api'
|
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
ScrollEdgeEffect,
|
ScrollEdgeEffect,
|
||||||
ScrollEdgeEffectProvider,
|
ScrollEdgeEffectProvider,
|
||||||
@@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types'
|
|||||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
||||||
|
import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
||||||
import {atoms as a, web} from '#/alf'
|
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 {ChatDisabled} from './components/ChatDisabled'
|
||||||
import {ChatEnded} from './components/ChatEnded'
|
import {ChatEnded} from './components/ChatEnded'
|
||||||
import {ChatLocked} from './components/ChatLocked'
|
import {ChatLocked} from './components/ChatLocked'
|
||||||
|
import {RequestStatus} from './components/RequestStatus'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -180,6 +182,12 @@ function InnerReady({
|
|||||||
const {needsEmailVerification} = useEmail()
|
const {needsEmailVerification} = useEmail()
|
||||||
const emailDialogControl = useEmailDialogControl()
|
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
|
* Must be non-reactive, otherwise the update to open the global dialog will
|
||||||
* cause a re-render loop.
|
* cause a re-render loop.
|
||||||
@@ -264,8 +272,25 @@ function InnerReady({
|
|||||||
{header}
|
{header}
|
||||||
</ScrollEdgeEffect>
|
</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 && (
|
{isActive && (
|
||||||
<MessagesList
|
<MessagesList
|
||||||
hasScrolled={hasScrolled}
|
hasScrolled={hasScrolled}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
precacheConvoQuery,
|
precacheConvoQuery,
|
||||||
useMarkAsReadMutation,
|
useMarkAsReadMutation,
|
||||||
} from '#/state/queries/messages/conversation'
|
} from '#/state/queries/messages/conversation'
|
||||||
|
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||||
@@ -214,15 +215,15 @@ function GroupChatItem({
|
|||||||
primaryProfileModeration={moderation}
|
primaryProfileModeration={moderation}
|
||||||
isBlockedAccount={false}
|
isBlockedAccount={false}
|
||||||
isDeletedAccount={false}
|
isDeletedAccount={false}
|
||||||
subtitle={
|
requestInfo={
|
||||||
convo.details.joinRequestCount
|
convo.details.unreadJoinRequestCount
|
||||||
? convo.details.joinRequestCount > 20
|
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
|
||||||
? l({
|
? l({
|
||||||
message: '20+ new join requests',
|
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||||
context:
|
context:
|
||||||
'Displayed when there are more than 20 requests to join a group chat',
|
'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',
|
one: '# new join request',
|
||||||
other: '# new join requests',
|
other: '# new join requests',
|
||||||
})
|
})
|
||||||
@@ -241,6 +242,7 @@ function BaseChatItem({
|
|||||||
avatar,
|
avatar,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
requestInfo,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
isDeletedAccount,
|
isDeletedAccount,
|
||||||
isBlockedAccount,
|
isBlockedAccount,
|
||||||
@@ -256,6 +258,7 @@ function BaseChatItem({
|
|||||||
avatar: React.ReactNode
|
avatar: React.ReactNode
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
requestInfo?: string
|
||||||
accessibilityHint: string
|
accessibilityHint: string
|
||||||
isDeletedAccount: boolean
|
isDeletedAccount: boolean
|
||||||
isBlockedAccount: boolean
|
isBlockedAccount: boolean
|
||||||
@@ -280,8 +283,10 @@ function BaseChatItem({
|
|||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const hasUnread =
|
const hasUnread =
|
||||||
convo.view.unreadCount > 0 &&
|
|
||||||
!isDeletedAccount &&
|
!isDeletedAccount &&
|
||||||
|
(convo.view.unreadCount > 0 ||
|
||||||
|
(convo.kind === 'group' &&
|
||||||
|
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
|
||||||
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
||||||
|
|
||||||
const blockInfo = useMemo(() => {
|
const blockInfo = useMemo(() => {
|
||||||
@@ -607,6 +612,19 @@ function BaseChatItem({
|
|||||||
|
|
||||||
{postAlerts}
|
{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]}>
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
{LastMessageIcon && (
|
{LastMessageIcon && (
|
||||||
<LastMessageIcon
|
<LastMessageIcon
|
||||||
@@ -623,8 +641,6 @@ function BaseChatItem({
|
|||||||
emoji
|
emoji
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={[
|
style={[
|
||||||
a.text_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||||
isDimStyle && t.atoms.text_contrast_medium,
|
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 {useAgent} from '#/state/session'
|
||||||
import {STALE} from '..'
|
import {STALE} from '..'
|
||||||
|
|
||||||
|
export const JOIN_REQUESTS_THRESHOLD = 20
|
||||||
|
|
||||||
const listJoinRequestsQueryKeyRoot = 'list-join-requests'
|
const listJoinRequestsQueryKeyRoot = 'list-join-requests'
|
||||||
|
|
||||||
export const createListJoinRequestsQueryKey = (args: {convoId: string}) =>
|
export const createListJoinRequestsQueryKey = (args: {convoId: string}) =>
|
||||||
@@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({
|
|||||||
queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}),
|
queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const {data} = await agent.chat.bsky.group.listJoinRequests(
|
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},
|
{headers: DM_SERVICE_HEADERS},
|
||||||
)
|
)
|
||||||
return data
|
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