feat(auth): allow loginless

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-11-14 00:32:16 +08:00
parent 08f11481b0
commit 35437ecf8e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
13 changed files with 127 additions and 60 deletions

View File

@ -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<ModalProps, "content"> {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: ReactNode
@ -20,25 +21,39 @@ export interface DeclarativeModalProps extends Omit<ModalProps, "content"> {
const Noop = () => null
const DeclarativeModalImpl: FC<DeclarativeModalProps> = ({
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 (
<AnimatePresence>
{open && (
<ModalInternal isTop onClose={onOpenChange} index={index} item={item}>
{internalOpen && (
<ModalInternal isTop onClose={handleOpenChange} index={index} item={item}>
{children}
</ModalInternal>
)}

View File

@ -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],
)
}

View File

@ -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,
})
}
}

View File

@ -32,11 +32,19 @@ export class ChatSliceActions {
this._current = instance
}
private chatInstance: ZustandChat
constructor(
private params: Parameters<StateCreator<ChatSlice, [], [], ChatSlice>>,
private chatInstance: ZustandChat,
options: {
chatInstance: ZustandChat
hasChatId: boolean
},
) {
this.chatInstance.resumeStream()
if (options.hasChatId) {
options.chatInstance.resumeStream()
}
this.chatInstance = options.chatInstance
return autoBindThis(this)
}

View File

@ -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,

View File

@ -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<AIChatStoreInitial>) =>
const { blocks, chatId, generateId } = initialState || {}
return createWithEqualityFn<AiChatStore>((...a) => {
const blockSlice = createBlockSlice(blocks)(...a)
const chatSlice = createChatSlice({ chatId: chatId || nanoid(), generateId })(...a)
const chatSlice = createChatSlice({ chatId, generateId })(...a)
return {
...blockSlice,

View File

@ -172,14 +172,14 @@ export function MainDestopLayout() {
<RootPortal>
<DeclarativeModal
id="login"
defaultOpen
CustomModalComponent={PlainModal}
open
overlay
title="Login"
canClose={false}
clickOutsideToDismiss={false}
canClose={!IN_ELECTRON}
clickOutsideToDismiss={!IN_ELECTRON}
>
<LoginModalContent canClose={false} runtime={IN_ELECTRON ? "app" : "browser"} />
<LoginModalContent canClose={!IN_ELECTRON} runtime={IN_ELECTRON ? "app" : "browser"} />
</DeclarativeModal>
</RootPortal>
)}

View File

@ -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)

View File

@ -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(
() => ({

View File

@ -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,
}),
}
})
: [],
})
}

View File

@ -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,
})
}

View File

@ -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 = () => {

View File

@ -16,7 +16,7 @@ export type MeModel = AuthUser & {
emailVerified?: boolean
twoFactorEnabled?: boolean | null
}
type UserStore = {
export type UserStore = {
users: Record<string, UserModel>
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<UserProfileEditable>) {