Re-enable group invite link management (#10407)

This commit is contained in:
DS Boyce
2026-05-04 23:44:27 +01:00
committed by GitHub
parent e34080c4a0
commit 482fb77e36
5 changed files with 142 additions and 194 deletions
+1 -1
View File
@@ -81,7 +81,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.11",
"@atproto/api": "^0.19.12",
"@atproto/syntax": "0.5.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
@@ -72,7 +72,7 @@ export function Member({
true,
moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
)}`
: `Added by invite link`
: l`Added by invite link`
return (
<SubtleHoverWrapper>
@@ -1,8 +1,8 @@
import {useState} from 'react'
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
@@ -38,7 +38,6 @@ import {
} from '#/components/icons/Bell2'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
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'
@@ -47,18 +46,13 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {InviteLinkDialog} from '../components/InviteLinkDialog'
import {AddMembersLink} from './AddMembersLink'
import {Member, MemberPlaceholder} from './Member'
import {MembersAndRequests} from './MembersAndRequests'
import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
type Item =
| {type: 'MEMBERS_AND_REQUESTS'; key: string}
| {type: 'ADD_MEMBERS_LINK'; key: string}
@@ -119,11 +113,9 @@ function SettingsInner() {
if (!isConvoActive(convoState)) {
return (
<Layout.Content>
<View style={[a.align_center, a.justify_center, a.flex_1, a.py_4xl]}>
<Loader size="xl" />
</View>
</Layout.Content>
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
)
}
@@ -150,6 +142,19 @@ function keyExtractor(item: Item) {
return item.key
}
function isGroupMember(
member: ChatBskyActorDefs.ProfileViewBasic,
): member is GroupConvoMember {
// Kind is missing when the account has been deleted.
return (
member.kind === undefined ||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
member.kind,
ChatBskyActorDefs.isGroupConvoMember,
)
)
}
function GroupSettings({
convo,
}: {
@@ -160,12 +165,12 @@ function GroupSettings({
const {currentAccount} = useSession()
const primaryMember = convo?.primaryMember
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const primaryMember = convo.primaryMember
const isOwner = primaryMember.did === currentAccount?.did
const {data: memberListData = [], isPending} = useListConvoMembersQuery({
convoId: convo.view.id,
placeholderData: convo?.members,
placeholderData: convo.members,
})
// TODO Need this data in order to populate this array. -dsb
@@ -193,18 +198,19 @@ function GroupSettings({
]
if (isPending) {
// should never be pending if we correctly set the query cache data
Array.from({length: 5}).forEach((_, i) =>
items.push({
type: 'CHAT_MEMBER_PLACEHOLDER',
items.push(
...Array.from({length: 5}, (_, i) => ({
type: 'CHAT_MEMBER_PLACEHOLDER' as const,
key: `chat-member-placeholder-${i}`,
}),
})),
)
} else {
items.push(
...memberListData
.filter(isGroupMember)
.sort((a, b) => {
const aIsOwner = a.did === primaryMember?.did
const bIsOwner = b.did === primaryMember?.did
const aIsOwner = a.did === primaryMember.did
const bIsOwner = b.did === primaryMember.did
const aIsSelf = a.did === currentAccount?.did
const bIsSelf = b.did === currentAccount?.did
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
@@ -215,9 +221,9 @@ function GroupSettings({
(profile): Item => ({
type: 'CHAT_MEMBER',
key: profile.did,
profile: profile as GroupConvoMember,
profile,
status:
primaryMember?.did === profile.did
primaryMember.did === profile.did
? 'owner'
: invites.includes(profile.did)
? 'invited'
@@ -239,16 +245,16 @@ function GroupSettings({
/>
)
case 'ADD_MEMBERS_LINK':
return convo ? <AddMembersLink convo={convo} /> : null
return <AddMembersLink convo={convo} />
case 'CHAT_MEMBER':
return convo ? (
return (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
) : null
)
case 'CHAT_MEMBER_PLACEHOLDER':
return <MemberPlaceholder />
default:
@@ -265,13 +271,7 @@ function GroupSettings({
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
)
}
ListHeaderComponent={<SettingsHeader convo={convo} isOwner={isOwner} />}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
@@ -287,7 +287,7 @@ function SettingsHeader({
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const {i18n, t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
@@ -296,24 +296,25 @@ function SettingsHeader({
const lockStatus = convo.details.lockStatus
// 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')
const {joinLink} = convo.details
const isJoinLinkEnabled = 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)
logger.error('Failed to edit group chat name', {message: e})
Toast.show(l`Failed to edit group chat name`, {type: 'error'})
},
})
const {mutate: editGroupName, isPending: isEditingName} =
useEditGroupChatName(convo.view.id, {
onSuccess: () => {
Toast.show(l({message: 'Group chat name updated', context: 'toast'}))
},
onError: e => {
setNewGroupName(groupName)
logger.error('Failed to edit group chat name', {message: e})
Toast.show(l`Failed to edit group chat name`, {type: 'error'})
},
})
const {mutate: muteConvo} = useMuteConvo(convo.view.id, {
const {mutate: muteConvo, isPending: isMuting} = useMuteConvo(convo.view.id, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(l({message: 'Group chat muted', context: 'toast'}))
@@ -327,38 +328,44 @@ function SettingsHeader({
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onSuccess: () => {
// Settings > Chat > Chat list
navigation.dispatch(StackActions.pop(2))
const {mutate: leaveConvo, isPending: isLeaving} = useLeaveConvo(
convo.view.id,
{
onSuccess: () => {
navigation.replace('Messages', {animation: 'pop'})
},
onError: e => {
logger.error('Failed to leave group chat', {message: e})
Toast.show(
l({message: 'Failed to leave group chat', context: 'toast'}),
{type: 'error'},
)
},
},
onError: e => {
logger.error('Failed to leave group chat', {message: e})
Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), {
type: 'error',
})
},
})
)
const {mutate: lockConvo} = useLockConvo(convo.view.id, {
onSuccess: data => {
const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo
if (kind.lockStatus === 'locked') {
Toast.show(l({message: 'Group chat locked', context: 'toast'}))
} else {
Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
}
const {mutate: lockConvo, isPending: isLocking} = useLockConvo(
convo.view.id,
{
onSuccess: data => {
if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return
if (data.convo.kind.lockStatus === 'locked') {
Toast.show(l({message: 'Group chat locked', context: 'toast'}))
} else {
Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
}
},
onError: (e, {lock}) => {
if (lock) {
logger.error('Failed to lock group chat', {message: e})
Toast.show(l`Failed to lock group chat`, {type: 'error'})
} else {
logger.error('Failed to unlock group chat', {message: e})
Toast.show(l`Failed to unlock group chat`, {type: 'error'})
}
},
},
onError: (e, {lock}) => {
if (lock) {
logger.error('Failed to lock group chat', {message: e})
Toast.show(l`Failed to lock group chat`, {type: 'error'})
} else {
logger.error('Failed to unlock group chat', {message: e})
Toast.show(l`Failed to unlock group chat`, {type: 'error'})
}
},
})
)
const inviteLinkDialog = Dialog.useDialogControl()
const editNamePrompt = Prompt.usePromptControl()
@@ -389,9 +396,7 @@ function SettingsHeader({
lockConvo({lock: false})
}
// TODO The creation date doesn't exist yet. -dsb
const showCreatedAt = false
const createdAt = new Date()
const createdAt = new Date(convo.details.createdAt)
const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently'
@@ -412,18 +417,23 @@ function SettingsHeader({
]}>
{groupName}
</Text>
{showCreatedAt ? (
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans>Created {dateFormatter.format(createdAt)}</Trans>
</Text>
) : null}
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans>
Created{' '}
{i18n.date(createdAt, {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</Trans>
</Text>
<View
style={[
a.flex_row,
@@ -434,6 +444,7 @@ function SettingsHeader({
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
disabled={isMuting}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo.view.muted
@@ -445,6 +456,7 @@ function SettingsHeader({
/>
{isOwner ? (
<SettingsButton
disabled={isEditingName}
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
@@ -453,6 +465,7 @@ function SettingsHeader({
) : null}
{isJoinLinkEnabled ? (
<SettingsButton
disabled={lockStatus !== 'unlocked'}
icon={ChainLinkIcon}
label={
isOwner
@@ -466,6 +479,7 @@ function SettingsHeader({
{canLockGroupChat ? (
<SettingsButton
color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
disabled={isLocking}
icon={LockIcon}
label={
lockStatus === 'locked'
@@ -478,18 +492,17 @@ function SettingsHeader({
}
/>
) : null}
{isOwner ? null : isReportLinkEnabled ? (
{!isOwner && isReportLinkEnabled && (
<SettingsButton
color="secondary"
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={handleReportChat}
/>
) : null}
{isOwner ? null : (
)}
{!isOwner && (
<SettingsButton
color="secondary"
disabled={isLeaving}
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
@@ -519,45 +532,6 @@ function SettingsHeader({
)
}
function SettingsHeaderPlaceholder() {
const t = useTheme()
return (
<View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={[]} />
</View>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}>
</Text>
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
</Text>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
</View>
</View>
)
}
function SettingsButton({
color = 'secondary',
disabled,
@@ -606,27 +580,3 @@ function SettingsButton({
</View>
)
}
function SettingsButtonPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
return (
<View style={[a.align_center]}>
<Button color="secondary" size="large" shape="round" label={l`Loading…`}>
<ButtonIcon icon={EllipsisIcon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
</Text>
</View>
)
}
@@ -21,33 +21,31 @@ export function EditNamePrompt({
return (
<Prompt.Outer control={control}>
<>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Edit group name</Trans>
</Prompt.TitleText>
<View style={[a.my_sm]}>
<TextField.Root isInvalid={false}>
<TextField.Input
label={l`Edit group name`}
placeholder={l`Group name`}
value={value}
onChangeText={onChangeText}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus
onSubmitEditing={onConfirm}
/>
</TextField.Root>
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Edit group name</Trans>
</Prompt.TitleText>
<View style={[a.my_sm]}>
<TextField.Root isInvalid={false}>
<TextField.Input
label={l`Edit group name`}
placeholder={l`Group name`}
value={value}
onChangeText={onChangeText}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus
onSubmitEditing={onConfirm}
/>
</TextField.Root>
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)
}
+4 -4
View File
@@ -20,10 +20,10 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@atproto/api@^0.19.11":
version "0.19.11"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.11.tgz#73885a47959907f22b68d671011ee70f80afd44b"
integrity sha512-7V4Sg6hcv/UxoXobjfvy/Ox2ioKQtZ3DzbsiFndYCcBfsZ5GO8rNEroHPq3hT0CFBJK1NAD6JfOtTBN2z267Xg==
"@atproto/api@^0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.12.tgz#0c21d7de070270f6596f908e6d8dd590b2933395"
integrity sha512-5CxDPBPxWx30ZM0UwjsmY67l+Kl8lTiLnH/8QvymsuJuRwA47CVfqo+QzEwJSkojNm7s0mTp7Ln2mR+OcFGBuw==
dependencies:
"@atproto/common-web" "^0.4.21"
"@atproto/lexicon" "^0.6.2"