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