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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {logger} from '#/logger'
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {type ConvoWithDetails} from '#/components/dms/util'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
export function AddMembersLink({
convo,
members,
}: {
convo: ConvoWithDetails
members: string[]
}) {
const t = useTheme()
const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl()
const convoId = convo.view.id
const {mutate: addGroupMembers, isPending: isAddPending} = 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'})
},
},
)
return (
<>
<Button
disabled={isAddPending}
label={l`Add members`}
onPress={addMembersControl.open}>
{({interacting}) => (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.px_xl,
a.py_sm,
interacting ? [t.atoms.bg_contrast_25] : [],
]}>
<View style={[a.flex_row, a.align_center]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.rounded_full,
interacting
? t.atoms.bg_contrast_100
: t.atoms.bg_contrast_50,
{
height: 48,
width: 48,
},
]}>
<PlusIcon style={[t.atoms.text_contrast_high]} size="sm" />
</View>
<Text
numberOfLines={1}
style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}>
<Trans>Add members</Trans>
</Text>
</View>
{isAddPending ? (
<Loader size="md" />
) : (
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
)}
</View>
)}
</Button>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
members={members}
title={l`Add members`}
onAddMembers={(members, profiles) => {
addGroupMembers({members, profiles})
}}
/>
</Dialog.Outer>
</>
)
}
@@ -0,0 +1,129 @@
import {View} from 'react-native'
import {moderateProfile} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, native, useTheme, web} from '#/alf'
import {
type ConvoWithDetails,
type GroupConvoMember,
} from '#/components/dms/util'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {MemberMenu} from './MemberMenu'
import {StatusBadge} from './StatusBadge'
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm]
export function Member({
convo,
profile: profileUnshadowed,
status,
isOwner,
}: {
convo: ConvoWithDetails
profile: GroupConvoMember
status: 'owner' | 'standard' | 'invited'
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
if (!moderationOpts) {
return <MemberPlaceholder />
}
const moderation = moderateProfile(profile, moderationOpts)
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const isProfileOwner = profile.did === convo.primaryMember.did
const isSelf = currentAccount?.did === profile.did
let statusBadge: React.ReactNode | null = null
if (isSelf) {
if (status === 'owner') {
statusBadge = <StatusBadge label={l`Admin`} />
}
} else {
statusBadge = (
<MemberMenu
convo={convo}
profile={profile}
displayName={displayName}
type={status}
isOwner={isOwner}
/>
)
}
const joinedReason = profile.kind?.addedBy
? l`Added by ${createSanitizedDisplayName(
profile.kind.addedBy,
true,
moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
)}`
: `Added by invite link`
return (
<SubtleHoverWrapper>
<View style={outerStyles}>
<ProfileCard.Link profile={profile} style={[a.flex_1]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
size={48}
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Handle
profile={profile}
textStyle={[a.text_xs, native({top: -1})]}
/>
{!isProfileOwner && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
web(a.pt_2xs),
]}>
{joinedReason}
</Text>
)}
</View>
</ProfileCard.Header>
</ProfileCard.Outer>
</ProfileCard.Link>
{statusBadge}
</View>
</SubtleHoverWrapper>
)
}
export function MemberPlaceholder() {
return (
<View style={outerStyles}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder size={48} />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
</ProfileCard.Outer>
</View>
)
}
@@ -0,0 +1,252 @@
import {useState} from 'react'
import {Pressable} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {atoms as a, useTheme} from '#/alf'
import {type ConvoWithDetails} from '#/components/dms/util'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {
Person_Stroke2_Corner2_Rounded as PersonIcon,
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
} from '#/components/icons/Person'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
import {BlockMemberPrompt} from './prompts'
import {StatusBadge} from './StatusBadge'
export 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>()
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification()
const blockMemberPrompt = Prompt.usePromptControl()
const [menuDidOpen, setMenuDidOpen] = useState(false)
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
enabled: menuDidOpen,
})
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
},
onError: () => {
Toast.show(l`Failed to create conversation`, {type: 'error'})
},
})
const convoId = convo.view.id
const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
onError: e => {
logger.error('Failed to remove group chat member', {message: e})
Toast.show(l`Failed to remove group chat member`, {type: 'error'})
},
})
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const messageMember = () => {
if (!convoAvailability?.canChat) {
return
}
if (convoAvailability.convo) {
ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id,
})
} else {
ax.metric('chat:create', {logContext: 'ConvoSettings'})
initiateConvo([profile.did])
}
}
const handleMessageMember = requireEmailVerification(messageMember, {
instructions: [
<Trans key="message">
Before you can message another user, you must first verify your email.
</Trans>,
],
})
const handleBlockMember = async () => {
if (profile.viewer?.blocking) {
try {
await queueUnblock()
Toast.show(l({message: 'Account unblocked', context: 'toast'}))
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
}
} else {
try {
await queueBlock()
Toast.show(l({message: 'Account blocked', context: 'toast'}))
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
}
}
}
const canBlockMember = type === 'owner' || type === 'standard'
const canRemoveMember = isOwner && type !== 'invited'
// TODO Need to integrate this. -dsb
const canUninviteMember = false
// const canUninviteMember = isOwner && type === 'invited'
return (
<>
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({props, state, control: menuControl}) => {
const isActive =
state.hovered || state.pressed || menuControl.isOpen
const triggerProps = {
...props,
onPress: () => {
setMenuDidOpen(true)
props.onPress()
},
}
return type === 'owner' || type === 'invited' ? (
<StatusBadge
label={type === 'owner' ? l`Admin` : l`Invited`}
pressableProps={triggerProps}
style={[
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}
/>
) : (
<Pressable
{...triggerProps}
style={[
a.rounded_full,
a.p_sm,
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}>
<EllipsisIcon
style={[t.atoms.text_contrast_medium]}
size="md"
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`View ${displayName}s profile`}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonIcon} />
</Menu.Item>
<Menu.Item
label={l`Message ${displayName}`}
onPress={handleMessageMember}>
<Menu.ItemText>
<Trans context="action">Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
{canBlockMember ? (
<Menu.Item
label={
profile.viewer?.blocking
? l`Unblock ${displayName}`
: l`Block ${displayName}`
}
onPress={
profile.viewer?.blocking
? handleBlockMember
: blockMemberPrompt.open
}>
<Menu.ItemText>
<Trans>Block</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
) : null}
{canRemoveMember ? (
<Menu.Item
label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}>
<Menu.ItemText>
<Trans>Remove from chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
{canUninviteMember ? (
<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>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<BlockMemberPrompt
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
</>
)
}
@@ -0,0 +1,65 @@
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {MEMBER_LIMIT} from './constants'
export function MembersAndRequests({
memberCount,
requestCount,
hasMoreRequests,
isOwner,
}: {
memberCount: number
requestCount: number
hasMoreRequests: boolean
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>Members</Trans>
</Text>
<View
style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
{l({
message: `${memberCount}/${MEMBER_LIMIT}`,
comment:
'The number of group chat members out of the total number of permitted users.',
})}
</Text>
</View>
</View>
{isOwner && requestCount > 0 ? (
<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({
message: `${requestCount}+ requests`,
comment:
'Displayed when there are more than 50 requests to join a group chat',
})
: l({
message: plural(requestCount, {
one: '# request',
other: '# requests',
}),
comment: 'The number of requests to join a group chat.',
})}
</InlineLinkText>
) : null}
</View>
)
}
@@ -0,0 +1,44 @@
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {type TriggerChildProps} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
export function StatusBadge({
label,
style,
pressableProps,
}: {
label: string
style?: StyleProp<ViewStyle>
pressableProps?: TriggerChildProps['props']
}) {
const t = useTheme()
const badgeStyle = [
a.rounded_xs,
t.atoms.bg_contrast_50,
{
paddingTop: 3,
paddingBottom: 3,
paddingLeft: 6,
paddingRight: 6,
},
style,
]
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>
}
@@ -0,0 +1,27 @@
import {View} from 'react-native'
import {atoms as a} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {SubtleHover} from '#/components/SubtleHover'
export function SubtleHoverWrapper({
children,
}: React.PropsWithChildren<unknown>) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
// Web-only
onPointerEnter={onHoverIn}
// Web-only
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleHover hover={hover} />
{children}
</View>
)
}
@@ -0,0 +1 @@
export const MEMBER_LIMIT = 50
@@ -0,0 +1,637 @@
import {useState} from 'react'
import {View} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
import {useLockConvo} from '#/state/queries/messages/lock-conversation'
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {
type ConvoWithDetails,
type GroupConvoMember,
} from '#/components/dms/util'
import {Error} from '#/components/Error'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {
Bell2_Stroke2_Corner0_Rounded as BellIcon,
Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon,
} 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'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
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}
| {
type: 'CHAT_MEMBER'
key: string
profile: GroupConvoMember
status: 'owner' | 'standard' | 'invited'
}
| {
type: 'CHAT_MEMBER_PLACEHOLDER'
key: string
}
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversationSettings'
>
export function MessagesConversationSettingsScreen({route}: Props) {
const {gtTablet} = useBreakpoints()
const convoId = route.params.conversation
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
<Layout.Header.TitleText>
<Trans>Group chat settings</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function SettingsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
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>
)
}
if (convoState.convo?.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <GroupSettings convo={convoState.convo} />
}
function keyExtractor(item: Item) {
return item.key
}
function GroupSettings({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
const bottomBarOffset = useBottomBarOffset()
const {currentAccount} = useSession()
const primaryMember = convo?.primaryMember
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const {data: memberListData = [], isPending} = useListConvoMembersQuery({
convoId: convo.view.id,
placeholderData: convo?.members,
})
// TODO Need this data in order to populate this array. -dsb
const invites: string[] = []
const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
useListJoinRequestsQuery({
convoId: convo.view.id,
enabled: isOwner,
})
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const items: Item[] = [
{
type: 'MEMBERS_AND_REQUESTS',
key: 'members-and-requests',
},
...(isOwner
? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const]
: []),
]
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',
key: `chat-member-placeholder-${i}`,
}),
)
} else {
items.push(
...memberListData
.sort((a, b) => {
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
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
return 0
})
.map(
(profile): Item => ({
type: 'CHAT_MEMBER',
key: profile.did,
profile: profile as GroupConvoMember,
status:
primaryMember?.did === profile.did
? 'owner'
: invites.includes(profile.did)
? 'invited'
: 'standard',
}),
),
)
}
function renderItem({item}: {item: Item}) {
switch (item.type) {
case 'MEMBERS_AND_REQUESTS':
return (
<MembersAndRequests
memberCount={convo.details.memberCount}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
/>
)
case 'ADD_MEMBERS_LINK':
return convo ? (
<AddMembersLink
convo={convo}
members={memberListData.map(profile => profile.did)}
/>
) : null
case 'CHAT_MEMBER':
return convo ? (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
) : null
case 'CHAT_MEMBER_PLACEHOLDER':
return <MemberPlaceholder />
default:
return null
}
}
return (
<List
data={items}
contentContainerStyle={{
paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom,
}}
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
)
}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
/>
)
}
function SettingsHeader({
convo,
isOwner,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName)
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')
// 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: muteConvo} = useMuteConvo(convo.view.id, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(l({message: 'Group chat muted', context: 'toast'}))
} else {
Toast.show(l({message: 'Group chat unmuted', context: 'toast'}))
}
},
onError: e => {
logger.error('Failed to mute group chat', {message: e})
Toast.show(l`Failed to mute group chat`, {type: 'error'})
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onSuccess: () => {
// Settings > Chat > Chat list
navigation.dispatch(StackActions.pop(2))
},
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'}))
}
},
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()
const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl()
const handleToggleMute = () => {
muteConvo({mute: !convo.view.muted})
}
// TODO Need to implement this when the backend is ready. -dsb
const handleReportChat = () => {}
const handlePromptName = () => {
setNewGroupName(groupName)
editNamePrompt.open()
}
const handleEditName = () => {
editGroupName({name: newGroupName})
}
const handleConfirmLock = () => {
lockConvo({lock: true})
}
const handleUnlock = () => {
lockConvo({lock: false})
}
// TODO The creation date doesn't exist yet. -dsb
const showCreatedAt = false
const createdAt = new Date()
const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently'
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={convo.members} />
</View>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.pt_lg,
t.atoms.text,
]}>
{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}
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo.view.muted
? l`Unmute this group chat`
: l`Mute this group chat`
}
text={convo.view.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute}
/>
{isOwner ? (
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
) : null}
{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}
{canLockGroupChat ? (
<SettingsButton
color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
lockStatus === 'locked'
? l`Unlock this group chat`
: l`Lock this group chat`
}
text={lockStatus === 'locked' ? l`Locked` : l`Lock`}
onPress={
lockStatus === 'locked' ? handleUnlock : lockChatPrompt.open
}
/>
) : null}
{isOwner ? null : isReportLinkEnabled ? (
<SettingsButton
color="secondary"
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={handleReportChat}
/>
) : null}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={leaveChatPrompt.open}
/>
)}
</View>
</View>
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
<InviteLinkDialog
convo={convo}
control={inviteLinkDialog}
isOwner={isOwner}
/>
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
<LeaveChatPrompt
control={leaveChatPrompt}
groupName={groupName}
onConfirm={leaveConvo}
/>
</>
)
}
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,
icon,
label,
text,
onPress,
}: {
color?: ButtonColor
disabled?: boolean
icon: React.ComponentType<SVGIconProps>
label: string
text: string
onPress: () => void
}) {
const t = useTheme()
return (
<View style={[a.align_center]}>
<Button
color={color}
disabled={disabled}
size="large"
shape="round"
label={label}
onPress={onPress}
style={[
{
width: 48,
height: 48,
},
]}>
<ButtonIcon icon={icon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
{text}
</Text>
</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>
)
}
@@ -0,0 +1,119 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import type * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import * as Prompt from '#/components/Prompt'
export function EditNamePrompt({
control,
value,
onChangeText,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
value: string
onChangeText: (value: string) => void
onConfirm: () => void
}) {
const {t: l} = useLingui()
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.Outer>
)
}
export function LockChatPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Lock group chat?`}
description={l`Members can still read chat history but cant send new messages.`}
confirmButtonCta={l`Lock group chat`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function LeaveChatPrompt({
control,
groupName,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
groupName: string
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Are you sure you want to leave ${groupName}?`}
description={l`You wont be able to rejoin unless youre invited.`}
confirmButtonCta={l`Leave group chat`}
confirmButtonColor="negative"
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function BlockMemberPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Block account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={onConfirm}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
)
}
@@ -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>
</>