feat(mobile): support anonymous timeline reading (#4911)

This commit is contained in:
DIYgod 2026-03-12 11:29:45 +08:00 committed by GitHub
parent 565793fceb
commit f78d7e766b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 177 additions and 128 deletions

View File

@ -11,6 +11,13 @@ appId: is.follow
commands:
- tapOn:
id: no-login-timeline
- runFlow:
when:
visible:
id: home-avatar-trigger
commands:
- tapOn:
id: home-avatar-trigger
- extendedWaitUntil:
visible:
id: login-screen

View File

@ -3,6 +3,7 @@ import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { collectionSyncService } from "@follow/store/collection/store"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { requireNativeModule } from "expo"
import type { PropsWithChildren } from "react"
import { useRef } from "react"
@ -35,6 +36,7 @@ const getIOSNativeImageActions = () => {
export const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageContextMenuProps) => {
const { t } = useTranslation()
const isLoggedIn = useIsLoggedIn()
const entry = useEntry(entryId, (state) => ({
read: state.read,
feedId: state.feedId,
@ -58,7 +60,7 @@ export const ImageContextMenu = ({ imageUrl, entryId, children, view }: ImageCon
</ContextMenu.Trigger>
<ContextMenu.Content>
{entryId && feedId && view !== undefined && (
{isLoggedIn && entryId && feedId && view !== undefined && (
<>
<ContextMenu.Item
key="MarkAsRead"

View File

@ -4,6 +4,7 @@ import { collectionSyncService } from "@follow/store/collection/store"
import { getEntry } from "@follow/store/entry/getter"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { PortalProvider } from "@gorhom/portal"
import type { PropsWithChildren } from "react"
import { useCallback } from "react"
@ -32,6 +33,7 @@ export const EntryItemContextMenu = ({
const { t } = useTranslation()
const selectedView = useSelectedView()
const selectedFeed = useSelectedFeed()
const isLoggedIn = useIsLoggedIn()
const entry = useEntry(id, (state) => ({
read: state.read,
feedId: state.feedId,
@ -73,81 +75,85 @@ export const EntryItemContextMenu = ({
)}
</ContextMenu.Preview>
<ContextMenu.Item
key="MarkAsReadAbove"
onSelect={() => {
const payload = getFetchEntryPayload(selectedFeed, selectedView)
const { publishedAt } = entry
unreadSyncService.markBatchAsRead({
view: selectedView,
filter: payload,
time: {
startTime: new Date(publishedAt).getTime() + 1,
endTime: Date.now(),
},
excludePrivate: getHideAllReadSubscriptions(),
})
}}
>
<ContextMenu.ItemIcon
ios={{
name: "arrow.up",
}}
/>
<ContextMenu.ItemTitle>
{t("operation.mark_all_as_read_which", {
which: t("operation.mark_all_as_read_which_above"),
})}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
{isLoggedIn && (
<>
<ContextMenu.Item
key="MarkAsReadAbove"
onSelect={() => {
const payload = getFetchEntryPayload(selectedFeed, selectedView)
const { publishedAt } = entry
unreadSyncService.markBatchAsRead({
view: selectedView,
filter: payload,
time: {
startTime: new Date(publishedAt).getTime() + 1,
endTime: Date.now(),
},
excludePrivate: getHideAllReadSubscriptions(),
})
}}
>
<ContextMenu.ItemIcon
ios={{
name: "arrow.up",
}}
/>
<ContextMenu.ItemTitle>
{t("operation.mark_all_as_read_which", {
which: t("operation.mark_all_as_read_which_above"),
})}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="MarkAsRead"
onSelect={() => {
entry.read
? unreadSyncService.markEntryAsUnread(id)
: unreadSyncService.markEntryAsRead(id)
}}
>
<ContextMenu.ItemTitle>
{entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")}
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: entry.read ? "circle.fill" : "checkmark.circle",
}}
/>
</ContextMenu.Item>
<ContextMenu.Item
key="MarkAsRead"
onSelect={() => {
entry.read
? unreadSyncService.markEntryAsUnread(id)
: unreadSyncService.markEntryAsRead(id)
}}
>
<ContextMenu.ItemTitle>
{entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")}
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: entry.read ? "circle.fill" : "checkmark.circle",
}}
/>
</ContextMenu.Item>
<ContextMenu.Item
key="MarkAsReadBelow"
onSelect={() => {
const payload = getFetchEntryPayload(selectedFeed, selectedView)
const { publishedAt } = entry
unreadSyncService.markBatchAsRead({
view: selectedView,
filter: payload,
time: {
startTime: 1,
endTime: new Date(publishedAt).getTime() - 1,
},
excludePrivate: getHideAllReadSubscriptions(),
})
}}
>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down",
}}
/>
<ContextMenu.ItemTitle>
{t("operation.mark_all_as_read_which", {
which: t("operation.mark_all_as_read_which_below"),
})}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="MarkAsReadBelow"
onSelect={() => {
const payload = getFetchEntryPayload(selectedFeed, selectedView)
const { publishedAt } = entry
unreadSyncService.markBatchAsRead({
view: selectedView,
filter: payload,
time: {
startTime: 1,
endTime: new Date(publishedAt).getTime() - 1,
},
excludePrivate: getHideAllReadSubscriptions(),
})
}}
>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down",
}}
/>
<ContextMenu.ItemTitle>
{t("operation.mark_all_as_read_which", {
which: t("operation.mark_all_as_read_which_below"),
})}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
</>
)}
{feedId && view !== undefined && (
{isLoggedIn && feedId && view !== undefined && (
<ContextMenu.Item
key="Star"
onSelect={() => {

View File

@ -3,6 +3,7 @@ import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { collectionSyncService } from "@follow/store/collection/store"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import type { PropsWithChildren } from "react"
import { useTranslation } from "react-i18next"
import { Share } from "react-native"
@ -16,6 +17,7 @@ type VideoContextMenuProps = PropsWithChildren<{
export const VideoContextMenu = ({ entryId, children }: VideoContextMenuProps) => {
const { t } = useTranslation()
const isLoggedIn = useIsLoggedIn()
const entry = useEntry(entryId, (state) => ({
read: state.read,
feedId: state.feedId,
@ -35,24 +37,26 @@ export const VideoContextMenu = ({ entryId, children }: VideoContextMenuProps) =
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item
key="MarkAsRead"
onSelect={() => {
entry.read
? unreadSyncService.markEntryAsUnread(entryId)
: unreadSyncService.markEntryAsRead(entryId)
}}
>
<ContextMenu.ItemTitle>
{entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")}
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: entry.read ? "circle.fill" : "checkmark.circle",
{isLoggedIn && (
<ContextMenu.Item
key="MarkAsRead"
onSelect={() => {
entry.read
? unreadSyncService.markEntryAsUnread(entryId)
: unreadSyncService.markEntryAsRead(entryId)
}}
/>
</ContextMenu.Item>
{feedId && (
>
<ContextMenu.ItemTitle>
{entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")}
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: entry.read ? "circle.fill" : "checkmark.circle",
}}
/>
</ContextMenu.Item>
)}
{isLoggedIn && feedId && (
<ContextMenu.Item
key="Star"
onSelect={() => {

View File

@ -5,6 +5,7 @@ import { entrySyncServices } from "@follow/store/entry/store"
import { useFeedById } from "@follow/store/feed/hooks"
import { useSubscriptionById } from "@follow/store/subscription/hooks"
import { translationSyncService } from "@follow/store/translation/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { setStringAsync } from "expo-clipboard"
import { useAtom } from "jotai"
import { useCallback, useEffect, useState } from "react"
@ -58,6 +59,7 @@ const HeaderRightActionsImpl = ({
}: HeaderRightActionsProps) => {
const { t } = useTranslation()
const labelColor = useColor("label")
const isLoggedIn = useIsLoggedIn()
const isStarred = useIsEntryStarred(entryId)
const [extraActionContainerWidth, setExtraActionContainerWidth] = useState(0)
@ -136,18 +138,19 @@ const HeaderRightActionsImpl = ({
// Define action items for reuse
const actionItems = [
subscription && {
key: "Star",
title: isStarred ? t("operation.unstar") : t("operation.star"),
icon: isStarred ? <StarCuteFiIcon /> : <StarCuteReIcon />,
iconIOS: {
name: isStarred ? "star.fill" : "star",
paletteColors: isStarred ? ["#facc15"] : undefined,
isLoggedIn &&
subscription && {
key: "Star",
title: isStarred ? t("operation.unstar") : t("operation.star"),
icon: isStarred ? <StarCuteFiIcon /> : <StarCuteReIcon />,
iconIOS: {
name: isStarred ? "star.fill" : "star",
paletteColors: isStarred ? ["#facc15"] : undefined,
},
onPress: handleToggleStar,
active: isStarred,
iconColor: isStarred ? "#facc15" : undefined,
},
onPress: handleToggleStar,
active: isStarred,
iconColor: isStarred ? "#facc15" : undefined,
},
!showReadabilitySetting && {
key: "ShowReadability",
title: "Show Readability",
@ -158,16 +161,17 @@ const HeaderRightActionsImpl = ({
isCheckbox: true,
// inMenu: true,
},
!showAITranslationSetting && {
key: "ShowTranslation",
title: "Show Translation",
icon: <Translate2CuteReIcon />,
iconIOS: { name: "globe" },
onPress: toggleAITranslation,
active: showTranslation,
isCheckbox: true,
inMenu: true,
},
isLoggedIn &&
!showAITranslationSetting && {
key: "ShowTranslation",
title: "Show Translation",
icon: <Translate2CuteReIcon />,
iconIOS: { name: "globe" },
onPress: toggleAITranslation,
active: showTranslation,
isCheckbox: true,
inMenu: true,
},
{
key: "Share",
title: t("operation.share"),

View File

@ -1,4 +1,5 @@
import { useEntryReadHistory } from "@follow/store/entry/hooks"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { View } from "react-native"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
@ -7,9 +8,10 @@ import { useNavigation } from "@/src/lib/navigation/hooks"
import { ProfileScreen } from "@/src/screens/(modal)/ProfileScreen"
export const EntryReadHistory = ({ entryId }: { entryId: string }) => {
const data = useEntryReadHistory(entryId, 6)
const isLoggedIn = useIsLoggedIn()
const data = useEntryReadHistory(entryId, 6, isLoggedIn)
const navigation = useNavigation()
if (!data?.entryReadHistories) return null
if (!isLoggedIn || !data?.entryReadHistories) return null
return (
<View className="flex-row items-center justify-center">
{data?.entryReadHistories.userIds.map((userId, index) => {

View File

@ -13,14 +13,20 @@ import { useNavigation } from "@/src/lib/navigation/hooks"
import { EntryListContentPicture } from "@/src/modules/entry-list/EntryListContentPicture"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen"
import { useEntries } from "../screen/atoms"
import { useEntries, useEntryListContext } from "../screen/atoms"
import { EntryListContentArticle } from "./EntryListContentArticle"
import { EntryListContentSocial } from "./EntryListContentSocial"
import { EntryListContentVideo } from "./EntryListContentVideo"
const NoLoginGuard = ({ children }: { children: React.ReactNode }) => {
const whoami = useWhoami()
return whoami ? children : <NoLoginInfo target="timeline" />
const screenType = useEntryListContext().type
if (whoami || screenType !== "subscriptions") {
return children
}
return <NoLoginInfo target="subscriptions" />
}
type EntryListSelectorProps = {

View File

@ -1,5 +1,6 @@
import { debouncedFetchEntryContentByStream } from "@follow/store/entry/store"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import type { ViewToken } from "@shopify/flash-list"
import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from "react"
import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
@ -18,6 +19,7 @@ export function useOnViewableItemsChanged({
} = {}) {
const orientation = useRef<"down" | "up">("down")
const lastOffset = useRef(0)
const isLoggedIn = useIsLoggedIn()
const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread")
const markAsReadWhenRendering = useGeneralSettingKey("renderMarkUnread")
@ -58,7 +60,7 @@ export function useOnViewableItemsChanged({
useEffect(() => {
if (disabled) return
if (markAsReadWhenScrolling && lastRemovedItems) {
if (isLoggedIn && markAsReadWhenScrolling && lastRemovedItems) {
lastRemovedItems.forEach((item) => {
unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => {
setLastRemovedItems((prev) => {
@ -72,13 +74,14 @@ export function useOnViewableItemsChanged({
})
}
if (markAsReadWhenRendering && lastViewableItems) {
if (isLoggedIn && markAsReadWhenRendering && lastViewableItems) {
lastViewableItems.forEach((item) => {
unreadSyncService.markEntryAsRead(stableIdExtractor(item))
})
}
}, [
disabled,
isLoggedIn,
lastRemovedItems,
lastViewableItems,
markAsReadWhenRendering,

View File

@ -3,6 +3,7 @@ import type { MediaModel } from "@follow/database/schemas/types"
import { useEntry } from "@follow/store/entry/hooks"
import { getFeedById } from "@follow/store/feed/getter"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { tracker } from "@follow/tracker"
import { uniqBy } from "es-toolkit/compat"
import type { ImageSource } from "expo-image"
@ -19,6 +20,7 @@ import { Text } from "@/src/components/ui/typography/Text"
export function EntryPictureItem({ id }: { id: string }) {
const { t } = useTranslation()
const { openLightbox } = useLightboxControls()
const isLoggedIn = useIsLoggedIn()
const aviRef = useAnimatedRef<View>()
const item = useEntry(id, (state) => ({
media: state.media,
@ -81,7 +83,9 @@ export function EntryPictureItem({ id }: { id: string }) {
})
})()
unreadSyncService.markEntryAsRead(id)
if (isLoggedIn) {
unreadSyncService.markEntryAsRead(id)
}
}}
/>
</View>

View File

@ -4,6 +4,7 @@ import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { tracker } from "@follow/tracker"
import type { ImageSource } from "expo-image"
import { memo, useCallback } from "react"
@ -54,9 +55,12 @@ export const EntrySocialItem = memo(
})
const { openLightbox } = useLightboxControls()
const feed = useFeedById(entry?.feedId || "")
const isLoggedIn = useIsLoggedIn()
const navigation = useNavigation()
const handlePress = useCallback(() => {
unreadSyncService.markEntryAsRead(entryId)
if (isLoggedIn) {
unreadSyncService.markEntryAsRead(entryId)
}
tracker.navigateEntry({
feedId: entry?.feedId ?? "",
entryId,
@ -66,7 +70,7 @@ export const EntrySocialItem = memo(
entryIds: extraData.entryIds ?? [],
view: FeedViewType.SocialMedia,
})
}, [entry?.feedId, entryId, extraData.entryIds, navigation])
}, [entry?.feedId, entryId, extraData.entryIds, isLoggedIn, navigation])
const autoExpandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia")
const navigationToFeedEntryList = useCallback(() => {
if (!entry?.feedId) return

View File

@ -1,6 +1,7 @@
import { FeedViewType } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { useIsLoggedIn } from "@follow/store/user/hooks"
import { tracker } from "@follow/tracker"
import { formatDuration, transformVideoUrl } from "@follow/utils"
import { memo, useMemo } from "react"
@ -20,6 +21,7 @@ import { EntryGridFooter } from "../../entry-content/EntryGridFooter"
export const EntryVideoItem = memo(({ id }: { id: string }) => {
const { t } = useTranslation()
const isLoggedIn = useIsLoggedIn()
const item = useEntry(id, (state) => ({
attachments: state.attachments,
media: state.media,
@ -45,7 +47,9 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => {
<ItemPressable
itemStyle={ItemPressableStyle.Plain}
onPress={() => {
unreadSyncService.markEntryAsRead(id)
if (isLoggedIn) {
unreadSyncService.markEntryAsRead(id)
}
tracker.navigateEntry({
feedId: item.feedId!,
entryId: id,

View File

@ -1,3 +1,4 @@
import { useIsLoggedIn } from "@follow/store/user/hooks"
import type { FC } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { View } from "react-native"
@ -27,6 +28,7 @@ import { useEntries, useEntryListContext, useSelectedFeedTitle } from "./atoms"
export function TimelineHeader({ feedId }: { feedId?: string }) {
const viewTitle = useSelectedFeedTitle()
const screenType = useEntryListContext().type
const isLoggedIn = useIsLoggedIn()
const isFeed = screenType === "feed"
const isTimeline = screenType === "timeline"
@ -49,13 +51,13 @@ export function TimelineHeader({ feedId }: { feedId?: string }) {
return () => (
<View className="flex-row items-center justify-end">
<ActionGroup>
<UnreadOnlyActionButton />
<MarkAllAsReadActionButton />
{isLoggedIn && <UnreadOnlyActionButton />}
{isLoggedIn && <MarkAllAsReadActionButton />}
<FeedShareActionButton feedId={feedId} />
</ActionGroup>
</View>
)
}, [feedId])}
}, [feedId, isLoggedIn])}
headerHideableBottom={isTimeline || isSubscriptions ? TimelineViewSelector : undefined}
headerHideableBottomHeight={TIMELINE_VIEW_SELECTOR_HEIGHT}
/>

View File

@ -199,9 +199,10 @@ const EntryInfo = ({ entryId }: { entryId: string }) => {
publishedAt: state.publishedAt,
feedId: state.feedId,
}))
const isLoggedIn = useIsLoggedIn()
const feed = useFeedById(entry?.feedId)
const secondaryLabelColor = useColor("secondaryLabel")
const readCount = useEntryReadHistory(entryId)?.entryReadHistories?.readCount ?? 0
const readCount = useEntryReadHistory(entryId, 20, isLoggedIn)?.entryReadHistories?.readCount ?? 0
const hideRecentReader = useUISettingKey("hideRecentReader")
if (!entry) return null
const { publishedAt } = entry
@ -222,7 +223,7 @@ const EntryInfo = ({ entryId }: { entryId: string }) => {
className="text-xs leading-tight text-secondary-label"
/>
</View>
{!hideRecentReader && (
{isLoggedIn && !hideRecentReader && (
<View className="flex flex-row items-center gap-1">
<Eye2CuteReIcon width={16} height={16} color={secondaryLabelColor} />
<Text className="text-xs leading-tight text-secondary-label">{readCount}</Text>

View File

@ -223,7 +223,7 @@ export const useEntryIsInbox = (entryId: string) => {
return useEntryStore(useCallback((state) => getEntryIsInboxSelector(state)(entryId), [entryId]))
}
export const useEntryReadHistory = (entryId: string, size = 20) => {
export const useEntryReadHistory = (entryId: string, size = 20, enabled = true) => {
const isInboxEntry = useEntryIsInbox(entryId)
const { data } = useQuery({
queryKey: ["entry-read-history", entryId],
@ -231,7 +231,7 @@ export const useEntryReadHistory = (entryId: string, size = 20) => {
return entrySyncServices.fetchEntryReadHistory(entryId, size)
},
staleTime: 1000 * 60 * 5,
enabled: !isInboxEntry,
enabled: enabled && !isInboxEntry,
})
return data