From 877d5b0d9c6c5b02c07ff1480ec1f933b8f4aff0 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:49:45 -0700 Subject: [PATCH] Tweak conversation settings (#10347) --- src/screens/Messages/ConversationSettings.tsx | 1207 ----------------- .../ConversationSettings/AddMembersLink.tsx | 109 ++ .../Messages/ConversationSettings/Member.tsx | 103 ++ .../ConversationSettings/MemberMenu.tsx | 252 ++++ .../MembersAndRequests.tsx | 62 + .../ConversationSettings/StatusBadge.tsx | 44 + .../SubtleHoverWrapper.tsx | 27 + .../ConversationSettings/constants.ts | 2 + .../Messages/ConversationSettings/index.tsx | 555 ++++++++ .../Messages/ConversationSettings/prompts.tsx | 119 ++ 10 files changed, 1273 insertions(+), 1207 deletions(-) delete mode 100644 src/screens/Messages/ConversationSettings.tsx create mode 100644 src/screens/Messages/ConversationSettings/AddMembersLink.tsx create mode 100644 src/screens/Messages/ConversationSettings/Member.tsx create mode 100644 src/screens/Messages/ConversationSettings/MemberMenu.tsx create mode 100644 src/screens/Messages/ConversationSettings/MembersAndRequests.tsx create mode 100644 src/screens/Messages/ConversationSettings/StatusBadge.tsx create mode 100644 src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx create mode 100644 src/screens/Messages/ConversationSettings/constants.ts create mode 100644 src/screens/Messages/ConversationSettings/index.tsx create mode 100644 src/screens/Messages/ConversationSettings/prompts.tsx diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx deleted file mode 100644 index 0e1a01a639..0000000000 --- a/src/screens/Messages/ConversationSettings.tsx +++ /dev/null @@ -1,1207 +0,0 @@ -import {useState} from 'react' -import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' -import {moderateProfile} from '@atproto/api' -import {plural} from '@lingui/core/macro' -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 {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' -import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' -import { - type CommonNavigatorParams, - type NativeStackScreenProps, - type NavigationProp, -} from '#/lib/routes/types' -import {sanitizeHandle} from '#/lib/strings/handles' -import {logger} from '#/logger' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {type Shadow} from '#/state/cache/types' -import {ConvoProvider, useConvo} from '#/state/messages/convo' -import {ConvoStatus} from '#/state/messages/convo/types' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' -import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' -import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' -import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' -import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' -import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' -import {useMuteConvo} from '#/state/queries/messages/mute-conversation' -import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' -import {useProfileBlockMutationQueue} from '#/state/queries/profile' -import {useSession} from '#/state/session' -import {List} from '#/view/com/util/List' -import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import {AvatarBubbles} from '#/components/AvatarBubbles' -import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import {AddMembersFlow} from '#/components/dms/AddMembersFlow' -import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' -import {Error} from '#/components/Error' -import * as TextField from '#/components/forms/TextField' -import {useInteractionState} from '#/components/hooks/useInteractionState' -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 {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' -import {type Props as SVGIconProps} from '#/components/icons/common' -import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' -import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' -import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' -import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' -import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' -import { - Person_Stroke2_Corner2_Rounded as PersonIcon, - PersonX_Stroke2_Corner0_Rounded as PersonXIcon, -} from '#/components/icons/Person' -import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' -import * as Layout from '#/components/Layout' -import {InlineLinkText} from '#/components/Link' -import * as Menu from '#/components/Menu' -import {type TriggerChildProps} from '#/components/Menu/types' -import * as Prompt from '#/components/Prompt' -import {SubtleHover} from '#/components/SubtleHover' -import * as Toast from '#/components/Toast' -import {Text} from '#/components/Typography' -import {useAnalytics} from '#/analytics' -import {IS_NATIVE} from '#/env' -import type * as bsky from '#/types/bsky' -import {InviteLinkDialog} from './components/InviteLinkDialog' - -const MEMBER_LIMIT = 50 -const ROW_SPACING = 10 - -const dateFormatter = new Intl.DateTimeFormat(undefined, { - month: 'long', - day: 'numeric', - year: 'numeric', -}) - -type Item = - | { - type: 'MEMBERS_AND_REQUESTS' - } - | { - type: 'ADD_MEMBERS_LINK' - } - | { - type: 'CHAT_MEMBER' - profile: bsky.profile.AnyProfileView - status: 'owner' | 'standard' | 'invited' - } - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'MessagesConversationSettings' -> - -export function MessagesConversationSettingsScreen({route}: Props) { - const {gtTablet} = useBreakpoints() - - const convoId = route.params.conversation - - return ( - - - - - - Group chat settings - - - - - - - - - ) -} - -function keyExtractor(item: Item) { - return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type -} - -function SettingsInner({convoId}: {convoId: string}) { - const {t: l} = useLingui() - - const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) - const bottomBarOffset = useBottomBarOffset() - - const convoState = useConvo() - const {currentAccount} = useSession() - - const convo = convoState.convo - ? parseConvoView(convoState.convo, currentAccount?.did) - : null - const primaryMember = convo?.primaryMember - const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did - - const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] - // TODO Need this data in order to populate this array. -dsb - const invites: string[] = [] - - const {data: joinRequestsData, hasNextPage: hasMoreRequests} = - useListJoinRequestsQuery({ - convoId, - enabled: isOwner, - }) - const requestCount = - joinRequestsData?.pages.reduce( - (sum, page) => sum + page.requests.length, - 0, - ) ?? 0 - - const items: Item[] = [ - { - type: 'MEMBERS_AND_REQUESTS', - }, - { - type: 'ADD_MEMBERS_LINK', - }, - ...[...data] - .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', - profile, - 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 ( - - ) - case 'ADD_MEMBERS_LINK': - return ( - profile.did)} - /> - ) - case 'CHAT_MEMBER': - return convo ? ( - - ) : null - default: - return null - } - } - - if (convoState.status === ConvoStatus.Error) { - return ( - convoState.error.retry()} - sideBorders={false} - /> - ) - } - - return ( - - ) : ( - - ) - } - renderItem={renderItem} - sideBorders={false} - windowSize={11} - onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} - /> - ) -} - -function MembersAndRequests({ - memberCount, - requestCount, - hasMoreRequests, - isOwner, -}: { - memberCount: number - requestCount: number - hasMoreRequests: boolean - isOwner: boolean -}) { - const t = useTheme() - const {t: l} = useLingui() - - return ( - - - - Members - - - {l({ - message: `${memberCount}/${MEMBER_LIMIT}`, - comment: - 'The number of group chat members out of the total number of permitted users.', - })} - - - {isOwner && requestCount > 0 ? ( - - {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.', - })} - - ) : null} - - ) -} - -function AddMembersLink({ - convo, - isOwner, - members, -}: { - convo: ConvoWithDetails | null - isOwner: boolean - members: string[] -}) { - const t = useTheme() - const {t: l} = useLingui() - - const addMembersControl = Dialog.useDialogControl() - - 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'}) - }, - }) - - if (!isOwner) { - return null - } - - return ( - <> - - - [ - a.flex_row, - a.align_center, - a.justify_between, - pressed && web({outline: 'none'}), - ]} - onPress={() => addMembersControl.open()}> - {({pressed}) => ( - <> - - - - - - - Add members - - - - - - )} - - - - - - - { - addGroupMembers({members}) - }} - /> - - - ) -} - -function Member({ - convo, - profile: profileUnshadowed, - status, - isOwner, -}: { - convo: ConvoWithDetails - profile: bsky.profile.AnyProfileView - status: 'owner' | 'standard' | 'invited' - isOwner: boolean -}) { - const navigation = useNavigation() - const t = useTheme() - const {t: l} = useLingui() - - const profile = useProfileShadow(profileUnshadowed) - const {currentAccount} = useSession() - const moderationOpts = useModerationOpts() - const moderation = moderationOpts - ? moderateProfile(profile, moderationOpts) - : undefined - - if (!moderation) return null - - const isDeletedAccount = profile.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? l`Deleted Account` - : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) - - const isSelf = currentAccount?.did === profile.did - let statusBadge: React.ReactNode | null = null - if (isSelf) { - if (status === 'owner') { - statusBadge = - } - } else { - statusBadge = convo ? ( - - ) : null - } - - return ( - - - { - navigation.navigate('Profile', {name: profile.did}) - }}> - - - - {displayName} - - - {sanitizeHandle(profile.handle, '@')} - - - - {statusBadge} - - - ) -} - -function StatusBadge({ - label, - style, - pressableProps, -}: { - label: string - style?: StyleProp - 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 = ( - - {label} - - ) - - if (pressableProps) { - return ( - - {labelText} - - ) - } - return {labelText} -} - -function MemberMenu({ - convo, - profile, - displayName, - type, - isOwner, -}: { - convo: ConvoWithDetails - profile: Shadow - type: 'owner' | 'standard' | 'invited' - displayName: string - isOwner: boolean -}) { - const navigation = useNavigation() - 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`) - }, - }) - 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`) - }, - }) - 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: [ - - Before you can message another user, you must first verify your email. - , - ], - }) - - 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', - }) - } - } - } - } - - return ( - <> - - - {({props, state, control: menuControl}) => { - const triggerProps = { - ...props, - onPress: () => { - setMenuDidOpen(true) - props.onPress() - }, - } - return type === 'owner' || type === 'invited' ? ( - - ) : ( - - - - ) - }} - - - - { - navigation.navigate('Profile', {name: profile.did}) - }}> - - Go to profile - - - - - - Message - - - - - - - {type === 'owner' || type === 'standard' ? ( - blockMemberPrompt.open()}> - - Block - - - - ) : null} - {isOwner && type !== 'invited' ? ( - removeMembers({members: [profile.did]})}> - - Remove from chat - - - - ) : null} - {isOwner && type === 'invited' ? ( - {}}> - - Uninvite - - - - ) : null} - - - - void handleBlockMember()} - /> - - ) -} - -function SettingsHeader({ - convo, - isOwner, -}: { - convo: Extract - isOwner: boolean -}) { - const t = useTheme() - const {t: l} = useLingui() - - const navigation = useNavigation() - - const groupName = convo.details.name - const [newGroupName, setNewGroupName] = useState(groupName) - - const [isLocked, setIsLocked] = useState(false) - - // TODO Enable this once the feature is working end-to-end. -dsb - // const {joinLink} = convo.details - const isJoinLinkEnabled = false - // const isJoinLinkEnabled = - // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') - - // TODO Enable this once the feature is working end-to-end. -dsb - const isReportLinkEnabled = false - - const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { - onError: e => { - setNewGroupName(groupName) - 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: () => { - 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 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 = () => { - setIsLocked(true) - } - - const handleUnlock = () => { - setIsLocked(false) - } - - // TODO The creation date doesn't exist yet. -dsb - const createdAt = new Date() - - return ( - <> - - - - - - {groupName} - - - Created {dateFormatter.format(createdAt)} - - - - {isOwner ? ( - - ) : null} - {isJoinLinkEnabled ? ( - - ) : null} - {isOwner ? ( - - ) : null} - {isOwner ? null : isReportLinkEnabled ? ( - - ) : null} - {isOwner ? null : ( - - )} - - - - - - - - ) -} - -function SettingsHeaderPlaceholder() { - const t = useTheme() - - return ( - - - - - - … - - - … - - - - - - - - - ) -} - -function SettingsButton({ - color = 'secondary', - icon, - label, - text, - onPress, -}: { - color?: ButtonColor - icon: React.ComponentType - label: string - text: string - onPress: () => void -}) { - const t = useTheme() - - return ( - - - - {text} - - - ) -} - -function SettingsButtonPlaceholder() { - const t = useTheme() - const {t: l} = useLingui() - - return ( - - - - … - - - ) -} - -function EditNamePrompt({ - control, - value, - onChangeText, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - value: string - onChangeText: (value: string) => void - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - <> - - - Edit group name - - - - - - - - - - - - - - ) -} - -function LockChatPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function LeaveChatPrompt({ - control, - groupName, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - groupName: string - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function BlockMemberPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} - -function SubtleHoverWrapper({children}: React.PropsWithChildren) { - const { - state: hover, - onIn: onHoverIn, - onOut: onHoverOut, - } = useInteractionState() - - return ( - - - {children} - - ) -} diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx new file mode 100644 index 0000000000..5fbcfaaa4b --- /dev/null +++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx @@ -0,0 +1,109 @@ +import {Pressable, 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, web} from '#/alf' +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 * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {SubtleHoverWrapper} from './SubtleHoverWrapper' + +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} = 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 ( + <> + + + [ + a.flex_row, + a.align_center, + a.justify_between, + pressed && web({outline: 'none'}), + ]} + onPress={addMembersControl.open}> + {({pressed}) => ( + <> + + + + + + + Add members + + + + + + )} + + + + + + + { + addGroupMembers({members}) + }} + /> + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx new file mode 100644 index 0000000000..8d7cee84fc --- /dev/null +++ b/src/screens/Messages/ConversationSettings/Member.tsx @@ -0,0 +1,103 @@ +import {Pressable, View} from 'react-native' +import {moderateProfile} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import {type ConvoWithDetails} from '#/components/dms/util' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {MemberMenu} from './MemberMenu' +import {StatusBadge} from './StatusBadge' +import {SubtleHoverWrapper} from './SubtleHoverWrapper' + +export function Member({ + convo, + profile: profileUnshadowed, + status, + isOwner, +}: { + convo: ConvoWithDetails + profile: bsky.profile.AnyProfileView + status: 'owner' | 'standard' | 'invited' + isOwner: boolean +}) { + const navigation = useNavigation() + const t = useTheme() + const {t: l} = useLingui() + + const profile = useProfileShadow(profileUnshadowed) + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const moderation = moderationOpts + ? moderateProfile(profile, moderationOpts) + : undefined + + // TODO Render a skeleton here. -dsb + if (!moderation) return null + + const isDeletedAccount = profile.handle === 'missing.invalid' + const displayName = isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) + + const isSelf = currentAccount?.did === profile.did + let statusBadge: React.ReactNode | null = null + if (isSelf) { + if (status === 'owner') { + statusBadge = + } + } else { + statusBadge = ( + + ) + } + + return ( + + + { + navigation.navigate('Profile', {name: profile.handle}) + }}> + + + + {displayName} + + + {sanitizeHandle(profile.handle, '@')} + + + + {statusBadge} + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx new file mode 100644 index 0000000000..127a474239 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -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 + type: 'owner' | 'standard' | 'invited' + displayName: string + isOwner: boolean +}) { + const navigation = useNavigation() + 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: [ + + Before you can message another user, you must first verify your email. + , + ], + }) + + 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 ( + <> + + + {({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' ? ( + + ) : ( + + + + ) + }} + + + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + Go to profile + + + + + + Message + + + + + + + {canBlockMember ? ( + + + Block + + + + ) : null} + {canRemoveMember ? ( + removeMembers({members: [profile.did]})}> + + Remove from chat + + + + ) : null} + {canUninviteMember ? ( + {}}> + + Uninvite + + + + ) : null} + + + + void handleBlockMember()} + /> + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx new file mode 100644 index 0000000000..9dc26f7f5e --- /dev/null +++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx @@ -0,0 +1,62 @@ +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 ( + + + + Members + + + {l({ + message: `${memberCount}/${MEMBER_LIMIT}`, + comment: + 'The number of group chat members out of the total number of permitted users.', + })} + + + {isOwner && requestCount > 0 ? ( + + {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.', + })} + + ) : null} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/StatusBadge.tsx b/src/screens/Messages/ConversationSettings/StatusBadge.tsx new file mode 100644 index 0000000000..e5416bd033 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/StatusBadge.tsx @@ -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 + 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 = ( + + {label} + + ) + + if (pressableProps) { + return ( + + {labelText} + + ) + } + return {labelText} +} diff --git a/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx new file mode 100644 index 0000000000..d7f032b06e --- /dev/null +++ b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx @@ -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) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + + return ( + + + {children} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/constants.ts b/src/screens/Messages/ConversationSettings/constants.ts new file mode 100644 index 0000000000..dc6b7b9257 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/constants.ts @@ -0,0 +1,2 @@ +export const ROW_SPACING = 20 +export const MEMBER_LIMIT = 50 diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx new file mode 100644 index 0000000000..954e90e55a --- /dev/null +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -0,0 +1,555 @@ +import {useState} from 'react' +import {View} from 'react-native' +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, 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 {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' +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, parseConvoView} 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 * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' +import {InviteLinkDialog} from '../components/InviteLinkDialog' +import {AddMembersLink} from './AddMembersLink' +import {ROW_SPACING} from './constants' +import {Member} 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' + } + | { + type: 'ADD_MEMBERS_LINK' + } + | { + type: 'CHAT_MEMBER' + profile: bsky.profile.AnyProfileView + status: 'owner' | 'standard' | 'invited' + } + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversationSettings' +> + +export function MessagesConversationSettingsScreen({route}: Props) { + const {gtTablet} = useBreakpoints() + + const convoId = route.params.conversation + + return ( + + + + + + Group chat settings + + + + + + + + + ) +} + +function keyExtractor(item: Item) { + return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type +} + +function SettingsInner({convoId}: {convoId: string}) { + const {t: l} = useLingui() + + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) + const bottomBarOffset = useBottomBarOffset() + + const convoState = useConvo() + const {currentAccount} = useSession() + + const convo = convoState.convo + ? parseConvoView(convoState.convo, currentAccount?.did) + : null + const primaryMember = convo?.primaryMember + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did + + const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] + // TODO Need this data in order to populate this array. -dsb + const invites: string[] = [] + + const {data: joinRequestsData, hasNextPage: hasMoreRequests} = + useListJoinRequestsQuery({ + convoId, + enabled: isOwner, + }) + const requestCount = + joinRequestsData?.pages.reduce( + (sum, page) => sum + page.requests.length, + 0, + ) ?? 0 + + const items: Item[] = [ + { + type: 'MEMBERS_AND_REQUESTS', + }, + ...(isOwner ? [{type: 'ADD_MEMBERS_LINK'} as const] : []), + ...[...data] + .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', + profile, + 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 ( + + ) + case 'ADD_MEMBERS_LINK': + return convo ? ( + profile.did)} + /> + ) : null + case 'CHAT_MEMBER': + return convo ? ( + + ) : null + default: + return null + } + } + + if (convoState.status === ConvoStatus.Error) { + return ( + convoState.error.retry()} + sideBorders={false} + /> + ) + } + + return ( + + ) : ( + + ) + } + renderItem={renderItem} + sideBorders={false} + windowSize={11} + // TODO Paginate on relatedProfiles. -dsb + onEndReached={() => {}} + onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} + /> + ) +} + +function SettingsHeader({ + convo, + isOwner, +}: { + convo: Extract + isOwner: boolean +}) { + const t = useTheme() + const {t: l} = useLingui() + + const navigation = useNavigation() + + const groupName = convo.details.name + const [newGroupName, setNewGroupName] = useState(groupName) + + const [isLocked, setIsLocked] = useState(false) + + // TODO Enable this once the feature is working end-to-end. -dsb + // const {joinLink} = convo.details + const isJoinLinkEnabled = false + // const isJoinLinkEnabled = + // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') + + // TODO Enable this once the feature is working end-to-end. -dsb + const isReportLinkEnabled = false + + const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { + onError: e => { + setNewGroupName(groupName) + 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 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 = () => { + setIsLocked(true) + } + + const handleUnlock = () => { + setIsLocked(false) + } + + // TODO The creation date doesn't exist yet. -dsb + const showCreatedAt = false + const createdAt = new Date() + + // TODO Need to implement this. -dsb + const canLockGroupChat = false + // const canLockGroupChat = isOwner + + return ( + <> + + + + + + {groupName} + + {showCreatedAt ? ( + + Created {dateFormatter.format(createdAt)} + + ) : null} + + + {isOwner ? ( + + ) : null} + {isJoinLinkEnabled ? ( + + ) : null} + {canLockGroupChat ? ( + + ) : null} + {isOwner ? null : isReportLinkEnabled ? ( + + ) : null} + {isOwner ? null : ( + + )} + + + + + + + + ) +} + +function SettingsHeaderPlaceholder() { + const t = useTheme() + + return ( + + + + + + … + + + … + + + + + + + + + ) +} + +function SettingsButton({ + color = 'secondary', + icon, + label, + text, + onPress, +}: { + color?: ButtonColor + icon: React.ComponentType + label: string + text: string + onPress: () => void +}) { + const t = useTheme() + + return ( + + + + {text} + + + ) +} + +function SettingsButtonPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + … + + + ) +} diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx new file mode 100644 index 0000000000..f5b980b990 --- /dev/null +++ b/src/screens/Messages/ConversationSettings/prompts.tsx @@ -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 ( + + <> + + + Edit group name + + + + + + + + + + + + + + ) +} + +export function LockChatPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function LeaveChatPrompt({ + control, + groupName, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + groupName: string + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + +export function BlockMemberPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +}