feat(auth): allow loginless
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
08f11481b0
commit
35437ecf8e
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
})
|
||||
: [],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue