this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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