Skip to content

Commit

Permalink
feat(mobile): auto mark as read when scrolling (#2781)
Browse files Browse the repository at this point in the history
* feat(mobile): auto mark as read when scrolling

* reset unread state after fetch

* refetch with subscription and unread

* chore: auto-fix linting and formatting issues

* chore: trigger build
  • Loading branch information
hyoban authored Feb 17, 2025
1 parent a527987 commit c8f580c
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 73 deletions.
11 changes: 6 additions & 5 deletions apps/mobile/src/modules/entry-list/entry-list-article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { useFeed } from "@/src/store/feed/hooks"

import { EntryItemContextMenu } from "../context-menu/entry"
import { useFetchEntriesControls } from "../screen/atoms"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { useOnViewableItemsChanged } from "./hooks"
import { ItemSeparator } from "./ItemSeparator"

export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {
Expand All @@ -31,21 +31,22 @@ export function EntryListContentArticle({ entryIds }: { entryIds: string[] }) {
[isFetching],
)

const onViewableItemsChanged = useOnViewableItemsChanged()

return (
<TimelineSelectorList
onRefresh={() => {
refetch()
}}
isRefetching={isRefetching}
data={entryIds}
keyExtractor={(id) => id}
estimatedItemSize={100}
renderItem={renderItem}
onEndReached={() => {
fetchNextPage()
}}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
estimatedItemSize={100}
onViewableItemsChanged={onViewableItemsChanged}
ItemSeparatorComponent={ItemSeparator}
ListFooterComponent={ListFooterComponent}
/>
Expand Down
55 changes: 12 additions & 43 deletions apps/mobile/src/modules/entry-list/entry-list-gird.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,46 @@
import { FeedViewType } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"
import { useHeaderHeight } from "@react-navigation/elements"
import type { MasonryFlashListProps } from "@shopify/flash-list"
import { MasonryFlashList } from "@shopify/flash-list"
import { Image } from "expo-image"
import { Link } from "expo-router"
import { useColorScheme } from "nativewind"
import { useContext } from "react"
import { Pressable, RefreshControl, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Pressable, View } from "react-native"

import { NavigationContext } from "@/src/components/common/SafeNavigationScrollView"
import { ThemedText } from "@/src/components/common/ThemedText"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { useFetchEntriesControls, useSelectedView } from "@/src/modules/screen/atoms"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"

import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
import { useOnViewableItemsChanged } from "./hooks"

export function EntryListContentGrid({
entryIds,
...rest
}: {
entryIds: string[]
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">) {
const insets = useSafeAreaInsets()
const tabBarHeight = useBottomTabBarHeight()
const headerHeight = useHeaderHeight()
const { scrollY } = useContext(NavigationContext)!

const { colorScheme } = useColorScheme()
const { fetchNextPage, refetch, isRefetching } = useFetchEntriesControls()
const onViewableItemsChanged = useOnViewableItemsChanged()

return (
<MasonryFlashList
refreshControl={
<RefreshControl
progressViewOffset={headerHeight}
// FIXME: not sure why we need set tintColor manually here, otherwise we can not see the refresh indicator
tintColor={colorScheme === "dark" ? "white" : "black"}
onRefresh={() => {
refetch()
}}
refreshing={isRefetching}
/>
<TimelineSelectorMasonryList
isRefetching={isRefetching}
onRefresh={
(() => {
refetch()
}) as any
}
data={entryIds}
renderItem={useTypeScriptHappyCallback(({ item }) => {
return <RenderEntryItem id={item} />
}, [])}
keyExtractor={(id) => id}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
onViewableItemsChanged={onViewableItemsChanged}
onEndReached={() => {
fetchNextPage()
}}
numColumns={2}
onScroll={useTypeScriptHappyCallback(
(e) => {
scrollY.setValue(e.nativeEvent.contentOffset.y)
},
[scrollY],
)}
scrollIndicatorInsets={{
top: headerHeight - insets.top,
bottom: tabBarHeight - insets.bottom,
}}
estimatedItemSize={100}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: tabBarHeight,
}}
{...rest}
/>
)
Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/modules/entry-list/entry-list-social.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { gentleSpringPreset } from "@/src/constants/spring"
import { quickLookImage } from "@/src/lib/native"
import { useEntry } from "@/src/store/entry/hooks"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { useFeed } from "@/src/store/feed/hooks"
import { unreadSyncService } from "@/src/store/unread/store"

import { EntryItemContextMenu } from "../context-menu/entry"
import { useFetchEntriesControls } from "../screen/atoms"
import { TimelineSelectorList } from "../screen/TimelineSelectorList"
import { useOnViewableItemsChanged } from "./hooks"
import { ItemSeparatorFullWidth } from "./ItemSeparator"

export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {
Expand All @@ -38,21 +38,22 @@ export function EntryListContentSocial({ entryIds }: { entryIds: string[] }) {
[isFetching],
)

const onViewableItemsChanged = useOnViewableItemsChanged()

return (
<TimelineSelectorList
onRefresh={() => {
refetch()
}}
isRefetching={isRefetching}
data={entryIds}
keyExtractor={(id) => id}
estimatedItemSize={100}
renderItem={renderItem}
onEndReached={() => {
fetchNextPage()
}}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
estimatedItemSize={100}
onViewableItemsChanged={onViewableItemsChanged}
ItemSeparatorComponent={ItemSeparatorFullWidth}
ListFooterComponent={ListFooterComponent}
/>
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/modules/entry-list/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type ViewToken from "@shopify/flash-list/dist/viewability/ViewToken"
import { useCallback } from "react"

import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { unreadSyncService } from "@/src/store/unread/store"

export function useOnViewableItemsChanged(): (info: {
viewableItems: ViewToken[]
changed: ViewToken[]
}) => void {
const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread")

return useCallback(
({ viewableItems, changed }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
if (markAsReadWhenScrolling) {
changed
.filter((item) => !item.isViewable)
.forEach((item) => {
unreadSyncService.markEntryAsRead(item.key)
})
}
},
[markAsReadWhenScrolling],
)
}
74 changes: 65 additions & 9 deletions apps/mobile/src/modules/screen/TimelineSelectorList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"
import type { FlashListProps } from "@shopify/flash-list"
import { FlashList } from "@shopify/flash-list"
import type { FlashListProps, MasonryFlashListProps } from "@shopify/flash-list"
import { FlashList, MasonryFlashList } from "@shopify/flash-list"
import { forwardRef, useCallback, useContext } from "react"
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
import { RefreshControl } from "react-native"
Expand All @@ -9,7 +9,8 @@ import { useColor } from "react-native-uikit-colors"

import { NavigationContext } from "@/src/components/common/SafeNavigationScrollView"
import { useHeaderHeight } from "@/src/modules/screen/hooks/useHeaderHeight"
import { debouncedFetchEntryContentByStream } from "@/src/store/entry/store"
import { usePrefetchSubscription } from "@/src/store/subscription/hooks"
import { usePrefetchUnread } from "@/src/store/unread/hooks"

type Props = {
onRefresh: () => void
Expand All @@ -18,6 +19,9 @@ type Props = {

export const TimelineSelectorList = forwardRef<FlashList<any>, Props & FlashListProps<any>>(
({ onRefresh, isRefetching, ...props }, ref) => {
const { refetch: unreadRefetch } = usePrefetchUnread()
const { refetch: subscriptionRefetch } = usePrefetchSubscription()

const insets = useSafeAreaInsets()

const headerHeight = useHeaderHeight()
Expand All @@ -42,20 +46,19 @@ export const TimelineSelectorList = forwardRef<FlashList<any>, Props & FlashList
progressViewOffset={headerHeight}
// // FIXME: not sure why we need set tintColor manually here, otherwise we can not see the refresh indicator
tintColor={systemFill}
onRefresh={onRefresh}
onRefresh={() => {
unreadRefetch()
subscriptionRefetch()
onRefresh()
}}
refreshing={isRefetching}
/>
}
onScroll={onScroll}
keyExtractor={(id) => id}
onViewableItemsChanged={({ viewableItems }) => {
debouncedFetchEntryContentByStream(viewableItems.map((item) => item.key))
}}
scrollIndicatorInsets={{
top: headerHeight - insets.top,
bottom: tabBarHeight ? tabBarHeight - insets.bottom : undefined,
}}
estimatedItemSize={100}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: tabBarHeight,
Expand All @@ -65,3 +68,56 @@ export const TimelineSelectorList = forwardRef<FlashList<any>, Props & FlashList
)
},
)

export const TimelineSelectorMasonryList = ({
onRefresh,
isRefetching,
...props
}: Props & MasonryFlashListProps<any>) => {
const { refetch: unreadRefetch } = usePrefetchUnread()
const { refetch: subscriptionRefetch } = usePrefetchSubscription()

const insets = useSafeAreaInsets()

const headerHeight = useHeaderHeight()
const scrollY = useContext(NavigationContext)?.scrollY

const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollY?.setValue(e.nativeEvent.contentOffset.y)
},
[scrollY],
)

const tabBarHeight = useBottomTabBarHeight()

const systemFill = useColor("secondaryLabel")

return (
<MasonryFlashList
refreshControl={
<RefreshControl
progressViewOffset={headerHeight}
// // FIXME: not sure why we need set tintColor manually here, otherwise we can not see the refresh indicator
tintColor={systemFill}
onRefresh={() => {
unreadRefetch()
subscriptionRefetch()
onRefresh()
}}
refreshing={isRefetching}
/>
}
onScroll={onScroll}
scrollIndicatorInsets={{
top: headerHeight - insets.top,
bottom: tabBarHeight ? tabBarHeight - insets.bottom : undefined,
}}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: tabBarHeight,
}}
{...props}
/>
)
}
5 changes: 0 additions & 5 deletions apps/mobile/src/screens/(stack)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re"
import { Settings1CuteFiIcon } from "@/src/icons/settings_1_cute_fi"
import { Settings1CuteReIcon } from "@/src/icons/settings_1_cute_re"
import { FeedDrawer } from "@/src/modules/feed-drawer/drawer"
import { usePrefetchSubscription } from "@/src/store/subscription/hooks"
import { usePrefetchUnread } from "@/src/store/unread/hooks"
import { useColor } from "@/src/theme/colors"

const fifthTap = Gesture.Tap()
Expand All @@ -34,9 +32,6 @@ const fifthTap = Gesture.Tap()
})

export default function TabLayout() {
usePrefetchUnread()
usePrefetchSubscription()

const opacity = useSharedValue(1)
const animatedTransformY = useAnimatedValue(1)

Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/services/unread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { db } from "../database"
import { unreadTable } from "../database/schemas"
import type { UnreadSchema } from "../database/schemas/types"
import { unreadActions } from "../store/unread/store"
import type { Hydratable } from "./internal/base"
import type { Hydratable, Resetable } from "./internal/base"
import { conflictUpdateAllExcept } from "./internal/utils"

class UnreadServiceStatic implements Hydratable {
class UnreadServiceStatic implements Hydratable, Resetable {
async reset() {
await db.delete(unreadTable).execute()
}
async hydrate() {
const unreads = await db.query.unreadTable.findMany()
unreadActions.upsertManyInSession(unreads)
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/src/store/unread/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class UnreadSyncService {
query: {},
})

await unreadActions.reset()
await unreadActions.upsertMany(res.data)
return res.data
}
Expand Down Expand Up @@ -95,7 +96,7 @@ class UnreadSyncService {
}

class UnreadActions {
async upsertManyInSession(unreads: UnreadSchema[]) {
upsertManyInSession(unreads: UnreadSchema[]) {
const state = useUnreadStore.getState()
const nextData = { ...state.data }
for (const unread of unreads) {
Expand Down Expand Up @@ -131,10 +132,19 @@ class UnreadActions {
await unreadActions.upsertMany([{ subscriptionId, count: Math.max(0, currentCount - count) }])
}

reset() {
set({
data: {},
async reset() {
const tx = createTransaction()
tx.store(() => {
set({
data: {},
})
})

tx.persist(() => {
return UnreadService.reset()
})

await tx.run()
}
}
export const unreadActions = new UnreadActions()
Expand Down

0 comments on commit c8f580c

Please sign in to comment.