mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useRef} from 'react'
2import {StyleSheet, useWindowDimensions, View} from 'react-native'
3import {runOnJS} from 'react-native-reanimated'
4import Animated from 'react-native-reanimated'
5import {useSafeAreaInsets} from 'react-native-safe-area-context'
6import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
7import {msg, Trans} from '@lingui/macro'
8import {useLingui} from '@lingui/react'
9
10import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
11import {clamp} from '#/lib/numbers'
12import {ScrollProvider} from '#/lib/ScrollContext'
13import {isAndroid, isNative, isWeb} from '#/platform/detection'
14import {useModerationOpts} from '#/state/preferences/moderation-opts'
15import {
16 fillThreadModerationCache,
17 sortThread,
18 ThreadBlocked,
19 ThreadModerationCache,
20 ThreadNode,
21 ThreadNotFound,
22 ThreadPost,
23 usePostThreadQuery,
24} from '#/state/queries/post-thread'
25import {usePreferencesQuery} from '#/state/queries/preferences'
26import {useSession} from '#/state/session'
27import {useComposerControls} from '#/state/shell'
28import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
29import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
30import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
31import {useSetTitle} from 'lib/hooks/useSetTitle'
32import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
33import {sanitizeDisplayName} from 'lib/strings/display-names'
34import {cleanError} from 'lib/strings/errors'
35import {CenteredView} from 'view/com/util/Views'
36import {atoms as a, useTheme} from '#/alf'
37import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
38import {Text} from '#/components/Typography'
39import {List, ListMethods} from '../util/List'
40import {ViewHeader} from '../util/ViewHeader'
41import {PostThreadComposePrompt} from './PostThreadComposePrompt'
42import {PostThreadItem} from './PostThreadItem'
43import {PostThreadLoadMore} from './PostThreadLoadMore'
44import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
45
46// FlatList maintainVisibleContentPosition breaks if too many items
47// are prepended. This seems to be an optimal number based on *shrug*.
48const PARENTS_CHUNK_SIZE = 15
49
50const MAINTAIN_VISIBLE_CONTENT_POSITION = {
51 // We don't insert any elements before the root row while loading.
52 // So the row we want to use as the scroll anchor is the first row.
53 minIndexForVisible: 0,
54}
55
56const REPLY_PROMPT = {_reactKey: '__reply__'}
57const LOAD_MORE = {_reactKey: '__load_more__'}
58const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
59const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
60
61enum HiddenRepliesState {
62 Hide,
63 Show,
64 ShowAndOverridePostHider,
65}
66
67type YieldedItem =
68 | ThreadPost
69 | ThreadBlocked
70 | ThreadNotFound
71 | typeof SHOW_HIDDEN_REPLIES
72 | typeof SHOW_MUTED_REPLIES
73type RowItem =
74 | YieldedItem
75 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
76 | typeof REPLY_PROMPT
77 | typeof LOAD_MORE
78
79type ThreadSkeletonParts = {
80 parents: YieldedItem[]
81 highlightedPost: ThreadNode
82 replies: YieldedItem[]
83}
84
85const keyExtractor = (item: RowItem) => {
86 return item._reactKey
87}
88
89export function PostThread({uri}: {uri: string | undefined}) {
90 const {hasSession, currentAccount} = useSession()
91 const {_} = useLingui()
92 const t = useTheme()
93 const {isMobile, isTabletOrMobile} = useWebMediaQueries()
94 const initialNumToRender = useInitialNumToRender()
95 const {height: windowHeight} = useWindowDimensions()
96 const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
97 HiddenRepliesState.Hide,
98 )
99
100 const {data: preferences} = usePreferencesQuery()
101 const {
102 isFetching,
103 isError: isThreadError,
104 error: threadError,
105 refetch,
106 data: {thread, threadgate} = {},
107 } = usePostThreadQuery(uri)
108
109 const treeView = React.useMemo(
110 () =>
111 !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
112 hasBranchingReplies(thread),
113 [preferences?.threadViewPrefs, thread],
114 )
115 const rootPost = thread?.type === 'post' ? thread.post : undefined
116 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
117 const threadgateRecord = threadgate?.record as
118 | AppBskyFeedThreadgate.Record
119 | undefined
120 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
121 threadgateRecord,
122 })
123
124 const moderationOpts = useModerationOpts()
125 const isNoPwi = React.useMemo(() => {
126 const mod =
127 rootPost && moderationOpts
128 ? moderatePost(rootPost, moderationOpts)
129 : undefined
130 return !!mod
131 ?.ui('contentList')
132 .blurs.find(
133 cause =>
134 cause.type === 'label' &&
135 cause.labelDef.identifier === '!no-unauthenticated',
136 )
137 }, [rootPost, moderationOpts])
138
139 // Values used for proper rendering of parents
140 const ref = useRef<ListMethods>(null)
141 const highlightedPostRef = useRef<View | null>(null)
142 const [maxParents, setMaxParents] = React.useState(
143 isWeb ? Infinity : PARENTS_CHUNK_SIZE,
144 )
145 const [maxReplies, setMaxReplies] = React.useState(50)
146
147 useSetTitle(
148 rootPost && !isNoPwi
149 ? `${sanitizeDisplayName(
150 rootPost.author.displayName || `@${rootPost.author.handle}`,
151 )}: "${rootPostRecord!.text}"`
152 : '',
153 )
154
155 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
156 // This ensures that the first render contains no parents--even if they are already available in the cache.
157 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
158 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
159 const [deferParents, setDeferParents] = React.useState(isNative)
160
161 const currentDid = currentAccount?.did
162 const threadModerationCache = React.useMemo(() => {
163 const cache: ThreadModerationCache = new WeakMap()
164 if (thread && moderationOpts) {
165 fillThreadModerationCache(cache, thread, moderationOpts)
166 }
167 return cache
168 }, [thread, moderationOpts])
169
170 const [justPostedUris, setJustPostedUris] = React.useState(
171 () => new Set<string>(),
172 )
173
174 const skeleton = React.useMemo(() => {
175 const threadViewPrefs = preferences?.threadViewPrefs
176 if (!threadViewPrefs || !thread) return null
177
178 return createThreadSkeleton(
179 sortThread(
180 thread,
181 threadViewPrefs,
182 threadModerationCache,
183 currentDid,
184 justPostedUris,
185 threadgateHiddenReplies,
186 ),
187 currentDid,
188 treeView,
189 threadModerationCache,
190 hiddenRepliesState !== HiddenRepliesState.Hide,
191 threadgateHiddenReplies,
192 )
193 }, [
194 thread,
195 preferences?.threadViewPrefs,
196 currentDid,
197 treeView,
198 threadModerationCache,
199 hiddenRepliesState,
200 justPostedUris,
201 threadgateHiddenReplies,
202 ])
203
204 const error = React.useMemo(() => {
205 if (AppBskyFeedDefs.isNotFoundPost(thread)) {
206 return {
207 title: _(msg`Post not found`),
208 message: _(msg`The post may have been deleted.`),
209 }
210 } else if (skeleton?.highlightedPost.type === 'blocked') {
211 return {
212 title: _(msg`Post hidden`),
213 message: _(
214 msg`You have blocked the author or you have been blocked by the author.`,
215 ),
216 }
217 } else if (threadError?.message.startsWith('Post not found')) {
218 return {
219 title: _(msg`Post not found`),
220 message: _(msg`The post may have been deleted.`),
221 }
222 } else if (isThreadError) {
223 return {
224 message: threadError ? cleanError(threadError) : undefined,
225 }
226 }
227
228 return null
229 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
230
231 // construct content
232 const posts = React.useMemo(() => {
233 if (!skeleton) return []
234
235 const {parents, highlightedPost, replies} = skeleton
236 let arr: RowItem[] = []
237 if (highlightedPost.type === 'post') {
238 // We want to wait for parents to load before rendering.
239 // If you add something here, you'll need to update both
240 // maintainVisibleContentPosition and onContentSizeChange
241 // to "hold onto" the correct row instead of the first one.
242
243 if (!highlightedPost.ctx.isParentLoading && !deferParents) {
244 // When progressively revealing parents, rendering a placeholder
245 // here will cause scrolling jumps. Don't add it unless you test it.
246 // QT'ing this thread is a great way to test all the scrolling hacks:
247 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
248
249 // Everything is loaded
250 let startIndex = Math.max(0, parents.length - maxParents)
251 for (let i = startIndex; i < parents.length; i++) {
252 arr.push(parents[i])
253 }
254 }
255 arr.push(highlightedPost)
256 if (!highlightedPost.post.viewer?.replyDisabled) {
257 arr.push(REPLY_PROMPT)
258 }
259 for (let i = 0; i < replies.length; i++) {
260 arr.push(replies[i])
261 if (i === maxReplies) {
262 break
263 }
264 }
265 }
266 return arr
267 }, [skeleton, deferParents, maxParents, maxReplies])
268
269 // This is only used on the web to keep the post in view when its parents load.
270 // On native, we rely on `maintainVisibleContentPosition` instead.
271 const didAdjustScrollWeb = useRef<boolean>(false)
272 const onContentSizeChangeWeb = React.useCallback(() => {
273 // only run once
274 if (didAdjustScrollWeb.current) {
275 return
276 }
277 // wait for loading to finish
278 if (thread?.type === 'post' && !!thread.parent) {
279 function onMeasure(pageY: number) {
280 ref.current?.scrollToOffset({
281 animated: false,
282 offset: pageY,
283 })
284 }
285 // Measure synchronously to avoid a layout jump.
286 const domNode = highlightedPostRef.current
287 if (domNode) {
288 const pageY = (domNode as any as Element).getBoundingClientRect().top
289 onMeasure(pageY)
290 }
291 didAdjustScrollWeb.current = true
292 }
293 }, [thread])
294
295 // On native, we reveal parents in chunks. Although they're all already
296 // loaded and FlatList already has its own virtualization, unfortunately FlatList
297 // has a bug that causes the content to jump around if too many items are getting
298 // prepended at once. It also jumps around if items get prepended during scroll.
299 // To work around this, we prepend rows after scroll bumps against the top and rests.
300 const needsBumpMaxParents = React.useRef(false)
301 const onStartReached = React.useCallback(() => {
302 if (skeleton?.parents && maxParents < skeleton.parents.length) {
303 needsBumpMaxParents.current = true
304 }
305 }, [maxParents, skeleton?.parents])
306 const bumpMaxParentsIfNeeded = React.useCallback(() => {
307 if (!isNative) {
308 return
309 }
310 if (needsBumpMaxParents.current) {
311 needsBumpMaxParents.current = false
312 setMaxParents(n => n + PARENTS_CHUNK_SIZE)
313 }
314 }, [])
315 const onScrollToTop = bumpMaxParentsIfNeeded
316 const onMomentumEnd = React.useCallback(() => {
317 'worklet'
318 runOnJS(bumpMaxParentsIfNeeded)()
319 }, [bumpMaxParentsIfNeeded])
320
321 const onEndReached = React.useCallback(() => {
322 if (isFetching || posts.length < maxReplies) return
323 setMaxReplies(prev => prev + 50)
324 }, [isFetching, maxReplies, posts.length])
325
326 const onPostReply = React.useCallback(
327 (postUri: string | undefined) => {
328 refetch()
329 if (postUri) {
330 setJustPostedUris(set => {
331 const nextSet = new Set(set)
332 nextSet.add(postUri)
333 return nextSet
334 })
335 }
336 },
337 [refetch],
338 )
339
340 const {openComposer} = useComposerControls()
341 const onPressReply = React.useCallback(() => {
342 if (thread?.type !== 'post') {
343 return
344 }
345 openComposer({
346 replyTo: {
347 uri: thread.post.uri,
348 cid: thread.post.cid,
349 text: thread.record.text,
350 author: thread.post.author,
351 embed: thread.post.embed,
352 },
353 onPost: onPostReply,
354 })
355 }, [openComposer, thread, onPostReply])
356
357 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
358 const hasParents =
359 skeleton?.highlightedPost?.type === 'post' &&
360 (skeleton.highlightedPost.ctx.isParentLoading ||
361 Boolean(skeleton?.parents && skeleton.parents.length > 0))
362 const showHeader =
363 isNative || (isTabletOrMobile && (!hasParents || !isFetching))
364
365 const renderItem = ({item, index}: {item: RowItem; index: number}) => {
366 if (item === REPLY_PROMPT && hasSession) {
367 return (
368 <View>
369 {!isMobile && (
370 <PostThreadComposePrompt onPressCompose={onPressReply} />
371 )}
372 </View>
373 )
374 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
375 return (
376 <PostThreadShowHiddenReplies
377 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
378 onPress={() =>
379 setHiddenRepliesState(
380 item === SHOW_HIDDEN_REPLIES
381 ? HiddenRepliesState.Show
382 : HiddenRepliesState.ShowAndOverridePostHider,
383 )
384 }
385 hideTopBorder={index === 0}
386 />
387 )
388 } else if (isThreadNotFound(item)) {
389 return (
390 <View
391 style={[
392 a.p_lg,
393 index !== 0 && a.border_t,
394 t.atoms.border_contrast_low,
395 t.atoms.bg_contrast_25,
396 ]}>
397 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
398 <Trans>Deleted post.</Trans>
399 </Text>
400 </View>
401 )
402 } else if (isThreadBlocked(item)) {
403 return (
404 <View
405 style={[
406 a.p_lg,
407 index !== 0 && a.border_t,
408 t.atoms.border_contrast_low,
409 t.atoms.bg_contrast_25,
410 ]}>
411 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
412 <Trans>Blocked post.</Trans>
413 </Text>
414 </View>
415 )
416 } else if (isThreadPost(item)) {
417 if (!treeView && item.ctx.hasMoreSelfThread) {
418 return <PostThreadLoadMore post={item.post} />
419 }
420 const prev = isThreadPost(posts[index - 1])
421 ? (posts[index - 1] as ThreadPost)
422 : undefined
423 const next = isThreadPost(posts[index + 1])
424 ? (posts[index + 1] as ThreadPost)
425 : undefined
426 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
427 const showParentReplyLine =
428 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
429 const hasUnrevealedParents =
430 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
431
432 return (
433 <View
434 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
435 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
436 <PostThreadItem
437 post={item.post}
438 record={item.record}
439 threadgateRecord={threadgateRecord ?? undefined}
440 moderation={threadModerationCache.get(item)}
441 treeView={treeView}
442 depth={item.ctx.depth}
443 prevPost={prev}
444 nextPost={next}
445 isHighlightedPost={item.ctx.isHighlightedPost}
446 hasMore={item.ctx.hasMore}
447 showChildReplyLine={showChildReplyLine}
448 showParentReplyLine={showParentReplyLine}
449 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
450 overrideBlur={
451 hiddenRepliesState ===
452 HiddenRepliesState.ShowAndOverridePostHider &&
453 item.ctx.depth > 0
454 }
455 onPostReply={onPostReply}
456 hideTopBorder={index === 0 && !item.ctx.isParentLoading}
457 />
458 </View>
459 )
460 }
461 return null
462 }
463
464 if (!thread || !preferences || error) {
465 return (
466 <ListMaybePlaceholder
467 isLoading={!error}
468 isError={Boolean(error)}
469 noEmpty
470 onRetry={refetch}
471 errorTitle={error?.title}
472 errorMessage={error?.message}
473 />
474 )
475 }
476
477 return (
478 <CenteredView style={[a.flex_1]} sideBorders={true}>
479 {showHeader && (
480 <ViewHeader
481 title={_(msg({message: `Post`, context: 'description'}))}
482 showBorder
483 />
484 )}
485
486 <ScrollProvider onMomentumEnd={onMomentumEnd}>
487 <List
488 ref={ref}
489 data={posts}
490 renderItem={renderItem}
491 keyExtractor={keyExtractor}
492 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
493 onStartReached={onStartReached}
494 onEndReached={onEndReached}
495 onEndReachedThreshold={2}
496 onScrollToTop={onScrollToTop}
497 maintainVisibleContentPosition={
498 isNative && hasParents
499 ? MAINTAIN_VISIBLE_CONTENT_POSITION
500 : undefined
501 }
502 // @ts-ignore our .web version only -prf
503 desktopFixedHeight
504 removeClippedSubviews={isAndroid ? false : undefined}
505 ListFooterComponent={
506 <ListFooter
507 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
508 // initial render
509 isFetchingNextPage={isFetching}
510 error={cleanError(threadError)}
511 onRetry={refetch}
512 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
513 // work without causing weird jumps on web or glitches on native
514 height={windowHeight - 200}
515 />
516 }
517 initialNumToRender={initialNumToRender}
518 windowSize={11}
519 sideBorders={false}
520 />
521 </ScrollProvider>
522 {isMobile && canReply && hasSession && (
523 <MobileComposePrompt onPressReply={onPressReply} />
524 )}
525 </CenteredView>
526 )
527}
528
529function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
530 const safeAreaInsets = useSafeAreaInsets()
531 const fabMinimalShellTransform = useMinimalShellFabTransform()
532 return (
533 <Animated.View
534 style={[
535 styles.prompt,
536 fabMinimalShellTransform,
537 {
538 bottom: clamp(safeAreaInsets.bottom, 15, 30),
539 },
540 ]}>
541 <PostThreadComposePrompt onPressCompose={onPressReply} />
542 </Animated.View>
543 )
544}
545
546function isThreadPost(v: unknown): v is ThreadPost {
547 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
548}
549
550function isThreadNotFound(v: unknown): v is ThreadNotFound {
551 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
552}
553
554function isThreadBlocked(v: unknown): v is ThreadBlocked {
555 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
556}
557
558function createThreadSkeleton(
559 node: ThreadNode,
560 currentDid: string | undefined,
561 treeView: boolean,
562 modCache: ThreadModerationCache,
563 showHiddenReplies: boolean,
564 threadgateRecordHiddenReplies: Set<string>,
565): ThreadSkeletonParts | null {
566 if (!node) return null
567
568 return {
569 parents: Array.from(flattenThreadParents(node, !!currentDid)),
570 highlightedPost: node,
571 replies: Array.from(
572 flattenThreadReplies(
573 node,
574 currentDid,
575 treeView,
576 modCache,
577 showHiddenReplies,
578 threadgateRecordHiddenReplies,
579 ),
580 ),
581 }
582}
583
584function* flattenThreadParents(
585 node: ThreadNode,
586 hasSession: boolean,
587): Generator<YieldedItem, void> {
588 if (node.type === 'post') {
589 if (node.parent) {
590 yield* flattenThreadParents(node.parent, hasSession)
591 }
592 if (!node.ctx.isHighlightedPost) {
593 yield node
594 }
595 } else if (node.type === 'not-found') {
596 yield node
597 } else if (node.type === 'blocked') {
598 yield node
599 }
600}
601
602// The enum is ordered to make them easy to merge
603enum HiddenReplyType {
604 None = 0,
605 Muted = 1,
606 Hidden = 2,
607}
608
609function* flattenThreadReplies(
610 node: ThreadNode,
611 currentDid: string | undefined,
612 treeView: boolean,
613 modCache: ThreadModerationCache,
614 showHiddenReplies: boolean,
615 threadgateRecordHiddenReplies: Set<string>,
616): Generator<YieldedItem, HiddenReplyType> {
617 if (node.type === 'post') {
618 // dont show pwi-opted-out posts to logged out users
619 if (!currentDid && hasPwiOptOut(node)) {
620 return HiddenReplyType.None
621 }
622
623 // handle blurred items
624 if (node.ctx.depth > 0) {
625 const modui = modCache.get(node)?.ui('contentList')
626 if (modui?.blur || modui?.filter) {
627 if (!showHiddenReplies || node.ctx.depth > 1) {
628 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
629 return HiddenReplyType.Muted
630 }
631 return HiddenReplyType.Hidden
632 }
633 }
634
635 if (!showHiddenReplies) {
636 const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
637 node.post.uri,
638 )
639 const authorIsViewer = node.post.author.did === currentDid
640 if (hiddenByThreadgate && !authorIsViewer) {
641 return HiddenReplyType.Hidden
642 }
643 }
644 }
645
646 if (!node.ctx.isHighlightedPost) {
647 yield node
648 }
649
650 if (node.replies?.length) {
651 let hiddenReplies = HiddenReplyType.None
652 for (const reply of node.replies) {
653 let hiddenReply = yield* flattenThreadReplies(
654 reply,
655 currentDid,
656 treeView,
657 modCache,
658 showHiddenReplies,
659 threadgateRecordHiddenReplies,
660 )
661 if (hiddenReply > hiddenReplies) {
662 hiddenReplies = hiddenReply
663 }
664 if (!treeView && !node.ctx.isHighlightedPost) {
665 break
666 }
667 }
668
669 // show control to enable hidden replies
670 if (node.ctx.depth === 0) {
671 if (hiddenReplies === HiddenReplyType.Muted) {
672 yield SHOW_MUTED_REPLIES
673 } else if (hiddenReplies === HiddenReplyType.Hidden) {
674 yield SHOW_HIDDEN_REPLIES
675 }
676 }
677 }
678 } else if (node.type === 'not-found') {
679 yield node
680 } else if (node.type === 'blocked') {
681 yield node
682 }
683 return HiddenReplyType.None
684}
685
686function hasPwiOptOut(node: ThreadPost) {
687 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
688}
689
690function hasBranchingReplies(node?: ThreadNode) {
691 if (!node) {
692 return false
693 }
694 if (node.type !== 'post') {
695 return false
696 }
697 if (!node.replies) {
698 return false
699 }
700 if (node.replies.length === 1) {
701 return hasBranchingReplies(node.replies[0])
702 }
703 return true
704}
705
706const styles = StyleSheet.create({
707 prompt: {
708 // @ts-ignore web-only
709 position: isWeb ? 'fixed' : 'absolute',
710 left: 0,
711 right: 0,
712 },
713})