Add UI for creating group clip clop invite links (#10318)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" 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 |
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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 couldn’t load this conversation’s settings`}
|
||||
onRetry={() => convoState.error.retry()}
|
||||
sideBorders={false}
|
||||
/>
|
||||
</>
|
||||
<Error
|
||||
title={l`Something went wrong`}
|
||||
message={l`We couldn’t load this conversation’s 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 member’s 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"
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user