Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 * as Toast from '#/view/com/util/Toast'
41import {UserAvatar} from '#/view/com/util/UserAvatar'
42import {atoms as a, useTheme, web} from '#/alf'
43import {Button, ButtonIcon, ButtonText} from '#/components/Button'
44import * as Dialog from '#/components/Dialog'
45import * as Toggle from '#/components/forms/Toggle'
46import {
47 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
48 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
49} from '#/components/icons/Chevron'
50import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
51import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
52import {Loader} from '#/components/Loader'
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 'xmark',
244 )
245 } finally {
246 setIsSaving(false)
247 }
248 }, [
249 _,
250 ax,
251 props.postUri,
252 props.rootPostUri,
253 props.control,
254 editedPostgate,
255 editedAllowUISettings,
256 setIsSaving,
257 writePostgateRecord,
258 setThreadgateAllow,
259 isThreadgateOwnedByViewer,
260 ])
261
262 return (
263 <Dialog.ScrollableInner
264 label={_(msg`Edit post interaction settings`)}
265 style={[web({maxWidth: 400}), a.w_full]}>
266 {isLoading ? (
267 <View
268 style={[
269 a.flex_1,
270 a.py_5xl,
271 a.gap_md,
272 a.align_center,
273 a.justify_center,
274 ]}>
275 <Loader size="xl" />
276 <Text style={[a.italic, a.text_center]}>
277 <Trans>Loading post interaction settings...</Trans>
278 </Text>
279 </View>
280 ) : (
281 <>
282 <Header />
283 <PostInteractionSettingsForm
284 replySettingsDisabled={!isThreadgateOwnedByViewer}
285 isSaving={isSaving}
286 onSave={onSave}
287 postgate={postgateValue}
288 onChangePostgate={setEditedPostgate}
289 threadgateAllowUISettings={allowUIValue}
290 onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
291 />
292 </>
293 )}
294 <Dialog.Close />
295 </Dialog.ScrollableInner>
296 )
297}
298
299export function PostInteractionSettingsForm({
300 canSave = true,
301 onSave,
302 isSaving,
303 postgate,
304 onChangePostgate,
305 threadgateAllowUISettings,
306 onChangeThreadgateAllowUISettings,
307 replySettingsDisabled,
308 isDirty,
309 persist,
310 onChangePersist,
311}: PostInteractionSettingsFormProps) {
312 const t = useTheme()
313 const {_} = useLingui()
314 const playHaptic = useHaptics()
315 const [showLists, setShowLists] = useState(false)
316 const {
317 data: lists,
318 isPending: isListsPending,
319 isError: isListsError,
320 } = useMyListsQuery('curate')
321 const [quotesEnabled, setQuotesEnabled] = useState(
322 !(
323 postgate.embeddingRules &&
324 postgate.embeddingRules.find(
325 v => v.$type === embeddingRules.disableRule.$type,
326 )
327 ),
328 )
329
330 const onChangeQuotesEnabled = useCallback(
331 (enabled: boolean) => {
332 setQuotesEnabled(enabled)
333 onChangePostgate(
334 createPostgateRecord({
335 ...postgate,
336 embeddingRules: enabled ? [] : [embeddingRules.disableRule],
337 }),
338 )
339 },
340 [setQuotesEnabled, postgate, onChangePostgate],
341 )
342
343 const noOneCanReply = !!threadgateAllowUISettings.find(
344 v => v.type === 'nobody',
345 )
346 const everyoneCanReply = !!threadgateAllowUISettings.find(
347 v => v.type === 'everybody',
348 )
349 const numberOfListsSelected = threadgateAllowUISettings.filter(
350 v => v.type === 'list',
351 ).length
352
353 const toggleGroupValues = useMemo(() => {
354 const values: string[] = []
355 for (const setting of threadgateAllowUISettings) {
356 switch (setting.type) {
357 case 'everybody':
358 case 'nobody':
359 // no granularity, early return with nothing
360 return []
361 case 'followers':
362 values.push('followers')
363 break
364 case 'following':
365 values.push('following')
366 break
367 case 'mention':
368 values.push('mention')
369 break
370 case 'list':
371 values.push(`list:${setting.list}`)
372 break
373 default:
374 break
375 }
376 }
377 return values
378 }, [threadgateAllowUISettings])
379
380 const toggleGroupOnChange = (values: string[]) => {
381 const settings: ThreadgateAllowUISetting[] = []
382
383 if (values.length === 0) {
384 settings.push({type: 'everybody'})
385 } else {
386 for (const value of values) {
387 if (value.startsWith('list:')) {
388 const listId = value.slice('list:'.length)
389 settings.push({type: 'list', list: listId})
390 } else {
391 settings.push({type: value as 'followers' | 'following' | 'mention'})
392 }
393 }
394 }
395
396 onChangeThreadgateAllowUISettings(settings)
397 }
398
399 return (
400 <View style={[a.flex_1, a.gap_lg]}>
401 <View style={[a.gap_lg]}>
402 {replySettingsDisabled && (
403 <View
404 style={[
405 a.px_md,
406 a.py_sm,
407 a.rounded_sm,
408 a.flex_row,
409 a.align_center,
410 a.gap_sm,
411 t.atoms.bg_contrast_25,
412 ]}>
413 <CircleInfo fill={t.atoms.text_contrast_low.color} />
414 <Text
415 style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}>
416 <Trans>
417 Reply settings are chosen by the author of the thread
418 </Trans>
419 </Text>
420 </View>
421 )}
422
423 <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}>
424 <Text style={[a.text_md, a.font_medium]}>
425 <Trans>Who can reply</Trans>
426 </Text>
427
428 <Toggle.Group
429 label={_(msg`Set who can reply to your post`)}
430 type="radio"
431 maxSelections={1}
432 disabled={replySettingsDisabled}
433 values={
434 everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : []
435 }
436 onChange={val => {
437 if (val.includes('everyone')) {
438 onChangeThreadgateAllowUISettings([{type: 'everybody'}])
439 } else if (val.includes('nobody')) {
440 onChangeThreadgateAllowUISettings([{type: 'nobody'}])
441 } else {
442 onChangeThreadgateAllowUISettings([{type: 'mention'}])
443 }
444 }}>
445 <View style={[a.flex_row, a.gap_sm]}>
446 <Toggle.Item
447 name="everyone"
448 type="checkbox"
449 label={_(msg`Allow anyone to reply`)}
450 style={[a.flex_1]}>
451 {({selected}) => (
452 <Toggle.Panel active={selected}>
453 <Toggle.Radio />
454 <Toggle.PanelText>
455 <Trans>Anyone</Trans>
456 </Toggle.PanelText>
457 </Toggle.Panel>
458 )}
459 </Toggle.Item>
460 <Toggle.Item
461 name="nobody"
462 type="checkbox"
463 label={_(msg`Disable replies entirely`)}
464 style={[a.flex_1]}>
465 {({selected}) => (
466 <Toggle.Panel active={selected}>
467 <Toggle.Radio />
468 <Toggle.PanelText>
469 <Trans>Nobody</Trans>
470 </Toggle.PanelText>
471 </Toggle.Panel>
472 )}
473 </Toggle.Item>
474 </View>
475 </Toggle.Group>
476
477 <Toggle.Group
478 label={_(
479 msg`Set precisely which groups of people can reply to your post`,
480 )}
481 values={toggleGroupValues}
482 onChange={toggleGroupOnChange}
483 disabled={replySettingsDisabled}>
484 <Toggle.PanelGroup>
485 <Toggle.Item
486 name="followers"
487 type="checkbox"
488 label={_(msg`Allow your followers to reply`)}
489 hitSlop={0}>
490 {({selected}) => (
491 <Toggle.Panel active={selected} adjacent="trailing">
492 <Toggle.Checkbox />
493 <Toggle.PanelText>
494 <Trans>Your followers</Trans>
495 </Toggle.PanelText>
496 </Toggle.Panel>
497 )}
498 </Toggle.Item>
499 <Toggle.Item
500 name="following"
501 type="checkbox"
502 label={_(msg`Allow people you follow to reply`)}
503 hitSlop={0}>
504 {({selected}) => (
505 <Toggle.Panel active={selected} adjacent="both">
506 <Toggle.Checkbox />
507 <Toggle.PanelText>
508 <Trans>People you follow</Trans>
509 </Toggle.PanelText>
510 </Toggle.Panel>
511 )}
512 </Toggle.Item>
513 <Toggle.Item
514 name="mention"
515 type="checkbox"
516 label={_(msg`Allow people you mention to reply`)}
517 hitSlop={0}>
518 {({selected}) => (
519 <Toggle.Panel active={selected} adjacent="both">
520 <Toggle.Checkbox />
521 <Toggle.PanelText>
522 <Trans>People you mention</Trans>
523 </Toggle.PanelText>
524 </Toggle.Panel>
525 )}
526 </Toggle.Item>
527
528 <Button
529 label={
530 showLists
531 ? _(msg`Hide lists`)
532 : _(msg`Show lists of users to select from`)
533 }
534 accessibilityRole="togglebutton"
535 hitSlop={0}
536 onPress={() => {
537 playHaptic('Light')
538 if (IS_IOS && !showLists) {
539 LayoutAnimation.configureNext({
540 ...LayoutAnimation.Presets.linear,
541 duration: 175,
542 })
543 }
544 setShowLists(s => !s)
545 }}>
546 <Toggle.Panel
547 active={numberOfListsSelected > 0}
548 adjacent={showLists ? 'both' : 'leading'}>
549 <Toggle.PanelText>
550 {numberOfListsSelected === 0 ? (
551 <Trans>Select from your lists</Trans>
552 ) : (
553 <Trans>
554 Select from your lists{' '}
555 <NestedText style={[a.font_normal, a.italic]}>
556 <Plural
557 value={numberOfListsSelected}
558 other="(# selected)"
559 />
560 </NestedText>
561 </Trans>
562 )}
563 </Toggle.PanelText>
564 <Toggle.PanelIcon
565 icon={showLists ? ChevronUpIcon : ChevronDownIcon}
566 />
567 </Toggle.Panel>
568 </Button>
569 {showLists &&
570 (isListsPending ? (
571 <Toggle.Panel>
572 <Toggle.PanelText>
573 <Trans>Loading lists...</Trans>
574 </Toggle.PanelText>
575 </Toggle.Panel>
576 ) : isListsError ? (
577 <Toggle.Panel>
578 <Toggle.PanelText>
579 <Trans>
580 An error occurred while loading your lists :/
581 </Trans>
582 </Toggle.PanelText>
583 </Toggle.Panel>
584 ) : lists.length === 0 ? (
585 <Toggle.Panel>
586 <Toggle.PanelText>
587 <Trans>You don't have any lists yet.</Trans>
588 </Toggle.PanelText>
589 </Toggle.Panel>
590 ) : (
591 lists.map((list, i) => (
592 <Toggle.Item
593 key={list.uri}
594 name={`list:${list.uri}`}
595 type="checkbox"
596 label={_(msg`Allow users in ${list.name} to reply`)}
597 hitSlop={0}>
598 {({selected}) => (
599 <Toggle.Panel
600 active={selected}
601 adjacent={
602 i === lists.length - 1 ? 'leading' : 'both'
603 }>
604 <Toggle.Checkbox />
605 <UserAvatar
606 size={24}
607 type="list"
608 avatar={list.avatar}
609 />
610 <Toggle.PanelText>{list.name}</Toggle.PanelText>
611 </Toggle.Panel>
612 )}
613 </Toggle.Item>
614 ))
615 ))}
616 </Toggle.PanelGroup>
617 </Toggle.Group>
618 </View>
619 </View>
620
621 <Toggle.Item
622 name="quoteposts"
623 type="checkbox"
624 label={
625 quotesEnabled
626 ? _(msg`Disable quote posts of this post`)
627 : _(msg`Enable quote posts of this post`)
628 }
629 value={quotesEnabled}
630 onChange={onChangeQuotesEnabled}>
631 {({selected}) => (
632 <Toggle.Panel active={selected}>
633 <Toggle.PanelText icon={QuoteIcon}>
634 <Trans>Allow quote posts</Trans>
635 </Toggle.PanelText>
636 <Toggle.Switch />
637 </Toggle.Panel>
638 )}
639 </Toggle.Item>
640
641 {typeof persist !== 'undefined' && (
642 <View style={[{minHeight: 24}, a.justify_center]}>
643 {isDirty ? (
644 <Toggle.Item
645 name="persist"
646 type="checkbox"
647 label={_(msg`Save these options for next time`)}
648 value={persist}
649 onChange={() => onChangePersist?.(!persist)}>
650 <Toggle.Checkbox />
651 <Toggle.LabelText
652 style={[a.text_md, a.font_normal, t.atoms.text]}>
653 <Trans>Save these options for next time</Trans>
654 </Toggle.LabelText>
655 </Toggle.Item>
656 ) : (
657 <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
658 <Trans>These are your default settings</Trans>
659 </Text>
660 )}
661 </View>
662 )}
663
664 <Button
665 disabled={!canSave || isSaving}
666 label={_(msg`Save`)}
667 onPress={onSave}
668 color="primary"
669 size="large">
670 <ButtonText>
671 <Trans>Save</Trans>
672 </ButtonText>
673 {isSaving && <ButtonIcon icon={Loader} />}
674 </Button>
675 </View>
676 )
677}
678
679function Header() {
680 return (
681 <View style={[a.pb_lg]}>
682 <Text style={[a.text_2xl, a.font_bold]}>
683 <Trans>Post interaction settings</Trans>
684 </Text>
685 </View>
686 )
687}
688
689export function usePrefetchPostInteractionSettings({
690 postUri,
691 rootPostUri,
692}: {
693 postUri: string
694 rootPostUri: string
695}) {
696 const ax = useAnalytics()
697 const queryClient = useQueryClient()
698 const agent = useAgent()
699 const getPost = useGetPost()
700
701 return useCallback(async () => {
702 try {
703 await Promise.all([
704 queryClient.prefetchQuery({
705 queryKey: createPostgateQueryKey(postUri),
706 queryFn: () =>
707 getPostgateRecord({agent, postUri}).then(res => res ?? null),
708 staleTime: STALE.SECONDS.THIRTY,
709 }),
710 queryClient.prefetchQuery({
711 queryKey: createThreadgateViewQueryKey(rootPostUri),
712 queryFn: async () => {
713 const post = await getPost({uri: rootPostUri})
714 return post.threadgate ?? null
715 },
716 staleTime: STALE.SECONDS.THIRTY,
717 }),
718 ])
719 } catch (e: any) {
720 ax.logger.error(`Failed to prefetch post interaction settings`, {
721 safeMessage: e.message,
722 })
723 }
724 }, [ax, queryClient, agent, postUri, rootPostUri, getPost])
725}