Refactor notifications to use react-query (#1878)
* Move broadcast channel to lib * Refactor view/com/post/Post and remove temporary 2 components * Add useModerationOpts hook * Refactor notifications to use react-query * Fix: only trigger updates in useModerationOpts when the values have changed * Implement unread notification tracking * Add moderation filtering to notifications * Handle native/push notifications * Remove dead code --------- Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
parent
c584a3378d
commit
b445c15cc9
29 changed files with 941 additions and 1739 deletions
|
@ -1,8 +1,6 @@
|
|||
import React, {MutableRefObject} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {CenteredView, FlatList} from '../util/Views'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {NotificationsFeedModel} from 'state/models/feeds/notifications'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
|
@ -12,20 +10,22 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
|||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
|
||||
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
const LOADING_SPINNER = {_reactKey: '__loading_spinner__'}
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
|
||||
export const Feed = observer(function Feed({
|
||||
view,
|
||||
export function Feed({
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onScroll,
|
||||
ListHeaderComponent,
|
||||
}: {
|
||||
view: NotificationsFeedModel
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
onScroll?: OnScrollHandler
|
||||
|
@ -33,35 +33,54 @@ export const Feed = observer(function Feed({
|
|||
}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems: any[] = []
|
||||
if (view.isRefreshing && !isPTRing) {
|
||||
feedItems = [LOADING_SPINNER]
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {markAllRead} = useUnreadNotificationsApi()
|
||||
const {
|
||||
data,
|
||||
dataUpdatedAt,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useNotificationFeedQuery({enabled: !!moderationOpts})
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
||||
const firstItem = data?.pages[0]?.items[0]
|
||||
|
||||
// mark all read on fresh data
|
||||
React.useEffect(() => {
|
||||
if (firstItem) {
|
||||
markAllRead()
|
||||
}
|
||||
if (view.hasLoaded) {
|
||||
if (view.isEmpty) {
|
||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||
} else {
|
||||
feedItems = feedItems.concat(view.notifications)
|
||||
}, [firstItem, markAllRead])
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let arr: any[] = []
|
||||
if (isFetched) {
|
||||
if (isEmpty) {
|
||||
arr = arr.concat([EMPTY_FEED_ITEM])
|
||||
} else if (data) {
|
||||
for (const page of data?.pages) {
|
||||
arr = arr.concat(page.items)
|
||||
}
|
||||
}
|
||||
if (isError && !isEmpty) {
|
||||
arr = arr.concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
} else {
|
||||
arr.push(LOADING_ITEM)
|
||||
}
|
||||
if (view.loadMoreError) {
|
||||
feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
return feedItems
|
||||
}, [
|
||||
view.hasLoaded,
|
||||
view.isEmpty,
|
||||
view.notifications,
|
||||
view.loadMoreError,
|
||||
view.isRefreshing,
|
||||
isPTRing,
|
||||
])
|
||||
return arr
|
||||
}, [isFetched, isError, isEmpty, data])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
try {
|
||||
setIsPTRing(true)
|
||||
await view.refresh()
|
||||
await refetch()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh notifications feed', {
|
||||
error: err,
|
||||
|
@ -69,21 +88,21 @@ export const Feed = observer(function Feed({
|
|||
} finally {
|
||||
setIsPTRing(false)
|
||||
}
|
||||
}, [view, setIsPTRing])
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
||||
try {
|
||||
await view.loadMore()
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more notifications', {
|
||||
error: err,
|
||||
})
|
||||
logger.error('Failed to load more notifications', {error: err})
|
||||
}
|
||||
}, [view])
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const onPressRetryLoadMore = React.useCallback(() => {
|
||||
view.retryLoadMore()
|
||||
}, [view])
|
||||
fetchNextPage()
|
||||
}, [fetchNextPage])
|
||||
|
||||
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
|
||||
// VirtualizedList: You have a large list that is slow to update - make sure your
|
||||
|
@ -106,78 +125,72 @@ export const Feed = observer(function Feed({
|
|||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING_SPINNER) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
)
|
||||
} else if (item === LOADING_ITEM) {
|
||||
return <NotificationFeedLoadingPlaceholder />
|
||||
}
|
||||
return <FeedItem item={item} />
|
||||
return (
|
||||
<FeedItem
|
||||
item={item}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
moderationOpts={moderationOpts!}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[onPressRetryLoadMore],
|
||||
[onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
|
||||
)
|
||||
|
||||
const FeedFooter = React.useCallback(
|
||||
() =>
|
||||
view.isLoading ? (
|
||||
isFetchingNextPage ? (
|
||||
<View style={styles.feedFooter}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
),
|
||||
[view],
|
||||
[isFetchingNextPage],
|
||||
)
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<CenteredView>
|
||||
{view.isLoading && !data.length && (
|
||||
<NotificationFeedLoadingPlaceholder />
|
||||
)}
|
||||
{view.hasError && (
|
||||
{error && (
|
||||
<CenteredView>
|
||||
<ErrorMessage
|
||||
message={view.error}
|
||||
message={cleanError(error)}
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)}
|
||||
</CenteredView>
|
||||
{data.length ? (
|
||||
<FlatList
|
||||
testID="notifsFeed"
|
||||
ref={scrollElRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={1}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
) : null}
|
||||
</CenteredView>
|
||||
)}
|
||||
<FlatList
|
||||
testID="notifsFeed"
|
||||
ref={scrollElRef}
|
||||
data={items}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={1}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
paddingVertical: 20,
|
||||
},
|
||||
feedFooter: {paddingTop: 20},
|
||||
emptyState: {paddingVertical: 40},
|
||||
})
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, {useMemo, useState, useEffect} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
Animated,
|
||||
TouchableOpacity,
|
||||
|
@ -9,6 +8,9 @@ import {
|
|||
} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
ModerationOpts,
|
||||
ProfileModeration,
|
||||
moderateProfile,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
|
@ -19,8 +21,7 @@ import {
|
|||
FontAwesomeIconStyle,
|
||||
Props,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
|
||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {niceDate} from 'lib/strings/time'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
|
@ -33,7 +34,6 @@ import {UserPreviewLink} from '../util/UserPreviewLink'
|
|||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||
import {Post} from '../post/Post'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
|
@ -56,40 +56,36 @@ interface Author {
|
|||
moderation: ProfileModeration
|
||||
}
|
||||
|
||||
export const FeedItem = observer(function FeedItemImpl({
|
||||
export function FeedItem({
|
||||
item,
|
||||
dataUpdatedAt,
|
||||
moderationOpts,
|
||||
}: {
|
||||
item: NotificationsFeedItemModel
|
||||
item: FeedNotification
|
||||
dataUpdatedAt: number
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
if (item.isLike || item.isRepost) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
if (item.type === 'post-like' || item.type === 'repost') {
|
||||
if (item.subjectUri) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||
}
|
||||
} else if (item.type === 'follow') {
|
||||
return makeProfileLink(item.notification.author)
|
||||
} else if (item.type === 'reply') {
|
||||
const urip = new AtUri(item.notification.uri)
|
||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||
} else if (item.isFollow) {
|
||||
return makeProfileLink(item.author)
|
||||
} else if (item.isReply) {
|
||||
const urip = new AtUri(item.uri)
|
||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||
} else if (item.isCustomFeedLike) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
return `/profile/${urip.host}/feed/${urip.rkey}`
|
||||
} else if (item.type === 'feedgen-like') {
|
||||
if (item.subjectUri) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
return `/profile/${urip.host}/feed/${urip.rkey}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}, [item])
|
||||
const itemTitle = useMemo(() => {
|
||||
if (item.isLike || item.isRepost) {
|
||||
return 'Post'
|
||||
} else if (item.isFollow) {
|
||||
return item.author.handle
|
||||
} else if (item.isReply) {
|
||||
return 'Post'
|
||||
} else if (item.isCustomFeedLike) {
|
||||
return 'Custom Feed'
|
||||
}
|
||||
}, [item])
|
||||
|
||||
const onToggleAuthorsExpanded = () => {
|
||||
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
||||
|
@ -98,15 +94,12 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
const authors: Author[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
href: makeProfileLink(item.author),
|
||||
did: item.author.did,
|
||||
handle: item.author.handle,
|
||||
displayName: item.author.displayName,
|
||||
avatar: item.author.avatar,
|
||||
moderation: moderateProfile(
|
||||
item.author,
|
||||
store.preferences.moderationOpts,
|
||||
),
|
||||
href: makeProfileLink(item.notification.author),
|
||||
did: item.notification.author.did,
|
||||
handle: item.notification.author.handle,
|
||||
displayName: item.notification.author.displayName,
|
||||
avatar: item.notification.author.avatar,
|
||||
moderation: moderateProfile(item.notification.author, moderationOpts),
|
||||
},
|
||||
...(item.additional?.map(({author}) => {
|
||||
return {
|
||||
|
@ -115,33 +108,36 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
avatar: author.avatar,
|
||||
moderation: moderateProfile(author, store.preferences.moderationOpts),
|
||||
moderation: moderateProfile(author, moderationOpts),
|
||||
}
|
||||
}) || []),
|
||||
]
|
||||
}, [store, item.additional, item.author])
|
||||
}, [item, moderationOpts])
|
||||
|
||||
if (item.additionalPost?.notFound) {
|
||||
if (item.subjectUri && !item.subject) {
|
||||
// don't render anything if the target post was deleted or unfindable
|
||||
return <View />
|
||||
}
|
||||
|
||||
if (item.isReply || item.isMention || item.isQuote) {
|
||||
if (!item.additionalPost || item.additionalPost?.error) {
|
||||
// hide errors - it doesnt help the user to show them
|
||||
return <View />
|
||||
if (
|
||||
item.type === 'reply' ||
|
||||
item.type === 'mention' ||
|
||||
item.type === 'quote'
|
||||
) {
|
||||
if (!item.subject) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${item.author.handle}`}
|
||||
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
noFeedback
|
||||
accessible={false}>
|
||||
<Post
|
||||
view={item.additionalPost}
|
||||
post={item.subject}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
style={
|
||||
item.isRead
|
||||
item.notification.isRead
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: pal.colors.unreadNotifBg,
|
||||
|
@ -156,23 +152,25 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
let action = ''
|
||||
let icon: Props['icon'] | 'HeartIconSolid'
|
||||
let iconStyle: Props['style'] = []
|
||||
if (item.isLike) {
|
||||
if (item.type === 'post-like') {
|
||||
action = 'liked your post'
|
||||
icon = 'HeartIconSolid'
|
||||
iconStyle = [
|
||||
s.likeColor as FontAwesomeIconStyle,
|
||||
{position: 'relative', top: -4},
|
||||
]
|
||||
} else if (item.isRepost) {
|
||||
} else if (item.type === 'repost') {
|
||||
action = 'reposted your post'
|
||||
icon = 'retweet'
|
||||
iconStyle = [s.green3 as FontAwesomeIconStyle]
|
||||
} else if (item.isFollow) {
|
||||
} else if (item.type === 'follow') {
|
||||
action = 'followed you'
|
||||
icon = 'user-plus'
|
||||
iconStyle = [s.blue3 as FontAwesomeIconStyle]
|
||||
} else if (item.isCustomFeedLike) {
|
||||
action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
|
||||
} else if (item.type === 'feedgen-like') {
|
||||
action = `liked your custom feed${
|
||||
item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
|
||||
}`
|
||||
icon = 'HeartIconSolid'
|
||||
iconStyle = [
|
||||
s.likeColor as FontAwesomeIconStyle,
|
||||
|
@ -184,12 +182,12 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${item.author.handle}`}
|
||||
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.view,
|
||||
pal.border,
|
||||
item.isRead
|
||||
item.notification.isRead
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: pal.colors.unreadNotifBg,
|
||||
|
@ -197,9 +195,11 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
},
|
||||
]}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
noFeedback
|
||||
accessible={(item.isLike && authors.length === 1) || item.isRepost}>
|
||||
accessible={
|
||||
(item.type === 'post-like' && authors.length === 1) ||
|
||||
item.type === 'repost'
|
||||
}>
|
||||
<View style={styles.layoutIcon}>
|
||||
{/* TODO: Prevent conditional rendering and move toward composable
|
||||
notifications for clearer accessibility labeling */}
|
||||
|
@ -244,24 +244,24 @@ export const FeedItem = observer(function FeedItemImpl({
|
|||
</>
|
||||
) : undefined}
|
||||
<Text style={[pal.text]}> {action}</Text>
|
||||
<TimeElapsed timestamp={item.indexedAt}>
|
||||
<TimeElapsed timestamp={item.notification.indexedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[pal.textLight, styles.pointer]}
|
||||
title={niceDate(item.indexedAt)}>
|
||||
title={niceDate(item.notification.indexedAt)}>
|
||||
{' ' + timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</Text>
|
||||
</ExpandListPressable>
|
||||
{item.isLike || item.isRepost || item.isQuote ? (
|
||||
<AdditionalPostText additionalPost={item.additionalPost} />
|
||||
{item.type === 'post-like' || item.type === 'repost' ? (
|
||||
<AdditionalPostText post={item.subject} />
|
||||
) : null}
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function ExpandListPressable({
|
||||
hasMultipleAuthors,
|
||||
|
@ -423,34 +423,25 @@ function ExpandedAuthorsList({
|
|||
)
|
||||
}
|
||||
|
||||
function AdditionalPostText({
|
||||
additionalPost,
|
||||
}: {
|
||||
additionalPost?: PostThreadModel
|
||||
}) {
|
||||
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
const pal = usePalette('default')
|
||||
if (
|
||||
!additionalPost ||
|
||||
!additionalPost.thread?.postRecord ||
|
||||
additionalPost.error
|
||||
) {
|
||||
return <View />
|
||||
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
||||
const text = post.record.text
|
||||
const images = AppBskyEmbedImages.isView(post.embed)
|
||||
? post.embed.images
|
||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedImages.isView(post.embed.media)
|
||||
? post.embed.media.images
|
||||
: undefined
|
||||
return (
|
||||
<>
|
||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||
{images && images?.length > 0 && (
|
||||
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const text = additionalPost.thread?.postRecord.text
|
||||
const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
|
||||
? additionalPost.thread.post.embed.images
|
||||
: AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) &&
|
||||
AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media)
|
||||
? additionalPost.thread.post.embed.media.images
|
||||
: undefined
|
||||
return (
|
||||
<>
|
||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||
{images && images?.length > 0 && (
|
||||
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue