Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo, useState} from 'react'
2import {LayoutAnimation, Text as NestedText, View} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 type AppBskyFeedPostgate,
6 AtUri,
7} from '@atproto/api'
8import {msg} from '@lingui/core/macro'
9import {useLingui} from '@lingui/react'
10import {Plural, Trans} from '@lingui/react/macro'
11import {useQueryClient} from '@tanstack/react-query'
12
13import {useHaptics} from '#/lib/haptics'
14import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
15import {STALE} from '#/state/queries'
16import {useMyListsQuery} from '#/state/queries/my-lists'
17import {useGetPost} from '#/state/queries/post'
18import {
19 createPostgateQueryKey,
20 getPostgateRecord,
21 usePostgateQuery,
22 useWritePostgateMutation,
23} from '#/state/queries/postgate'
24import {
25 createPostgateRecord,
26 embeddingRules,
27} from '#/state/queries/postgate/util'
28import {
29 createThreadgateViewQueryKey,
30 type ThreadgateAllowUISetting,
31 threadgateViewToAllowUISetting,
32 useSetThreadgateAllowMutation,
33 useThreadgateViewQuery,
34} from '#/state/queries/threadgate'
35import {
36 PostThreadContextProvider,
37 usePostThreadContext,
38} from '#/state/queries/usePostThread'
39import {useAgent, useSession} from '#/state/session'
40import {UserAvatar} from '#/view/com/util/UserAvatar'
41import {atoms as a, useTheme, web} from '#/alf'
42import {Button, ButtonIcon, ButtonText} from '#/components/Button'
43import * as Dialog from '#/components/Dialog'
44import * as Toggle from '#/components/forms/Toggle'
45import {
46 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
47 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
48} from '#/components/icons/Chevron'
49import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
50import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
51import {Loader} from '#/components/Loader'
52import * as Toast from '#/components/Toast'
53import {Text} from '#/components/Typography'
54import {useAnalytics} from '#/analytics'
55import {IS_IOS} from '#/env'
56
57export type PostInteractionSettingsFormProps = {
58 canSave?: boolean
59 onSave: () => void
60 isSaving?: boolean
61
62 isDirty?: boolean
63 persist?: boolean
64 onChangePersist?: (v: boolean) => void
65
66 postgate: AppBskyFeedPostgate.Record
67 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
68
69 threadgateAllowUISettings: ThreadgateAllowUISetting[]
70 onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
71
72 replySettingsDisabled?: boolean
73}
74
75/**
76 * Threadgate settings dialog. Used in the composer.
77 */
78export function PostInteractionSettingsControlledDialog({
79 control,
80 ...rest
81}: PostInteractionSettingsFormProps & {
82 control: Dialog.DialogControlProps
83}) {
84 const ax = useAnalytics()
85 const onClose = useNonReactiveCallback(() => {
86 ax.metric('composer:threadgate:save', {
87 hasChanged: !!rest.isDirty,
88 persist: !!rest.persist,
89 replyOptions:
90 rest.threadgateAllowUISettings?.map(gate => gate.type)?.join(',') ?? '',
91 quotesEnabled: !rest.postgate?.embeddingRules?.find(
92 v => v.$type === embeddingRules.disableRule.$type,
93 ),
94 })
95 })
96
97 return (
98 <Dialog.Outer
99 control={control}
100 nativeOptions={{
101 preventExpansion: true,
102 preventDismiss: rest.isDirty && rest.persist,
103 }}
104 onClose={onClose}>
105 <Dialog.Handle />
106 <DialogInner {...rest} />
107 </Dialog.Outer>
108 )
109}
110
111function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) {
112 const {_} = useLingui()
113
114 return (
115 <Dialog.ScrollableInner
116 label={_(msg`Edit post interaction settings`)}
117 style={[web({maxWidth: 400}), a.w_full]}>
118 <Header />
119 <PostInteractionSettingsForm {...props} />
120 <Dialog.Close />
121 </Dialog.ScrollableInner>
122 )
123}
124
125export type PostInteractionSettingsDialogProps = {
126 control: Dialog.DialogControlProps
127 /**
128 * URI of the post to edit the interaction settings for. Could be a root post
129 * or could be a reply.
130 */
131 postUri: string
132 /**
133 * The URI of the root post in the thread. Used to determine if the viewer
134 * owns the threadgate record and can therefore edit it.
135 */
136 rootPostUri: string
137 /**
138 * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
139 * happen to have one before opening the settings dialog.
140 */
141 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
142}
143
144/**
145 * Threadgate settings dialog. Used in the thread.
146 */
147export function PostInteractionSettingsDialog(
148 props: PostInteractionSettingsDialogProps,
149) {
150 const postThreadContext = usePostThreadContext()
151 return (
152 <Dialog.Outer
153 control={props.control}
154 nativeOptions={{preventExpansion: true}}>
155 <Dialog.Handle />
156 <PostThreadContextProvider context={postThreadContext}>
157 <PostInteractionSettingsDialogControlledInner {...props} />
158 </PostThreadContextProvider>
159 </Dialog.Outer>
160 )
161}
162
163export function PostInteractionSettingsDialogControlledInner(
164 props: PostInteractionSettingsDialogProps,
165) {
166 const ax = useAnalytics()
167 const {_} = useLingui()
168 const {currentAccount} = useSession()
169 const [isSaving, setIsSaving] = useState(false)
170
171 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
172 useThreadgateViewQuery({postUri: props.rootPostUri})
173 const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
174 postUri: props.postUri,
175 })
176
177 const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
178 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
179
180 const [editedPostgate, setEditedPostgate] =
181 useState<AppBskyFeedPostgate.Record>()
182 const [editedAllowUISettings, setEditedAllowUISettings] =
183 useState<ThreadgateAllowUISetting[]>()
184
185 const isLoading = isLoadingThreadgate || isLoadingPostgate
186 const threadgateView = threadgateViewLoaded || props.initialThreadgateView
187 const isThreadgateOwnedByViewer = useMemo(() => {
188 return currentAccount?.did === new AtUri(props.rootPostUri).host
189 }, [props.rootPostUri, currentAccount?.did])
190
191 const postgateValue = useMemo(() => {
192 return (
193 editedPostgate || postgate || createPostgateRecord({post: props.postUri})
194 )
195 }, [postgate, editedPostgate, props.postUri])
196 const allowUIValue = useMemo(() => {
197 return (
198 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
199 )
200 }, [threadgateView, editedAllowUISettings])
201
202 const onSave = useCallback(async () => {
203 if (!editedPostgate && !editedAllowUISettings) {
204 props.control.close()
205 return
206 }
207
208 setIsSaving(true)
209
210 try {
211 const requests = []
212
213 if (editedPostgate) {
214 requests.push(
215 writePostgateRecord({
216 postUri: props.postUri,
217 postgate: editedPostgate,
218 }),
219 )
220 }
221
222 if (editedAllowUISettings && isThreadgateOwnedByViewer) {
223 requests.push(
224 setThreadgateAllow({
225 postUri: props.rootPostUri,
226 allow: editedAllowUISettings,
227 }),
228 )
229 }
230
231 await Promise.all(requests)
232
233 props.control.close()
234 } catch (e: any) {
235 ax.logger.error(`Failed to save post interaction settings`, {
236 source: 'PostInteractionSettingsDialogControlledInner',
237 safeMessage: e.message,
238 })
239 Toast.show(
240 _(
241 msg`There was an issue. Please check your internet connection and try again.`,
242 ),
243 {
244 type: 'error',
245 },
246 )
247 } finally {
248 setIsSaving(false)
249 }
250 }, [
251 _,
252 ax,
253 props.postUri,
254 props.rootPostUri,
255 props.control,
256 editedPostgate,
257 editedAllowUISettings,
258 setIsSaving,
259 writePostgateRecord,
260 setThreadgateAllow,
261 isThreadgateOwnedByViewer,
262 ])
263
264 return (
265 <Dialog.ScrollableInner
266 label={_(msg`Edit post interaction settings`)}
267 style={[web({maxWidth: 400}), a.w_full]}>
268 {isLoading ? (
269 <View
270 style={[
271 a.flex_1,
272 a.py_5xl,
273 a.gap_md,
274 a.align_center,
275 a.justify_center,
276 ]}>
277 <Loader size="xl" />
278 <Text style={[a.italic, a.text_center]}>
279 <Trans>Loading post interaction settings...</Trans>
280 </Text>
281 </View>
282 ) : (
283 <>
284 <Header />
285 <PostInteractionSettingsForm
286 replySettingsDisabled={!isThreadgateOwnedByViewer}
287 isSaving={isSaving}
288 onSave={onSave}
289 postgate={postgateValue}
290 onChangePostgate={setEditedPostgate}
291 threadgateAllowUISettings={allowUIValue}
292 onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
293 />
294 </>
295 )}
296 <Dialog.Close />
297 </Dialog.ScrollableInner>
298 )
299}
300
301export function PostInteractionSettingsForm({
302 canSave = true,
303 onSave,
304 isSaving,
305 postgate,
306 onChangePostgate,
307 threadgateAllowUISettings,
308 onChangeThreadgateAllowUISettings,
309 replySettingsDisabled,
310 isDirty,
311 persist,
312 onChangePersist,
313}: PostInteractionSettingsFormProps) {
314 const t = useTheme()
315 const {_} = useLingui()
316 const playHaptic = useHaptics()
317 const [showLists, setShowLists] = useState(false)
318 const {
319 data: lists,
320 isPending: isListsPending,
321 isError: isListsError,
322 } = useMyListsQuery('curate')
323 const [quotesEnabled, setQuotesEnabled] = useState(
324 !(
325 postgate.embeddingRules &&
326 postgate.embeddingRules.find(
327 v => v.$type === embeddingRules.disableRule.$type,
328 )
329 ),
330 )
331
332 const onChangeQuotesEnabled = useCallback(
333 (enabled: boolean) => {
334 setQuotesEnabled(enabled)
335 onChangePostgate(
336 createPostgateRecord({
337 ...postgate,
338 embeddingRules: enabled ? [] : [embeddingRules.disableRule],
339 }),
340 )
341 },
342 [setQuotesEnabled, postgate, onChangePostgate],
343 )
344
345 const noOneCanReply = !!threadgateAllowUISettings.find(
346 v => v.type === 'nobody',
347 )
348 const everyoneCanReply = !!threadgateAllowUISettings.find(
349 v => v.type === 'everybody',
350 )
351 const numberOfListsSelected = threadgateAllowUISettings.filter(
352 v => v.type === 'list',
353 ).length
354
355 const toggleGroupValues = (() => {
356 const values: string[] = []
357 for (const setting of threadgateAllowUISettings) {
358 switch (setting.type) {
359 case 'everybody':
360 case 'nobody':
361 // no granularity, early return with nothing
362 return []
363 case 'followers':
364 values.push('followers')
365 break
366 case 'following':
367 values.push('following')
368 break
369 case 'mention':
370 values.push('mention')
371 break
372 case 'list':
373 values.push(`list:${setting.list}`)
374 break
375 default:
376 break
377 }
378 }
379 return values
380 })()
381
382 const toggleGroupOnChange = (values: string[]) => {
383 const settings: ThreadgateAllowUISetting[] = []
384
385 if (values.length === 0) {
386 settings.push({type: 'everybody'})
387 } else {
388 for (const value of values) {
389 if (value.startsWith('list:')) {
390 const listId = value.slice('list:'.length)
391 settings.push({type: 'list', list: listId})
392 } else {
393 settings.push({type: value as 'followers' | 'following' | 'mention'})
394 }
395 }
396 }
397
398 onChangeThreadgateAllowUISettings(settings)
399 }
400
401 return (
402 <View style={[a.flex_1, a.gap_lg]}>
403 <View style={[a.gap_lg]}>
404 {replySettingsDisabled && (
405 <View
406 style={[
407 a.px_md,
408 a.py_sm,
409 a.rounded_sm,
410 a.flex_row,
411 a.align_center,
412 a.gap_sm,
413 t.atoms.bg_contrast_25,
414 ]}>
415 <CircleInfo fill={t.atoms.text_contrast_low.color} />
416 <Text
417 style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}>
418 <Trans>
419 Reply settings are chosen by the author of the thread
420 </Trans>
421 </Text>
422 </View>
423 )}
424
425 <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}>
426 <Text style={[a.text_md, a.font_medium]}>
427 <Trans>Who can reply</Trans>
428 </Text>
429
430 <Toggle.Group
431 label={_(msg`Set who can reply to your post`)}
432 type="radio"
433 maxSelections={1}
434 disabled={replySettingsDisabled}
435 values={
436 everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : []
437 }
438 onChange={val => {
439 if (val.includes('everyone')) {
440 onChangeThreadgateAllowUISettings([{type: 'everybody'}])
441 } else if (val.includes('nobody')) {
442 onChangeThreadgateAllowUISettings([{type: 'nobody'}])
443 } else {
444 onChangeThreadgateAllowUISettings([{type: 'mention'}])
445 }
446 }}>
447 <View style={[a.flex_row, a.gap_sm]}>
448 <Toggle.Item
449 name="everyone"
450 type="checkbox"
451 label={_(msg`Allow anyone to reply`)}
452 style={[a.flex_1]}>
453 {({selected}) => (
454 <Toggle.Panel active={selected}>
455 <Toggle.Radio />
456 <Toggle.PanelText>
457 <Trans>Anyone</Trans>
458 </Toggle.PanelText>
459 </Toggle.Panel>
460 )}
461 </Toggle.Item>
462 <Toggle.Item
463 name="nobody"
464 type="checkbox"
465 label={_(msg`Disable replies entirely`)}
466 style={[a.flex_1]}>
467 {({selected}) => (
468 <Toggle.Panel active={selected}>
469 <Toggle.Radio />
470 <Toggle.PanelText>
471 <Trans>Nobody</Trans>
472 </Toggle.PanelText>
473 </Toggle.Panel>
474 )}
475 </Toggle.Item>
476 </View>
477 </Toggle.Group>
478
479 <Toggle.Group
480 label={_(
481 msg`Set precisely which groups of people can reply to your post`,
482 )}
483 values={toggleGroupValues}
484 onChange={toggleGroupOnChange}
485 disabled={replySettingsDisabled}>
486 <Toggle.PanelGroup>
487 <Toggle.Item
488 name="followers"
489 type="checkbox"
490 label={_(msg`Allow your followers to reply`)}
491 hitSlop={0}>
492 {({selected}) => (
493 <Toggle.Panel active={selected} adjacent="trailing">
494 <Toggle.Checkbox />
495 <Toggle.PanelText>
496 <Trans>Your followers</Trans>
497 </Toggle.PanelText>
498 </Toggle.Panel>
499 )}
500 </Toggle.Item>
501 <Toggle.Item
502 name="following"
503 type="checkbox"
504 label={_(msg`Allow people you follow to reply`)}
505 hitSlop={0}>
506 {({selected}) => (
507 <Toggle.Panel active={selected} adjacent="both">
508 <Toggle.Checkbox />
509 <Toggle.PanelText>
510 <Trans>People you follow</Trans>
511 </Toggle.PanelText>
512 </Toggle.Panel>
513 )}
514 </Toggle.Item>
515 <Toggle.Item
516 name="mention"
517 type="checkbox"
518 label={_(msg`Allow people you mention to reply`)}
519 hitSlop={0}>
520 {({selected}) => (
521 <Toggle.Panel active={selected} adjacent="both">
522 <Toggle.Checkbox />
523 <Toggle.PanelText>
524 <Trans>People you mention</Trans>
525 </Toggle.PanelText>
526 </Toggle.Panel>
527 )}
528 </Toggle.Item>
529
530 <Button
531 label={
532 showLists
533 ? _(msg`Hide lists`)
534 : _(msg`Show lists of users to select from`)
535 }
536 accessibilityRole="togglebutton"
537 hitSlop={0}
538 onPress={() => {
539 playHaptic('Light')
540 if (IS_IOS && !showLists) {
541 LayoutAnimation.configureNext({
542 ...LayoutAnimation.Presets.linear,
543 duration: 175,
544 })
545 }
546 setShowLists(s => !s)
547 }}>
548 <Toggle.Panel
549 active={numberOfListsSelected > 0}
550 adjacent={showLists ? 'both' : 'leading'}>
551 <Toggle.PanelText>
552 {numberOfListsSelected === 0 ? (
553 <Trans>Select from your lists</Trans>
554 ) : (
555 <Trans>
556 Select from your lists{' '}
557 <NestedText style={[a.font_normal, a.italic]}>
558 <Plural
559 value={numberOfListsSelected}
560 other="(# selected)"
561 />
562 </NestedText>
563 </Trans>
564 )}
565 </Toggle.PanelText>
566 <Toggle.PanelIcon
567 icon={showLists ? ChevronUpIcon : ChevronDownIcon}
568 />
569 </Toggle.Panel>
570 </Button>
571 {showLists &&
572 (isListsPending ? (
573 <Toggle.Panel>
574 <Toggle.PanelText>
575 <Trans>Loading lists...</Trans>
576 </Toggle.PanelText>
577 </Toggle.Panel>
578 ) : isListsError ? (
579 <Toggle.Panel>
580 <Toggle.PanelText>
581 <Trans>
582 An error occurred while loading your lists :/
583 </Trans>
584 </Toggle.PanelText>
585 </Toggle.Panel>
586 ) : lists.length === 0 ? (
587 <Toggle.Panel>
588 <Toggle.PanelText>
589 <Trans>You don't have any lists yet.</Trans>
590 </Toggle.PanelText>
591 </Toggle.Panel>
592 ) : (
593 lists.map((list, i) => (
594 <Toggle.Item
595 key={list.uri}
596 name={`list:${list.uri}`}
597 type="checkbox"
598 label={_(msg`Allow users in ${list.name} to reply`)}
599 hitSlop={0}>
600 {({selected}) => (
601 <Toggle.Panel
602 active={selected}
603 adjacent={
604 i === lists.length - 1 ? 'leading' : 'both'
605 }>
606 <Toggle.Checkbox />
607 <UserAvatar
608 size={24}
609 type="list"
610 avatar={list.avatar}
611 />
612 <Toggle.PanelText>{list.name}</Toggle.PanelText>
613 </Toggle.Panel>
614 )}
615 </Toggle.Item>
616 ))
617 ))}
618 </Toggle.PanelGroup>
619 </Toggle.Group>
620 </View>
621 </View>
622
623 <Toggle.Item
624 name="quoteposts"
625 type="checkbox"
626 label={
627 quotesEnabled
628 ? _(msg`Disable quote posts of this post`)
629 : _(msg`Enable quote posts of this post`)
630 }
631 value={quotesEnabled}
632 onChange={onChangeQuotesEnabled}>
633 {({selected}) => (
634 <Toggle.Panel active={selected}>
635 <Toggle.PanelText icon={QuoteIcon}>
636 <Trans>Allow quote posts</Trans>
637 </Toggle.PanelText>
638 <Toggle.Switch />
639 </Toggle.Panel>
640 )}
641 </Toggle.Item>
642
643 {typeof persist !== 'undefined' && (
644 <View style={[{minHeight: 24}, a.justify_center]}>
645 {isDirty ? (
646 <Toggle.Item
647 name="persist"
648 type="checkbox"
649 label={_(msg`Save these options for next time`)}
650 value={persist}
651 onChange={() => onChangePersist?.(!persist)}>
652 <Toggle.Checkbox />
653 <Toggle.LabelText
654 style={[a.text_md, a.font_normal, t.atoms.text]}>
655 <Trans>Save these options for next time</Trans>
656 </Toggle.LabelText>
657 </Toggle.Item>
658 ) : (
659 <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
660 <Trans>These are your default settings</Trans>
661 </Text>
662 )}
663 </View>
664 )}
665
666 <Button
667 disabled={!canSave || isSaving}
668 label={_(msg`Save`)}
669 onPress={onSave}
670 color="primary"
671 size="large">
672 <ButtonText>
673 <Trans>Save</Trans>
674 </ButtonText>
675 {isSaving && <ButtonIcon icon={Loader} />}
676 </Button>
677 </View>
678 )
679}
680
681function Header() {
682 return (
683 <View style={[a.pb_lg]}>
684 <Text style={[a.text_2xl, a.font_bold]}>
685 <Trans>Post interaction settings</Trans>
686 </Text>
687 </View>
688 )
689}
690
691export function usePrefetchPostInteractionSettings({
692 postUri,
693 rootPostUri,
694}: {
695 postUri: string
696 rootPostUri: string
697}) {
698 const ax = useAnalytics()
699 const queryClient = useQueryClient()
700 const agent = useAgent()
701 const getPost = useGetPost()
702
703 return useCallback(async () => {
704 try {
705 await Promise.all([
706 queryClient.prefetchQuery({
707 queryKey: createPostgateQueryKey(postUri),
708 queryFn: () =>
709 getPostgateRecord({agent, postUri}).then(res => res ?? null),
710 staleTime: STALE.SECONDS.THIRTY,
711 }),
712 queryClient.prefetchQuery({
713 queryKey: createThreadgateViewQueryKey(rootPostUri),
714 queryFn: async () => {
715 const post = await getPost({uri: rootPostUri})
716 return post.threadgate ?? null
717 },
718 staleTime: STALE.SECONDS.THIRTY,
719 }),
720 ])
721 } catch (e: any) {
722 ax.logger.error(`Failed to prefetch post interaction settings`, {
723 safeMessage: e.message,
724 })
725 }
726 }, [ax, queryClient, agent, postUri, rootPostUri, getPost])
727}