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