From f78d7e766bcb7fdb8fcc8e9678769b5fc741902e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Mar 2026 11:29:45 +0800 Subject: [PATCH] feat(mobile): support anonymous timeline reading (#4911) --- apps/mobile/e2e/flows/shared/open-auth.yaml | 7 + .../components/ui/image/ImageContextMenu.tsx | 4 +- .../mobile/src/modules/context-menu/entry.tsx | 150 +++++++++--------- .../mobile/src/modules/context-menu/video.tsx | 38 +++-- .../EntryContentHeaderRightActions.tsx | 46 +++--- .../entry-content/EntryReadHistory.tsx | 6 +- .../modules/entry-list/EntryListSelector.tsx | 10 +- apps/mobile/src/modules/entry-list/hooks.ts | 7 +- .../entry-list/templates/EntryPictureItem.tsx | 6 +- .../entry-list/templates/EntrySocialItem.tsx | 8 +- .../entry-list/templates/EntryVideoItem.tsx | 6 +- .../screen/TimelineSelectorProvider.tsx | 8 +- .../entries/[entryId]/EntryDetailScreen.tsx | 5 +- .../internal/store/src/modules/entry/hooks.ts | 4 +- 14 files changed, 177 insertions(+), 128 deletions(-) diff --git a/apps/mobile/e2e/flows/shared/open-auth.yaml b/apps/mobile/e2e/flows/shared/open-auth.yaml index c43355f6a..faf645af7 100644 --- a/apps/mobile/e2e/flows/shared/open-auth.yaml +++ b/apps/mobile/e2e/flows/shared/open-auth.yaml @@ -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 diff --git a/apps/mobile/src/components/ui/image/ImageContextMenu.tsx b/apps/mobile/src/components/ui/image/ImageContextMenu.tsx index 68076d2bc..800122e68 100644 --- a/apps/mobile/src/components/ui/image/ImageContextMenu.tsx +++ b/apps/mobile/src/components/ui/image/ImageContextMenu.tsx @@ -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 - {entryId && feedId && view !== undefined && ( + {isLoggedIn && entryId && feedId && view !== undefined && ( <> ({ read: state.read, feedId: state.feedId, @@ -73,81 +75,85 @@ export const EntryItemContextMenu = ({ )} - { - 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(), - }) - }} - > - - - {t("operation.mark_all_as_read_which", { - which: t("operation.mark_all_as_read_which_above"), - })} - - + {isLoggedIn && ( + <> + { + 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(), + }) + }} + > + + + {t("operation.mark_all_as_read_which", { + which: t("operation.mark_all_as_read_which_above"), + })} + + - { - entry.read - ? unreadSyncService.markEntryAsUnread(id) - : unreadSyncService.markEntryAsRead(id) - }} - > - - {entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")} - - - + { + entry.read + ? unreadSyncService.markEntryAsUnread(id) + : unreadSyncService.markEntryAsRead(id) + }} + > + + {entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")} + + + - { - 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(), - }) - }} - > - - - {t("operation.mark_all_as_read_which", { - which: t("operation.mark_all_as_read_which_below"), - })} - - + { + 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(), + }) + }} + > + + + {t("operation.mark_all_as_read_which", { + which: t("operation.mark_all_as_read_which_below"), + })} + + + + )} - {feedId && view !== undefined && ( + {isLoggedIn && feedId && view !== undefined && ( { diff --git a/apps/mobile/src/modules/context-menu/video.tsx b/apps/mobile/src/modules/context-menu/video.tsx index 365d2548e..708635b6a 100644 --- a/apps/mobile/src/modules/context-menu/video.tsx +++ b/apps/mobile/src/modules/context-menu/video.tsx @@ -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) = {children} - { - entry.read - ? unreadSyncService.markEntryAsUnread(entryId) - : unreadSyncService.markEntryAsRead(entryId) - }} - > - - {entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")} - - { + entry.read + ? unreadSyncService.markEntryAsUnread(entryId) + : unreadSyncService.markEntryAsRead(entryId) }} - /> - - {feedId && ( + > + + {entry.read ? t("operation.mark_as_unread") : t("operation.mark_as_read")} + + + + )} + {isLoggedIn && feedId && ( { diff --git a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx index 3af48e8a9..7c53e8105 100644 --- a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx +++ b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx @@ -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 ? : , - iconIOS: { - name: isStarred ? "star.fill" : "star", - paletteColors: isStarred ? ["#facc15"] : undefined, + isLoggedIn && + subscription && { + key: "Star", + title: isStarred ? t("operation.unstar") : t("operation.star"), + icon: isStarred ? : , + 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: , - iconIOS: { name: "globe" }, - onPress: toggleAITranslation, - active: showTranslation, - isCheckbox: true, - inMenu: true, - }, + isLoggedIn && + !showAITranslationSetting && { + key: "ShowTranslation", + title: "Show Translation", + icon: , + iconIOS: { name: "globe" }, + onPress: toggleAITranslation, + active: showTranslation, + isCheckbox: true, + inMenu: true, + }, { key: "Share", title: t("operation.share"), diff --git a/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx b/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx index 2efd0bf49..b376bca1e 100644 --- a/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx +++ b/apps/mobile/src/modules/entry-content/EntryReadHistory.tsx @@ -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 ( {data?.entryReadHistories.userIds.map((userId, index) => { diff --git a/apps/mobile/src/modules/entry-list/EntryListSelector.tsx b/apps/mobile/src/modules/entry-list/EntryListSelector.tsx index 3dd327c5e..5bb019a26 100644 --- a/apps/mobile/src/modules/entry-list/EntryListSelector.tsx +++ b/apps/mobile/src/modules/entry-list/EntryListSelector.tsx @@ -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 : + const screenType = useEntryListContext().type + + if (whoami || screenType !== "subscriptions") { + return children + } + + return } type EntryListSelectorProps = { diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 90d6369e8..2bd5bd79b 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -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, diff --git a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx index 2c60a44c9..0dbb7d254 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx @@ -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() 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) + } }} /> diff --git a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx index a706329d5..5b91dddf0 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx @@ -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 diff --git a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx index 727daeb2f..fa792a128 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx @@ -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 }) => { { - unreadSyncService.markEntryAsRead(id) + if (isLoggedIn) { + unreadSyncService.markEntryAsRead(id) + } tracker.navigateEntry({ feedId: item.feedId!, entryId: id, diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx index 7dfbbc4e8..36d9cf86b 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx @@ -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 () => ( - - + {isLoggedIn && } + {isLoggedIn && } ) - }, [feedId])} + }, [feedId, isLoggedIn])} headerHideableBottom={isTimeline || isSubscriptions ? TimelineViewSelector : undefined} headerHideableBottomHeight={TIMELINE_VIEW_SELECTOR_HEIGHT} /> diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx index def617a9c..434969f92 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx @@ -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" /> - {!hideRecentReader && ( + {isLoggedIn && !hideRecentReader && ( {readCount} diff --git a/packages/internal/store/src/modules/entry/hooks.ts b/packages/internal/store/src/modules/entry/hooks.ts index b074fe0e1..e3e32d4a2 100644 --- a/packages/internal/store/src/modules/entry/hooks.ts +++ b/packages/internal/store/src/modules/entry/hooks.ts @@ -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