diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx
--- a/src/screens/Settings/DeerSettings.tsx
+++ b/src/screens/Settings/DeerSettings.tsx
@@ -31,6 +31,10 @@ import {
useNoDiscoverFallback,
useSetNoDiscoverFallback,
} from '#/state/preferences/no-discover-fallback'
+import {
+ useRepostCarouselEnabled,
+ useSetRepostCarouselEnabled,
+} from '#/state/preferences/repost-carousel-enabled'
import {TextInput} from '#/view/com/modals/util'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a} from '#/alf'
@@ -134,6 +138,9 @@ const setNoDiscoverFallback = useSetNoDiscoverFallback()
const location = useGeolocation()
const setLocationControl = Dialog.useDialogControl()
+
+ const repostCarouselEnabled = useRepostCarouselEnabled()
+ const setRepostCarouselEnabled = useSetRepostCarouselEnabled()
const [gates, setGatesView] = useState(Object.fromEntries(useGatesCache()))
const dangerousSetGate = useDangerousSetGate()
@@ -283,6 +290,17 @@
Tweaks
+ setRepostCarouselEnabled(value)}
+ style={[a.w_full]}>
+
+ Combine reposts into a horizontal carousel
+
+
+
- {children}
-
+
+ {children}
+
+
diff --git a/src/state/preferences/repost-carousel-enabled.tsx b/src/state/preferences/repost-carousel-enabled.tsx
new file mode 100644
--- /dev/null
+++ b/src/state/preferences/repost-carousel-enabled.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = boolean
+type SetContext = (v: boolean) => void
+
+const stateContext = React.createContext(
+ Boolean(persisted.defaults.repostCarouselEnabled),
+)
+const setContext = React.createContext((_: boolean) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const [state, setState] = React.useState(
+ Boolean(persisted.get('repostCarouselEnabled')),
+ )
+
+ const setStateWrapped = React.useCallback(
+ (value: persisted.Schema['repostCarouselEnabled']) => {
+ setState(Boolean(value))
+ persisted.write('repostCarouselEnabled', value)
+ },
+ [setState],
+ )
+
+ React.useEffect(() => {
+ return persisted.onUpdate('repostCarouselEnabled', nextValue => {
+ setState(Boolean(nextValue))
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export const useRepostCarouselEnabled = () => React.useContext(stateContext)
+export const useSetRepostCarouselEnabled = () => React.useContext(setContext)
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -9,7 +9,11 @@ StyleSheet,
View,
type ViewStyle,
} from 'react-native'
-import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
+import {
+ type AppBskyActorDefs,
+ AppBskyEmbedVideo,
+ AppBskyFeedDefs,
+} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -21,6 +25,7 @@ import {logger} from '#/logger'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {useRepostCarouselEnabled} from '#/state/preferences/repost-carousel-enabled'
import {useTrendingSettings} from '#/state/preferences/trending'
import {STALE} from '#/state/queries'
import {
@@ -51,6 +56,7 @@ import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
import {PostFeedItem} from './PostFeedItem'
+import {PostFeedItemCarousel} from './PostFeedItemCarousel'
import {ViewFullThread} from './ViewFullThread'
type FeedRow =
@@ -84,6 +90,11 @@ key: string
slice: FeedPostSlice
indexInSlice: number
showReplyTo: boolean
+ }
+ | {
+ type: 'reposts'
+ key: string
+ items: FeedPostSlice[]
}
| {
type: 'videoGridRowPlaceholder'
@@ -118,6 +129,15 @@ type: 'interstitialTrendingVideos'
key: string
}
+type FeedPostSliceOrGroup =
+ | (FeedPostSlice & {
+ isRepostSlice?: false
+ })
+ | {
+ isRepostSlice: true
+ slices: FeedPostSlice[]
+ }
+
export function getItemsForFeedback(feedRow: FeedRow):
| {
item: FeedPostSliceItem
@@ -128,6 +148,11 @@ return feedRow.slice.items.map(item => ({
item,
feedContext: feedRow.slice.feedContext,
}))
+ } else if (feedRow.type === 'reposts') {
+ return feedRow.items.map((item, i) => ({
+ item: item.items[0],
+ feedContext: feedRow.items[i].feedContext,
+ }))
} else if (feedRow.type === 'videoGridRow') {
return feedRow.items.map((item, i) => ({
item,
@@ -138,6 +163,52 @@ return []
}
}
+// logic from https://github.com/cheeaun/phanpy/blob/d608ee0a7594e3c83cdb087e81002f176d0d7008/src/utils/timeline-utils.js#L9
+function groupReposts(values: FeedPostSlice[]) {
+ let newValues: FeedPostSliceOrGroup[] = []
+ const reposts: FeedPostSlice[] = []
+
+ // serial reposts lain
+ let serialReposts = 0
+
+ for (const row of values) {
+ if (AppBskyFeedDefs.isReasonRepost(row.reason)) {
+ reposts.push(row)
+ serialReposts++
+ continue
+ }
+
+ newValues.push(row)
+ if (serialReposts < 3) {
+ serialReposts = 0
+ }
+ }
+
+ // TODO: handle counts for multi-item slices
+ if (
+ values.length > 10 &&
+ (reposts.length > values.length / 4 || serialReposts >= 3)
+ ) {
+ // if boostStash is more than 3 quarter of values
+ if (reposts.length > (values.length * 3) / 4) {
+ // insert boost array at the end of specialHome list
+ newValues = [...newValues, {isRepostSlice: true, slices: reposts}]
+ } else {
+ // insert boosts array in the middle of specialHome list
+ const half = Math.floor(newValues.length / 2)
+ newValues = [
+ ...newValues.slice(0, half),
+ {isRepostSlice: true, slices: reposts},
+ ...newValues.slice(half),
+ ]
+ }
+
+ return newValues
+ }
+
+ return values as FeedPostSliceOrGroup[]
+}
+
// DISABLED need to check if this is causing random feed refreshes -prf
// const REFRESH_AFTER = STALE.HOURS.ONE
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
@@ -164,6 +235,7 @@ extraData,
savedFeedConfig,
initialNumToRender: initialNumToRenderOverride,
isVideoFeed = false,
+ useRepostCarousel = false,
}: {
feed: FeedDescriptor
feedParams?: FeedParams
@@ -186,6 +258,7 @@ extraData?: any
savedFeedConfig?: AppBskyActorDefs.SavedFeed
initialNumToRender?: number
isVideoFeed?: boolean
+ useRepostCarousel?: boolean
}): React.ReactNode => {
const {_} = useLingui()
const queryClient = useQueryClient()
@@ -320,6 +393,12 @@ (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
+ const repostCarouselEnabled = useRepostCarouselEnabled()
+
+ if (feedType === 'following') {
+ useRepostCarousel = repostCarouselEnabled
+ }
+
const feedItems: FeedRow[] = React.useMemo(() => {
let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
if (feedType === 'following') {
@@ -399,7 +478,11 @@ })
}
} else {
for (const page of data?.pages) {
- for (const slice of page.slices) {
+ let slices = useRepostCarousel
+ ? groupReposts(page.slices)
+ : (page.slices as FeedPostSliceOrGroup[])
+
+ for (const slice of slices) {
sliceIndex++
if (hasSession) {
@@ -441,7 +524,13 @@ }
}
}
- if (slice.isFallbackMarker) {
+ if (slice.isRepostSlice) {
+ arr.push({
+ type: 'reposts',
+ key: slice.slices[0]._reactKey,
+ items: slice.slices,
+ })
+ } else if (slice.isFallbackMarker) {
arr.push({
type: 'fallbackMarker',
key:
@@ -531,6 +620,7 @@ rightNavVisible,
gtMobile,
isVideoFeed,
areVideoFeedsEnabled,
+ useRepostCarousel,
])
// events
@@ -652,6 +742,8 @@ hideTopBorder={rowIndex === 0 && indexInSlice === 0}
rootPost={slice.items[0].post}
/>
)
+ } else if (row.type === 'reposts') {
+ return
} else if (row.type === 'sliceViewFullThread') {
return
} else if (row.type === 'videoGridRowPlaceholder') {
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -1,23 +1,23 @@
import React, {memo, useMemo, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import {
- AppBskyActorDefs,
+ type AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
- ModerationDecision,
+ type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {
FontAwesomeIcon,
- FontAwesomeIconStyle,
+ type FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
+import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
import {MAX_POST_LINES} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
@@ -25,7 +25,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {countLines} from '#/lib/strings/helpers'
import {s} from '#/lib/styles'
-import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
+import {POST_TOMBSTONE, type Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -43,7 +43,7 @@ import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
-import {AppModerationCause} from '#/components/Pills'
+import {type AppModerationCause} from '#/components/Pills'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
import {SubtleWebHover} from '#/components/SubtleWebHover'
@@ -69,6 +69,7 @@ feedContext: string | undefined
hideTopBorder?: boolean
isParentBlocked?: boolean
isParentNotFound?: boolean
+ isCarouselItem?: boolean
}
export function PostFeedItem({
@@ -86,6 +87,7 @@ hideTopBorder,
isParentBlocked,
isParentNotFound,
rootPost,
+ isCarouselItem,
}: FeedItemProps & {
post: AppBskyFeedDefs.PostView
rootPost: AppBskyFeedDefs.PostView
@@ -121,6 +123,7 @@ isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
+ isCarouselItem={isCarouselItem}
rootPost={rootPost}
/>
)
@@ -143,6 +146,7 @@ isThreadParent,
hideTopBorder,
isParentBlocked,
isParentNotFound,
+ isCarouselItem,
rootPost,
}: FeedItemProps & {
richText: RichTextAPI
@@ -258,7 +262,7 @@ setHover(false)
}}>
-
+
{isThreadChild && (
(null)
+ const [scrollX, setScrollX] = React.useState(0)
+
+ const scrollTo = React.useCallback(
+ (item: number) => {
+ setScrollX(item)
+
+ ref.current?.scrollTo({
+ x: item * CARD_INTERVAL,
+ y: 0,
+ animated: true,
+ })
+ },
+ [ref],
+ )
+
+ const scrollLeft = React.useCallback(() => {
+ const newPos = scrollX > 0 ? scrollX - 1 : items.length - 1
+ scrollTo(newPos)
+ }, [scrollTo, scrollX, items.length])
+
+ const scrollRight = React.useCallback(() => {
+ const newPos = scrollX < items.length - 1 ? scrollX + 1 : 0
+ scrollTo(newPos)
+ }, [scrollTo, scrollX, items.length])
+
+ return (
+
+
+
+ {items.length}{' '}
+
+
+
+
+
+
+
+
+
+ {
+ setScrollX(Math.floor(e.nativeEvent.contentOffset.x / CARD_INTERVAL))
+ }}
+*/
+ ref={ref}>
+
+ {items.map(slice => {
+ const item = slice.items[0]
+
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -59,7 +59,7 @@
return (
([])
+ const outerFlex = isAndroid ? a.flex_1 : a.flex_shrink
+
switch (count) {
case 2: {
const containerRefs = [containerRef1, containerRef2]
return (
-
+
+