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