Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
at main 310 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} 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 * as Toast from '#/view/com/util/Toast' 25import {atoms as a, platform, useTheme, web} from '#/alf' 26import {Admonition} from '#/components/Admonition' 27import { 28 Button, 29 ButtonIcon, 30 type ButtonProps, 31 ButtonText, 32} from '#/components/Button' 33import * as Dialog from '#/components/Dialog' 34import * as Toggle from '#/components/forms/Toggle' 35import {Loader} from '#/components/Loader' 36import * as ProfileCard from '#/components/ProfileCard' 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 'check', 143 ) 144 145 // filter out the subscription 146 queryClient.setQueryData( 147 RQKEY_getActivitySubscriptions, 148 ( 149 old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>, 150 ) => { 151 if (!old) return old 152 return { 153 ...old, 154 pages: old.pages.map(page => ({ 155 ...page, 156 subscriptions: page.subscriptions.filter( 157 item => item.did !== profile.did, 158 ), 159 })), 160 } 161 }, 162 ) 163 } else { 164 ax.metric('activitySubscription:enable', { 165 setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', 166 }) 167 if (!initialState.post && !initialState.reply) { 168 Toast.show( 169 _( 170 msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`, 171 ), 172 'check', 173 ) 174 } else { 175 Toast.show(_(msg`Changes saved`), 'check') 176 } 177 } 178 }) 179 }, 180 onError: err => { 181 ax.logger.error('Could not save activity subscription', {message: err}) 182 }, 183 }) 184 185 const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => { 186 const isDirty = 187 state.post !== initialState.post || state.reply !== initialState.reply 188 const hasAny = state.post || state.reply 189 190 if (isDirty) { 191 return { 192 label: _(msg`Save changes`), 193 color: hasAny ? 'primary' : 'negative', 194 onPress: () => saveChanges(state), 195 disabled: isSaving, 196 } 197 } else { 198 // on web, a disabled save button feels more natural than a massive close button 199 if (IS_WEB) { 200 return { 201 label: _(msg`Save changes`), 202 color: 'secondary', 203 disabled: true, 204 } 205 } else { 206 return { 207 label: _(msg`Cancel`), 208 color: 'secondary', 209 onPress: () => control.close(), 210 } 211 } 212 } 213 }, [state, initialState, control, _, isSaving, saveChanges]) 214 215 const name = createSanitizedDisplayName(profile, false) 216 217 return ( 218 <Dialog.ScrollableInner 219 style={web({maxWidth: 400})} 220 label={_(msg`Get notified of new posts from ${name}`)}> 221 <View style={[a.gap_lg]}> 222 <View style={[a.gap_xs]}> 223 <Text style={[a.font_bold, a.text_2xl]}> 224 <Trans>Keep me posted</Trans> 225 </Text> 226 <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 227 <Trans>Get notified of this accounts activity</Trans> 228 </Text> 229 </View> 230 231 {includeProfile && ( 232 <ProfileCard.Header> 233 <ProfileCard.Avatar 234 profile={profile} 235 moderationOpts={moderationOpts} 236 disabledPreview 237 /> 238 <ProfileCard.NameAndHandle 239 profile={profile} 240 moderationOpts={moderationOpts} 241 /> 242 </ProfileCard.Header> 243 )} 244 245 <Toggle.Group 246 label={_(msg`Subscribe to account activity`)} 247 values={values} 248 onChange={onChange}> 249 <View style={[a.gap_sm]}> 250 <Toggle.Item 251 label={_(msg`Posts`)} 252 name="post" 253 style={[ 254 a.flex_1, 255 a.py_xs, 256 platform({ 257 native: [a.justify_between], 258 web: [a.flex_row_reverse, a.gap_sm], 259 }), 260 ]}> 261 <Toggle.LabelText 262 style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}> 263 <Trans>Posts</Trans> 264 </Toggle.LabelText> 265 <Toggle.Switch /> 266 </Toggle.Item> 267 <Toggle.Item 268 label={_(msg`Replies`)} 269 name="reply" 270 style={[ 271 a.flex_1, 272 a.py_xs, 273 platform({ 274 native: [a.justify_between], 275 web: [a.flex_row_reverse, a.gap_sm], 276 }), 277 ]}> 278 <Toggle.LabelText 279 style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}> 280 <Trans>Replies</Trans> 281 </Toggle.LabelText> 282 <Toggle.Switch /> 283 </Toggle.Item> 284 </View> 285 </Toggle.Group> 286 287 {error && ( 288 <Admonition type="error"> 289 <Trans>Could not save changes: {cleanError(error)}</Trans> 290 </Admonition> 291 )} 292 293 <Button {...buttonProps} size="large" variant="solid"> 294 <ButtonText>{buttonProps.label}</ButtonText> 295 {isSaving && <ButtonIcon icon={Loader} />} 296 </Button> 297 </View> 298 299 <Dialog.Close /> 300 </Dialog.ScrollableInner> 301 ) 302} 303 304function parseActivitySubscription( 305 sub?: AppBskyNotificationDefs.ActivitySubscription, 306): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> { 307 if (!sub) return {post: false, reply: false} 308 const {post, reply} = sub 309 return {post, reply} 310}