Files
bsky-social-app/src/screens/Messages/ConversationSettings/index.tsx
T

627 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {useState} from 'react'
import {View} from 'react-native'
import {
ChatBskyActorDefs,
ChatBskyConvoDefs,
type ModerationOpts,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {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 {useModerationOpts} from '#/state/preferences/moderation-opts'
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, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog'
import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
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 {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 * 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'
type Item =
| {type: 'MEMBERS_AND_REQUESTS'; key: string}
| {type: 'ADD_MEMBERS_LINK'; key: string}
| {
type: 'CHAT_MEMBER'
key: string
profile: GroupConvoMember
status: 'owner' | 'standard'
}
| {
type: 'CHAT_MEMBER_PLACEHOLDER'
key: string
}
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversationSettings'
>
export function MessagesConversationSettingsScreen({route}: Props) {
const convoId = route.params.conversation
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<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>()
const moderationOpts = useModerationOpts()
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}
/>
)
}
if (!convoState.convo || !moderationOpts) {
return (
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
)
}
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}
moderationOpts={moderationOpts}
isReady={isConvoActive(convoState)}
/>
)
}
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,
moderationOpts,
isReady,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts
isReady: boolean
}) {
const [isPTRing, setIsPTRing] = useState(false)
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 = [], refetch} = useListConvoMembersQuery({
convoId: convo.view.id,
placeholderData: convo.members,
})
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 groupMembers = memberListData.filter(isGroupMember).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
})
const items: Item[] = [
{
type: 'MEMBERS_AND_REQUESTS',
key: 'members-and-requests',
},
...(isOwner && convo.details.lockStatus === 'unlocked'
? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const]
: []),
]
items.push(
...groupMembers.map(
(profile): Item => ({
type: 'CHAT_MEMBER',
key: profile.did,
profile,
status: primaryMember?.did === profile.did ? 'owner' : 'standard',
}),
),
)
const placeholderCount = Math.max(
0,
convo.details.memberCount - groupMembers.length,
)
for (let i = 0; i < placeholderCount; i++) {
items.push({
type: 'CHAT_MEMBER_PLACEHOLDER',
key: `chat-member-placeholder-${i}`,
})
}
function renderItem({item}: {item: Item}) {
switch (item.type) {
case 'MEMBERS_AND_REQUESTS':
return (
<MembersAndRequests
memberCount={convo.details.memberCount}
memberLimit={convo.details.memberLimit}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
/>
)
case 'ADD_MEMBERS_LINK':
return <AddMembersLink convo={convo} disabled={!isReady} />
case 'CHAT_MEMBER':
return (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
)
case 'CHAT_MEMBER_PLACEHOLDER':
return <MemberPlaceholder />
default:
return null
}
}
const onRefresh = async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh group chat members', {message: err})
}
setIsPTRing(false)
}
return (
<List
data={items}
contentContainerStyle={{
paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom,
}}
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
<SettingsHeader
convo={convo}
isOwner={isOwner}
moderationOpts={moderationOpts}
isReady={isReady}
/>
}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
refreshing={isPTRing}
onRefresh={() => void onRefresh()}
/>
)
}
function SettingsHeader({
convo,
isOwner,
moderationOpts,
isReady,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
moderationOpts: ModerationOpts
isReady: boolean
}) {
const t = useTheme()
const {i18n, t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName)
const lockStatus = convo.details.lockStatus
const {joinLink} = convo.details
const isJoinLinkEnabled = isOwner || joinLink?.enabledStatus === 'enabled'
const reportSubjectDid = convo.primaryMember?.did
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, isPending: isMuting} = 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, 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'},
)
},
},
)
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'})
}
},
},
)
const inviteLinkDialog = Dialog.useDialogControl()
const editNamePrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const deleteControl = Prompt.usePromptControl()
const createdAt = new Date(convo.details.createdAt)
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}
moderationOpts={moderationOpts}
/>
</View>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.pt_lg,
t.atoms.text,
]}>
{groupName}
</Text>
<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,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
disabled={!isReady || isMuting || lockStatus !== 'unlocked'}
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={() => muteConvo({mute: !convo.view.muted})}
/>
{isOwner ? (
<SettingsButton
disabled={!isReady || isEditingName || lockStatus !== 'unlocked'}
icon={EditIcon}
label={l`Edit this group chat’s name`}
text={l`Edit name`}
onPress={() => {
setNewGroupName(groupName)
editNamePrompt.open()
}}
/>
) : null}
{isJoinLinkEnabled ? (
<SettingsButton
disabled={!isReady || lockStatus !== 'unlocked'}
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'}
disabled={!isReady || isLocking}
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'
? () => lockConvo({lock: false})
: lockChatPrompt.open
}
/>
) : null}
{!isOwner && reportSubjectDid ? (
<SettingsButton
disabled={!isReady}
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={reportControl.open}
/>
) : null}
{!isOwner ? (
<SettingsButton
disabled={!isReady || isLeaving}
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={leaveChatPrompt.open}
/>
) : null}
</View>
</View>
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
onChangeText={setNewGroupName}
onConfirm={() => editGroupName({name: newGroupName})}
/>
{convo.primaryMember && (
<InviteLinkDialog
convo={convo}
owner={convo.primaryMember}
control={inviteLinkDialog}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
)}
<LockChatPrompt
control={lockChatPrompt}
onConfirm={() => lockConvo({lock: true})}
/>
<LeaveChatPrompt
control={leaveChatPrompt}
groupName={groupName}
onConfirm={leaveConvo}
/>
{reportSubjectDid ? (
<>
<ReportConversationDialog
control={reportControl}
convoId={convo.view.id}
did={reportSubjectDid}
onAfterSubmit={deleteControl.open}
/>
<AfterReportConversationDialog
control={deleteControl}
currentScreen="conversation"
params={{
convoId: convo.view.id,
did: reportSubjectDid,
}}
/>
</>
) : null}
</>
)
}
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,
]}>
{text}
</Text>
</View>
)
}