Bluesky app fork with some witchin' additions 💫
witchsky.app
bluesky
fork
client
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 account’s 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}