diff --git a/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg b/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..6d661a55 --- /dev/null +++ b/assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 33d77797..dc319eb5 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -64,7 +64,7 @@ type NonTextElements = export type ButtonProps = Pick< PressableProps, - 'disabled' | 'onPress' | 'testID' + 'disabled' | 'onPress' | 'testID' | 'onLongPress' > & AccessibilityProps & VariantProps & { diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 051e95b9..3be69b34 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,27 +1,29 @@ import React from 'react' -import {View, Pressable, ViewStyle, StyleProp} from 'react-native' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import flattenReactChildren from 'react-keyed-flatten-children' +import {isNative} from 'platform/detection' import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Text} from '#/components/Typography' - import {Context} from '#/components/Menu/context' import { ContextType, - TriggerProps, - ItemProps, GroupProps, - ItemTextProps, ItemIconProps, + ItemProps, + ItemTextProps, + TriggerProps, } from '#/components/Menu/types' -import {Button, ButtonText} from '#/components/Button' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {isNative} from 'platform/detection' +import {Text} from '#/components/Typography' -export {useDialogControl as useMenuControl} from '#/components/Dialog' +export { + type DialogControlProps as MenuControlProps, + useDialogControl as useMenuControl, +} from '#/components/Dialog' export function useMemoControlContext() { return React.useContext(Context) diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx new file mode 100644 index 00000000..777d6c08 --- /dev/null +++ b/src/components/dms/ConvoMenu.tsx @@ -0,0 +1,177 @@ +import React, {useCallback} from 'react' +import {Pressable} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + useMuteConvo, + useUnmuteConvo, +} from '#/state/queries/messages/mute-conversation' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' + +let ConvoMenu = ({ + convo, + profile, + onUpdateConvo, + control, + hideTrigger, + currentScreen, +}: { + convo: ChatBskyConvoDefs.ConvoView + profile: AppBskyActorDefs.ProfileViewBasic + onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void + control?: Menu.MenuControlProps + hideTrigger?: boolean + currentScreen: 'list' | 'conversation' +}): React.ReactNode => { + const navigation = useNavigation() + const {_} = useLingui() + const t = useTheme() + const leaveConvoControl = Prompt.usePromptControl() + + const onNavigateToProfile = useCallback(() => { + navigation.navigate('Profile', {name: profile.did}) + }, [navigation, profile.did]) + + const {mutate: muteConvo} = useMuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat muted`)) + }, + onError: () => { + Toast.show(_(msg`Could not mute chat`)) + }, + }) + + const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat unmuted`)) + }, + onError: () => { + Toast.show(_(msg`Could not unmute chat`)) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onSuccess: () => { + if (currentScreen === 'conversation') { + navigation.replace('MessagesList') + } + }, + onError: () => { + Toast.show(_(msg`Could not leave chat`)) + }, + }) + + return ( + <> + + {!hideTrigger && ( + + {({props, state}) => ( + + + + )} + + )} + + + + + Go to profile + + + + (convo?.muted ? unmuteConvo() : muteConvo())}> + + {convo?.muted ? ( + Unmute notifications + ) : ( + Mute notifications + )} + + + + + {/* TODO(samuel): implement these */} + + {}} + disabled> + + Block account + + + + {}} + disabled> + + Report account + + + + + + + + Leave conversation + + + + + + + + leaveConvo()} + /> + + ) +} +ConvoMenu = React.memo(ConvoMenu) + +export {ConvoMenu} diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx new file mode 100644 index 00000000..011bf6af --- /dev/null +++ b/src/components/icons/ArrowBoxLeft.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 79c49f05..f5663fdc 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,6 +1,7 @@ -import React from 'react' +import React, {useCallback} from 'react' import {TouchableOpacity, View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' +import {ChatBskyConvoDefs} from '@atproto-labs/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -14,12 +15,11 @@ import {isWeb} from 'platform/detection' import {ChatProvider, useChat} from 'state/messages' import {ConvoStatus} from 'state/messages/convo' import {useSession} from 'state/session' -import {UserAvatar} from 'view/com/util/UserAvatar' +import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' import {CenteredView} from 'view/com/util/Views' import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid' +import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ListMaybePlaceholder} from '#/components/Lists' import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' @@ -78,8 +78,9 @@ let Header = ({ const {_} = useLingui() const {gtTablet} = useBreakpoints() const navigation = useNavigation() + const {service} = useChat() - const onPressBack = React.useCallback(() => { + const onPressBack = useCallback(() => { if (isWeb) { navigation.replace('MessagesList') } else { @@ -87,6 +88,13 @@ let Header = ({ } }, [navigation]) + const onUpdateConvo = useCallback( + (convo: ChatBskyConvoDefs.ConvoView) => { + service.convo = convo + }, + [service], + ) + return ( {!gtTablet ? ( + accessibilityHint=""> )} - + {profile.displayName} - - - + {service.convo ? ( + + ) : ( + + )} ) } - Header = React.memo(Header) diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 3d8723ec..497b2389 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -12,6 +12,7 @@ import {MessagesTabNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {isNative} from '#/platform/detection' import {useListConvos} from '#/state/queries/messages/list-converations' import {useSession} from '#/state/session' import {List} from '#/view/com/util/List' @@ -22,11 +23,13 @@ import {CenteredView} from '#/view/com/util/Views' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogControlProps, useDialogControl} from '#/components/Dialog' +import {ConvoMenu} from '#/components/dms/ConvoMenu' import {NewChat} from '#/components/dms/NewChat' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' import {Link} from '#/components/Link' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import {useMenuControl} from '#/components/Menu' import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' @@ -190,6 +193,7 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() + const menuControl = useMenuControl() let lastMessage = _(msg`No messages yet`) let lastMessageSentAt: string | null = null @@ -214,7 +218,10 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { } return ( - + {({hovered, pressed}) => ( )} + )} diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx index c5991525..cdc5a4db 100644 --- a/src/state/messages/index.tsx +++ b/src/state/messages/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useContext, useEffect, useMemo, useState} from 'react' import {BskyAgent} from '@atproto-labs/api' import {Convo, ConvoParams} from '#/state/messages/convo' @@ -8,15 +8,14 @@ import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlSto const ChatContext = React.createContext<{ service: Convo state: Convo['state'] -}>({ - // @ts-ignore - service: null, - // @ts-ignore - state: null, -}) +} | null>(null) export function useChat() { - return React.useContext(ChatContext) + const ctx = useContext(ChatContext) + if (!ctx) { + throw new Error('useChat must be used within a ChatProvider') + } + return ctx } export function ChatProvider({ @@ -25,7 +24,7 @@ export function ChatProvider({ }: Pick & {children: React.ReactNode}) { const {serviceUrl} = useDmServiceUrlStorage() const {getAgent} = useAgent() - const [service] = React.useState( + const [service] = useState( () => new Convo({ convoId, @@ -35,13 +34,13 @@ export function ChatProvider({ __tempFromUserDid: getAgent().session?.did!, }), ) - const [state, setState] = React.useState(service.state) + const [state, setState] = useState(service.state) - React.useEffect(() => { + useEffect(() => { service.initialize() }, [service]) - React.useEffect(() => { + useEffect(() => { const update = () => setState(service.state) service.on('update', update) return () => { @@ -49,9 +48,7 @@ export function ChatProvider({ } }, [service]) - return ( - - {children} - - ) + const value = useMemo(() => ({service, state}), [service, state]) + + return {children} } diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts index 8a58a98d..0a657c07 100644 --- a/src/state/queries/messages/get-convo-for-members.ts +++ b/src/state/queries/messages/get-convo-for-members.ts @@ -1,6 +1,7 @@ import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api' import {useMutation, useQueryClient} from '@tanstack/react-query' +import {logger} from '#/logger' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {RQKEY as CONVO_KEY} from './conversation' import {useHeaders} from './temp-headers' @@ -30,6 +31,9 @@ export function useGetConvoForMembers({ queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) onSuccess?.(data) }, - onError, + onError: error => { + logger.error(error) + onError?.(error) + }, }) } diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts new file mode 100644 index 00000000..0dd67fa0 --- /dev/null +++ b/src/state/queries/messages/leave-conversation.ts @@ -0,0 +1,68 @@ +import { + BskyAgent, + ChatBskyConvoLeaveConvo, + ChatBskyConvoListConvos, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useLeaveConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.leaveConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onMutate: () => { + queryClient.setQueryData( + CONVO_LIST_KEY, + (old?: { + pageParams: Array + pages: Array + }) => { + console.log('old', old) + if (!old) return old + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.filter(convo => convo.id !== convoId), + } + }), + } + }, + ) + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts new file mode 100644 index 00000000..4840c65a --- /dev/null +++ b/src/state/queries/messages/mute-conversation.ts @@ -0,0 +1,84 @@ +import { + BskyAgent, + ChatBskyConvoMuteConvo, + ChatBskyConvoUnmuteConvo, +} from '@atproto-labs/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {RQKEY as CONVO_KEY} from './conversation' +import {RQKEY as CONVO_LIST_KEY} from './list-converations' +import {useHeaders} from './temp-headers' + +export function useMuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.muteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} + +export function useUnmuteConvo( + convoId: string, + { + onSuccess, + onError, + }: { + onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + + return useMutation({ + mutationFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + {convoId}, + {headers, encoding: 'application/json'}, + ) + + return data + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +}