From a53ec04c1a7ed20ca67a189fc1f9e2dabacf9c4a Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:13:12 -0700
Subject: [PATCH] Add UI for creating group clip clop invite links (#10318)
---
.../icons/editBig_stroke2_corner2_rounded.svg | 1 +
...reBehindSquare_stroke2_corner0_rounded.svg | 1 +
src/analytics/metrics/types.ts | 7 +-
src/components/forms/Toggle/index.tsx | 12 +-
src/components/icons/EditBig.tsx | 7 +
src/components/icons/SquareBehindSquare4.tsx | 7 +
src/screens/Messages/ConversationSettings.tsx | 393 +++++++--------
.../Messages/components/CopyTextButton.tsx | 90 ++++
.../Messages/components/EditTextButton.tsx | 59 +++
.../Messages/components/InviteLinkDialog.tsx | 462 ++++++++++++++++++
.../components/MessagesListInfoPanel.tsx | 31 +-
src/state/messages/convo/agent.ts | 21 +
src/state/messages/convo/index.tsx | 10 +-
.../queries/messages/create-join-link.ts | 84 ++++
.../queries/messages/disable-join-link.ts | 72 +++
.../queries/messages/edit-group-chat-name.ts | 75 +--
src/state/queries/messages/edit-join-link.ts | 79 +++
.../queries/messages/enable-join-link.ts | 69 +++
.../messages/get-convo-availability.ts | 6 +-
.../queries/messages/leave-conversation.ts | 4 +-
.../queries/messages/mute-conversation.ts | 72 +--
.../queries/messages/utils/convo-cache.ts | 87 ++++
22 files changed, 1301 insertions(+), 348 deletions(-)
create mode 100644 assets/icons/editBig_stroke2_corner2_rounded.svg
create mode 100644 assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
create mode 100644 src/screens/Messages/components/CopyTextButton.tsx
create mode 100644 src/screens/Messages/components/EditTextButton.tsx
create mode 100644 src/screens/Messages/components/InviteLinkDialog.tsx
create mode 100644 src/state/queries/messages/create-join-link.ts
create mode 100644 src/state/queries/messages/disable-join-link.ts
create mode 100644 src/state/queries/messages/edit-join-link.ts
create mode 100644 src/state/queries/messages/enable-join-link.ts
create mode 100644 src/state/queries/messages/utils/convo-cache.ts
diff --git a/assets/icons/editBig_stroke2_corner2_rounded.svg b/assets/icons/editBig_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..d6ba5cba70
--- /dev/null
+++ b/assets/icons/editBig_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..69c48eead1
--- /dev/null
+++ b/assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index df0d08c52b..40da5021c5 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -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'
diff --git a/src/components/forms/Toggle/index.tsx b/src/components/forms/Toggle/index.tsx
index d25028ff9f..76ab62df66 100644
--- a/src/components/forms/Toggle/index.tsx
+++ b/src/components/forms/Toggle/index.tsx
@@ -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 (
+ style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
{typeof children === 'function' ? children(state) : children}
diff --git a/src/components/icons/EditBig.tsx b/src/components/icons/EditBig.tsx
index 3065f99a74..35c59687c8 100644
--- a/src/components/icons/EditBig.tsx
+++ b/src/components/icons/EditBig.tsx
@@ -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',
+})
diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx
index 425599cbce..7f693ec475 100644
--- a/src/components/icons/SquareBehindSquare4.tsx
+++ b/src/components/icons/SquareBehindSquare4.tsx
@@ -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',
+})
diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx
index 04794611cc..0e1a01a639 100644
--- a/src/screens/Messages/ConversationSettings.tsx
+++ b/src/screens/Messages/ConversationSettings.tsx
@@ -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
+ 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 ? (
- )
+ ) : null
default:
return null
}
@@ -209,14 +223,12 @@ function SettingsInner({convoId}: {convoId: string}) {
if (convoState.status === ConvoStatus.Error) {
return (
- <>
- convoState.error.retry()}
- sideBorders={false}
- />
- >
+ convoState.error.retry()}
+ sideBorders={false}
+ />
)
}
@@ -228,7 +240,7 @@ function SettingsInner({convoId}: {convoId: string}) {
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
- convo ? (
+ convo?.kind === 'group' ? (
) : (
@@ -258,9 +270,9 @@ function MembersAndRequests({
return (
-
+
- Members{' '}
+ Members
@@ -275,6 +287,7 @@ function MembersAndRequests({
{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()
}}
/>
@@ -403,12 +418,12 @@ function AddMembersLink({
function Member({
convo,
- profile,
+ profile: profileUnshadowed,
status,
isOwner,
}: {
- convo: ConvoWithDetails | null
- profile: Shadow
+ 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 =
- break
+ if (isSelf) {
+ if (status === 'owner') {
+ statusBadge =
}
} else {
statusBadge = convo ? (
@@ -454,42 +465,46 @@ function Member({
return (
- {
- navigation.navigate('Profile', {name: profile.did})
- }}>
-
-
-
-
-
- {displayName}
-
-
- {sanitizeHandle(profile.handle, '@')}
-
-
+ ]}>
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+
+
+ {displayName}
+
+
+ {sanitizeHandle(profile.handle, '@')}
+
- {statusBadge}
-
-
+
+ {statusBadge}
+
)
}
@@ -497,72 +512,53 @@ function Member({
function StatusBadge({
label,
style,
+ pressableProps,
}: {
label: string
style?: StyleProp
+ pressableProps?: TriggerChildProps['props']
}) {
const t = useTheme()
- return (
-
-
- {label}
-
-
- )
-}
+ 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
-} & TriggerChildProps['props']) {
- const t = useTheme()
-
- return (
-
-
- {label}
-
-
+ const labelText = (
+
+ {label}
+
)
+
+ if (pressableProps) {
+ return (
+
+ {labelText}
+
+ )
+ }
+ return {labelText}
}
function MemberMenu({
convo,
profile,
+ displayName,
type,
isOwner,
}: {
convo: ConvoWithDetails
profile: Shadow
type: 'owner' | 'standard' | 'invited'
+ displayName: string
isOwner: boolean
}) {
const navigation = useNavigation()
@@ -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 (
<>
- {({props, state, control: menuControl}) =>
- type === 'owner' || type === 'invited' ? (
- {
+ const triggerProps = {
+ ...props,
+ onPress: () => {
+ setMenuDidOpen(true)
+ props.onPress()
+ },
+ }
+ return type === 'owner' || type === 'invited' ? (
+
) : (
)
- }
+ }}
@@ -738,7 +727,7 @@ function MemberMenu({
) : null}
- {isOwner ? (
+ {isOwner && type !== 'invited' ? (
removeMembers({members: [profile.did]})}>
@@ -751,6 +740,7 @@ function MemberMenu({
{isOwner && type === 'invited' ? (
{}}>
Uninvite
@@ -773,7 +763,7 @@ function SettingsHeader({
convo,
isOwner,
}: {
- convo: ConvoWithDetails
+ convo: Extract
isOwner: boolean
}) {
const t = useTheme()
@@ -781,11 +771,20 @@ function SettingsHeader({
const navigation = useNavigation()
- 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 (
<>
- Created April 2, 2026
+ Created {dateFormatter.format(createdAt)}
) : null}
-
+ {isJoinLinkEnabled ? (
+
+ ) : null}
{isOwner ? (
) : null}
- {isOwner ? null : (
+ {isOwner ? null : isReportLinkEnabled ? (
- )}
+ ) : null}
{isOwner ? null : (
)}
@@ -965,9 +958,10 @@ function SettingsHeader({
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
-
- …
+ …
-
+
>
@@ -1132,27 +1122,6 @@ function EditNamePrompt({
)
}
-function InviteLinkPrompt({
- control,
- onConfirm,
-}: {
- control: Dialog.DialogOuterProps['control']
- onConfirm: () => void
-}) {
- const {t: l} = useLingui()
-
- return (
-
- )
-}
-
function LockChatPrompt({
control,
onConfirm,
diff --git a/src/screens/Messages/components/CopyTextButton.tsx b/src/screens/Messages/components/CopyTextButton.tsx
new file mode 100644
index 0000000000..29c815f2d6
--- /dev/null
+++ b/src/screens/Messages/components/CopyTextButton.tsx
@@ -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 (
+
+ {hasBeenCopied && (
+
+
+ Copied!
+
+
+ )}
+
+
+ )
+}
diff --git a/src/screens/Messages/components/EditTextButton.tsx b/src/screens/Messages/components/EditTextButton.tsx
new file mode 100644
index 0000000000..8a1ccc620a
--- /dev/null
+++ b/src/screens/Messages/components/EditTextButton.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx
new file mode 100644
index 0000000000..843f6a833e
--- /dev/null
+++ b/src/screens/Messages/components/InviteLinkDialog.tsx
@@ -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
+ 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(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 = (
+ <>
+
+
+
+ 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.
+
+
+
+
+
+
+ >
+ )
+ break
+ case Step.GENERATE:
+ header = l`Generate invite link`
+ content = (
+ <>
+
+
+ Choose who can join this group chat and how.
+
+
+
+
+
+ {whoCanJoinOptions.map(option => (
+
+ {({selected}) => (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ >
+ )
+ 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 = (
+ <>
+
+
+
+ {joinLinkURI}
+
+
+ {createdAt ? (
+
+
+ Created {timeFormatter.format(createdAt)}{' '}
+ {dateFormatter.format(createdAt)}
+
+
+ ) : null}
+
+ {enabledStatus === 'enabled' ? (
+
+ {isOwner ? (
+ setStep(Step.GENERATE)}>
+
+ {ownerValue}
+
+
+ ) : (
+ {memberValue}
+ )}
+
+ ) : null}
+ {enabledStatus === 'enabled' ? (
+
+ {isOwner ? (
+ {
+ disableJoinLink()
+ }}>
+ Disable
+
+ ) : null}
+ {
+ control.close(() => {
+ openComposer({
+ text: joinLinkURI,
+ logContext: 'Other',
+ })
+ })
+ }}>
+ Post link
+
+ {
+ void shareUrl(joinLinkURI)
+ }}>
+ Share
+
+
+ ) : (
+
+
+
+
+ )}
+ >
+ )
+ break
+ }
+ }
+
+ if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
+ header = l`Invite link`
+ content = (
+ <>
+
+
+ There is no invite link for this group chat.
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ {
+ setStep(defaultStep)
+ setWhoCanJoin(defaultWhoCanJoin)
+ }}>
+
+
+
+
+ {header}
+
+
+
+
+ }
+ label={l`Group chat invite link dialog`}
+ style={web({maxWidth: 400})}>
+ {content}
+
+
+ )
+}
+
+function TargetOption({label, selected}: {label: string; selected: boolean}) {
+ const t = useTheme()
+
+ return (
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx
index ec42341012..02064d111c 100644
--- a/src/screens/Messages/components/MessagesListInfoPanel.tsx
+++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx
@@ -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}) {
) : null}
- {isOwner || isLinkEnabled ? (
+ {isJoinLinkEnabled ? (
) : null}
+ {groupConvo ? (
+
+ ) : null}
(
+ 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)`,
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index 5402ab787e..b57f625739 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -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 &&
diff --git a/src/state/queries/messages/create-join-link.ts b/src/state/queries/messages/create-join-link.ts
new file mode 100644
index 0000000000..fbac855466
--- /dev/null
+++ b/src/state/queries/messages/create-join-link.ts
@@ -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)
+ },
+ })
+}
diff --git a/src/state/queries/messages/disable-join-link.ts b/src/state/queries/messages/disable-join-link.ts
new file mode 100644
index 0000000000..522b5939eb
--- /dev/null
+++ b/src/state/queries/messages/disable-join-link.ts
@@ -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)
+ },
+ })
+}
diff --git a/src/state/queries/messages/edit-group-chat-name.ts b/src/state/queries/messages/edit-group-chat-name.ts
index b605c999cc..ef2a72f4ec 100644
--- a/src/state/queries/messages/edit-group-chat-name.ts
+++ b/src/state/queries/messages/edit-group-chat-name.ts
@@ -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(
- CONVO_KEY(convoId),
- )
- const prevListEntries = queryClient.getQueriesData<
- InfiniteData
- >({queryKey: [CONVO_LIST_KEY]})
-
- // Update for a single chat thread
- queryClient.setQueryData(
- 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
- >({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)
},
diff --git a/src/state/queries/messages/edit-join-link.ts b/src/state/queries/messages/edit-join-link.ts
new file mode 100644
index 0000000000..7e34ce88d9
--- /dev/null
+++ b/src/state/queries/messages/edit-join-link.ts
@@ -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)
+ },
+ })
+}
diff --git a/src/state/queries/messages/enable-join-link.ts b/src/state/queries/messages/enable-join-link.ts
new file mode 100644
index 0000000000..a2ba505f1d
--- /dev/null
+++ b/src/state/queries/messages/enable-join-link.ts
@@ -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)
+ },
+ })
+}
diff --git a/src/state/queries/messages/get-convo-availability.ts b/src/state/queries/messages/get-convo-availability.ts
index 2392edb099..b73efe7953 100644
--- a/src/state/queries/messages/get-convo-availability.ts
+++ b/src/state/queries/messages/get-convo-availability.ts
@@ -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,
})
}
diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts
index 986351a072..620ce7ee2c 100644
--- a/src/state/queries/messages/leave-conversation.ts
+++ b/src/state/queries/messages/leave-conversation.ts
@@ -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)
},
})
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
index d90ebb1b55..03a9ab0b4a 100644
--- a/src/state/queries/messages/mute-conversation.ts
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -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(
- CONVO_KEY(convoId),
- )
- const prevListEntries = queryClient.getQueriesData<
- InfiniteData
- >({queryKey: [CONVO_LIST_KEY]})
-
- // Update for a single chat thread
- queryClient.setQueryData(
- CONVO_KEY(convoId),
- prev => {
- if (!prev) return
- return {
- ...prev,
- muted: mute,
- }
- },
- )
-
- // Update for the chat list
- queryClient.setQueriesData<
- InfiniteData
- >({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)
},
diff --git a/src/state/queries/messages/utils/convo-cache.ts b/src/state/queries/messages/utils/convo-cache.ts
new file mode 100644
index 0000000000..2c9e2b3f96
--- /dev/null
+++ b/src/state/queries/messages/utils/convo-cache.ts
@@ -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 | 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(
+ CONVO_KEY(convoId),
+ )
+ const prevListEntries = queryClient.getQueriesData<
+ InfiniteData
+ >({queryKey: [CONVO_LIST_KEY]})
+
+ queryClient.setQueryData(
+ CONVO_KEY(convoId),
+ prev => {
+ if (!prev) return
+ const next = updater(prev)
+ return next ?? prev
+ },
+ )
+
+ queryClient.setQueriesData<
+ InfiniteData
+ >({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)
+ }
+}