Add UI for creating group clip clop invite links (#10318)

This commit is contained in:
DS Boyce
2026-04-23 13:13:12 -07:00
committed by Eric Bailey
parent d5a37f76ab
commit a53ec04c1a
22 changed files with 1301 additions and 348 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.782v-9.64c0-.546-.001-1.015.03-1.4.033-.397.105-.792.299-1.172a3 3 0 0 1 1.315-1.316c.38-.194.775-.266 1.172-.298.386-.032.856-.03 1.402-.03h3.815a1.004 1.004 0 1 1 0 2.008H7.218c-.58 0-.953 0-1.238.023-.273.023-.374.06-.424.086a1 1 0 0 0-.438.439c-.025.05-.063.15-.085.424a17 17 0 0 0-.025 1.236v9.64c0 .58.002.953.025 1.238.022.272.06.374.085.424.096.188.25.342.438.438.05.025.151.063.424.085.285.023.659.025 1.238.025h9.639c.579 0 .953-.002 1.238-.025.272-.022.373-.06.423-.085.189-.096.342-.25.438-.438.026-.05.063-.152.086-.424.023-.285.024-.659.024-1.238v-3.815a1.004 1.004 0 1 1 2.009 0v3.815c0 .546 0 1.016-.03 1.4-.033.398-.106.793-.299 1.173a3 3 0 0 1-1.316 1.317c-.38.193-.775.264-1.172.297-.385.031-.855.031-1.401.031h-9.64c-.545 0-1.015 0-1.4-.031-.398-.033-.793-.104-1.173-.297a3 3 0 0 1-1.315-1.317c-.194-.38-.266-.775-.299-1.172-.031-.385-.03-.855-.03-1.4m13.685-13.9a3.01 3.01 0 0 1 4.26 0l.173.172a3.013 3.013 0 0 1 0 4.26l-8.077 8.077a2 2 0 0 1-1.42.588H9.026c-.555 0-1.004-.45-1.004-1.004V12.38c0-.533.211-1.044.588-1.42zM10.03 13.971h1.592l8.077-8.076a1.004 1.004 0 0 0 0-1.42l-.173-.173a1.004 1.004 0 0 0-1.42 0L10.03 12.38z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

+6 -1
View File
@@ -556,7 +556,11 @@ export type Events = {
| 'FindContacts'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'chat:open': {
logContext:
@@ -564,6 +568,7 @@ export type Events = {
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
| 'ConvoSettings'
}
'groupchat:create': {
logContext: 'NewChatDialog'
+11 -1
View File
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
}
export function useItemContext() {
@@ -160,8 +161,11 @@ export function Item({
style,
type = 'checkbox',
label,
highlightRow,
...rest
}: ItemProps) {
const t = useTheme()
const {
values: selectedValues,
type: groupType,
@@ -207,6 +211,12 @@ export function Item({
[name, selected, disabled, hovered, pressed, focused, isInvalid],
)
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
: null
return (
<ItemContext.Provider value={state}>
<Pressable
@@ -232,7 +242,7 @@ export function Item({
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[a.flex_row, a.align_center, a.gap_sm, style]}>
style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
{typeof children === 'function' ? children(state) : children}
</Pressable>
</ItemContext.Provider>
+7
View File
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
})
/**
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
*/
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
})
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M3 16.782v-9.64c0-.546-.001-1.015.03-1.4.033-.397.105-.792.299-1.172a3 3 0 0 1 1.315-1.316c.38-.194.775-.266 1.172-.298.386-.032.856-.03 1.402-.03h3.815a1.004 1.004 0 1 1 0 2.008H7.218c-.58 0-.953 0-1.238.023-.273.023-.374.06-.424.086-.189.096-.342.25-.438.439-.025.05-.063.15-.085.424-.023.284-.025.658-.025 1.236v9.64c0 .58.002.953.025 1.238.022.272.06.374.085.424.096.188.25.342.438.438.05.025.151.063.424.085.285.023.659.025 1.238.025h9.639c.579 0 .953-.002 1.238-.025.272-.022.373-.06.423-.085.189-.096.342-.25.438-.438.026-.05.063-.152.086-.424.023-.285.024-.659.024-1.238v-3.815a1.004 1.004 0 1 1 2.009 0v3.815c0 .546 0 1.016-.03 1.4-.033.398-.106.793-.299 1.173a3 3 0 0 1-1.316 1.317c-.38.193-.775.264-1.172.297-.385.031-.855.031-1.401.031h-9.64c-.545 0-1.015 0-1.4-.031-.398-.033-.793-.104-1.173-.297a3 3 0 0 1-1.315-1.317c-.194-.38-.266-.775-.299-1.172-.031-.385-.03-.855-.03-1.4m13.685-13.9a3.01 3.01 0 0 1 4.26 0l.173.172a3.013 3.013 0 0 1 0 4.26l-8.077 8.077a2 2 0 0 1-1.42.588H9.026c-.555 0-1.004-.45-1.004-1.004V12.38c0-.533.211-1.044.588-1.42zM10.03 13.971h1.592l8.077-8.076a1.004 1.004 0 0 0 0-1.42l-.173-.173a1.004 1.004 0 0 0-1.42 0L10.03 12.38z',
})
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE'
/**
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
*/
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
})
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
})
+181 -212
View File
@@ -1,4 +1,4 @@
import {useMemo, useState} from 'react'
import {useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
@@ -8,14 +8,15 @@ import {StackActions, useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow} from '#/state/cache/types'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
@@ -50,7 +51,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
@@ -70,10 +71,17 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
const MEMBER_LIMIT = 50
const ROW_SPACING = 10
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
type Item =
| {
type: 'MEMBERS_AND_REQUESTS'
@@ -83,7 +91,7 @@ type Item =
}
| {
type: 'CHAT_MEMBER'
profile: Shadow<bsky.profile.AnyProfileView>
profile: bsky.profile.AnyProfileView
status: 'owner' | 'standard' | 'invited'
}
@@ -92,9 +100,6 @@ type Props = NativeStackScreenProps<
'MessagesConversationSettings'
>
/**
* TODO This is just layout for now.
*/
export function MessagesConversationSettingsScreen({route}: Props) {
const {gtTablet} = useBreakpoints()
@@ -138,6 +143,8 @@ function SettingsInner({convoId}: {convoId: string}) {
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
// TODO Need this data in order to populate this array. -dsb
const invites: string[] = []
const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
useListJoinRequestsQuery({
@@ -150,7 +157,7 @@ function SettingsInner({convoId}: {convoId: string}) {
0,
) ?? 0
const items = [
const items: Item[] = [
{
type: 'MEMBERS_AND_REQUESTS',
},
@@ -167,11 +174,18 @@ function SettingsInner({convoId}: {convoId: string}) {
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
return 0
})
.map(profile => ({
type: 'CHAT_MEMBER',
profile,
status: primaryMember?.did === profile.did ? 'owner' : 'standard',
})),
.map(
(profile): Item => ({
type: 'CHAT_MEMBER',
profile,
status:
primaryMember?.did === profile.did
? 'owner'
: invites.includes(profile.did)
? 'invited'
: 'standard',
}),
),
]
function renderItem({item}: {item: Item}) {
@@ -194,14 +208,14 @@ function SettingsInner({convoId}: {convoId: string}) {
/>
)
case 'CHAT_MEMBER':
return (
return convo ? (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
)
) : null
default:
return null
}
@@ -209,14 +223,12 @@ function SettingsInner({convoId}: {convoId: string}) {
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
@@ -228,7 +240,7 @@ function SettingsInner({convoId}: {convoId: string}) {
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convo ? (
convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
@@ -258,9 +270,9 @@ function MembersAndRequests({
return (
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
<View style={[a.flex_row, a.align_center]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>Members</Trans>{' '}
<Trans>Members</Trans>
</Text>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
@@ -275,6 +287,7 @@ function MembersAndRequests({
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{hasMoreRequests
? l({
@@ -311,6 +324,9 @@ function AddMembersLink({
const convoId = convo?.view.id
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
onSuccess: () => {
addMembersControl.close()
},
onError: e => {
logger.error('Failed to add group chat members', {message: e})
Toast.show(l`Failed to add members`, {type: 'error'})
@@ -393,7 +409,6 @@ function AddMembersLink({
title={l`Add members`}
onAddMembers={members => {
addGroupMembers({members})
addMembersControl.close()
}}
/>
</Dialog.Outer>
@@ -403,12 +418,12 @@ function AddMembersLink({
function Member({
convo,
profile,
profile: profileUnshadowed,
status,
isOwner,
}: {
convo: ConvoWithDetails | null
profile: Shadow<bsky.profile.AnyProfileView>
convo: ConvoWithDetails
profile: bsky.profile.AnyProfileView
status: 'owner' | 'standard' | 'invited'
isOwner: boolean
}) {
@@ -416,36 +431,32 @@ function Member({
const t = useTheme()
const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
[profile, moderationOpts],
)
const moderation = moderationOpts
? moderateProfile(profile, moderationOpts)
: undefined
if (!moderation) return null
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const isSelf = currentAccount?.did === profile.did
let statusBadge: React.ReactNode | null = null
if (currentAccount?.did === profile.did) {
switch (status) {
case 'owner':
statusBadge = <StatusBadge label={l`Admin`} />
break
if (isSelf) {
if (status === 'owner') {
statusBadge = <StatusBadge label={l`Admin`} />
}
} else {
statusBadge = convo ? (
<MemberMenu
convo={convo}
profile={profile}
displayName={displayName}
type={status}
isOwner={isOwner}
/>
@@ -454,42 +465,46 @@ function Member({
return (
<SubtleHoverWrapper>
<Pressable
accessibilityRole="button"
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.mx_xl,
{
marginTop: ROW_SPACING,
marginBottom: ROW_SPACING,
},
]}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<View style={[a.flex_row, a.align_center]}>
<PreviewableUserAvatar
profile={profile}
size={48}
moderation={moderation.ui('avatar')}
/>
<View style={[a.mx_sm]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`View ${displayName}s profile`}
accessibilityHint={l`Opens this members profile`}
style={[a.flex_1, a.flex_row, a.align_center]}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<PreviewableUserAvatar
profile={profile}
size={48}
moderation={moderation.ui('avatar')}
/>
<View style={[a.mx_sm]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<View>{statusBadge}</View>
</View>
</Pressable>
</Pressable>
<View>{statusBadge}</View>
</View>
</SubtleHoverWrapper>
)
}
@@ -497,72 +512,53 @@ function Member({
function StatusBadge({
label,
style,
pressableProps,
}: {
label: string
style?: StyleProp<ViewStyle>
pressableProps?: TriggerChildProps['props']
}) {
const t = useTheme()
return (
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
paddingTop: 3,
paddingBottom: 3,
paddingLeft: 6,
paddingRight: 6,
},
style,
]}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
</View>
)
}
const badgeStyle = [
a.rounded_xs,
t.atoms.bg_contrast_50,
{
paddingTop: 3,
paddingBottom: 3,
paddingLeft: 6,
paddingRight: 6,
},
style,
]
function StatusButton({
label,
style,
...rest
}: {
label: string
style?: StyleProp<ViewStyle>
} & TriggerChildProps['props']) {
const t = useTheme()
return (
<Pressable
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
paddingTop: 3,
paddingBottom: 3,
paddingLeft: 6,
paddingRight: 6,
},
style,
]}
{...rest}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
</Pressable>
const labelText = (
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
)
if (pressableProps) {
return (
<Pressable style={badgeStyle} {...pressableProps}>
{labelText}
</Pressable>
)
}
return <View style={badgeStyle}>{labelText}</View>
}
function MemberMenu({
convo,
profile,
displayName,
type,
isOwner,
}: {
convo: ConvoWithDetails
profile: Shadow<bsky.profile.AnyProfileView>
type: 'owner' | 'standard' | 'invited'
displayName: string
isOwner: boolean
}) {
const navigation = useNavigation<NavigationProp>()
@@ -574,10 +570,13 @@ function MemberMenu({
const blockMemberPrompt = Prompt.usePromptControl()
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const [menuDidOpen, setMenuDidOpen] = useState(false)
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
enabled: menuDidOpen,
})
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
ax.metric('chat:open', {logContext: 'ProfileHeader'})
ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
},
onError: () => {
@@ -599,12 +598,12 @@ function MemberMenu({
}
if (convoAvailability.convo) {
ax.metric('chat:open', {logContext: 'ProfileHeader'})
ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id,
})
} else {
ax.metric('chat:create', {logContext: 'ProfileHeader'})
ax.metric('chat:create', {logContext: 'ConvoSettings'})
initiateConvo([profile.did])
}
}
@@ -625,7 +624,7 @@ function MemberMenu({
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
ax.logger.error('Failed to unblock account', {message: e})
logger.error('Failed to unblock account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
@@ -638,7 +637,7 @@ function MemberMenu({
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
ax.logger.error('Failed to block account', {message: e})
logger.error('Failed to block account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
@@ -647,32 +646,22 @@ function MemberMenu({
}
}
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
[profile, moderationOpts],
)
if (!moderation) return null
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
return (
<>
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({props, state, control: menuControl}) =>
type === 'owner' || type === 'invited' ? (
<StatusButton
{...props}
{({props, state, control: menuControl}) => {
const triggerProps = {
...props,
onPress: () => {
setMenuDidOpen(true)
props.onPress()
},
}
return type === 'owner' || type === 'invited' ? (
<StatusBadge
label={type === 'owner' ? l`Admin` : l`Invited`}
pressableProps={triggerProps}
style={[
state.hovered || state.pressed || menuControl.isOpen
? {
@@ -683,7 +672,7 @@ function MemberMenu({
/>
) : (
<Pressable
{...props}
{...triggerProps}
style={[
a.rounded_full,
a.p_sm,
@@ -699,7 +688,7 @@ function MemberMenu({
/>
</Pressable>
)
}
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
@@ -738,7 +727,7 @@ function MemberMenu({
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
) : null}
{isOwner ? (
{isOwner && type !== 'invited' ? (
<Menu.Item
label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}>
@@ -751,6 +740,7 @@ function MemberMenu({
{isOwner && type === 'invited' ? (
<Menu.Item
label={l`Uninvite ${displayName} from this group chat`}
// TODO Need to wire up the uninvite flow. -dsb
onPress={() => {}}>
<Menu.ItemText>
<Trans>Uninvite</Trans>
@@ -773,7 +763,7 @@ function SettingsHeader({
convo,
isOwner,
}: {
convo: ConvoWithDetails
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
}) {
const t = useTheme()
@@ -781,11 +771,20 @@ function SettingsHeader({
const navigation = useNavigation<NavigationProp>()
const groupName = convo.kind === 'group' ? convo.details.name : ''
const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName)
const [isLocked, setIsLocked] = useState(false)
// TODO Enable this once the feature is working end-to-end. -dsb
// const {joinLink} = convo.details
const isJoinLinkEnabled = false
// const isJoinLinkEnabled =
// isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
// TODO Enable this once the feature is working end-to-end. -dsb
const isReportLinkEnabled = false
const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
onError: e => {
setNewGroupName(groupName)
@@ -813,7 +812,7 @@ function SettingsHeader({
})
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onMutate: () => {
onSuccess: () => {
navigation.dispatch(StackActions.pop(2))
},
onError: e => {
@@ -824,8 +823,8 @@ function SettingsHeader({
},
})
const inviteLinkDialog = Dialog.useDialogControl()
const editNamePrompt = Prompt.usePromptControl()
const inviteLinkPrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl()
@@ -833,31 +832,16 @@ function SettingsHeader({
muteConvo({mute: !convo.view.muted})
}
const handleLeaveChat = () => {
leaveChatPrompt.open()
}
// TODO Need to implement this when the backend is ready. -dsb
const handleReportChat = () => {}
const handlePromptName = () => {
setNewGroupName(groupName)
editNamePrompt.open()
}
const handleEditName = () => {
editGroupName({name: newGroupName})
editNamePrompt.close()
}
const handlePromptInviteLink = () => {
inviteLinkPrompt.open()
}
const handleConfirmInviteLink = () => {
inviteLinkPrompt.close()
}
const handlePromptLock = () => {
lockChatPrompt.open()
}
const handleConfirmLock = () => {
@@ -868,6 +852,9 @@ function SettingsHeader({
setIsLocked(false)
}
// TODO The creation date doesn't exist yet. -dsb
const createdAt = new Date()
return (
<>
<View
@@ -893,7 +880,7 @@ function SettingsHeader({
a.px_xl,
t.atoms.text_contrast_high,
]}>
Created April 2, 2026
<Trans>Created {dateFormatter.format(createdAt)}</Trans>
</Text>
<View
style={[
@@ -922,12 +909,18 @@ function SettingsHeader({
onPress={handlePromptName}
/>
) : null}
<SettingsButton
icon={ChainLinkIcon}
label={l`Create an invite link for this group chat`}
text={l`Invite link`}
onPress={handlePromptInviteLink}
/>
{isJoinLinkEnabled ? (
<SettingsButton
icon={ChainLinkIcon}
label={
isOwner
? l`Create or modify an invite link for this group chat`
: l`View the invite link for this group chat`
}
text={l`Invite link`}
onPress={inviteLinkDialog.open}
/>
) : null}
{isOwner ? (
<SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'}
@@ -936,10 +929,10 @@ function SettingsHeader({
isLocked ? l`Unlock this group chat` : l`Lock this group chat`
}
text={isLocked ? l`Locked` : l`Lock`}
onPress={isLocked ? handleUnlock : handlePromptLock}
onPress={isLocked ? handleUnlock : lockChatPrompt.open}
/>
) : null}
{isOwner ? null : (
{isOwner ? null : isReportLinkEnabled ? (
<SettingsButton
color="secondary"
icon={FlagIcon}
@@ -947,14 +940,14 @@ function SettingsHeader({
text={l`Report`}
onPress={handleReportChat}
/>
)}
) : null}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={handleLeaveChat}
onPress={leaveChatPrompt.open}
/>
)}
</View>
@@ -965,9 +958,10 @@ function SettingsHeader({
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
<InviteLinkPrompt
control={inviteLinkPrompt}
onConfirm={handleConfirmInviteLink}
<InviteLinkDialog
convo={convo}
control={inviteLinkDialog}
isOwner={isOwner}
/>
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
<LeaveChatPrompt
@@ -999,7 +993,7 @@ function SettingsHeaderPlaceholder() {
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans></Trans>
</Text>
<View
style={[
@@ -1120,11 +1114,7 @@ function EditNamePrompt({
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
cta={l`Save`}
shouldCloseOnPress={false}
onPress={onConfirm}
/>
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</>
@@ -1132,27 +1122,6 @@ function EditNamePrompt({
)
}
function InviteLinkPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Invite link`}
description={l`An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone.`}
confirmButtonCta={l`Get started`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
function LockChatPrompt({
control,
onConfirm,
@@ -0,0 +1,90 @@
import {useCallback, useEffect, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import Animated, {
FadeOutUp,
useReducedMotion,
ZoomIn,
} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {Text} from '#/components/Typography'
export function CopyTextButton({
children,
disabled,
style,
value,
onPress: onPressProp,
...props
}: ButtonProps & {value: string}) {
const t = useTheme()
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const isReducedMotionEnabled = useReducedMotion()
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(
() => setHasBeenCopied(false),
isReducedMotionEnabled ? 2000 : 100,
)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied, isReducedMotionEnabled])
const onPress = useCallback(
(evt: GestureResponderEvent) => {
void Clipboard.setStringAsync(value)
setHasBeenCopied(true)
onPressProp?.(evt)
},
[value, onPressProp],
)
return (
<View style={[a.relative]}>
{hasBeenCopied && (
<Animated.View
entering={ZoomIn.duration(100)}
exiting={FadeOutUp.duration(2000)}
style={[
a.absolute,
{bottom: '100%', right: 0},
a.justify_center,
a.gap_sm,
a.z_10,
a.pb_sm,
]}
pointerEvents="none">
<Text
style={[
a.font_medium,
a.text_right,
a.text_sm,
t.atoms.text_contrast_high,
]}>
<Trans>Copied!</Trans>
</Text>
</Animated.View>
)}
<Button
color="secondary"
disabled={disabled}
style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]}
onPress={onPress}
{...props}>
{context => (
<View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}>
{typeof children === 'function' ? children(context) : children}
{disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />}
</View>
)}
</Button>
</View>
)
}
@@ -0,0 +1,59 @@
import {View} from 'react-native'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import {Text} from '#/components/Typography'
export function EditTextButton({
children,
style,
onPress,
...props
}: ButtonProps & {value: string}) {
const t = useTheme()
return (
<View style={[a.relative]}>
<Button
color="secondary"
style={[
a.flex_1,
a.justify_between,
a.rounded_full,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
style,
]}
onPress={onPress}
{...props}>
{context => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.py_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
]}>
<Text
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
<Trans>Edit</Trans>
</Text>
</View>
</View>
)}
</Button>
</View>
)
}
@@ -0,0 +1,462 @@
import {useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {shareUrl} from '#/lib/sharing'
import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
import {atoms as a, useTheme, web} from '#/alf'
import {
Button,
ButtonIcon,
ButtonText,
StackedButton,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {CopyTextButton} from './CopyTextButton'
import {EditTextButton} from './EditTextButton'
enum Step {
INFO,
GENERATE,
MANAGE,
}
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
export function InviteLinkDialog({
convo,
control,
isOwner,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
control: Dialog.DialogOuterProps['control']
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const ownerName = createSanitizedDisplayName(convo.primaryMember)
const {joinLink} = convo.details
const enabledStatus = joinLink?.enabledStatus
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
const defaultWhoCanJoin = joinLink
? [
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
]
: ['anyone']
const [step, setStep] = useState<Step>(defaultStep)
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
const {openComposer} = useOpenComposer()
const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink(
convo.view.id,
{
onSuccess: () => {
setStep(Step.MANAGE)
},
onError: () => {
Toast.show(l`Failed to create invite link`, {
type: 'error',
})
},
},
)
const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink(
convo.view.id,
{
onSuccess: () => {
setStep(Step.MANAGE)
},
onError: () => {
Toast.show(l`Failed to edit invite link`, {
type: 'error',
})
},
},
)
const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink(
convo.view.id,
{
onError: () => {
Toast.show(l`Failed to disable invite link`, {
type: 'error',
})
},
},
)
const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink(
convo.view.id,
{
onError: () => {
Toast.show(l`Failed to enable invite link`, {
type: 'error',
})
},
},
)
const isSaving = isCreating || isEditing
const whoCanJoinOptions = [
{
name: 'anyone',
owner: l`Anyone can join instantly`,
member: l`Anyone can join instantly`,
},
{
name: 'anyone:requireApproval',
owner: l`Anyone can request to join`,
member: l`Anyone can request to join`,
},
{
name: 'followedByOwner',
owner: l`People I follow can join instantly`,
member: l`People ${ownerName} follows can join instantly`,
},
{
name: 'followedByOwner:requireApproval',
owner: l`People I follow can request to join`,
member: l`People ${ownerName} follows can request to join`,
},
]
let content: React.ReactNode = null
let header: string | null = null
switch (step) {
case Step.INFO:
header = l`Invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>
An invite link lets people join this group chat without being
added directly. You control who can use the link and whether
they need your approval. You can disable the link at any time.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
</Trans>
</Text>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Get started`}
color="primary"
size="large"
onPress={() => {
setStep(Step.GENERATE)
}}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
</View>
</>
)
break
case Step.GENERATE:
header = l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
<View style={[a.mt_lg]}>
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
highlightRow={true}
label={isOwner ? option.owner : option.member}
name={option.name}
style={[a.flex_1]}>
{({selected}) => (
<TargetOption
label={isOwner ? option.owner : option.member}
selected={selected}
/>
)}
</Toggle.Item>
))}
</View>
</Toggle.Group>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Generate invite link`}
color="primary"
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
editJoinLink({
joinRule,
requireApproval,
})
} else {
createJoinLink({
joinRule,
requireApproval,
})
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
</Button>
</View>
</>
)
break
case Step.MANAGE: {
const hasJoinLinkCode = joinLink && joinLink.code !== ''
const joinLinkURI = hasJoinLinkCode
? `https://bsky.app/chat/${joinLink.code}`
: 'https://bsky.app/chat'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOption = whoCanJoinOptions.find(
o => o.name === whoCanJoin[0],
)
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
header =
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
content = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !hasJoinLinkCode}
label={l`Invite link`}
value={joinLinkURI}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
]}>
{joinLinkURI}
</Text>
</CopyTextButton>
{createdAt ? (
<Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>
Created {timeFormatter.format(createdAt)}{' '}
{dateFormatter.format(createdAt)}
</Trans>
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
t.atoms.text,
{maxWidth: '80%'},
]}>
{ownerValue}
</Text>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
label={l`Disable`}
icon={isDisabling ? Loader : ChainLinkBrokenIcon}
color="negative_subtle"
style={[a.flex_1, a.rounded_full]}
disabled={isDisabling}
onPress={() => {
disableJoinLink()
}}>
<Trans>Disable</Trans>
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
control.close(() => {
openComposer({
text: joinLinkURI,
logContext: 'Other',
})
})
}}>
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
void shareUrl(joinLinkURI)
}}>
<Trans>Share</Trans>
</StackedButton>
</View>
) : (
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Re-enable invite link`}
color="primary"
size="large"
disabled={isEnabling}
onPress={() => {
enableJoinLink()
}}>
<ButtonText>
<Trans>Re-enable link</Trans>
</ButtonText>
{isEnabling && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={l`Generate new invite link`}
color="secondary"
size="large"
onPress={() => setStep(Step.GENERATE)}>
<ButtonText>
<Trans>Generate new link</Trans>
</ButtonText>
</Button>
</View>
)}
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
header = l`Invite link`
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Close`}
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</>
)
}
return (
<Dialog.Outer
control={control}
onClose={() => {
setStep(defaultStep)
setWhoCanJoin(defaultWhoCanJoin)
}}>
<Dialog.Handle />
<Dialog.ScrollableInner
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
</View>
<Dialog.Close />
</View>
}
label={l`Group chat invite link dialog`}
style={web({maxWidth: 400})}>
{content}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function TargetOption({label, selected}: {label: string; selected: boolean}) {
const t = useTheme()
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText
style={[
a.font_normal,
a.flex_1,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{label}
</Toggle.LabelText>
</View>
)
}
@@ -10,16 +10,19 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {parseConvoView} from '#/components/dms/util'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {InviteLinkDialog} from './InviteLinkDialog'
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession()
@@ -30,12 +33,21 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
Toast.show(l`Failed to add members`, {type: 'error'})
},
})
const convo = convoState.convo
? parseConvoView(convoState.convo, currentAccount?.did)
: null
const groupConvo = convo?.kind === 'group' ? convo : null
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
// TODO Get this from @api/atproto - dsb
// TODO Get this from @api/atproto -dsb
const isLinkEnabled = false
const groupName = convoState.getGroupInfo?.()?.name
@@ -113,12 +125,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</ButtonText>
</Button>
) : null}
{isOwner || isLinkEnabled ? (
{isJoinLinkEnabled ? (
<Button
color="secondary"
size="small"
label={l`Click here to view or create an invite link for this group chat`}
onPress={() => {}}>
label={
isOwner
? l`Click here to create or manage an invite link for this group chat`
: l`Click here to view the invite link for this group chat`
}
onPress={inviteLinkControl.open}>
<ButtonIcon icon={ChainLinkIcon} />
<ButtonText>
<Trans>Invite link</Trans>
@@ -128,6 +144,13 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</View>
) : null}
</View>
{groupConvo ? (
<InviteLinkDialog
isOwner={isOwner}
convo={groupConvo}
control={inviteLinkControl}
/>
) : null}
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
+21
View File
@@ -4,6 +4,7 @@ import {
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
@@ -146,6 +147,7 @@ export class Convo {
this.getPrimaryMember = this.getPrimaryMember.bind(this)
this.updateGroupName = this.updateGroupName.bind(this)
this.updateGroupMembers = this.updateGroupMembers.bind(this)
this.updateJoinLink = this.updateJoinLink.bind(this)
}
private commit() {
@@ -972,6 +974,25 @@ export class Convo {
this.commit()
}
updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
this.convo = {
...this.convo,
kind: {
...this.convo.kind,
joinLink,
},
}
}
this.commit()
}
async processPendingMessages() {
logger.debug(
`processing messages (${this.pendingMessages.size} remaining)`,
+7 -3
View File
@@ -143,10 +143,14 @@ export function ConvoProvider({
data &&
convo.convo &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) &&
data.kind.name !== convo.convo.kind.name
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind)
) {
convo.updateGroupName(data.kind.name)
if (data.kind.name !== convo.convo.kind.name) {
convo.updateGroupName(data.kind.name)
}
if (data.kind.joinLink !== convo.convo.kind.joinLink) {
convo.updateJoinLink(data.kind.joinLink)
}
}
if (
data &&
@@ -0,0 +1,84 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupCreateJoinLink,
type ChatBskyGroupDefs,
} 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 {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useCreateJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({
joinRule,
requireApproval,
}: {
joinRule: ChatBskyGroupDefs.JoinRule
requireApproval: boolean
}) => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.createJoinLink(
{convoId, joinRule, requireApproval},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: ({joinRule, requireApproval}) => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {
...prev.kind,
joinLink: {
$type: 'chat.bsky.group.defs#joinLinkView',
code: '',
enabledStatus: 'enabled',
joinRule,
requireApproval,
createdAt: new Date().toISOString(),
},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -0,0 +1,72 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupDisableJoinLink,
} 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 {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useDisableJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.disableJoinLink(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: () => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -1,19 +1,13 @@
import {
ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
type ChatBskyGroupEditGroup,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} 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 {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEditGroupChatName(
convoId: string | undefined,
@@ -39,68 +33,21 @@ export function useEditGroupChatName(
},
onMutate: ({name: groupName}) => {
if (!convoId) return
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
return {
...prev,
kind: {
...prev.kind,
name: groupName,
},
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
pages: prev.pages.map(page => ({
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
return {
...convo,
kind: {
...convo.kind,
name: groupName,
},
}
}),
})),
kind: {...prev.kind, name: groupName},
}
})
return {prevConvo, prevListEntries}
},
onSuccess: data => {
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (context?.prevConvo && convoId) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
@@ -0,0 +1,79 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupDefs,
type ChatBskyGroupEditJoinLink,
} 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 {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEditJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({
joinRule,
requireApproval,
}: {
joinRule: ChatBskyGroupDefs.JoinRule
requireApproval: boolean
}) => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.editJoinLink(
{convoId, joinRule, requireApproval},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: ({joinRule, requireApproval}) => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, joinRule, requireApproval},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -0,0 +1,69 @@
import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} 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 {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEnableJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.enableJoinLink(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: () => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -7,7 +7,10 @@ import {STALE} from '..'
const RQKEY_ROOT = 'convo-availability'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useGetConvoAvailabilityQuery(did: string) {
export function useGetConvoAvailabilityQuery(
did: string,
{enabled = true}: {enabled?: boolean} = {},
) {
const agent = useAgent()
return useQuery({
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
return data
},
staleTime: STALE.INFINITY,
enabled,
})
}
@@ -71,7 +71,7 @@ export function useLeaveConvo(
return {prevPages}
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data)
},
onError: (error, _, context) => {
@@ -89,7 +89,7 @@ export function useLeaveConvo(
}
},
)
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onError?.(error)
},
})
+12 -60
View File
@@ -1,18 +1,12 @@
import {
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
type ChatBskyConvoMuteConvo,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {type ChatBskyConvoMuteConvo} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useMuteConvo(
convoId: string | undefined,
@@ -46,59 +40,17 @@ export function useMuteConvo(
},
onMutate: ({mute}) => {
if (!convoId) return
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
return {
...prev,
muted: mute,
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return {
...prev,
pages: prev.pages.map(page => ({
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
return {
...convo,
muted: mute,
}
}),
})),
}
})
return {prevConvo, prevListEntries}
return updateConvoOptimistic(queryClient, convoId, prev => ({
...prev,
muted: mute,
}))
},
onSuccess: data => {
onSuccess?.(data)
},
onError: (e, _variables, context) => {
if (context?.prevConvo && convoId) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
@@ -0,0 +1,87 @@
import {
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
type QueryKey,
} from '@tanstack/react-query'
import {RQKEY as CONVO_KEY} from '../conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations'
type ConvoUpdater = (
prev: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView | undefined
export type ConvoCacheSnapshot = {
prevConvo: ChatBskyConvoDefs.ConvoView | undefined
prevListEntries: Array<
[QueryKey, InfiniteData<ChatBskyConvoListConvos.OutputSchema> | undefined]
>
}
/**
* Writes an optimistic update to a convo across both the single-convo and
* convo-list caches. The updater receives the current ConvoView and returns
* the next one - return undefined to bail out (e.g. when the convo's kind
* doesn't match what the mutation requires). Returns a snapshot that can be
* passed to `rollbackConvoOptimistic`.
*/
export function updateConvoOptimistic(
queryClient: QueryClient,
convoId: string,
updater: ConvoUpdater,
): ConvoCacheSnapshot {
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
const next = updater(prev)
return next ?? prev
},
)
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return {
...prev,
pages: prev.pages.map(page => ({
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
const next = updater(convo)
return next ?? convo
}),
})),
}
})
return {prevConvo, prevListEntries}
}
/**
* Restores the caches to the state captured by `updateConvoOptimistic`.
*/
export function rollbackConvoOptimistic(
queryClient: QueryClient,
convoId: string,
snapshot: ConvoCacheSnapshot,
) {
if (snapshot.prevConvo) {
queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo)
}
for (const [key, data] of snapshot.prevListEntries) {
queryClient.setQueryData(key, data)
}
}