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 <Loader size="xl" />
208 ) : (
209 <PostInteractionSettingsForm
210 replySettingsDisabled={!isThreadgateOwnedByViewer}
211 isSaving={isSaving}
212 onSave={onSave}
213 postgate={postgateValue}
214 onChangePostgate={setEditedPostgate}
215 threadgateAllowUISettings={allowUIValue}
216 onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
217 />
218 )}
219 </Dialog.ScrollableInner>
220 )
221}
222
223export function PostInteractionSettingsForm({
224 onSave,
225 isSaving,
226 postgate,
227 onChangePostgate,
228 threadgateAllowUISettings,
229 onChangeThreadgateAllowUISettings,
230 replySettingsDisabled,
231}: PostInteractionSettingsFormProps) {
232 const t = useTheme()
233 const {_} = useLingui()
234 const control = Dialog.useDialogContext()
235 const {data: lists} = useMyListsQuery('curate')
236 const [quotesEnabled, setQuotesEnabled] = React.useState(
237 !(
238 postgate.embeddingRules &&
239 postgate.embeddingRules.find(
240 v => v.$type === embeddingRules.disableRule.$type,
241 )
242 ),
243 )
244
245 const onPressAudience = (setting: ThreadgateAllowUISetting) => {
246 // remove boolean values
247 let newSelected: ThreadgateAllowUISetting[] =
248 threadgateAllowUISettings.filter(
249 v => v.type !== 'nobody' && v.type !== 'everybody',
250 )
251 // toggle
252 const i = newSelected.findIndex(v => isEqual(v, setting))
253 if (i === -1) {
254 newSelected.push(setting)
255 } else {
256 newSelected.splice(i, 1)
257 }
258
259 onChangeThreadgateAllowUISettings(newSelected)
260 }
261
262 const onChangeQuotesEnabled = React.useCallback(
263 (enabled: boolean) => {
264 setQuotesEnabled(enabled)
265 onChangePostgate(
266 createPostgateRecord({
267 ...postgate,
268 embeddingRules: enabled ? [] : [embeddingRules.disableRule],
269 }),
270 )
271 },
272 [setQuotesEnabled, postgate, onChangePostgate],
273 )
274
275 const noOneCanReply = !!threadgateAllowUISettings.find(
276 v => v.type === 'nobody',
277 )
278
279 return (
280 <View>
281 <View style={[a.flex_1, a.gap_md]}>
282 <Text style={[a.text_2xl, a.font_bold]}>
283 <Trans>Post interaction settings</Trans>
284 </Text>
285
286 <View style={[a.gap_lg]}>
287 <Text style={[a.text_md]}>
288 <Trans>Customize who can interact with this post.</Trans>
289 </Text>
290
291 <Divider />
292
293 <View style={[a.gap_sm]}>
294 <Text style={[a.font_bold, a.text_lg]}>
295 <Trans>Quote settings</Trans>
296 </Text>
297
298 <Toggle.Item
299 name="quoteposts"
300 type="checkbox"
301 label={
302 quotesEnabled
303 ? _(msg`Click to disable quote posts of this post.`)
304 : _(msg`Click to enable quote posts of this post.`)
305 }
306 value={quotesEnabled}
307 onChange={onChangeQuotesEnabled}
308 style={[, a.justify_between, a.pt_xs]}>
309 <Text style={[t.atoms.text_contrast_medium]}>
310 {quotesEnabled ? (
311 <Trans>Quote posts enabled</Trans>
312 ) : (
313 <Trans>Quote posts disabled</Trans>
314 )}
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 onAccessibilityEscape={control.close}
441 color="primary"
442 size="medium"
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_semibold]}>
495 {label}
496 </Text>
497 {isSelected ? (
498 <Check size="sm" fill={t.palette.primary_500} />
499 ) : (
500 <View />
501 )}
502 </View>
503 )}
504 </Button>
505 )
506}
507
508export function usePrefetchPostInteractionSettings({
509 postUri,
510 rootPostUri,
511}: {
512 postUri: string
513 rootPostUri: string
514}) {
515 const queryClient = useQueryClient()
516 const agent = useAgent()
517
518 return React.useCallback(async () => {
519 try {
520 await Promise.all([
521 queryClient.prefetchQuery({
522 queryKey: createPostgateQueryKey(postUri),
523 queryFn: () => getPostgateRecord({agent, postUri}),
524 staleTime: STALE.SECONDS.THIRTY,
525 }),
526 queryClient.prefetchQuery({
527 queryKey: createThreadgateViewQueryKey(rootPostUri),
528 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
529 staleTime: STALE.SECONDS.THIRTY,
530 }),
531 ])
532 } catch (e: any) {
533 logger.error(`Failed to prefetch post interaction settings`, {
534 safeMessage: e.message,
535 })
536 }
537 }, [queryClient, agent, postUri, rootPostUri])
538}