mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {type StyleProp, View, type ViewStyle} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 type AppBskyFeedPostgate,
6 AtUri,
7} from '@atproto/api'
8import {msg, Trans} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10import {useQueryClient} from '@tanstack/react-query'
11import isEqual from 'lodash.isequal'
12
13import {logger} from '#/logger'
14import {STALE} from '#/state/queries'
15import {useMyListsQuery} from '#/state/queries/my-lists'
16import {
17 createPostgateQueryKey,
18 getPostgateRecord,
19 usePostgateQuery,
20 useWritePostgateMutation,
21} from '#/state/queries/postgate'
22import {
23 createPostgateRecord,
24 embeddingRules,
25} from '#/state/queries/postgate/util'
26import {
27 createThreadgateViewQueryKey,
28 getThreadgateView,
29 type ThreadgateAllowUISetting,
30 threadgateViewToAllowUISetting,
31 useSetThreadgateAllowMutation,
32 useThreadgateViewQuery,
33} from '#/state/queries/threadgate'
34import {useAgent, useSession} from '#/state/session'
35import * as Toast from '#/view/com/util/Toast'
36import {atoms as a, useTheme} from '#/alf'
37import {Button, ButtonIcon, ButtonText} from '#/components/Button'
38import * as Dialog from '#/components/Dialog'
39import {Divider} from '#/components/Divider'
40import * as Toggle from '#/components/forms/Toggle'
41import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
42import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
43import {Loader} from '#/components/Loader'
44import {Text} from '#/components/Typography'
45
46export type PostInteractionSettingsFormProps = {
47 canSave?: boolean
48 onSave: () => void
49 isSaving?: boolean
50
51 postgate: AppBskyFeedPostgate.Record
52 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
53
54 threadgateAllowUISettings: ThreadgateAllowUISetting[]
55 onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
56
57 replySettingsDisabled?: boolean
58}
59
60export function PostInteractionSettingsControlledDialog({
61 control,
62 ...rest
63}: PostInteractionSettingsFormProps & {
64 control: Dialog.DialogControlProps
65}) {
66 const t = useTheme()
67 const {_} = useLingui()
68
69 return (
70 <Dialog.Outer control={control}>
71 <Dialog.Handle />
72 <Dialog.ScrollableInner
73 label={_(msg`Edit post interaction settings`)}
74 style={[{maxWidth: 500}, a.w_full]}>
75 <View style={[a.gap_md]}>
76 <Header />
77 <PostInteractionSettingsForm {...rest} />
78 <Text
79 style={[
80 a.pt_sm,
81 a.text_sm,
82 a.leading_snug,
83 t.atoms.text_contrast_medium,
84 ]}>
85 <Trans>
86 You can set default interaction settings in{' '}
87 <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
88 Settings → Moderation → Interaction settings
89 </Text>
90 .
91 </Trans>
92 </Text>
93 </View>
94 <Dialog.Close />
95 </Dialog.ScrollableInner>
96 </Dialog.Outer>
97 )
98}
99
100export function Header() {
101 return (
102 <View style={[a.gap_md, a.pb_sm]}>
103 <Text style={[a.text_2xl, a.font_bold]}>
104 <Trans>Post interaction settings</Trans>
105 </Text>
106 <Text style={[a.text_md, a.pb_xs]}>
107 <Trans>Customize who can interact with this post.</Trans>
108 </Text>
109 <Divider />
110 </View>
111 )
112}
113
114export type PostInteractionSettingsDialogProps = {
115 control: Dialog.DialogControlProps
116 /**
117 * URI of the post to edit the interaction settings for. Could be a root post
118 * or could be a reply.
119 */
120 postUri: string
121 /**
122 * The URI of the root post in the thread. Used to determine if the viewer
123 * owns the threadgate record and can therefore edit it.
124 */
125 rootPostUri: string
126 /**
127 * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
128 * happen to have one before opening the settings dialog.
129 */
130 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
131}
132
133export function PostInteractionSettingsDialog(
134 props: PostInteractionSettingsDialogProps,
135) {
136 return (
137 <Dialog.Outer control={props.control}>
138 <Dialog.Handle />
139 <PostInteractionSettingsDialogControlledInner {...props} />
140 </Dialog.Outer>
141 )
142}
143
144export function PostInteractionSettingsDialogControlledInner(
145 props: PostInteractionSettingsDialogProps,
146) {
147 const {_} = useLingui()
148 const {currentAccount} = useSession()
149 const [isSaving, setIsSaving] = React.useState(false)
150
151 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
152 useThreadgateViewQuery({postUri: props.rootPostUri})
153 const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
154 postUri: props.postUri,
155 })
156
157 const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
158 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
159
160 const [editedPostgate, setEditedPostgate] =
161 React.useState<AppBskyFeedPostgate.Record>()
162 const [editedAllowUISettings, setEditedAllowUISettings] =
163 React.useState<ThreadgateAllowUISetting[]>()
164
165 const isLoading = isLoadingThreadgate || isLoadingPostgate
166 const threadgateView = threadgateViewLoaded || props.initialThreadgateView
167 const isThreadgateOwnedByViewer = React.useMemo(() => {
168 return currentAccount?.did === new AtUri(props.rootPostUri).host
169 }, [props.rootPostUri, currentAccount?.did])
170
171 const postgateValue = React.useMemo(() => {
172 return (
173 editedPostgate || postgate || createPostgateRecord({post: props.postUri})
174 )
175 }, [postgate, editedPostgate, props.postUri])
176 const allowUIValue = React.useMemo(() => {
177 return (
178 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
179 )
180 }, [threadgateView, editedAllowUISettings])
181
182 const onSave = React.useCallback(async () => {
183 if (!editedPostgate && !editedAllowUISettings) {
184 props.control.close()
185 return
186 }
187
188 setIsSaving(true)
189
190 try {
191 const requests = []
192
193 if (editedPostgate) {
194 requests.push(
195 writePostgateRecord({
196 postUri: props.postUri,
197 postgate: editedPostgate,
198 }),
199 )
200 }
201
202 if (editedAllowUISettings && isThreadgateOwnedByViewer) {
203 requests.push(
204 setThreadgateAllow({
205 postUri: props.rootPostUri,
206 allow: editedAllowUISettings,
207 }),
208 )
209 }
210
211 await Promise.all(requests)
212
213 props.control.close()
214 } catch (e: any) {
215 logger.error(`Failed to save post interaction settings`, {
216 source: 'PostInteractionSettingsDialogControlledInner',
217 safeMessage: e.message,
218 })
219 Toast.show(
220 _(
221 msg`There was an issue. Please check your internet connection and try again.`,
222 ),
223 'xmark',
224 )
225 } finally {
226 setIsSaving(false)
227 }
228 }, [
229 _,
230 props.postUri,
231 props.rootPostUri,
232 props.control,
233 editedPostgate,
234 editedAllowUISettings,
235 setIsSaving,
236 writePostgateRecord,
237 setThreadgateAllow,
238 isThreadgateOwnedByViewer,
239 ])
240
241 return (
242 <Dialog.ScrollableInner
243 label={_(msg`Edit post interaction settings`)}
244 style={[{maxWidth: 500}, a.w_full]}>
245 <View style={[a.gap_md]}>
246 <Header />
247
248 {isLoading ? (
249 <View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
250 <Loader size="xl" />
251 </View>
252 ) : (
253 <PostInteractionSettingsForm
254 replySettingsDisabled={!isThreadgateOwnedByViewer}
255 isSaving={isSaving}
256 onSave={onSave}
257 postgate={postgateValue}
258 onChangePostgate={setEditedPostgate}
259 threadgateAllowUISettings={allowUIValue}
260 onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
261 />
262 )}
263 </View>
264 </Dialog.ScrollableInner>
265 )
266}
267
268export function PostInteractionSettingsForm({
269 canSave = true,
270 onSave,
271 isSaving,
272 postgate,
273 onChangePostgate,
274 threadgateAllowUISettings,
275 onChangeThreadgateAllowUISettings,
276 replySettingsDisabled,
277}: PostInteractionSettingsFormProps) {
278 const t = useTheme()
279 const {_} = useLingui()
280 const {data: lists} = useMyListsQuery('curate')
281 const [quotesEnabled, setQuotesEnabled] = React.useState(
282 !(
283 postgate.embeddingRules &&
284 postgate.embeddingRules.find(
285 v => v.$type === embeddingRules.disableRule.$type,
286 )
287 ),
288 )
289
290 const onPressAudience = (setting: ThreadgateAllowUISetting) => {
291 // remove boolean values
292 let newSelected: ThreadgateAllowUISetting[] =
293 threadgateAllowUISettings.filter(
294 v => v.type !== 'nobody' && v.type !== 'everybody',
295 )
296 // toggle
297 const i = newSelected.findIndex(v => isEqual(v, setting))
298 if (i === -1) {
299 newSelected.push(setting)
300 } else {
301 newSelected.splice(i, 1)
302 }
303 if (newSelected.length === 0) {
304 newSelected.push({type: 'everybody'})
305 }
306
307 onChangeThreadgateAllowUISettings(newSelected)
308 }
309
310 const onChangeQuotesEnabled = React.useCallback(
311 (enabled: boolean) => {
312 setQuotesEnabled(enabled)
313 onChangePostgate(
314 createPostgateRecord({
315 ...postgate,
316 embeddingRules: enabled ? [] : [embeddingRules.disableRule],
317 }),
318 )
319 },
320 [setQuotesEnabled, postgate, onChangePostgate],
321 )
322
323 const noOneCanReply = !!threadgateAllowUISettings.find(
324 v => v.type === 'nobody',
325 )
326
327 return (
328 <View>
329 <View style={[a.flex_1, a.gap_md]}>
330 <View style={[a.gap_lg]}>
331 <View style={[a.gap_sm]}>
332 <Text style={[a.font_bold, a.text_lg]}>
333 <Trans>Quote settings</Trans>
334 </Text>
335
336 <Toggle.Item
337 name="quoteposts"
338 type="checkbox"
339 label={
340 quotesEnabled
341 ? _(msg`Click to disable quote posts of this post.`)
342 : _(msg`Click to enable quote posts of this post.`)
343 }
344 value={quotesEnabled}
345 onChange={onChangeQuotesEnabled}
346 style={[a.justify_between, a.pt_xs]}>
347 <Text style={[t.atoms.text_contrast_medium]}>
348 <Trans>Allow quote posts</Trans>
349 </Text>
350 <Toggle.Switch />
351 </Toggle.Item>
352 </View>
353
354 <Divider />
355
356 {replySettingsDisabled && (
357 <View
358 style={[
359 a.px_md,
360 a.py_sm,
361 a.rounded_sm,
362 a.flex_row,
363 a.align_center,
364 a.gap_sm,
365 t.atoms.bg_contrast_25,
366 ]}>
367 <CircleInfo fill={t.atoms.text_contrast_low.color} />
368 <Text
369 style={[
370 a.flex_1,
371 a.leading_snug,
372 t.atoms.text_contrast_medium,
373 ]}>
374 <Trans>
375 Reply settings are chosen by the author of the thread
376 </Trans>
377 </Text>
378 </View>
379 )}
380
381 <View
382 style={[
383 a.gap_sm,
384 {
385 opacity: replySettingsDisabled ? 0.3 : 1,
386 },
387 ]}>
388 <Text style={[a.font_bold, a.text_lg]}>
389 <Trans>Reply settings</Trans>
390 </Text>
391
392 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
393 <Trans>Allow replies from:</Trans>
394 </Text>
395
396 <View style={[a.flex_row, a.gap_sm]}>
397 <Selectable
398 label={_(msg`Everybody`)}
399 isSelected={
400 !!threadgateAllowUISettings.find(v => v.type === 'everybody')
401 }
402 onPress={() =>
403 onChangeThreadgateAllowUISettings([{type: 'everybody'}])
404 }
405 style={{flex: 1}}
406 disabled={replySettingsDisabled}
407 />
408 <Selectable
409 label={_(msg`Nobody`)}
410 isSelected={noOneCanReply}
411 onPress={() =>
412 onChangeThreadgateAllowUISettings([{type: 'nobody'}])
413 }
414 style={{flex: 1}}
415 disabled={replySettingsDisabled}
416 />
417 </View>
418
419 {!noOneCanReply && (
420 <>
421 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
422 <Trans>Or combine these options:</Trans>
423 </Text>
424
425 <View style={[a.gap_sm]}>
426 <Selectable
427 label={_(msg`Mentioned users`)}
428 isSelected={
429 !!threadgateAllowUISettings.find(
430 v => v.type === 'mention',
431 )
432 }
433 onPress={() => onPressAudience({type: 'mention'})}
434 disabled={replySettingsDisabled}
435 />
436 <Selectable
437 label={_(msg`Users you follow`)}
438 isSelected={
439 !!threadgateAllowUISettings.find(
440 v => v.type === 'following',
441 )
442 }
443 onPress={() => onPressAudience({type: 'following'})}
444 disabled={replySettingsDisabled}
445 />
446 <Selectable
447 label={_(msg`Your followers`)}
448 isSelected={
449 !!threadgateAllowUISettings.find(
450 v => v.type === 'followers',
451 )
452 }
453 onPress={() => onPressAudience({type: 'followers'})}
454 disabled={replySettingsDisabled}
455 />
456 {lists && lists.length > 0
457 ? lists.map(list => (
458 <Selectable
459 key={list.uri}
460 label={_(msg`Users in "${list.name}"`)}
461 isSelected={
462 !!threadgateAllowUISettings.find(
463 v => v.type === 'list' && v.list === list.uri,
464 )
465 }
466 onPress={() =>
467 onPressAudience({type: 'list', list: list.uri})
468 }
469 disabled={replySettingsDisabled}
470 />
471 ))
472 : // No loading states to avoid jumps for the common case (no lists)
473 null}
474 </View>
475 </>
476 )}
477 </View>
478 </View>
479 </View>
480
481 <Button
482 disabled={!canSave || isSaving}
483 label={_(msg`Save`)}
484 onPress={onSave}
485 color="primary"
486 size="large"
487 variant="solid"
488 style={a.mt_xl}>
489 <ButtonText>{_(msg`Save`)}</ButtonText>
490 {isSaving && <ButtonIcon icon={Loader} position="right" />}
491 </Button>
492 </View>
493 )
494}
495
496function Selectable({
497 label,
498 isSelected,
499 onPress,
500 style,
501 disabled,
502}: {
503 label: string
504 isSelected: boolean
505 onPress: () => void
506 style?: StyleProp<ViewStyle>
507 disabled?: boolean
508}) {
509 const t = useTheme()
510 return (
511 <Button
512 disabled={disabled}
513 onPress={onPress}
514 label={label}
515 accessibilityRole="checkbox"
516 aria-checked={isSelected}
517 accessibilityState={{
518 checked: isSelected,
519 }}
520 style={a.flex_1}>
521 {({hovered, focused}) => (
522 <View
523 style={[
524 a.flex_1,
525 a.flex_row,
526 a.align_center,
527 a.justify_between,
528 a.rounded_sm,
529 a.p_md,
530 {minHeight: 40}, // for consistency with checkmark icon visible or not
531 t.atoms.bg_contrast_50,
532 (hovered || focused) && t.atoms.bg_contrast_100,
533 isSelected && {
534 backgroundColor: t.palette.primary_100,
535 },
536 style,
537 ]}>
538 <Text style={[a.text_sm, isSelected && a.font_bold]}>{label}</Text>
539 {isSelected ? (
540 <Check size="sm" fill={t.palette.primary_500} />
541 ) : (
542 <View />
543 )}
544 </View>
545 )}
546 </Button>
547 )
548}
549
550export function usePrefetchPostInteractionSettings({
551 postUri,
552 rootPostUri,
553}: {
554 postUri: string
555 rootPostUri: string
556}) {
557 const queryClient = useQueryClient()
558 const agent = useAgent()
559
560 return React.useCallback(async () => {
561 try {
562 await Promise.all([
563 queryClient.prefetchQuery({
564 queryKey: createPostgateQueryKey(postUri),
565 queryFn: () =>
566 getPostgateRecord({agent, postUri}).then(res => res ?? null),
567 staleTime: STALE.SECONDS.THIRTY,
568 }),
569 queryClient.prefetchQuery({
570 queryKey: createThreadgateViewQueryKey(rootPostUri),
571 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
572 staleTime: STALE.SECONDS.THIRTY,
573 }),
574 ])
575 } catch (e: any) {
576 logger.error(`Failed to prefetch post interaction settings`, {
577 safeMessage: e.message,
578 })
579 }
580 }, [queryClient, agent, postUri, rootPostUri])
581}