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 ( - + +