An ATproto social media client -- with an independent Appview.
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 account’s 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}