An ATproto social media client -- with an independent Appview.
at main 309 lines 9.1 kB view raw
1import {useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyNotificationDefs, 5 type AppBskyNotificationListActivitySubscriptions, 6 type ModerationOpts, 7 type Un$Typed, 8} from '@atproto/api' 9import {msg, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11import { 12 type InfiniteData, 13 useMutation, 14 useQueryClient, 15} from '@tanstack/react-query' 16 17import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 18import {cleanError} from '#/lib/strings/errors' 19import {sanitizeHandle} from '#/lib/strings/handles' 20import {logger} from '#/logger' 21import {isWeb} from '#/platform/detection' 22import {updateProfileShadow} from '#/state/cache/profile-shadow' 23import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' 24import {useAgent} from '#/state/session' 25import * as Toast from '#/view/com/util/Toast' 26import {platform, useTheme, web} from '#/alf' 27import {atoms as a} from '#/alf' 28import {Admonition} from '#/components/Admonition' 29import { 30 Button, 31 ButtonIcon, 32 type ButtonProps, 33 ButtonText, 34} from '#/components/Button' 35import * as Dialog from '#/components/Dialog' 36import * as Toggle from '#/components/forms/Toggle' 37import {Loader} from '#/components/Loader' 38import * as ProfileCard from '#/components/ProfileCard' 39import {Text} from '#/components/Typography' 40import type * as bsky from '#/types/bsky' 41 42export function SubscribeProfileDialog({ 43 control, 44 profile, 45 moderationOpts, 46 includeProfile, 47}: { 48 control: Dialog.DialogControlProps 49 profile: bsky.profile.AnyProfileView 50 moderationOpts: ModerationOpts 51 includeProfile?: boolean 52}) { 53 return ( 54 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 55 <Dialog.Handle /> 56 <DialogInner 57 profile={profile} 58 moderationOpts={moderationOpts} 59 includeProfile={includeProfile} 60 /> 61 </Dialog.Outer> 62 ) 63} 64 65function DialogInner({ 66 profile, 67 moderationOpts, 68 includeProfile, 69}: { 70 profile: bsky.profile.AnyProfileView 71 moderationOpts: ModerationOpts 72 includeProfile?: boolean 73}) { 74 const {_} = useLingui() 75 const t = useTheme() 76 const agent = useAgent() 77 const control = Dialog.useDialogContext() 78 const queryClient = useQueryClient() 79 const initialState = parseActivitySubscription( 80 profile.viewer?.activitySubscription, 81 ) 82 const [state, setState] = useState(initialState) 83 84 const values = useMemo(() => { 85 const {post, reply} = state 86 const res = [] 87 if (post) res.push('post') 88 if (reply) res.push('reply') 89 return res 90 }, [state]) 91 92 const onChange = (newValues: string[]) => { 93 setState(oldValues => { 94 // ensure you can't have reply without post 95 if (!oldValues.reply && newValues.includes('reply')) { 96 return { 97 post: true, 98 reply: true, 99 } 100 } 101 102 if (oldValues.post && !newValues.includes('post')) { 103 return { 104 post: false, 105 reply: false, 106 } 107 } 108 109 return { 110 post: newValues.includes('post'), 111 reply: newValues.includes('reply'), 112 } 113 }) 114 } 115 116 const { 117 mutate: saveChanges, 118 isPending: isSaving, 119 error, 120 } = useMutation({ 121 mutationFn: async ( 122 activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>, 123 ) => { 124 await agent.app.bsky.notification.putActivitySubscription({ 125 subject: profile.did, 126 activitySubscription, 127 }) 128 }, 129 onSuccess: (_data, activitySubscription) => { 130 control.close(() => { 131 updateProfileShadow(queryClient, profile.did, { 132 activitySubscription, 133 }) 134 135 if (!activitySubscription.post && !activitySubscription.reply) { 136 logger.metric('activitySubscription:disable', {}) 137 Toast.show( 138 _( 139 msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, 140 ), 141 'check', 142 ) 143 144 // filter out the subscription 145 queryClient.setQueryData( 146 RQKEY_getActivitySubscriptions, 147 ( 148 old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>, 149 ) => { 150 if (!old) return old 151 return { 152 ...old, 153 pages: old.pages.map(page => ({ 154 ...page, 155 subscriptions: page.subscriptions.filter( 156 item => item.did !== profile.did, 157 ), 158 })), 159 } 160 }, 161 ) 162 } else { 163 logger.metric('activitySubscription:enable', { 164 setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', 165 }) 166 if (!initialState.post && !initialState.reply) { 167 Toast.show( 168 _( 169 msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`, 170 ), 171 'check', 172 ) 173 } else { 174 Toast.show(_(msg`Changes saved`), 'check') 175 } 176 } 177 }) 178 }, 179 onError: err => { 180 logger.error('Could not save activity subscription', {message: err}) 181 }, 182 }) 183 184 const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => { 185 const isDirty = 186 state.post !== initialState.post || state.reply !== initialState.reply 187 const hasAny = state.post || state.reply 188 189 if (isDirty) { 190 return { 191 label: _(msg`Save changes`), 192 color: hasAny ? 'primary' : 'negative', 193 onPress: () => saveChanges(state), 194 disabled: isSaving, 195 } 196 } else { 197 // on web, a disabled save button feels more natural than a massive close button 198 if (isWeb) { 199 return { 200 label: _(msg`Save changes`), 201 color: 'secondary', 202 disabled: true, 203 } 204 } else { 205 return { 206 label: _(msg`Cancel`), 207 color: 'secondary', 208 onPress: () => control.close(), 209 } 210 } 211 } 212 }, [state, initialState, control, _, isSaving, saveChanges]) 213 214 const name = createSanitizedDisplayName(profile, false) 215 216 return ( 217 <Dialog.ScrollableInner 218 style={web({maxWidth: 400})} 219 label={_(msg`Get notified of new posts from ${name}`)}> 220 <View style={[a.gap_lg]}> 221 <View style={[a.gap_xs]}> 222 <Text style={[a.font_heavy, a.text_2xl]}> 223 <Trans>Keep me posted</Trans> 224 </Text> 225 <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 226 <Trans>Get notified of this accounts activity</Trans> 227 </Text> 228 </View> 229 230 {includeProfile && ( 231 <ProfileCard.Header> 232 <ProfileCard.Avatar 233 profile={profile} 234 moderationOpts={moderationOpts} 235 disabledPreview 236 /> 237 <ProfileCard.NameAndHandle 238 profile={profile} 239 moderationOpts={moderationOpts} 240 /> 241 </ProfileCard.Header> 242 )} 243 244 <Toggle.Group 245 label={_(msg`Subscribe to account activity`)} 246 values={values} 247 onChange={onChange}> 248 <View style={[a.gap_sm]}> 249 <Toggle.Item 250 label={_(msg`Posts`)} 251 name="post" 252 style={[ 253 a.flex_1, 254 a.py_xs, 255 platform({ 256 native: [a.justify_between], 257 web: [a.flex_row_reverse, a.gap_sm], 258 }), 259 ]}> 260 <Toggle.LabelText 261 style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}> 262 <Trans>Posts</Trans> 263 </Toggle.LabelText> 264 <Toggle.Switch /> 265 </Toggle.Item> 266 <Toggle.Item 267 label={_(msg`Replies`)} 268 name="reply" 269 style={[ 270 a.flex_1, 271 a.py_xs, 272 platform({ 273 native: [a.justify_between], 274 web: [a.flex_row_reverse, a.gap_sm], 275 }), 276 ]}> 277 <Toggle.LabelText 278 style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}> 279 <Trans>Replies</Trans> 280 </Toggle.LabelText> 281 <Toggle.Switch /> 282 </Toggle.Item> 283 </View> 284 </Toggle.Group> 285 286 {error && ( 287 <Admonition type="error"> 288 <Trans>Could not save changes: {cleanError(error)}</Trans> 289 </Admonition> 290 )} 291 292 <Button {...buttonProps} size="large" variant="solid"> 293 <ButtonText>{buttonProps.label}</ButtonText> 294 {isSaving && <ButtonIcon icon={Loader} />} 295 </Button> 296 </View> 297 298 <Dialog.Close /> 299 </Dialog.ScrollableInner> 300 ) 301} 302 303function parseActivitySubscription( 304 sub?: AppBskyNotificationDefs.ActivitySubscription, 305): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> { 306 if (!sub) return {post: false, reply: false} 307 const {post, reply} = sub 308 return {post, reply} 309}