diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 9016ce05db..587a25e95b 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -35,13 +35,11 @@ import { PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, PersonX_Stroke2_Corner0_Rounded as PersonX, } from '#/components/icons/Person' -import {SettingsGear2_Stroke2_Corner0_Rounded as GearIcon} from '#/components/icons/SettingsGear2' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import * as Menu from '#/components/Menu' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' -import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' let ConvoMenu = ({ @@ -183,7 +181,6 @@ function MenuContent({ reportControl: Prompt.PromptControlProps blockedByListControl: Prompt.PromptControlProps }) { - const ax = useAnalytics() const navigation = useNavigation() const {_} = useLingui() const {mutate: markAsRead} = useMarkAsReadMutation() @@ -195,12 +192,6 @@ function MenuContent({ const convoId = initialConvo.id const {data: convo} = useConvoQuery(initialConvo) - const onNavigateToSettings = useCallback(() => { - navigation.navigate('MessagesConversationSettings', { - conversation: convoId, - }) - }, [convoId, navigation]) - const onNavigateToProfile = useCallback(() => { navigation.navigate('Profile', {name: profile.did}) }, [navigation, profile.did]) @@ -229,14 +220,12 @@ function MenuContent({ } if (userBlock) { - queueUnblock() + void queueUnblock() } else { - queueBlock() + void queueBlock() } }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) - const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) - return isDeletedAccount ? ( ) : ( <> - {isGroupChatEnabled ? ( - - - - Settings - - - - - ) : null} {showMarkAsRead && ( () + const groupInfo = convoState.getGroupInfo?.() const isGroupChat = groupInfo != null @@ -141,6 +148,17 @@ function HeaderReady({ ? latestMessageFromOther.message : undefined + const handleNavigateToSettings = () => { + const convoId = convoState.convo?.id + if (convoId) { + navigation.navigate('MessagesConversationSettings', { + conversation: convoId, + }) + } else { + logger.error(`handleNavigateToSettings: missing convo ID`) + } + } + return ( @@ -199,15 +217,28 @@ function HeaderReady({ - {isConvoActive(convoState) && ( - - )} + {isConvoActive(convoState) ? ( + isGroupChat ? ( + + ) : ( + + ) + ) : null} diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx index 0b3bfa8b8e..e846e1c231 100644 --- a/src/screens/Messages/ConversationSettings.tsx +++ b/src/screens/Messages/ConversationSettings.tsx @@ -1,6 +1,6 @@ import {useMemo, useState} from 'react' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' -import {moderateProfile} from '@atproto/api' +import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' @@ -8,18 +8,30 @@ import {useNavigation} from '@react-navigation/native' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' -import {type NavigationProp} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, + type NavigationProp, +} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +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 {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useMuteConvo} from '#/state/queries/messages/mute-conversation' +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 type * as Dialog from '#/components/Dialog' +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' @@ -32,6 +44,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/ import {type Props as SVGIconProps} from '#/components/icons/common' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {EditBig_Stroke2_Corner0_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 { @@ -63,16 +76,23 @@ type Item = } | { type: 'CHAT_MEMBER' - profile: bsky.profile.AnyProfileView - status: 'admin' | 'member' | 'invited' + profile: Shadow + status: 'owner' | 'member' | 'invited' } +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'MessagesConversationSettings' +> + /** * TODO This is just layout for now. */ -export function MessagesConversationSettingsScreen() { +export function MessagesConversationSettingsScreen({route}: Props) { const {gtTablet} = useBreakpoints() + const convoId = route.params.conversation + return ( @@ -84,7 +104,9 @@ export function MessagesConversationSettingsScreen() { - + + + ) } @@ -94,10 +116,16 @@ function keyExtractor(item: Item) { } function SettingsInner() { + const {t: l} = useLingui() + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const bottomBarOffset = useBottomBarOffset() - const data: bsky.profile.AnyProfileView[] = [] + const convoState = useConvo() + const {currentAccount} = useSession() + const primaryMember = convoState?.getPrimaryMember?.() + + const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? [] const invites: string[] = [] const items = [ @@ -107,16 +135,26 @@ function SettingsInner() { { type: 'ADD_MEMBERS_LINK', }, - ...data.map((profile, index) => ({ - type: 'CHAT_MEMBER', - profile, - status: - index === 0 - ? 'admin' - : invites.includes(profile.did) - ? 'invited' - : 'member', - })), + ...[...data] + .sort((a, b) => { + const aIsAdmin = a.did === primaryMember?.did + const bIsAdmin = b.did === primaryMember?.did + const aIsSelf = a.did === currentAccount?.did + const bIsSelf = b.did === currentAccount?.did + if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 + return 0 + }) + .map(profile => ({ + type: 'CHAT_MEMBER', + profile, + status: + primaryMember?.did === profile.did + ? 'owner' + : invites.includes(profile.did) + ? 'invited' + : 'member', + })), ] function renderItem({item}: {item: Item}) { @@ -132,6 +170,19 @@ function SettingsInner() { } } + if (convoState.status === ConvoStatus.Error) { + return ( + <> + convoState.error.retry()} + sideBorders={false} + /> + + ) + } + return ( } + ListHeaderComponent={ + convoState.convo ? ( + + ) : ( + + ) + } renderItem={renderItem} sideBorders={false} windowSize={11} @@ -173,15 +230,17 @@ function MembersAndRequests({ {color: t.palette.contrast_500}, ]}>{l`${memberCount}/${MEMBER_LIMIT}`} - - {l`${plural(requestCount, { - one: '# request', - other: '# requests', - })}`} - + {requestCount > 0 ? ( + + {l`${plural(requestCount, { + one: '# request', + other: '# requests', + })}`} + + ) : null} ) } @@ -252,13 +311,14 @@ function Member({ profile, status, }: { - profile: bsky.profile.AnyProfileView - status: 'admin' | 'member' | 'invited' + profile: Shadow + status: 'owner' | 'member' | 'invited' }) { const navigation = useNavigation() const t = useTheme() const {t: l} = useLingui() + const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const moderation = useMemo( () => @@ -268,8 +328,6 @@ function Member({ if (!moderation) return null - const invitedByDisplayName = 'Darrin Loeliger' - const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount ? l`Deleted Account` @@ -278,27 +336,15 @@ function Member({ moderation.ui('displayName'), ) - let invitedBy: React.ReactNode | null = ( - - {l`Added by ${invitedByDisplayName}`} - - ) - let statusBadge: React.ReactNode | null = ( - - ) - switch (status) { - case 'admin': - invitedBy = null - statusBadge = - break - case 'invited': - invitedBy = ( - - {l`Invited by ${invitedByDisplayName}`} - - ) - statusBadge = - break + let statusBadge: React.ReactNode | null = null + if (currentAccount?.did === profile.did) { + switch (status) { + case 'owner': + statusBadge = + break + } + } else { + statusBadge = } return ( @@ -334,16 +380,6 @@ function Member({ ]}> {sanitizeHandle(profile.handle, '@')} - {invitedBy ? ( - - {invitedBy} - - ) : null} {statusBadge} @@ -353,7 +389,13 @@ function Member({ ) } -function StatusBadge({label}: {label: string}) { +function StatusBadge({ + label, + style, +}: { + label: string + style?: StyleProp +}) { const t = useTheme() return ( @@ -367,6 +409,7 @@ function StatusBadge({label}: {label: string}) { paddingLeft: 6, paddingRight: 6, }, + style, ]}> {label} @@ -410,14 +453,24 @@ function MemberMenu({ profile, type, }: { - profile: bsky.profile.AnyProfileView - type: 'member' | 'invited' + profile: Shadow + type: 'owner' | 'member' | 'invited' }) { const navigation = useNavigation() const t = useTheme() const {t: l} = useLingui() const ax = useAnalytics() + const requireEmailVerification = useRequireEmailVerification() + const convoState = useConvo() + const {currentAccount} = useSession() + + const blockMemberPrompt = Prompt.usePromptControl() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {mutate: initiateConvo} = useGetConvoForMembers({ @@ -429,8 +482,9 @@ function MemberMenu({ Toast.show(l`Failed to create conversation`) }, }) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const onPress = () => { + const messageMember = () => { if (!convoAvailability?.canChat) { return } @@ -446,7 +500,7 @@ function MemberMenu({ } } - const wrappedOnPress = requireEmailVerification(onPress, { + const handleMessageMember = requireEmailVerification(messageMember, { instructions: [ Before you can message another user, you must first verify your email. @@ -454,6 +508,36 @@ function MemberMenu({ ], }) + 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') { + ax.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') { + ax.logger.error('Failed to block account', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, { + type: 'error', + }) + } + } + } + } + const moderationOpts = useModerationOpts() const moderation = useMemo( () => @@ -472,67 +556,80 @@ function MemberMenu({ ) return ( - - - {({props, state}) => - type === 'invited' ? ( - - ) : ( - - - - ) - } - - - - { - navigation.navigate('Profile', {name: profile.did}) - }}> - - Go to profile - - - - - - Message - - - - - - - {type === 'member' ? ( - <> - {}}> + <> + + + {({props, state, control: menuControl}) => + type === 'owner' || type === 'invited' ? ( + + ) : ( + + + + ) + } + + + + { + navigation.navigate('Profile', {name: profile.did}) + }}> + + Go to profile + + + + + + Message + + + + + + + {type === 'owner' || type === 'member' ? ( + blockMemberPrompt.open()}> Block + ) : null} + {isOwner ? ( {}}> @@ -541,39 +638,80 @@ function MemberMenu({ - - ) : null} - {type === 'invited' ? ( - {}}> - - Uninvite - - - - ) : null} - - - + ) : null} + {isOwner && type === 'invited' ? ( + {}}> + + Uninvite + + + + ) : null} + + + + void handleBlockMember()} + /> + ) } -function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) { +function SettingsHeader({ + convo, + profiles, +}: { + convo: ChatBskyConvoDefs.ConvoView + profiles: bsky.profile.AnyProfileView[] +}) { const t = useTheme() const {t: l} = useLingui() + + const convoState = useConvo() + const {currentAccount} = useSession() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + + const {mutate: muteConvo} = useMuteConvo(convo.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: () => { + Toast.show(l`Could not mute group chat`, { + type: 'error', + }) + }, + }) + const editNamePrompt = Prompt.usePromptControl() const inviteLinkPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() - const [groupName, setGroupName] = useState('Work in Progress') + const [groupName, setGroupName] = useState( + convoState.getGroupInfo?.()?.name ?? '', + ) const [newGroupName, setNewGroupName] = useState(groupName) - const [isMuted, setIsMuted] = useState(false) const [isLocked, setIsLocked] = useState(false) const handleToggleMute = () => { - setIsMuted(prev => !prev) + try { + muteConvo({mute: !convo?.muted}) + } catch (err) { + const e = err as Error + logger.error('Failed to mute group chat', {message: e}) + Toast.show(l`There was an issue! ${e.toString()}`, {type: 'error'}) + } } const handlePromptName = () => { @@ -641,35 +779,57 @@ function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) { a.pt_2xl, ]}> - + {isOwner ? ( + + ) : null} - + {isOwner ? ( + + ) : null} + {isOwner ? null : ( + {}} + /> + )} + {isOwner ? null : ( + {}} + /> + )} + + + + + {l`…`} + + + + + + + + + + + + ) +} + function SettingsButton({ color = 'secondary', icon, @@ -713,6 +913,7 @@ function SettingsButton({ void - onConfirm: () => void +function SettingsButtonPlaceholder() { + const t = useTheme() + const {t: l} = useLingui() + + return ( + + + + {l`…`} + + + ) } function EditNamePrompt({ @@ -738,7 +956,12 @@ function EditNamePrompt({ value, onChangeText, onConfirm, -}: EditNamePromptProps) { +}: { + control: Dialog.DialogOuterProps['control'] + value: string + onChangeText: (value: string) => void + onConfirm: () => void +}) { const {t: l} = useLingui() return ( @@ -819,6 +1042,27 @@ function LockChatPrompt({ ) } +function BlockMemberPrompt({ + control, + onConfirm, +}: { + control: Dialog.DialogOuterProps['control'] + onConfirm: () => void +}) { + const {t: l} = useLingui() + + return ( + + ) +} + function SubtleHoverWrapper({children}: React.PropsWithChildren) { const { state: hover, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 08b96a9cc7..ae8f683661 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -365,7 +365,7 @@ export function MessagesList({ profile={convoState.convo.members.find( member => member.did === item.message.sender.did, )} - isGroupChat={convoState.convo.kind === 'group'} + isGroupChat={convoState.getGroupInfo?.() != null} /> ) } else if (item.type === 'deleted-message') { diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts index 08878d7fb5..d90ebb1b55 100644 --- a/src/state/queries/messages/mute-conversation.ts +++ b/src/state/queries/messages/mute-conversation.ts @@ -31,13 +31,13 @@ export function useMuteConvo( mutationFn: async ({mute}: {mute: boolean}) => { if (!convoId) throw new Error('No convoId provided') if (mute) { - const {data} = await agent.api.chat.bsky.convo.muteConvo( + const {data} = await agent.chat.bsky.convo.muteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) return data } else { - const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + const {data} = await agent.chat.bsky.convo.unmuteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, )