Bluesky app fork with some witchin' additions 💫

[Clipclops] Clop menu, leave clop, mute/unmute clop (#3804)

* convo menu

* memoize convomenu

* add convoId to useChat + memoize value

* leave convo

* Create mute-conversation.ts

* add mutes, remove changes to useChat and use chat.convo instead

* add todo comments

* leave convo confirm prompt

* remove dependency on useChat and pass in props instead

* show menu on long press

* optimistic update

* optimistic update leave + add error capture

* don't `popToTop` when unnecessary

---------

Co-authored-by: Hailey <me@haileyok.com>

authored by samuel.fm

Hailey and committed by
GitHub
e19f8824 d3fafdc0

+420 -57
+1
assets/icons/arrowBoxLeft_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1 -1
src/components/Button.tsx
··· 64 64 65 65 export type ButtonProps = Pick< 66 66 PressableProps, 67 - 'disabled' | 'onPress' | 'testID' 67 + 'disabled' | 'onPress' | 'testID' | 'onLongPress' 68 68 > & 69 69 AccessibilityProps & 70 70 VariantProps & {
+13 -11
src/components/Menu/index.tsx
··· 1 1 import React from 'react' 2 - import {View, Pressable, ViewStyle, StyleProp} from 'react-native' 2 + import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 3 5 import flattenReactChildren from 'react-keyed-flatten-children' 4 6 7 + import {isNative} from 'platform/detection' 5 8 import {atoms as a, useTheme} from '#/alf' 9 + import {Button, ButtonText} from '#/components/Button' 6 10 import * as Dialog from '#/components/Dialog' 7 11 import {useInteractionState} from '#/components/hooks/useInteractionState' 8 - import {Text} from '#/components/Typography' 9 - 10 12 import {Context} from '#/components/Menu/context' 11 13 import { 12 14 ContextType, 13 - TriggerProps, 14 - ItemProps, 15 15 GroupProps, 16 - ItemTextProps, 17 16 ItemIconProps, 17 + ItemProps, 18 + ItemTextProps, 19 + TriggerProps, 18 20 } from '#/components/Menu/types' 19 - import {Button, ButtonText} from '#/components/Button' 20 - import {Trans, msg} from '@lingui/macro' 21 - import {useLingui} from '@lingui/react' 22 - import {isNative} from 'platform/detection' 21 + import {Text} from '#/components/Typography' 23 22 24 - export {useDialogControl as useMenuControl} from '#/components/Dialog' 23 + export { 24 + type DialogControlProps as MenuControlProps, 25 + useDialogControl as useMenuControl, 26 + } from '#/components/Dialog' 25 27 26 28 export function useMemoControlContext() { 27 29 return React.useContext(Context)
+177
src/components/dms/ConvoMenu.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {Pressable} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + import {useNavigation} from '@react-navigation/native' 8 + 9 + import {NavigationProp} from '#/lib/routes/types' 10 + import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 11 + import { 12 + useMuteConvo, 13 + useUnmuteConvo, 14 + } from '#/state/queries/messages/mute-conversation' 15 + import * as Toast from '#/view/com/util/Toast' 16 + import {atoms as a, useTheme} from '#/alf' 17 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 18 + import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 19 + import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 20 + import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 21 + import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 22 + import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 23 + import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 24 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 25 + import * as Menu from '#/components/Menu' 26 + import * as Prompt from '#/components/Prompt' 27 + 28 + let ConvoMenu = ({ 29 + convo, 30 + profile, 31 + onUpdateConvo, 32 + control, 33 + hideTrigger, 34 + currentScreen, 35 + }: { 36 + convo: ChatBskyConvoDefs.ConvoView 37 + profile: AppBskyActorDefs.ProfileViewBasic 38 + onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void 39 + control?: Menu.MenuControlProps 40 + hideTrigger?: boolean 41 + currentScreen: 'list' | 'conversation' 42 + }): React.ReactNode => { 43 + const navigation = useNavigation<NavigationProp>() 44 + const {_} = useLingui() 45 + const t = useTheme() 46 + const leaveConvoControl = Prompt.usePromptControl() 47 + 48 + const onNavigateToProfile = useCallback(() => { 49 + navigation.navigate('Profile', {name: profile.did}) 50 + }, [navigation, profile.did]) 51 + 52 + const {mutate: muteConvo} = useMuteConvo(convo.id, { 53 + onSuccess: data => { 54 + onUpdateConvo?.(data.convo) 55 + Toast.show(_(msg`Chat muted`)) 56 + }, 57 + onError: () => { 58 + Toast.show(_(msg`Could not mute chat`)) 59 + }, 60 + }) 61 + 62 + const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, { 63 + onSuccess: data => { 64 + onUpdateConvo?.(data.convo) 65 + Toast.show(_(msg`Chat unmuted`)) 66 + }, 67 + onError: () => { 68 + Toast.show(_(msg`Could not unmute chat`)) 69 + }, 70 + }) 71 + 72 + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { 73 + onSuccess: () => { 74 + if (currentScreen === 'conversation') { 75 + navigation.replace('MessagesList') 76 + } 77 + }, 78 + onError: () => { 79 + Toast.show(_(msg`Could not leave chat`)) 80 + }, 81 + }) 82 + 83 + return ( 84 + <> 85 + <Menu.Root control={control}> 86 + {!hideTrigger && ( 87 + <Menu.Trigger label={_(msg`Chat settings`)}> 88 + {({props, state}) => ( 89 + <Pressable 90 + {...props} 91 + style={[ 92 + a.p_sm, 93 + a.rounded_sm, 94 + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 95 + // make sure pfp is in the middle 96 + {marginLeft: -10}, 97 + ]}> 98 + <DotsHorizontal size="lg" style={t.atoms.text} /> 99 + </Pressable> 100 + )} 101 + </Menu.Trigger> 102 + )} 103 + <Menu.Outer> 104 + <Menu.Group> 105 + <Menu.Item 106 + label={_(msg`Go to user's profile`)} 107 + onPress={onNavigateToProfile}> 108 + <Menu.ItemText> 109 + <Trans>Go to profile</Trans> 110 + </Menu.ItemText> 111 + <Menu.ItemIcon icon={Person} /> 112 + </Menu.Item> 113 + <Menu.Item 114 + label={_(msg`Mute notifications`)} 115 + onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}> 116 + <Menu.ItemText> 117 + {convo?.muted ? ( 118 + <Trans>Unmute notifications</Trans> 119 + ) : ( 120 + <Trans>Mute notifications</Trans> 121 + )} 122 + </Menu.ItemText> 123 + <Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} /> 124 + </Menu.Item> 125 + </Menu.Group> 126 + {/* TODO(samuel): implement these */} 127 + <Menu.Group> 128 + <Menu.Item 129 + label={_(msg`Block account`)} 130 + onPress={() => {}} 131 + disabled> 132 + <Menu.ItemText> 133 + <Trans>Block account</Trans> 134 + </Menu.ItemText> 135 + <Menu.ItemIcon 136 + icon={profile.viewer?.blocking ? PersonCheck : PersonX} 137 + /> 138 + </Menu.Item> 139 + <Menu.Item 140 + label={_(msg`Report account`)} 141 + onPress={() => {}} 142 + disabled> 143 + <Menu.ItemText> 144 + <Trans>Report account</Trans> 145 + </Menu.ItemText> 146 + <Menu.ItemIcon icon={Flag} /> 147 + </Menu.Item> 148 + </Menu.Group> 149 + <Menu.Group> 150 + <Menu.Item 151 + label={_(msg`Leave conversation`)} 152 + onPress={leaveConvoControl.open}> 153 + <Menu.ItemText> 154 + <Trans>Leave conversation</Trans> 155 + </Menu.ItemText> 156 + <Menu.ItemIcon icon={ArrowBoxLeft} /> 157 + </Menu.Item> 158 + </Menu.Group> 159 + </Menu.Outer> 160 + </Menu.Root> 161 + 162 + <Prompt.Basic 163 + control={leaveConvoControl} 164 + title={_(msg`Leave conversation`)} 165 + description={_( 166 + msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`, 167 + )} 168 + confirmButtonCta={_(msg`Leave`)} 169 + confirmButtonColor="negative" 170 + onConfirm={() => leaveConvo()} 171 + /> 172 + </> 173 + ) 174 + } 175 + ConvoMenu = React.memo(ConvoMenu) 176 + 177 + export {ConvoMenu}
+5
src/components/icons/ArrowBoxLeft.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+28 -24
src/screens/Messages/Conversation/index.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback} from 'react' 2 2 import {TouchableOpacity, View} from 'react-native' 3 3 import {AppBskyActorDefs} from '@atproto/api' 4 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 4 5 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' ··· 14 15 import {ChatProvider, useChat} from 'state/messages' 15 16 import {ConvoStatus} from 'state/messages/convo' 16 17 import {useSession} from 'state/session' 17 - import {UserAvatar} from 'view/com/util/UserAvatar' 18 + import {PreviewableUserAvatar} from 'view/com/util/UserAvatar' 18 19 import {CenteredView} from 'view/com/util/Views' 19 20 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' 20 21 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 21 - import {Button, ButtonIcon} from '#/components/Button' 22 - import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid' 22 + import {ConvoMenu} from '#/components/dms/ConvoMenu' 23 23 import {ListMaybePlaceholder} from '#/components/Lists' 24 24 import {Text} from '#/components/Typography' 25 25 import {ClipClopGate} from '../gate' ··· 78 78 const {_} = useLingui() 79 79 const {gtTablet} = useBreakpoints() 80 80 const navigation = useNavigation<NavigationProp>() 81 + const {service} = useChat() 81 82 82 - const onPressBack = React.useCallback(() => { 83 + const onPressBack = useCallback(() => { 83 84 if (isWeb) { 84 85 navigation.replace('MessagesList') 85 86 } else { ··· 87 88 } 88 89 }, [navigation]) 89 90 91 + const onUpdateConvo = useCallback( 92 + (convo: ChatBskyConvoDefs.ConvoView) => { 93 + service.convo = convo 94 + }, 95 + [service], 96 + ) 97 + 90 98 return ( 91 99 <View 92 100 style={[ ··· 95 103 a.border_b, 96 104 a.flex_row, 97 105 a.justify_between, 106 + a.align_start, 98 107 a.gap_lg, 99 108 a.px_lg, 100 109 a.py_sm, 101 110 ]}> 102 111 {!gtTablet ? ( 103 112 <TouchableOpacity 104 - testID="viewHeaderDrawerBtn" 113 + testID="conversationHeaderBackBtn" 105 114 onPress={onPressBack} 106 115 hitSlop={BACK_HITSLOP} 107 - style={{ 108 - width: 30, 109 - height: 30, 110 - }} 116 + style={{width: 30, height: 30}} 111 117 accessibilityRole="button" 112 118 accessibilityLabel={_(msg`Back`)} 113 - accessibilityHint={_(msg`Access navigation links and settings`)}> 119 + accessibilityHint=""> 114 120 <FontAwesomeIcon 115 121 size={18} 116 122 icon="angle-left" ··· 124 130 <View style={{width: 30}} /> 125 131 )} 126 132 <View style={[a.align_center, a.gap_sm]}> 127 - <UserAvatar size={32} avatar={profile.avatar} /> 133 + <PreviewableUserAvatar size={32} profile={profile} /> 128 134 <Text style={[a.text_lg, a.font_bold]}> 129 135 <Trans>{profile.displayName}</Trans> 130 136 </Text> 131 137 </View> 132 - <View> 133 - <Button 134 - label={_(msg`Chat settings`)} 135 - color="secondary" 136 - size="large" 137 - variant="ghost" 138 - style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]} 139 - onPress={() => {}}> 140 - <ButtonIcon icon={DotGrid_Stroke2_Corner0_Rounded} /> 141 - </Button> 142 - </View> 138 + {service.convo ? ( 139 + <ConvoMenu 140 + convo={service.convo} 141 + profile={profile} 142 + onUpdateConvo={onUpdateConvo} 143 + currentScreen="conversation" 144 + /> 145 + ) : ( 146 + <View style={{width: 30}} /> 147 + )} 143 148 </View> 144 149 ) 145 150 } 146 - 147 151 Header = React.memo(Header)
+24 -3
src/screens/Messages/List/index.tsx
··· 12 12 import {useGate} from '#/lib/statsig/statsig' 13 13 import {cleanError} from '#/lib/strings/errors' 14 14 import {logger} from '#/logger' 15 + import {isNative} from '#/platform/detection' 15 16 import {useListConvos} from '#/state/queries/messages/list-converations' 16 17 import {useSession} from '#/state/session' 17 18 import {List} from '#/view/com/util/List' ··· 22 23 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 23 24 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24 25 import {DialogControlProps, useDialogControl} from '#/components/Dialog' 26 + import {ConvoMenu} from '#/components/dms/ConvoMenu' 25 27 import {NewChat} from '#/components/dms/NewChat' 26 28 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 27 29 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 28 30 import {Link} from '#/components/Link' 29 31 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 32 + import {useMenuControl} from '#/components/Menu' 30 33 import {Text} from '#/components/Typography' 31 34 import {ClipClopGate} from '../gate' 32 35 ··· 190 193 const t = useTheme() 191 194 const {_} = useLingui() 192 195 const {currentAccount} = useSession() 196 + const menuControl = useMenuControl() 193 197 194 198 let lastMessage = _(msg`No messages yet`) 195 199 let lastMessageSentAt: string | null = null ··· 214 218 } 215 219 216 220 return ( 217 - <Link to={`/messages/${convo.id}`} style={a.flex_1}> 221 + <Link 222 + to={`/messages/${convo.id}`} 223 + style={a.flex_1} 224 + onLongPress={isNative ? menuControl.open : undefined}> 218 225 {({hovered, pressed}) => ( 219 226 <View 220 227 style={[ ··· 267 274 a.flex_0, 268 275 a.ml_md, 269 276 a.mt_sm, 270 - {backgroundColor: t.palette.primary_500}, 271 277 a.rounded_full, 272 - {height: 7, width: 7}, 278 + { 279 + backgroundColor: convo.muted 280 + ? t.palette.contrast_200 281 + : t.palette.primary_500, 282 + height: 7, 283 + width: 7, 284 + }, 273 285 ]} 274 286 /> 275 287 )} 288 + <ConvoMenu 289 + convo={convo} 290 + profile={otherUser} 291 + control={menuControl} 292 + // TODO(sam) show on hover on web 293 + // tricky because it captures the mouse event 294 + hideTrigger 295 + currentScreen="list" 296 + /> 276 297 </View> 277 298 )} 278 299 </Link>
+14 -17
src/state/messages/index.tsx
··· 1 - import React from 'react' 1 + import React, {useContext, useEffect, useMemo, useState} from 'react' 2 2 import {BskyAgent} from '@atproto-labs/api' 3 3 4 4 import {Convo, ConvoParams} from '#/state/messages/convo' ··· 8 8 const ChatContext = React.createContext<{ 9 9 service: Convo 10 10 state: Convo['state'] 11 - }>({ 12 - // @ts-ignore 13 - service: null, 14 - // @ts-ignore 15 - state: null, 16 - }) 11 + } | null>(null) 17 12 18 13 export function useChat() { 19 - return React.useContext(ChatContext) 14 + const ctx = useContext(ChatContext) 15 + if (!ctx) { 16 + throw new Error('useChat must be used within a ChatProvider') 17 + } 18 + return ctx 20 19 } 21 20 22 21 export function ChatProvider({ ··· 25 24 }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { 26 25 const {serviceUrl} = useDmServiceUrlStorage() 27 26 const {getAgent} = useAgent() 28 - const [service] = React.useState( 27 + const [service] = useState( 29 28 () => 30 29 new Convo({ 31 30 convoId, ··· 35 34 __tempFromUserDid: getAgent().session?.did!, 36 35 }), 37 36 ) 38 - const [state, setState] = React.useState(service.state) 37 + const [state, setState] = useState(service.state) 39 38 40 - React.useEffect(() => { 39 + useEffect(() => { 41 40 service.initialize() 42 41 }, [service]) 43 42 44 - React.useEffect(() => { 43 + useEffect(() => { 45 44 const update = () => setState(service.state) 46 45 service.on('update', update) 47 46 return () => { ··· 49 48 } 50 49 }, [service]) 51 50 52 - return ( 53 - <ChatContext.Provider value={{state, service}}> 54 - {children} 55 - </ChatContext.Provider> 56 - ) 51 + const value = useMemo(() => ({service, state}), [service, state]) 52 + 53 + return <ChatContext.Provider value={value}>{children}</ChatContext.Provider> 57 54 }
+5 -1
src/state/queries/messages/get-convo-for-members.ts
··· 1 1 import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api' 2 2 import {useMutation, useQueryClient} from '@tanstack/react-query' 3 3 4 + import {logger} from '#/logger' 4 5 import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 6 import {RQKEY as CONVO_KEY} from './conversation' 6 7 import {useHeaders} from './temp-headers' ··· 30 31 queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) 31 32 onSuccess?.(data) 32 33 }, 33 - onError, 34 + onError: error => { 35 + logger.error(error) 36 + onError?.(error) 37 + }, 34 38 }) 35 39 }
+68
src/state/queries/messages/leave-conversation.ts
··· 1 + import { 2 + BskyAgent, 3 + ChatBskyConvoLeaveConvo, 4 + ChatBskyConvoListConvos, 5 + } from '@atproto-labs/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {logger} from '#/logger' 9 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 10 + import {RQKEY as CONVO_LIST_KEY} from './list-converations' 11 + import {useHeaders} from './temp-headers' 12 + 13 + export function useLeaveConvo( 14 + convoId: string, 15 + { 16 + onSuccess, 17 + onError, 18 + }: { 19 + onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void 20 + onError?: (error: Error) => void 21 + }, 22 + ) { 23 + const queryClient = useQueryClient() 24 + const headers = useHeaders() 25 + const {serviceUrl} = useDmServiceUrlStorage() 26 + 27 + return useMutation({ 28 + mutationFn: async () => { 29 + const agent = new BskyAgent({service: serviceUrl}) 30 + const {data} = await agent.api.chat.bsky.convo.leaveConvo( 31 + {convoId}, 32 + {headers, encoding: 'application/json'}, 33 + ) 34 + 35 + return data 36 + }, 37 + onMutate: () => { 38 + queryClient.setQueryData( 39 + CONVO_LIST_KEY, 40 + (old?: { 41 + pageParams: Array<string | undefined> 42 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 43 + }) => { 44 + console.log('old', old) 45 + if (!old) return old 46 + return { 47 + ...old, 48 + pages: old.pages.map(page => { 49 + return { 50 + ...page, 51 + convos: page.convos.filter(convo => convo.id !== convoId), 52 + } 53 + }), 54 + } 55 + }, 56 + ) 57 + }, 58 + onSuccess: data => { 59 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 60 + onSuccess?.(data) 61 + }, 62 + onError: error => { 63 + logger.error(error) 64 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 65 + onError?.(error) 66 + }, 67 + }) 68 + }
+84
src/state/queries/messages/mute-conversation.ts
··· 1 + import { 2 + BskyAgent, 3 + ChatBskyConvoMuteConvo, 4 + ChatBskyConvoUnmuteConvo, 5 + } from '@atproto-labs/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {logger} from '#/logger' 9 + import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 10 + import {RQKEY as CONVO_KEY} from './conversation' 11 + import {RQKEY as CONVO_LIST_KEY} from './list-converations' 12 + import {useHeaders} from './temp-headers' 13 + 14 + export function useMuteConvo( 15 + convoId: string, 16 + { 17 + onSuccess, 18 + onError, 19 + }: { 20 + onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void 21 + onError?: (error: Error) => void 22 + }, 23 + ) { 24 + const queryClient = useQueryClient() 25 + const headers = useHeaders() 26 + const {serviceUrl} = useDmServiceUrlStorage() 27 + 28 + return useMutation({ 29 + mutationFn: async () => { 30 + const agent = new BskyAgent({service: serviceUrl}) 31 + const {data} = await agent.api.chat.bsky.convo.muteConvo( 32 + {convoId}, 33 + {headers, encoding: 'application/json'}, 34 + ) 35 + 36 + return data 37 + }, 38 + onSuccess: data => { 39 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 40 + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) 41 + onSuccess?.(data) 42 + }, 43 + onError: error => { 44 + logger.error(error) 45 + onError?.(error) 46 + }, 47 + }) 48 + } 49 + 50 + export function useUnmuteConvo( 51 + convoId: string, 52 + { 53 + onSuccess, 54 + onError, 55 + }: { 56 + onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void 57 + onError?: (error: Error) => void 58 + }, 59 + ) { 60 + const queryClient = useQueryClient() 61 + const headers = useHeaders() 62 + const {serviceUrl} = useDmServiceUrlStorage() 63 + 64 + return useMutation({ 65 + mutationFn: async () => { 66 + const agent = new BskyAgent({service: serviceUrl}) 67 + const {data} = await agent.api.chat.bsky.convo.unmuteConvo( 68 + {convoId}, 69 + {headers, encoding: 'application/json'}, 70 + ) 71 + 72 + return data 73 + }, 74 + onSuccess: data => { 75 + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) 76 + queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)}) 77 + onSuccess?.(data) 78 + }, 79 + onError: error => { 80 + logger.error(error) 81 + onError?.(error) 82 + }, 83 + }) 84 + }