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