From 35437ecf8e86bfbce2a7d4855c8853608cf0138c Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 14 Nov 2025 00:32:16 +0800 Subject: [PATCH] feat(auth): allow loginless Signed-off-by: Innei --- .../ui/modal/stacked/declarative-modal.tsx | 25 ++++++++-- .../layer/renderer/src/hooks/biz/useAsRead.ts | 6 ++- apps/desktop/layer/renderer/src/lib/ga4.ts | 9 ++-- .../ai-chat/store/chat-core/chat-actions.ts | 12 ++++- .../ai-chat/store/slices/chat.slice.ts | 15 ++++-- .../src/modules/ai-chat/store/store.ts | 3 +- .../modules/app-layout/MainDestopLayout.tsx | 8 ++-- .../src/modules/entry-column/index.tsx | 5 +- .../entries/[entryId]/EntryDetailScreen.tsx | 5 +- .../store/src/modules/translation/hooks.ts | 36 ++++++++------- .../store/src/modules/unread/hooks.ts | 3 ++ .../internal/store/src/modules/user/hooks.ts | 14 ++++-- .../internal/store/src/modules/user/store.ts | 46 ++++++++++++------- 13 files changed, 127 insertions(+), 60 deletions(-) diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx index 47209c442..82baee787 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/declarative-modal.tsx @@ -1,7 +1,7 @@ import { cn } from "@follow/utils/utils" import { AnimatePresence } from "motion/react" import type { FC, ReactNode } from "react" -import { useId, useMemo } from "react" +import { useCallback, useEffect, useId, useMemo, useState } from "react" import { jotaiStore } from "~/lib/jotai" @@ -11,6 +11,7 @@ import type { ModalProps } from "./types" export interface DeclarativeModalProps extends Omit { open?: boolean + defaultOpen?: boolean onOpenChange?: (open: boolean) => void children?: ReactNode @@ -20,25 +21,39 @@ export interface DeclarativeModalProps extends Omit { const Noop = () => null const DeclarativeModalImpl: FC = ({ open, + defaultOpen, onOpenChange, children, ...rest }) => { const index = useMemo(() => jotaiStore.get(modalStackAtom).length, []) - + const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false) const id = useId() const item = useMemo( () => ({ ...rest, content: Noop, id, + open: internalOpen, }), - [id, rest], + [id, internalOpen, rest], ) + const handleOpenChange = useCallback( + (open: boolean) => { + setInternalOpen(open) + onOpenChange?.(open) + }, + [onOpenChange, setInternalOpen], + ) + useEffect(() => { + if (open !== undefined && open !== internalOpen) { + setInternalOpen(open) + } + }, [open, internalOpen, setInternalOpen]) return ( - {open && ( - + {internalOpen && ( + {children} )} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts b/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts index 6adc78786..3c1acb89a 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useAsRead.ts @@ -1,5 +1,6 @@ import { useEntry } from "@follow/store/entry/hooks" import type { EntryModel } from "@follow/store/entry/types" +import { useIsLoggedIn } from "@follow/store/user/hooks" import { useRouteParamsSelector } from "./useRouteParams" @@ -7,14 +8,17 @@ const selector = (state: EntryModel) => state.read export function useEntryIsRead(entryId?: string) { const entryRead = useEntry(entryId, selector) + const isLoggedIn = useIsLoggedIn() + return useRouteParamsSelector( (params) => { + if (!isLoggedIn) return true if (params.isCollection) { return true } if (entryRead === undefined) return false return entryRead }, - [entryRead], + [entryRead, isLoggedIn], ) } diff --git a/apps/desktop/layer/renderer/src/lib/ga4.ts b/apps/desktop/layer/renderer/src/lib/ga4.ts index 53c3fa0e7..7141bff8e 100644 --- a/apps/desktop/layer/renderer/src/lib/ga4.ts +++ b/apps/desktop/layer/renderer/src/lib/ga4.ts @@ -1,3 +1,5 @@ +import { whoami } from "@follow/store/user/getters" + import { getClientId, getSessionId } from "~/lib/client-session" import { followClient } from "./api-client" @@ -47,9 +49,10 @@ class Analytics4 { user_properties: this.userProperties, } - return followClient.api.data.sendAnalytics({ - ...payload, - }) + if (whoami()) + return followClient.api.data.sendAnalytics({ + ...payload, + }) } } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts index b232cb8a7..535219dfa 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts @@ -32,11 +32,19 @@ export class ChatSliceActions { this._current = instance } + private chatInstance: ZustandChat constructor( private params: Parameters>, - private chatInstance: ZustandChat, + + options: { + chatInstance: ZustandChat + hasChatId: boolean + }, ) { - this.chatInstance.resumeStream() + if (options.hasChatId) { + options.chatInstance.resumeStream() + } + this.chatInstance = options.chatInstance return autoBindThis(this) } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts index e358698b3..c1a45c58b 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/chat.slice.ts @@ -1,4 +1,5 @@ import type { IdGenerator } from "ai" +import { nanoid } from "nanoid" import type { StateCreator } from "zustand" import { ChatSliceActions } from "../chat-core/chat-actions" @@ -7,7 +8,7 @@ import type { ChatSlice } from "../chat-core/types" import { createChatTitleHandler, createChatTransport } from "../transport" export const createChatSlice: (options: { - chatId: string + chatId?: string generateId?: IdGenerator isLocal?: boolean syncStatus?: "local" | "synced" @@ -17,13 +18,14 @@ export const createChatSlice: (options: { const [set, get] = params const { chatId, generateId, isLocal, syncStatus } = options + const nextChatId = chatId || nanoid() const chatInstance = new ZustandChat( { - id: chatId, + id: nextChatId, messages: [], transport: createChatTransport({ titleHandler: createChatTitleHandler({ - chatId, + chatId: nextChatId, getActiveChatId: () => get().chatId, onTitleChange: (title) => { set({ @@ -37,10 +39,13 @@ export const createChatSlice: (options: { set, ) - const chatActions = new ChatSliceActions(params, chatInstance) + const chatActions = new ChatSliceActions(params, { + chatInstance, + hasChatId: !!chatId, + }) return { - chatId, + chatId: nextChatId, messages: [], status: "ready", error: undefined, diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/store.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/store.ts index 142f9195e..db8ed082e 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/store.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/store.ts @@ -1,4 +1,3 @@ -import { nanoid } from "nanoid" import { createWithEqualityFn } from "zustand/traditional" import type { ChatSlice } from "./chat-core/types" @@ -16,7 +15,7 @@ export const createAIChatStore = (initialState?: Partial) => const { blocks, chatId, generateId } = initialState || {} return createWithEqualityFn((...a) => { const blockSlice = createBlockSlice(blocks)(...a) - const chatSlice = createChatSlice({ chatId: chatId || nanoid(), generateId })(...a) + const chatSlice = createChatSlice({ chatId, generateId })(...a) return { ...blockSlice, diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx index 9bc1323b8..c00b412e8 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx @@ -172,14 +172,14 @@ export function MainDestopLayout() { - + )} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index ff4b2f664..c8c5a9e3d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -4,6 +4,7 @@ import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" import { unreadSyncService } from "@follow/store/unread/store" +import { useIsLoggedIn } from "@follow/store/user/hooks" import { isBizId } from "@follow/utils/utils" import type { Range, Virtualizer } from "@tanstack/react-virtual" import { atom } from "jotai" @@ -61,6 +62,7 @@ function EntryColumnContent() { const feed = useFeedById(routeFeedId) const title = useFeedHeaderTitle() useTitle(title) + const isLoggedIn = useIsLoggedIn() useEffect(() => { if (!activeEntryId) return @@ -68,8 +70,9 @@ function EntryColumnContent() { if (isCollection || isPendingEntry) return if (!entry?.feedId) return + if (!isLoggedIn) return unreadSyncService.markEntryAsRead(activeEntryId) - }, [activeEntryId, entry?.feedId, isCollection, isPendingEntry]) + }, [activeEntryId, entry?.feedId, isCollection, isPendingEntry, isLoggedIn]) const isInteracted = useRef(false) diff --git a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx index 7ed1b0740..0b49f3f83 100644 --- a/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/EntryDetailScreen.tsx @@ -4,7 +4,7 @@ import { entrySyncServices } from "@follow/store/entry/store" import { useFeedById } from "@follow/store/feed/hooks" import { usePrefetchEntryTranslation } from "@follow/store/translation/hooks" import { useAutoMarkAsRead } from "@follow/store/unread/hooks" -import { useUserRole } from "@follow/store/user/hooks" +import { useIsLoggedIn, useUserRole } from "@follow/store/user/hooks" import { PortalProvider } from "@gorhom/portal" import * as WebBrowser from "expo-web-browser" import { atom, useAtomValue, useSetAtom } from "jotai" @@ -50,7 +50,8 @@ export const EntryDetailScreen: NavigationControllerView<{ readability: state.settings?.readability, sourceContent: state.settings?.sourceContent, })) - useAutoMarkAsRead(entryId, !!entry) + const isLoggedIn = useIsLoggedIn() + useAutoMarkAsRead(entryId, !!entry && isLoggedIn) const insets = useSafeAreaInsets() const ctxValue = useMemo( () => ({ diff --git a/packages/internal/store/src/modules/translation/hooks.ts b/packages/internal/store/src/modules/translation/hooks.ts index 48d43ffe9..6ca1210fd 100644 --- a/packages/internal/store/src/modules/translation/hooks.ts +++ b/packages/internal/store/src/modules/translation/hooks.ts @@ -5,6 +5,7 @@ import { useCallback } from "react" import { useEntry, useEntryList } from "../entry/hooks" import type { EntryModel } from "../entry/types" +import { useIsLoggedIn } from "../user/hooks" import { translationSyncService, useTranslationStore } from "./store" export const usePrefetchEntryTranslation = ({ @@ -24,24 +25,27 @@ export const usePrefetchEntryTranslation = ({ (entry) => entry !== null && (enabled || !!entry?.settings?.translation), ) || []) as EntryModel[] + const isLoggedIn = useIsLoggedIn() return useQueries({ - queries: entryList.map((entry) => { - const entryId = entry.id - const targetContent = - target === "readabilityContent" ? entry.readabilityContent : entry.content - const finalWithContent = withContent && !!targetContent + queries: isLoggedIn + ? entryList.map((entry) => { + const entryId = entry.id + const targetContent = + target === "readabilityContent" ? entry.readabilityContent : entry.content + const finalWithContent = withContent && !!targetContent - return { - queryKey: ["translation", entryId, language, finalWithContent, target], - queryFn: () => - translationSyncService.generateTranslation({ - entryId, - language, - withContent: finalWithContent, - target, - }), - } - }), + return { + queryKey: ["translation", entryId, language, finalWithContent, target], + queryFn: () => + translationSyncService.generateTranslation({ + entryId, + language, + withContent: finalWithContent, + target, + }), + } + }) + : [], }) } diff --git a/packages/internal/store/src/modules/unread/hooks.ts b/packages/internal/store/src/modules/unread/hooks.ts index 1a4bbe867..66102e72b 100644 --- a/packages/internal/store/src/modules/unread/hooks.ts +++ b/packages/internal/store/src/modules/unread/hooks.ts @@ -5,14 +5,17 @@ import { useCallback, useEffect } from "react" import { getEntry } from "../entry/getter" import { useListFeedIds } from "../list/hooks" import { useSubscriptionIdsByView } from "../subscription/hooks" +import { useIsLoggedIn } from "../user/hooks" import { unreadCountAllSelector, unreadCountIdSelector, unreadCountIdsSelector } from "./selectors" import { unreadSyncService, useUnreadStore } from "./store" export const usePrefetchUnread = () => { + const isLoggedIn = useIsLoggedIn() return useQuery({ queryKey: ["unread"], queryFn: () => unreadSyncService.resetFromRemote(), staleTime: 5 * 1000 * 60, // 5 minutes + enabled: isLoggedIn, }) } diff --git a/packages/internal/store/src/modules/user/hooks.ts b/packages/internal/store/src/modules/user/hooks.ts index dccd4325f..5eb23a6d3 100644 --- a/packages/internal/store/src/modules/user/hooks.ts +++ b/packages/internal/store/src/modules/user/hooks.ts @@ -5,6 +5,7 @@ import { useEffect } from "react" import { api, queryClient } from "../../context" import type { GeneralQueryOptions } from "../../types" import { isNewUserQueryKey } from "./constants" +import type { UserStore } from "./store" import { userSyncService, useUserStore } from "./store" export const whoamiQueryKey = ["user", "whoami"] @@ -40,16 +41,23 @@ export const usePrefetchUser = (userId: string | undefined) => { return query } +const whoamiSelector = (state: UserStore) => state.whoami export const useWhoami = () => { - return useUserStore((state) => state.whoami) + return useUserStore(whoamiSelector) } +const loggedInSelector = (state: UserStore) => !!state.whoami +const roleSelector = (state: UserStore) => state.role +export const useIsLoggedIn = () => { + return useUserStore(loggedInSelector) +} export const useUserRole = () => { - return useUserStore((state) => state.role) + return useUserStore(roleSelector) } +const roleEndAtSelector = (state: UserStore) => state.roleEndAt export const useRoleEndAt = () => { - return useUserStore((state) => state.roleEndAt) + return useUserStore(roleEndAtSelector) } export const useUserSubscriptionLimit = () => { diff --git a/packages/internal/store/src/modules/user/store.ts b/packages/internal/store/src/modules/user/store.ts index 378a39a82..ac0f480c0 100644 --- a/packages/internal/store/src/modules/user/store.ts +++ b/packages/internal/store/src/modules/user/store.ts @@ -16,7 +16,7 @@ export type MeModel = AuthUser & { emailVerified?: boolean twoFactorEnabled?: boolean | null } -type UserStore = { +export type UserStore = { users: Record whoami: MeModel | null role: UserRole | null @@ -66,26 +66,40 @@ class UserSyncService { }) async whoami() { - const res = await api().auth.getSession() - - if (res) { - if (!res.user) return res - const user = apiMorph.toWhoami(res.user) - immerSet((state) => { - state.whoami = { ...user, emailVerified: res.user?.emailVerified ?? false } - state.role = res.user?.role as UserRole | null - if (res.user?.roleEndAt) { - state.roleEndAt = new Date(res.user?.roleEndAt) + const res = await api() + .auth.getSession() + .catch((err) => { + if (err?.message.includes("Failed to fetch")) { + throw err } - state.rsshubSubscriptionLimit = res.rsshubSubscriptionLimit ?? null - state.feedSubscriptionLimit = res.feedSubscriptionLimit ?? null + return null }) - userActions.upsertMany([user]) - return res - } else { + if (!res) { + immerSet((state) => { + state.whoami = null + state.role = null + state.roleEndAt = null + state.rsshubSubscriptionLimit = null + state.feedSubscriptionLimit = null + }) return null } + + if (!res.user) return res + const user = apiMorph.toWhoami(res.user) + immerSet((state) => { + state.whoami = { ...user, emailVerified: res.user?.emailVerified ?? false } + state.role = res.user?.role as UserRole | null + if (res.user?.roleEndAt) { + state.roleEndAt = new Date(res.user?.roleEndAt) + } + state.rsshubSubscriptionLimit = res.rsshubSubscriptionLimit ?? null + state.feedSubscriptionLimit = res.feedSubscriptionLimit ?? null + }) + userActions.upsertMany([user]) + + return res } async updateProfile(data: Partial) {