Group Clops Feature Branch (#10360)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-04-24 13:38:50 -05:00
committed by GitHub
parent e832791367
commit cdb8d4bfb8
63 changed files with 3517 additions and 1844 deletions
@@ -298,7 +298,10 @@ function BaseChatItem({
// System message
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
const info = getSystemMessageInfo(
convo.lastMessage.data,
new Map(convo.members.map(m => [m.did, m])),
)
if (info) {
lastMessage = i18n._(info.message)
lastMessageSentAt = convo.lastMessage.sentAt
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {KnownFollowers} from '#/components/KnownFollowers'
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted()
}, [convoState])
const otherUser = convoState.recipients.find(
user => user.did !== currentAccount?.did,
)
// either the other person, or the chat owner
// if we ever allow someone other than the owner to invite people, this will need to change
const otherUser = convoState.convo.primaryMember
if (!moderationOpts) {
return null
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
{otherUser && (
<RejectMenu
label={_(msg`Block or report`)}
convo={convoState.convo}
convo={convoState.convo.view}
profile={otherUser}
color="negative_subtle"
size="small"
@@ -53,14 +51,14 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
)}
<DeleteChatButton
label={_(msg`Delete`)}
convo={convoState.convo}
convo={convoState.convo.view}
color="secondary"
size="small"
currentScreen="conversation"
onPress={leaveConvoControl.open}
/>
<LeaveConvoPrompt
convoId={convoState.convo.id}
convoId={convoState.convo.view.id}
control={leaveConvoControl}
currentScreen="conversation"
hasMessages={false}
@@ -69,7 +67,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<View style={[a.w_full, a.flex_row]}>
<AcceptChatButton
onAcceptConvo={onAcceptChat}
convo={convoState.convo}
convo={convoState.convo.view}
color="primary_subtle"
size="small"
currentScreen="conversation"
@@ -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>
)
}
@@ -250,10 +250,8 @@ export function MessagesList({
)
const onStartReached = useCallback(() => {
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
void convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled, layoutHeight])
void convoState.fetchMessageHistory()
}, [convoState])
const onScroll = useCallback(
(e: ScrollEvent) => {
@@ -376,10 +374,7 @@ export function MessagesList({
return (
<MessageItem
item={item}
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.isGroup()}
isGroupChat={convoState.convo.kind === 'group'}
/>
)
} else if (item.type === 'deleted-message') {
@@ -448,8 +443,9 @@ export function MessagesList({
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.isGroup() && convoState.hasAllHistory ? (
<MessagesListInfoPanel convoState={convoState} />
{convoState.convo?.kind === 'group' &&
convoState.hasAllHistory ? (
<MessagesListInfoPanel convo={convoState.convo} />
) : null}
</>
}
@@ -577,7 +573,7 @@ function getFooterState(
}
}
if (convoState.convo.status === 'request' && !hasAcceptOverride) {
if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
return 'request'
}
@@ -1,72 +1,90 @@
import {View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {type ConvoState} from '#/state/messages/convo/types'
import {logger} from '#/logger'
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
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 {type ConvoWithDetails} 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}) {
export function MessagesListInfoPanel({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
// TODO Get this from @api/atproto - dsb
const isLinkEnabled = false
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'})
},
})
const groupName = convoState.getGroupInfo?.()?.name
// 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 members = (convoState?.convo?.members ?? []).filter(
const isOwner = convo?.primaryMember.did === currentAccount?.did
const members = (convo?.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
let names: React.ReactNode | null = null
let names: React.ReactNode = null
if (members.length === 1) {
names = <Trans>New chat with {members[0].displayName}</Trans>
}
if (members.length === 2) {
} else if (members.length === 2) {
names = (
<Trans>
New chat with {members[0].displayName} and {members[1].displayName}
</Trans>
)
}
if (members.length > 2) {
} else if (members.length > 2) {
const memberCount = convo.details.memberCount - 2
names = (
<Trans>
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
<Plural
value={members.length - 2}
one={`${members.length - 2} more`}
other={`${members.length - 2} more`}
value={memberCount}
one={`${memberCount} more`}
other={`${memberCount} more`}
/>
.
</Trans>
)
}
const showButtons = isOwner || isLinkEnabled
const showButtons = isOwner || isJoinLinkEnabled
return (
<>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles animate={true} profiles={members} />
{groupName ? (
<AvatarBubbles animate={true} profiles={convo?.members} />
{convo.details.name ? (
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
{groupName}
{convo.details.name}
</Text>
) : null}
{names ? (
@@ -102,12 +120,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>
@@ -117,17 +139,22 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</View>
) : null}
</View>
<InviteLinkDialog
isOwner={isOwner}
convo={convo}
control={inviteLinkControl}
/>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
members={members.map(profile => profile.did)}
title={l`Add people`}
onAddMembers={(_dids: string[]) => {
// TODO Add members here
addMembersControl.close()
}}
onAddMembers={(members, profiles) =>
addGroupMembers({members, profiles})
}
/>
</Dialog.Outer>
</>