diff --git a/ios/patching/008-social-app-ios-policy-tos-error.patch b/ios/patching/008-social-app-ios-policy-tos-error.patch
index 83015ec..9fa76b9 100644
--- a/ios/patching/008-social-app-ios-policy-tos-error.patch
+++ b/ios/patching/008-social-app-ios-policy-tos-error.patch
@@ -1,8 +1,8 @@
diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx
-index a89eaadc4..1da393f03 100644
+index a89eaadc4..787a1409b 100644
--- a/src/view/screens/PrivacyPolicy.tsx
+++ b/src/view/screens/PrivacyPolicy.tsx
-@@ -1,52 +1,13 @@
+@@ -1,51 +1,28 @@
import React from 'react'
-import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
@@ -19,7 +19,7 @@ index a89eaadc4..1da393f03 100644
-import {TextLink} from '#/view/com/util/Link'
-import {Text} from '#/view/com/util/text/Text'
-import {ScrollView} from '#/view/com/util/Views'
-+import { WebView } from 'react-native-webview'
++import { ScrollView } from 'react-native'
import * as Layout from '#/components/Layout'
-import {ViewHeader} from '../com/util/ViewHeader'
-
@@ -28,16 +28,19 @@ index a89eaadc4..1da393f03 100644
- const pal = usePalette('default')
- const {_} = useLingui()
- const setMinimalShellMode = useSetMinimalShellMode()
--
++import { useSetTitle } from '#/lib/hooks/useSetTitle'
++import { atoms as a, useTheme } from '#/alf'
++import { Text } from '#/components/Typography'
+
- useFocusEffect(
- React.useCallback(() => {
- setMinimalShellMode(false)
- }, [setMinimalShellMode]),
- )
-+import {useSetTitle} from '#/lib/hooks/useSetTitle'
-
+export function PrivacyPolicyScreen() {
+ useSetTitle('Privacy Policy')
++ const t = useTheme()
+
return (
-
@@ -55,16 +58,26 @@ index a89eaadc4..1da393f03 100644
-
-
-
--
-+
++
++ Privacy Policy
++
++
++ Please refer to the following page for the Privacy Policy.
++
++
++
++ https://syu.is/about/support/privacy-policy
++
+
)
- }
diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx
-index d843c713c..b81767bd5 100644
+index d843c713c..28333cc5b 100644
--- a/src/view/screens/TermsOfService.tsx
+++ b/src/view/screens/TermsOfService.tsx
-@@ -1,50 +1,13 @@
+@@ -1,49 +1,28 @@
import React from 'react'
-import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
@@ -81,7 +94,7 @@ index d843c713c..b81767bd5 100644
-import {TextLink} from '#/view/com/util/Link'
-import {Text} from '#/view/com/util/text/Text'
-import {ScrollView} from '#/view/com/util/Views'
-+import { WebView } from 'react-native-webview'
++import { ScrollView } from 'react-native'
import * as Layout from '#/components/Layout'
-import {ViewHeader} from '../com/util/ViewHeader'
-
@@ -90,16 +103,19 @@ index d843c713c..b81767bd5 100644
- const pal = usePalette('default')
- const setMinimalShellMode = useSetMinimalShellMode()
- const {_} = useLingui()
--
++import { useSetTitle } from '#/lib/hooks/useSetTitle'
++import { atoms as a, useTheme } from '#/alf'
++import { Text } from '#/components/Typography'
+
- useFocusEffect(
- React.useCallback(() => {
- setMinimalShellMode(false)
- }, [setMinimalShellMode]),
- )
-+import {useSetTitle} from '#/lib/hooks/useSetTitle'
-
+export function TermsOfServiceScreen() {
+ useSetTitle('Terms of Service')
++ const t = useTheme()
+
return (
-
@@ -115,8 +131,18 @@ index d843c713c..b81767bd5 100644
-
-
-
--
-+
++
++ Terms of Service
++
++
++ Please refer to the following page for the Terms of Service.
++
++
++
++ https://syu.is/about/support/tos
++
+
)
- }
diff --git a/ios/patching/021-social-app-ios-clean-feed.patch b/ios/patching/021-social-app-ios-clean-feed.patch
index b1be019..d96eb5b 100644
--- a/ios/patching/021-social-app-ios-clean-feed.patch
+++ b/ios/patching/021-social-app-ios-clean-feed.patch
@@ -1,21 +1,1382 @@
-diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
-index e058e2883..e762b1418 100644
---- a/src/view/screens/Home.tsx
-+++ b/src/view/screens/Home.tsx
-@@ -39,6 +39,16 @@ import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
- import * as Layout from '#/components/Layout'
- import {useDemoMode} from '#/storage/hooks/demo-mode'
+diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
+index 352cc1dc0..a1bae1b05 100644
+--- a/src/view/com/posts/FollowingEmptyState.tsx
++++ b/src/view/com/posts/FollowingEmptyState.tsx
+@@ -1,19 +1,19 @@
+ import React from 'react'
+-import {StyleSheet, View} from 'react-native'
++import { StyleSheet, View } from 'react-native'
+ import {
+ FontAwesomeIcon,
+ type FontAwesomeIconStyle,
+ } from '@fortawesome/react-native-fontawesome'
+-import {Trans} from '@lingui/macro'
+-import {useNavigation} from '@react-navigation/native'
++import { Trans } from '@lingui/macro'
++import { useNavigation } from '@react-navigation/native'
-+const DEFAULT_PINNED_FEEDS = [{
-+ feedDescriptor: 'following',
-+ displayName: 'Following',
-+ id: 'following',
-+ type: 'feed',
-+ savedFeed: undefined,
-+ pinned: true,
-+}]
+-import {usePalette} from '#/lib/hooks/usePalette'
+-import {MagnifyingGlassIcon} from '#/lib/icons'
+-import {type NavigationProp} from '#/lib/routes/types'
+-import {s} from '#/lib/styles'
+-import {isWeb} from '#/platform/detection'
+-import {Button} from '../util/forms/Button'
+-import {Text} from '../util/text/Text'
++import { usePalette } from '#/lib/hooks/usePalette'
++import { MagnifyingGlassIcon } from '#/lib/icons'
++import { type NavigationProp } from '#/lib/routes/types'
++import { s } from '#/lib/styles'
++import { isWeb } from '#/platform/detection'
++import { Button } from '../util/forms/Button'
++import { Text } from '../util/text/Text'
+
+ export function FollowingEmptyState() {
+ const pal = usePalette('default')
+@@ -45,36 +45,6 @@ export function FollowingEmptyState() {
+ happening.
+
+
+-
+-
+-
+- You can also discover new Custom Feeds to follow.
+-
+-
+
+
+ )
+diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx
+index e3c84d782..a7b210db9 100644
+--- a/src/view/com/posts/FollowingEndOfFeed.tsx
++++ b/src/view/com/posts/FollowingEndOfFeed.tsx
+@@ -1,18 +1,18 @@
+ import React from 'react'
+-import {Dimensions, StyleSheet, View} from 'react-native'
++import { Dimensions, StyleSheet, View } from 'react-native'
+ import {
+ FontAwesomeIcon,
+ type FontAwesomeIconStyle,
+ } from '@fortawesome/react-native-fontawesome'
+-import {Trans} from '@lingui/macro'
+-import {useNavigation} from '@react-navigation/native'
++import { Trans } from '@lingui/macro'
++import { useNavigation } from '@react-navigation/native'
+
+-import {usePalette} from '#/lib/hooks/usePalette'
+-import {type NavigationProp} from '#/lib/routes/types'
+-import {s} from '#/lib/styles'
+-import {isWeb} from '#/platform/detection'
+-import {Button} from '../util/forms/Button'
+-import {Text} from '../util/text/Text'
++import { usePalette } from '#/lib/hooks/usePalette'
++import { type NavigationProp } from '#/lib/routes/types'
++import { s } from '#/lib/styles'
++import { isWeb } from '#/platform/detection'
++import { Button } from '../util/forms/Button'
++import { Text } from '../util/text/Text'
+
+ export function FollowingEndOfFeed() {
+ const pal = usePalette('default')
+@@ -37,7 +37,7 @@ export function FollowingEndOfFeed() {
+ style={[
+ styles.container,
+ pal.border,
+- {minHeight: Dimensions.get('window').height * 0.75},
++ { minHeight: Dimensions.get('window').height * 0.75 },
+ ]}>
+
+
+@@ -46,36 +46,6 @@ export function FollowingEndOfFeed() {
+ follow.
+
+
+-
+-
+-
+- You can also discover new Custom Feeds to follow.
+-
+-
+
+
+ )
+diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
+index 4f25468c9..b9f2db999 100644
+--- a/src/view/com/posts/PostFeed.tsx
++++ b/src/view/com/posts/PostFeed.tsx
+@@ -23,23 +23,23 @@ import {
+ AppBskyEmbedVideo,
+ type AppBskyFeedDefs,
+ } from '@atproto/api'
+-import {msg} from '@lingui/macro'
+-import {useLingui} from '@lingui/react'
+-import {useQueryClient} from '@tanstack/react-query'
++import { msg } from '@lingui/macro'
++import { useLingui } from '@lingui/react'
++import { useQueryClient } from '@tanstack/react-query'
+
+-import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
+-import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
+-import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+-import {logEvent, useGate} from '#/lib/statsig/statsig'
+-import {isNetworkError} from '#/lib/strings/errors'
+-import {logger} from '#/logger'
+-import {isIOS, isNative, isWeb} from '#/platform/detection'
+-import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
+-import {listenPostCreated} from '#/state/events'
+-import {useFeedFeedbackContext} from '#/state/feed-feedback'
+-import {useTrendingSettings} from '#/state/preferences/trending'
+-import {STALE} from '#/state/queries'
++import { isStatusStillActive, validateStatus } from '#/lib/actor-status'
++import { DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS } from '#/lib/constants'
++import { useInitialNumToRender } from '#/lib/hooks/useInitialNumToRender'
++import { useNonReactiveCallback } from '#/lib/hooks/useNonReactiveCallback'
++import { logEvent, useGate } from '#/lib/statsig/statsig'
++import { isNetworkError } from '#/lib/strings/errors'
++import { logger } from '#/logger'
++import { isIOS, isNative, isWeb } from '#/platform/detection'
++import { usePostAuthorShadowFilter } from '#/state/cache/profile-shadow'
++import { listenPostCreated } from '#/state/events'
++import { useFeedFeedbackContext } from '#/state/feed-feedback'
++import { useTrendingSettings } from '#/state/preferences/trending'
++import { STALE } from '#/state/queries'
+ import {
+ type AuthorFilter,
+ type FeedDescriptor,
+@@ -50,111 +50,111 @@ import {
+ RQKEY,
+ usePostFeedQuery,
+ } from '#/state/queries/post-feed'
+-import {useLiveNowConfig} from '#/state/service-config'
+-import {useSession} from '#/state/session'
+-import {useProgressGuide} from '#/state/shell/progress-guide'
+-import {useSelectedFeed} from '#/state/shell/selected-feed'
+-import {List, type ListRef} from '#/view/com/util/List'
+-import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+-import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
+-import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+-import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
++import { useLiveNowConfig } from '#/state/service-config'
++import { useSession } from '#/state/session'
++import { useProgressGuide } from '#/state/shell/progress-guide'
++import { useSelectedFeed } from '#/state/shell/selected-feed'
++import { List, type ListRef } from '#/view/com/util/List'
++import { PostFeedLoadingPlaceholder } from '#/view/com/util/LoadingPlaceholder'
++import { LoadMoreRetryBtn } from '#/view/com/util/LoadMoreRetryBtn'
++import { type VideoFeedSourceContext } from '#/screens/VideoFeed/types'
++import { useBreakpoints, useLayoutBreakpoints } from '#/alf'
+ import {
+ AgeAssuranceDismissibleFeedBanner,
+ useInternalState as useAgeAssuranceBannerState,
+ } from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner'
+-import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
++import { ProgressGuide, SuggestedFollows } from '#/components/FeedInterstitials'
+ import {
+ PostFeedVideoGridRow,
+ PostFeedVideoGridRowPlaceholder,
+ } from '#/components/feeds/PostFeedVideoGridRow'
+-import {TrendingInterstitial} from '#/components/interstitials/Trending'
+-import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
+-import {ComposerPrompt} from '../feeds/ComposerPrompt'
+-import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
+-import {FeedShutdownMsg} from './FeedShutdownMsg'
+-import {PostFeedErrorMessage} from './PostFeedErrorMessage'
+-import {PostFeedItem} from './PostFeedItem'
+-import {ShowLessFollowup} from './ShowLessFollowup'
+-import {ViewFullThread} from './ViewFullThread'
++import { TrendingInterstitial } from '#/components/interstitials/Trending'
++import { TrendingVideos as TrendingVideosInterstitial } from '#/components/interstitials/TrendingVideos'
++import { ComposerPrompt } from '../feeds/ComposerPrompt'
++import { DiscoverFallbackHeader } from './DiscoverFallbackHeader'
++import { FeedShutdownMsg } from './FeedShutdownMsg'
++import { PostFeedErrorMessage } from './PostFeedErrorMessage'
++import { PostFeedItem } from './PostFeedItem'
++import { ShowLessFollowup } from './ShowLessFollowup'
++import { ViewFullThread } from './ViewFullThread'
+
+ type FeedRow =
+ | {
+- type: 'loading'
+- key: string
+- }
++ type: 'loading'
++ key: string
++ }
+ | {
+- type: 'empty'
+- key: string
+- }
++ type: 'empty'
++ key: string
++ }
+ | {
+- type: 'error'
+- key: string
+- }
++ type: 'error'
++ key: string
++ }
+ | {
+- type: 'loadMoreError'
+- key: string
+- }
++ type: 'loadMoreError'
++ key: string
++ }
+ | {
+- type: 'feedShutdownMsg'
+- key: string
+- }
++ type: 'feedShutdownMsg'
++ key: string
++ }
+ | {
+- type: 'fallbackMarker'
+- key: string
+- }
++ type: 'fallbackMarker'
++ key: string
++ }
+ | {
+- type: 'sliceItem'
+- key: string
+- slice: FeedPostSlice
+- indexInSlice: number
+- showReplyTo: boolean
+- }
++ type: 'sliceItem'
++ key: string
++ slice: FeedPostSlice
++ indexInSlice: number
++ showReplyTo: boolean
++ }
+ | {
+- type: 'videoGridRowPlaceholder'
+- key: string
+- }
++ type: 'videoGridRowPlaceholder'
++ key: string
++ }
+ | {
+- type: 'videoGridRow'
+- key: string
+- items: FeedPostSliceItem[]
+- sourceFeedUri: string
+- feedContexts: (string | undefined)[]
+- reqIds: (string | undefined)[]
+- }
++ type: 'videoGridRow'
++ key: string
++ items: FeedPostSliceItem[]
++ sourceFeedUri: string
++ feedContexts: (string | undefined)[]
++ reqIds: (string | undefined)[]
++ }
+ | {
+- type: 'sliceViewFullThread'
+- key: string
+- uri: string
+- }
++ type: 'sliceViewFullThread'
++ key: string
++ uri: string
++ }
+ | {
+- type: 'interstitialFollows'
+- key: string
+- }
++ type: 'interstitialFollows'
++ key: string
++ }
+ | {
+- type: 'interstitialProgressGuide'
+- key: string
+- }
++ type: 'interstitialProgressGuide'
++ key: string
++ }
+ | {
+- type: 'interstitialTrending'
+- key: string
+- }
++ type: 'interstitialTrending'
++ key: string
++ }
+ | {
+- type: 'interstitialTrendingVideos'
+- key: string
+- }
++ type: 'interstitialTrendingVideos'
++ key: string
++ }
+ | {
+- type: 'showLessFollowup'
+- key: string
+- }
++ type: 'showLessFollowup'
++ key: string
++ }
+ | {
+- type: 'ageAssuranceBanner'
+- key: string
+- }
++ type: 'ageAssuranceBanner'
++ key: string
++ }
+ | {
+- type: 'composerPrompt'
+- key: string
+- }
++ type: 'composerPrompt'
++ key: string
++ }
+
+ export function getItemsForFeedback(feedRow: FeedRow): {
+ item: FeedPostSliceItem
+@@ -227,17 +227,17 @@ let PostFeed = ({
+ initialNumToRender?: number
+ isVideoFeed?: boolean
+ }): React.ReactNode => {
+- const {_} = useLingui()
++ const { _ } = useLingui()
+ const queryClient = useQueryClient()
+- const {currentAccount, hasSession} = useSession()
++ const { currentAccount, hasSession } = useSession()
+ const gate = useGate()
+ const initialNumToRender = useInitialNumToRender()
+ const feedFeedback = useFeedFeedbackContext()
+ const [isPTRing, setIsPTRing] = useState(false)
+ const lastFetchRef = useRef(Date.now())
+ const [feedType, feedUriOrActorDid, feedTab] = feed.split('|')
+- const {gtMobile} = useBreakpoints()
+- const {rightNavVisible} = useLayoutBreakpoints()
++ const { gtMobile } = useBreakpoints()
++ const { rightNavVisible } = useLayoutBreakpoints()
+ const areVideoFeedsEnabled = isNative
+
+ const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
+@@ -256,7 +256,7 @@ let PostFeed = ({
+
+ const feedCacheKey = feedParams?.feedCacheKey
+ const opts = useMemo(
+- () => ({enabled, ignoreFilterFor}),
++ () => ({ enabled, ignoreFilterFor }),
+ [enabled, ignoreFilterFor],
+ )
+ const {
+@@ -299,7 +299,7 @@ let PostFeed = ({
+ }
+ } catch (e) {
+ if (!isNetworkError(e)) {
+- logger.error('Poll latest failed', {feed, message: String(e)})
++ logger.error('Poll latest failed', { feed, message: String(e) })
+ }
+ }
+ })
+@@ -315,7 +315,7 @@ let PostFeed = ({
+ (feed === 'following' ||
+ feed === `author|${myDid}|posts_and_author_threads`)
+ ) {
+- queryClient.invalidateQueries({queryKey: RQKEY(feed)})
++ queryClient.invalidateQueries({ queryKey: RQKEY(feed) })
+ }
+ }, [queryClient, feed, data, myDid])
+ useEffect(() => {
+@@ -360,7 +360,7 @@ let PostFeed = ({
+ const showProgressIntersitial =
+ (followProgressGuide || followAndLikeProgressGuide) && !rightNavVisible
+
+- const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
++ const { trendingDisabled, trendingVideoDisabled } = useTrendingSettings()
+
+ const ageAssuranceBannerState = useAgeAssuranceBannerState()
+ const selectedFeed = useSelectedFeed()
+@@ -378,7 +378,7 @@ let PostFeed = ({
+ const feedItems: FeedRow[] = useMemo(() => {
+ // wraps a slice item, and replaces it with a showLessFollowup item
+ // if the user has pressed show less on it
+- const sliceItem = (row: Extract) => {
++ const sliceItem = (row: Extract) => {
+ if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) {
+ return {
+ type: 'showLessFollowup',
+@@ -537,403 +537,437 @@ let PostFeed = ({
+ })
+ }
+ } else if (sliceIndex === 30) {
+- arr.push({
+- type: 'interstitialFollows',
+- key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+- })
+ }
+ } else if (feedKind === 'following') {
+ if (sliceIndex === 0) {
+- // Show composer prompt for Following feed
+- if (hasSession && gate('show_composer_prompt')) {
+- arr.push({
+- type: 'composerPrompt',
+- key: 'composerPrompt-' + sliceIndex,
+- })
+- }
++ })
++}
+ }
+ } else if (feedKind === 'profile') {
+- if (sliceIndex === 5) {
+- arr.push({
+- type: 'interstitialFollows',
+- key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+- })
+- }
+- } else {
+- /*
+- * Only insert if this feed was the last selected feed at
+- * startup and the banner is eligible to be shown.
+- */
+- if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) {
+- arr.push({
+- type: 'ageAssuranceBanner',
+- key: 'ageAssuranceBanner-' + sliceIndex,
+- })
+- }
+- }
++ if (sliceIndex === 5) {
++ arr.push({
++ type: 'interstitialFollows',
++ key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
++ })
++ }
++} else {
++ /*
++ * Only insert if this feed was the last selected feed at
++ * startup and the banner is eligible to be shown.
++ */
++ if (sliceIndex === 0 && isCurrentFeedAtStartupSelected) {
++ arr.push({
++ type: 'ageAssuranceBanner',
++ key: 'ageAssuranceBanner-' + sliceIndex,
++ })
++ }
++}
+ }
+
+- if (slice.isFallbackMarker) {
+- arr.push({
+- type: 'fallbackMarker',
+- key:
+- 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt,
+- })
+- } else if (
+- slice.items.some(item =>
+- blockedOrMutedAuthors.includes(item.post.author.did),
+- )
+- ) {
+- // skip
+- } else if (slice.isIncompleteThread && slice.items.length >= 3) {
+- const beforeLast = slice.items.length - 2
+- const last = slice.items.length - 1
+- arr.push(
+- sliceItem({
+- type: 'sliceItem',
+- key: slice.items[0]._reactKey,
+- slice: slice,
+- indexInSlice: 0,
+- showReplyTo: false,
+- }),
+- )
+- arr.push({
+- type: 'sliceViewFullThread',
+- key: slice._reactKey + '-viewFullThread',
+- uri: slice.items[0].uri,
+- })
+- arr.push(
+- sliceItem({
+- type: 'sliceItem',
+- key: slice.items[beforeLast]._reactKey,
+- slice: slice,
+- indexInSlice: beforeLast,
+- showReplyTo:
+- slice.items[beforeLast].parentAuthor?.did !==
+- slice.items[beforeLast].post.author.did,
+- }),
+- )
+- arr.push(
+- sliceItem({
+- type: 'sliceItem',
+- key: slice.items[last]._reactKey,
+- slice: slice,
+- indexInSlice: last,
+- showReplyTo: false,
+- }),
+- )
+- } else {
+- for (let i = 0; i < slice.items.length; i++) {
+- arr.push(
+- sliceItem({
+- type: 'sliceItem',
+- key: slice.items[i]._reactKey,
+- slice: slice,
+- indexInSlice: i,
+- showReplyTo: i === 0,
+- }),
+- )
+- }
+- }
++if (slice.isFallbackMarker) {
++ arr.push({
++ type: 'fallbackMarker',
++ key:
++ 'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt,
++ })
++} else if (
++ slice.items.some(item =>
++ blockedOrMutedAuthors.includes(item.post.author.did),
++ )
++) {
++ // skip
++} else if (slice.isIncompleteThread && slice.items.length >= 3) {
++ const beforeLast = slice.items.length - 2
++ const last = slice.items.length - 1
++ arr.push(
++ sliceItem({
++ type: 'sliceItem',
++ key: slice.items[0]._reactKey,
++ slice: slice,
++ indexInSlice: 0,
++ showReplyTo: false,
++ }),
++ )
++ arr.push({
++ type: 'sliceViewFullThread',
++ key: slice._reactKey + '-viewFullThread',
++ uri: slice.items[0].uri,
++ })
++ arr.push(
++ sliceItem({
++ type: 'sliceItem',
++ key: slice.items[beforeLast]._reactKey,
++ slice: slice,
++ indexInSlice: beforeLast,
++ showReplyTo:
++ slice.items[beforeLast].parentAuthor?.did !==
++ slice.items[beforeLast].post.author.did,
++ }),
++ )
++ arr.push(
++ sliceItem({
++ type: 'sliceItem',
++ key: slice.items[last]._reactKey,
++ slice: slice,
++ indexInSlice: last,
++ showReplyTo: false,
++ }),
++ )
++} else {
++ for (let i = 0; i < slice.items.length; i++) {
++ arr.push(
++ sliceItem({
++ type: 'sliceItem',
++ key: slice.items[i]._reactKey,
++ slice: slice,
++ indexInSlice: i,
++ showReplyTo: i === 0,
++ }),
++ )
++ }
++}
+ }
+ }
+ }
+ }
+- if (isError && !isEmpty) {
+- arr.push({
+- type: 'loadMoreError',
+- key: 'loadMoreError',
+- })
+- }
++if (isError && !isEmpty) {
++ arr.push({
++ type: 'loadMoreError',
++ key: 'loadMoreError',
++ })
++}
+ } else {
+- if (isVideoFeed) {
+- arr.push({
+- type: 'videoGridRowPlaceholder',
+- key: 'videoGridRowPlaceholder',
+- })
+- } else {
+- arr.push({
+- type: 'loading',
+- key: 'loading',
+- })
+- }
+- }
++ if (isVideoFeed) {
++ arr.push({
++ type: 'videoGridRowPlaceholder',
++ key: 'videoGridRowPlaceholder',
++ })
++ } else {
++ arr.push({
++ type: 'loading',
++ key: 'loading',
++ })
++ }
++}
+
+- return arr
++return arr
+ }, [
+- isFetched,
+- isError,
+- isEmpty,
+- lastFetchedAt,
+- data,
+- feed,
+- feedType,
+- feedUriOrActorDid,
+- feedTab,
+- hasSession,
+- showProgressIntersitial,
+- trendingDisabled,
+- trendingVideoDisabled,
+- rightNavVisible,
+- gtMobile,
+- isVideoFeed,
+- areVideoFeedsEnabled,
+- hasPressedShowLessUris,
+- ageAssuranceBannerState,
+- isCurrentFeedAtStartupSelected,
+- gate,
+- blockedOrMutedAuthors,
+- ])
++ isFetched,
++ isError,
++ isEmpty,
++ lastFetchedAt,
++ data,
++ feed,
++ feedType,
++ feedUriOrActorDid,
++ feedTab,
++ hasSession,
++ showProgressIntersitial,
++ trendingDisabled,
++ trendingVideoDisabled,
++ rightNavVisible,
++ gtMobile,
++ isVideoFeed,
++ areVideoFeedsEnabled,
++ hasPressedShowLessUris,
++ ageAssuranceBannerState,
++ isCurrentFeedAtStartupSelected,
++ gate,
++ blockedOrMutedAuthors,
++])
+
+- // events
+- // =
++// events
++// =
+
+- const onRefresh = useCallback(async () => {
+- logEvent('feed:refresh', {
+- feedType: feedType,
+- feedUrl: feed,
+- reason: 'pull-to-refresh',
+- })
+- setIsPTRing(true)
+- try {
+- await refetch()
+- onHasNew?.(false)
+- } catch (err) {
+- logger.error('Failed to refresh posts feed', {message: err})
+- }
+- setIsPTRing(false)
+- }, [refetch, setIsPTRing, onHasNew, feed, feedType])
++const onRefresh = useCallback(async () => {
++ logEvent('feed:refresh', {
++ feedType: feedType,
++ feedUrl: feed,
++ reason: 'pull-to-refresh',
++ })
++ setIsPTRing(true)
++ try {
++ await refetch()
++ onHasNew?.(false)
++ } catch (err) {
++ logger.error('Failed to refresh posts feed', { message: err })
++ }
++ setIsPTRing(false)
++}, [refetch, setIsPTRing, onHasNew, feed, feedType])
+
+- const onEndReached = useCallback(async () => {
+- if (isFetching || !hasNextPage || isError) return
++const onEndReached = useCallback(async () => {
++ if (isFetching || !hasNextPage || isError) return
+
+- logEvent('feed:endReached', {
+- feedType: feedType,
+- feedUrl: feed,
+- itemCount: feedItems.length,
+- })
+- try {
+- await fetchNextPage()
+- } catch (err) {
+- logger.error('Failed to load more posts', {message: err})
+- }
+- }, [
+- isFetching,
+- hasNextPage,
+- isError,
+- fetchNextPage,
+- feed,
+- feedType,
+- feedItems.length,
+- ])
++ logEvent('feed:endReached', {
++ feedType: feedType,
++ feedUrl: feed,
++ itemCount: feedItems.length,
++ })
++ try {
++ await fetchNextPage()
++ } catch (err) {
++ logger.error('Failed to load more posts', { message: err })
++ }
++}, [
++ isFetching,
++ hasNextPage,
++ isError,
++ fetchNextPage,
++ feed,
++ feedType,
++ feedItems.length,
++])
+
+- const onPressTryAgain = useCallback(() => {
+- refetch()
+- onHasNew?.(false)
+- }, [refetch, onHasNew])
++const onPressTryAgain = useCallback(() => {
++ refetch()
++ onHasNew?.(false)
++}, [refetch, onHasNew])
+
+- const onPressRetryLoadMore = useCallback(() => {
+- fetchNextPage()
+- }, [fetchNextPage])
++const onPressRetryLoadMore = useCallback(() => {
++ fetchNextPage()
++}, [fetchNextPage])
+
+- // rendering
+- // =
++// rendering
++// =
+
+- const renderItem = useCallback(
+- ({item: row, index: rowIndex}: ListRenderItemInfo) => {
+- if (row.type === 'empty') {
+- return renderEmptyState()
+- } else if (row.type === 'error') {
+- return (
+-
+- )
+- } else if (row.type === 'loadMoreError') {
+- return (
+-
+- )
+- } else if (row.type === 'loading') {
+- return
+- } else if (row.type === 'feedShutdownMsg') {
+- return
+- } else if (row.type === 'interstitialFollows') {
+- return
+- } else if (row.type === 'interstitialProgressGuide') {
+- return
+- } else if (row.type === 'ageAssuranceBanner') {
+- return
+- } else if (row.type === 'interstitialTrending') {
+- return
+- } else if (row.type === 'composerPrompt') {
+- return
+- } else if (row.type === 'interstitialTrendingVideos') {
+- return
+- } else if (row.type === 'fallbackMarker') {
+- // HACK
+- // tell the user we fell back to discover
+- // see home.ts (feed api) for more info
+- // -prf
+- return
+- } else if (row.type === 'sliceItem') {
+- const slice = row.slice
+- const indexInSlice = row.indexInSlice
+- const item = slice.items[indexInSlice]
+- return (
+-
+- )
+- } else if (row.type === 'sliceViewFullThread') {
+- return
+- } else if (row.type === 'videoGridRowPlaceholder') {
+- return (
+-
+-
+-
+-
+-
+- )
+- } else if (row.type === 'videoGridRow') {
+- let sourceContext: VideoFeedSourceContext
+- if (feedType === 'author') {
+- sourceContext = {
+- type: 'author',
+- did: feedUriOrActorDid,
+- filter: feedTab as AuthorFilter,
+- }
+- } else {
+- sourceContext = {
+- type: 'feedgen',
+- uri: row.sourceFeedUri,
+- sourceInterstitial: feedCacheKey ?? 'none',
++const renderItem = useCallback(
++ ({ item: row, index: rowIndex }: ListRenderItemInfo) => {
++ if (row.type === 'empty') {
++ return renderEmptyState()
++ } else if (row.type === 'error') {
++ return (
++
++ )
++ } else if (row.type === 'loadMoreError') {
++ return (
++
++ )
++ } else if (row.type === 'loading') {
++ return
++ } else if (row.type === 'feedShutdownMsg') {
++ return
++ } else if (row.type === 'interstitialFollows') {
++ return
++ } else if (row.type === 'interstitialProgressGuide') {
++ return
++ } else if (row.type === 'ageAssuranceBanner') {
++ return
++ } else if (row.type === 'interstitialTrending') {
++ return
++ } else if (row.type === 'composerPrompt') {
++ return
++ } else if (row.type === 'interstitialTrendingVideos') {
++ return
++ } else if (row.type === 'fallbackMarker') {
++ // HACK
++ // tell the user we fell back to discover
++ // see home.ts (feed api) for more info
++ // -prf
++ return
++ } else if (row.type === 'sliceItem') {
++ const slice = row.slice
++ const indexInSlice = row.indexInSlice
++ const item = slice.items[indexInSlice]
++ return (
++
++ )
++ } else if (row.type === 'sliceViewFullThread') {
++ return
++ } else if (row.type === 'videoGridRowPlaceholder') {
++ return (
++
++
++
++
++
++ )
++ } else if (row.type === 'videoGridRow') {
++ let sourceContext: VideoFeedSourceContext
++ if (feedType === 'author') {
++ sourceContext = {
++ type: 'author',
++ did: feedUriOrActorDid,
++ filter: feedTab as AuthorFilter,
+ }
+-
+- return (
+-
+- )
+- } else if (row.type === 'showLessFollowup') {
+- return
+ } else {
+- return null
++ sourceContext = {
++ type: 'feedgen',
++ uri: row.sourceFeedUri,
++ sourceInterstitial: feedCacheKey ?? 'none',
++ }
+ }
+- },
+- [
+- renderEmptyState,
+- feed,
+- error,
+- onPressTryAgain,
+- savedFeedConfig,
+- _,
+- onPressRetryLoadMore,
+- feedType,
+- feedUriOrActorDid,
+- feedTab,
+- feedCacheKey,
+- onPressShowLess,
+- ],
+- )
+
+- const shouldRenderEndOfFeed =
+- !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
+- const FeedFooter = useCallback(() => {
+- /**
+- * A bit of padding at the bottom of the feed as you scroll and when you
+- * reach the end, so that content isn't cut off by the bottom of the
+- * screen.
+- */
+- const offset = Math.max(headerOffset, 32) * (isWeb ? 1 : 2)
++ return (
++
++ )
++ } else if (row.type === 'showLessFollowup') {
++ return
++ } else {
++ return null
++ }
++ },
++ [
++ renderEmptyState,
++ feed,
++ error,
++ onPressTryAgain,
++ savedFeedConfig,
++ _,
++ onPressRetryLoadMore,
++ feedType,
++ feedUriOrActorDid,
++ feedTab,
++ feedCacheKey,
++ onPressShowLess,
++ ],
++)
+
++const shouldRenderEndOfFeed =
++ !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed
++const FeedFooter = useCallback(() => {
++ /**
++ * A bit of padding at the bottom of the feed as you scroll and when you
++ * reach the end, so that content isn't cut off by the bottom of the
++ * screen.
++ */
++ const offset = Math.max(headerOffset, 32) * (isWeb ? 1 : 2)
+
+- return isFetchingNextPage ? (
+-
+-
+-
+-
+- ) : shouldRenderEndOfFeed ? (
+- {renderEndOfFeed()}
+- ) : (
+-
+- )
+- }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
++ return isFetchingNextPage ? (
++
++
++
++
++ ) : shouldRenderEndOfFeed ? (
++ {renderEndOfFeed()}
++ ) : (
++
++ )
++}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
+
+- const liveNowConfig = useLiveNowConfig()
++const liveNowConfig = useLiveNowConfig()
+
+- const seenActorWithStatusRef = useRef>(new Set())
+- const seenPostUrisRef = useRef>(new Set())
++const seenActorWithStatusRef = useRef>(new Set())
++const seenPostUrisRef = useRef>(new Set())
+
+- // Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
+- const getPostPosition = useNonReactiveCallback(
+- (type: FeedRow['type'], key: string) => {
+- // Calculate position: find the row index in feedItems, then calculate position
+- const rowIndex = feedItems.findIndex(
+- row => row.type === 'sliceItem' && row.key === key,
+- )
++// Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
++const getPostPosition = useNonReactiveCallback(
++ (type: FeedRow['type'], key: string) => {
++ // Calculate position: find the row index in feedItems, then calculate position
++ const rowIndex = feedItems.findIndex(
++ row => row.type === 'sliceItem' && row.key === key,
++ )
+
+- if (rowIndex >= 0) {
+- let position = 0
+- for (let i = 0; i < rowIndex && i < feedItems.length; i++) {
+- const row = feedItems[i]
+- if (row.type === 'sliceItem') {
+- // Only count root posts (indexInSlice === 0), not thread replies
+- if (row.indexInSlice === 0) {
+- position++
+- }
+- } else if (row.type === 'videoGridRow') {
+- // Count each video in the grid row
+- position += row.items.length
++ if (rowIndex >= 0) {
++ let position = 0
++ for (let i = 0; i < rowIndex && i < feedItems.length; i++) {
++ const row = feedItems[i]
++ if (row.type === 'sliceItem') {
++ // Only count root posts (indexInSlice === 0), not thread replies
++ if (row.indexInSlice === 0) {
++ position++
+ }
++ } else if (row.type === 'videoGridRow') {
++ // Count each video in the grid row
++ position += row.items.length
+ }
+- return position
+ }
+- },
+- )
++ return position
++ }
++ },
++)
+
- type Props = NativeStackScreenProps
- export function HomeScreen(props: Props) {
- const {setShowLoggedOut} = useLoggedOutViewControls()
++const onItemSeen = useCallback(
++ (item: FeedRow) => {
++ feedFeedback.onItemSeen(item)
+
+- const onItemSeen = useCallback(
+- (item: FeedRow) => {
+- feedFeedback.onItemSeen(item)
++ // Track post:view events
++ if (item.type === 'sliceItem') {
++ const slice = item.slice
++ const indexInSlice = item.indexInSlice
++ const postItem = slice.items[indexInSlice]
++ const post = postItem.post
++
++ // Only track the root post of each slice (index 0) to avoid double-counting thread items
++ if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
++ seenPostUrisRef.current.add(post.uri)
++
++ const position = getPostPosition('sliceItem', item.key)
++
++ logger.metric(
++ 'post:view',
++ {
++ uri: post.uri,
++ authorDid: post.author.did,
++ logContext: 'FeedItem',
++ feedDescriptor: feedFeedback.feedDescriptor || feed,
++ position,
++ },
++ { statsig: false },
++ )
++ }
+
+- // Track post:view events
+- if (item.type === 'sliceItem') {
+- const slice = item.slice
+- const indexInSlice = item.indexInSlice
+- const postItem = slice.items[indexInSlice]
++ // Live status tracking (existing code)
++ const actor = post.author
++ if (
++ actor.status &&
++ validateStatus(actor.did, actor.status, liveNowConfig) &&
++ isStatusStillActive(actor.status.expiresAt)
++ ) {
++ if (!seenActorWithStatusRef.current.has(actor.did)) {
++ seenActorWithStatusRef.current.add(actor.did)
++ logger.metric(
++ 'live:view:post',
++ {
++ subject: actor.did,
++ feed,
++ },
++ { statsig: false },
++ )
++ }
++ }
++ } else if (item.type === 'videoGridRow') {
++ // Track each video in the grid row
++ for (let i = 0; i < item.items.length; i++) {
++ const postItem = item.items[i]
+ const post = postItem.post
+
+- // Only track the root post of each slice (index 0) to avoid double-counting thread items
+- if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
++ if (!seenPostUrisRef.current.has(post.uri)) {
+ seenPostUrisRef.current.add(post.uri)
+
+- const position = getPostPosition('sliceItem', item.key)
++ const position = getPostPosition('videoGridRow', item.key)
+
+ logger.metric(
+ 'post:view',
+@@ -944,97 +978,54 @@ let PostFeed = ({
+ feedDescriptor: feedFeedback.feedDescriptor || feed,
+ position,
+ },
+- {statsig: false},
++ { statsig: false },
+ )
+ }
+-
+- // Live status tracking (existing code)
+- const actor = post.author
+- if (
+- actor.status &&
+- validateStatus(actor.did, actor.status, liveNowConfig) &&
+- isStatusStillActive(actor.status.expiresAt)
+- ) {
+- if (!seenActorWithStatusRef.current.has(actor.did)) {
+- seenActorWithStatusRef.current.add(actor.did)
+- logger.metric(
+- 'live:view:post',
+- {
+- subject: actor.did,
+- feed,
+- },
+- {statsig: false},
+- )
+- }
+- }
+- } else if (item.type === 'videoGridRow') {
+- // Track each video in the grid row
+- for (let i = 0; i < item.items.length; i++) {
+- const postItem = item.items[i]
+- const post = postItem.post
+-
+- if (!seenPostUrisRef.current.has(post.uri)) {
+- seenPostUrisRef.current.add(post.uri)
+-
+- const position = getPostPosition('videoGridRow', item.key)
+-
+- logger.metric(
+- 'post:view',
+- {
+- uri: post.uri,
+- authorDid: post.author.did,
+- logContext: 'FeedItem',
+- feedDescriptor: feedFeedback.feedDescriptor || feed,
+- position,
+- },
+- {statsig: false},
+- )
+- }
+- }
+ }
+- },
+- [feedFeedback, feed, liveNowConfig, getPostPosition],
+- )
++ }
++ },
++ [feedFeedback, feed, liveNowConfig, getPostPosition],
++)
+
+- return (
+-
+- item.key}
+- renderItem={renderItem}
+- ListFooterComponent={FeedFooter}
+- ListHeaderComponent={ListHeaderComponent}
+- refreshing={isPTRing}
+- onRefresh={onRefresh}
+- headerOffset={headerOffset}
+- progressViewOffset={progressViewOffset}
+- contentContainerStyle={{
+- minHeight: Dimensions.get('window').height * 1.5,
+- }}
+- onScrolledDownChange={onScrolledDownChange}
+- onEndReached={onEndReached}
+- onEndReachedThreshold={2} // number of posts left to trigger load more
+- removeClippedSubviews={true}
+- extraData={extraData}
+- desktopFixedHeight={
+- desktopFixedHeightOffset ? desktopFixedHeightOffset : true
+- }
+- initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
+- windowSize={9}
+- maxToRenderPerBatch={isIOS ? 5 : 1}
+- updateCellsBatchingPeriod={40}
+- onItemSeen={onItemSeen}
+- />
+-
+- )
++return (
++
++ item.key}
++ renderItem={renderItem}
++ ListFooterComponent={FeedFooter}
++ ListHeaderComponent={ListHeaderComponent}
++ refreshing={isPTRing}
++ onRefresh={onRefresh}
++ headerOffset={headerOffset}
++ progressViewOffset={progressViewOffset}
++ contentContainerStyle={{
++ minHeight: Dimensions.get('window').height * 1.5,
++ }}
++ onScrolledDownChange={onScrolledDownChange}
++ onEndReached={onEndReached}
++ onEndReachedThreshold={2} // number of posts left to trigger load more
++ removeClippedSubviews={true}
++ extraData={extraData}
++ desktopFixedHeight={
++ desktopFixedHeightOffset ? desktopFixedHeightOffset : true
++ }
++ initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
++ windowSize={9}
++ maxToRenderPerBatch={isIOS ? 5 : 1}
++ updateCellsBatchingPeriod={40}
++ onItemSeen={onItemSeen}
++ />
++
++)
+ }
+ PostFeed = memo(PostFeed)
+-export {PostFeed}
++export { PostFeed }
+
+ const styles = StyleSheet.create({
+- feedFooter: {paddingTop: 20},
++ feedFooter: { paddingTop: 20 },
+ })
+
+ export function isThreadParentAt(arr: Array, i: number) {