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 <Trans>Quote posts enabled</Trans>
315 </Text>
316 <Toggle.Switch />
317 </Toggle.Item>
318 </View>
319
320 <Divider />
321
322 {replySettingsDisabled && (
323 <View
324 style={[
325 a.px_md,
326 a.py_sm,
327 a.rounded_sm,
328 a.flex_row,
329 a.align_center,
330 a.gap_sm,
331 t.atoms.bg_contrast_25,
332 ]}>
333 <CircleInfo fill={t.atoms.text_contrast_low.color} />
334 <Text
335 style={[
336 a.flex_1,
337 a.leading_snug,
338 t.atoms.text_contrast_medium,
339 ]}>
340 <Trans>
341 Reply settings are chosen by the author of the thread
342 </Trans>
343 </Text>
344 </View>
345 )}
346
347 <View
348 style={[
349 a.gap_sm,
350 {
351 opacity: replySettingsDisabled ? 0.3 : 1,
352 },
353 ]}>
354 <Text style={[a.font_bold, a.text_lg]}>
355 <Trans>Reply settings</Trans>
356 </Text>
357
358 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
359 <Trans>Allow replies from:</Trans>
360 </Text>
361
362 <View style={[a.flex_row, a.gap_sm]}>
363 <Selectable
364 label={_(msg`Everybody`)}
365 isSelected={
366 !!threadgateAllowUISettings.find(v => v.type === 'everybody')
367 }
368 onPress={() =>
369 onChangeThreadgateAllowUISettings([{type: 'everybody'}])
370 }
371 style={{flex: 1}}
372 disabled={replySettingsDisabled}
373 />
374 <Selectable
375 label={_(msg`Nobody`)}
376 isSelected={noOneCanReply}
377 onPress={() =>
378 onChangeThreadgateAllowUISettings([{type: 'nobody'}])
379 }
380 style={{flex: 1}}
381 disabled={replySettingsDisabled}
382 />
383 </View>
384
385 {!noOneCanReply && (
386 <>
387 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
388 <Trans>Or combine these options:</Trans>
389 </Text>
390
391 <View style={[a.gap_sm]}>
392 <Selectable
393 label={_(msg`Mentioned users`)}
394 isSelected={
395 !!threadgateAllowUISettings.find(
396 v => v.type === 'mention',
397 )
398 }
399 onPress={() => onPressAudience({type: 'mention'})}
400 disabled={replySettingsDisabled}
401 />
402 <Selectable
403 label={_(msg`Followed users`)}
404 isSelected={
405 !!threadgateAllowUISettings.find(
406 v => v.type === 'following',
407 )
408 }
409 onPress={() => onPressAudience({type: 'following'})}
410 disabled={replySettingsDisabled}
411 />
412 {lists && lists.length > 0
413 ? lists.map(list => (
414 <Selectable
415 key={list.uri}
416 label={_(msg`Users in "${list.name}"`)}
417 isSelected={
418 !!threadgateAllowUISettings.find(
419 v => v.type === 'list' && v.list === list.uri,
420 )
421 }
422 onPress={() =>
423 onPressAudience({type: 'list', list: list.uri})
424 }
425 disabled={replySettingsDisabled}
426 />
427 ))
428 : // No loading states to avoid jumps for the common case (no lists)
429 null}
430 </View>
431 </>
432 )}
433 </View>
434 </View>
435 </View>
436
437 <Button
438 label={_(msg`Save`)}
439 onPress={onSave}
440 color="primary"
441 size="large"
442 variant="solid"
443 style={a.mt_xl}>
444 <ButtonText>{_(msg`Save`)}</ButtonText>
445 {isSaving && <ButtonIcon icon={Loader} position="right" />}
446 </Button>
447 </View>
448 )
449}
450
451function Selectable({
452 label,
453 isSelected,
454 onPress,
455 style,
456 disabled,
457}: {
458 label: string
459 isSelected: boolean
460 onPress: () => void
461 style?: StyleProp<ViewStyle>
462 disabled?: boolean
463}) {
464 const t = useTheme()
465 return (
466 <Button
467 disabled={disabled}
468 onPress={onPress}
469 label={label}
470 accessibilityRole="checkbox"
471 aria-checked={isSelected}
472 accessibilityState={{
473 checked: isSelected,
474 }}
475 style={a.flex_1}>
476 {({hovered, focused}) => (
477 <View
478 style={[
479 a.flex_1,
480 a.flex_row,
481 a.align_center,
482 a.justify_between,
483 a.rounded_sm,
484 a.p_md,
485 {minHeight: 40}, // for consistency with checkmark icon visible or not
486 t.atoms.bg_contrast_50,
487 (hovered || focused) && t.atoms.bg_contrast_100,
488 isSelected && {
489 backgroundColor: t.palette.primary_100,
490 },
491 style,
492 ]}>
493 <Text style={[a.text_sm, isSelected && a.font_bold]}>{label}</Text>
494 {isSelected ? (
495 <Check size="sm" fill={t.palette.primary_500} />
496 ) : (
497 <View />
498 )}
499 </View>
500 )}
501 </Button>
502 )
503}
504
505export function usePrefetchPostInteractionSettings({
506 postUri,
507 rootPostUri,
508}: {
509 postUri: string
510 rootPostUri: string
511}) {
512 const queryClient = useQueryClient()
513 const agent = useAgent()
514
515 return React.useCallback(async () => {
516 try {
517 await Promise.all([
518 queryClient.prefetchQuery({
519 queryKey: createPostgateQueryKey(postUri),
520 queryFn: () => getPostgateRecord({agent, postUri}),
521 staleTime: STALE.SECONDS.THIRTY,
522 }),
523 queryClient.prefetchQuery({
524 queryKey: createThreadgateViewQueryKey(rootPostUri),
525 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
526 staleTime: STALE.SECONDS.THIRTY,
527 }),
528 ])
529 } catch (e: any) {
530 logger.error(`Failed to prefetch post interaction settings`, {
531 safeMessage: e.message,
532 })
533 }
534 }, [queryClient, agent, postUri, rootPostUri])
535}