Folo/packages/internal/store/src/entry/store.ts

706 lines
18 KiB
TypeScript

import { FeedViewType } from "@follow/constants"
import { EntryService } from "@follow/database/services/entry"
import { cloneDeep } from "es-toolkit"
import { debounce } from "es-toolkit/compat"
import { clearAllFeedUnreadDirty, clearFeedUnreadDirty } from "../atoms/feed"
import { collectionActions } from "../collection/store"
import { apiClient } from "../context"
import { feedActions } from "../feed/store"
import type { Hydratable, Resetable } from "../internal/base"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { dbStoreMorph } from "../morph/db-store"
import { honoMorph } from "../morph/hono"
import { storeDbMorph } from "../morph/store-db"
import { getSubscriptionById } from "../subscription/getter"
import { getDefaultCategory } from "../subscription/utils"
import type { FeedIdOrInboxHandle, PublishAtTimeRangeFilter } from "../unread/types"
import { whoami } from "../user/getters"
import { userActions } from "../user/store"
import { getEntry } from "./getter"
import type { EntryModel, FetchEntriesProps, FetchEntriesPropsSettings } from "./types"
import { getEntriesParams } from "./utils"
type EntryId = string
type FeedId = string
type InboxId = string
type Category = string
type ListId = string
interface EntryState {
data: Record<EntryId, EntryModel>
entryIdByView: Record<FeedViewType, Set<EntryId>>
entryIdByCategory: Record<Category, Set<EntryId>>
entryIdByFeed: Record<FeedId, Set<EntryId>>
entryIdByInbox: Record<InboxId, Set<EntryId>>
entryIdByList: Record<ListId, Set<EntryId>>
entryIdSet: Set<EntryId>
}
const defaultState: EntryState = {
data: {},
entryIdByView: {
[FeedViewType.Articles]: new Set(),
[FeedViewType.Audios]: new Set(),
[FeedViewType.Notifications]: new Set(),
[FeedViewType.Pictures]: new Set(),
[FeedViewType.SocialMedia]: new Set(),
[FeedViewType.Videos]: new Set(),
},
entryIdByCategory: {},
entryIdByFeed: {},
entryIdByInbox: {},
entryIdByList: {},
entryIdSet: new Set(),
}
export const useEntryStore = createZustandStore<EntryState>("entry")(() => defaultState)
const get = useEntryStore.getState
const immerSet = createImmerSetter(useEntryStore)
class EntryActions implements Hydratable, Resetable {
async hydrate() {
const entries = await EntryService.getEntriesToHydrate()
entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e)))
}
getFlattenMapEntries() {
const state = get()
return state.data
}
private addEntryIdToView({
draft,
feedId,
entryId,
sources,
hidePrivateSubscriptionsInTimeline,
}: {
draft: EntryState
feedId?: FeedId | null
entryId: EntryId
sources?: string[] | null
hidePrivateSubscriptionsInTimeline?: boolean
}) {
if (!feedId) return
const subscription = getSubscriptionById(feedId)
const ignore = hidePrivateSubscriptionsInTimeline && subscription?.isPrivate
if (typeof subscription?.view === "number" && !ignore) {
draft.entryIdByView[subscription.view].add(entryId)
}
// lists
for (const s of sources ?? []) {
const subscription = getSubscriptionById(s)
const ignore = hidePrivateSubscriptionsInTimeline && subscription?.isPrivate
if (typeof subscription?.view === "number" && !ignore) {
draft.entryIdByView[subscription.view].add(entryId)
}
}
}
private addEntryIdToCategory({
draft,
feedId,
entryId,
}: {
draft: EntryState
feedId?: FeedId | null
entryId: EntryId
}) {
if (!feedId) return
const subscription = getSubscriptionById(feedId)
const category = subscription?.category || getDefaultCategory(subscription)
if (!category) return
const entryIdSetByCategory = draft.entryIdByCategory[category]
if (!entryIdSetByCategory) {
draft.entryIdByCategory[category] = new Set([entryId])
} else {
entryIdSetByCategory.add(entryId)
}
}
private addEntryIdToFeed({
draft,
feedId,
entryId,
}: {
draft: EntryState
feedId?: FeedId | null
entryId: EntryId
}) {
if (!feedId) return
const entryIdSetByFeed = draft.entryIdByFeed[feedId]
if (!entryIdSetByFeed) {
draft.entryIdByFeed[feedId] = new Set([entryId])
} else {
entryIdSetByFeed.add(entryId)
}
}
private addEntryIdToInbox({
draft,
inboxHandle,
entryId,
}: {
draft: EntryState
inboxHandle?: InboxId | null
entryId: EntryId
}) {
if (!inboxHandle) return
const entryIdSetByInbox = draft.entryIdByInbox[inboxHandle]
if (!entryIdSetByInbox) {
draft.entryIdByInbox[inboxHandle] = new Set([entryId])
} else {
entryIdSetByInbox.add(entryId)
}
}
private addEntryIdToList({
draft,
listId,
entryId,
}: {
draft: EntryState
listId?: ListId | null
entryId: EntryId
}) {
if (!listId) return
const entryIdSetByList = draft.entryIdByList[listId]
if (!entryIdSetByList) {
draft.entryIdByList[listId] = new Set([entryId])
} else {
entryIdSetByList.add(entryId)
}
}
upsertManyInSession(entries: EntryModel[], options?: FetchEntriesPropsSettings) {
if (entries.length === 0) return
const { unreadOnly, hidePrivateSubscriptionsInTimeline } = options || {}
immerSet((draft) => {
for (const entry of entries) {
draft.entryIdSet.add(entry.id)
draft.data[entry.id] = entry
const { feedId, inboxHandle, read, sources } = entry
if (unreadOnly && read) continue
if (inboxHandle) {
this.addEntryIdToInbox({
draft,
inboxHandle,
entryId: entry.id,
})
} else {
this.addEntryIdToFeed({
draft,
feedId,
entryId: entry.id,
})
}
this.addEntryIdToView({
draft,
feedId,
entryId: entry.id,
sources,
hidePrivateSubscriptionsInTimeline,
})
this.addEntryIdToCategory({
draft,
feedId,
entryId: entry.id,
})
entry.sources
?.filter((s) => !!s && s !== "feed")
.forEach((s) => {
this.addEntryIdToList({
draft,
listId: s,
entryId: entry.id,
})
})
}
})
}
async upsertMany(entries: EntryModel[]) {
const tx = createTransaction()
tx.store(() => {
this.upsertManyInSession(entries)
})
tx.persist(() => {
return EntryService.upsertMany(entries.map((e) => storeDbMorph.toEntrySchema(e)))
})
await tx.run()
}
updateEntryContentInSession({
entryId,
content,
readabilityContent,
}: {
entryId: EntryId
content?: string
readabilityContent?: string
}) {
immerSet((draft) => {
const entry = draft.data[entryId]
if (!entry) return
if (content) {
entry.content = content
}
if (readabilityContent) {
entry.readabilityContent = readabilityContent
}
})
}
async updateEntryContent({
entryId,
content,
readabilityContent,
}: {
entryId: EntryId
content?: string
readabilityContent?: string
}) {
const tx = createTransaction()
tx.store(() => {
this.updateEntryContentInSession({ entryId, content, readabilityContent })
})
tx.persist(() => {
if (content) {
EntryService.patch({ id: entryId, content })
}
if (readabilityContent) {
EntryService.patch({ id: entryId, readabilityContent })
}
})
await tx.run()
}
markEntryReadStatusInSession({
entryIds,
ids,
read,
time,
}: {
entryIds?: EntryId[]
ids?: FeedIdOrInboxHandle[]
read: boolean
time?: PublishAtTimeRangeFilter
}) {
const affectedEntryIds = new Set<EntryId>()
immerSet((draft) => {
if (entryIds) {
for (const entryId of entryIds) {
const entry = draft.data[entryId]
if (!entry) {
continue
}
if (
time &&
(+new Date(entry.publishedAt) < time.startTime ||
+new Date(entry.publishedAt) > time.endTime)
) {
continue
}
if (entry.read !== read) {
entry.read = read
affectedEntryIds.add(entryId)
}
}
}
if (ids) {
const entries = Array.from(draft.entryIdSet)
.map((id) => draft.data[id])
.filter((entry): entry is EntryModel => {
if (!entry) return false
const id = entry.inboxHandle || entry.feedId || ""
if (!id) return false
return ids.includes(id)
})
for (const entry of entries) {
if (
time &&
(+new Date(entry.publishedAt) < time.startTime ||
+new Date(entry.publishedAt) > time.endTime)
) {
continue
}
if (entry.read !== read) {
entry.read = read
affectedEntryIds.add(entry.id)
}
}
}
})
return Array.from(affectedEntryIds)
}
resetByView({ view, entries }: { view?: FeedViewType; entries: EntryModel[] }) {
if (view === undefined) return
immerSet((draft) => {
draft.entryIdByView[view] = new Set(entries.map((e) => e.id))
})
}
resetByCategory({ category, entries }: { category?: Category; entries: EntryModel[] }) {
if (!category) return
immerSet((draft) => {
draft.entryIdByCategory[category] = new Set(entries.map((e) => e.id))
})
}
resetByFeed({ feedId, entries }: { feedId?: FeedId; entries: EntryModel[] }) {
if (!feedId) return
immerSet((draft) => {
draft.entryIdByFeed[feedId] = new Set(entries.map((e) => e.id))
})
}
resetByInbox({ inboxId, entries }: { inboxId?: InboxId; entries: EntryModel[] }) {
if (!inboxId) return
immerSet((draft) => {
draft.entryIdByInbox[inboxId] = new Set(entries.map((e) => e.id))
})
}
resetByList({ listId, entries }: { listId?: ListId; entries: EntryModel[] }) {
if (!listId) return
immerSet((draft) => {
draft.entryIdByList[listId] = new Set(entries.map((e) => e.id))
})
}
deleteInboxEntryById(entryId: EntryId) {
const entry = get().data[entryId]
if (!entry || !entry.inboxHandle) return
immerSet((draft) => {
delete draft.data[entryId]
draft.entryIdSet.delete(entryId)
draft.entryIdByInbox[entry.inboxHandle!]?.delete(entryId)
})
}
async reset() {
const tx = createTransaction()
tx.store(() => {
immerSet(() => defaultState)
})
tx.persist(() => {
return EntryService.reset()
})
await tx.run()
}
}
class EntrySyncServices {
async fetchEntries(props: FetchEntriesProps) {
const {
feedId,
inboxId,
listId,
view,
read,
limit,
pageParam,
isCollection,
feedIdList,
excludePrivate,
} = props
const params = getEntriesParams({
feedId,
inboxId,
listId,
view,
feedIdList,
})
const res = params.inboxId
? await apiClient().entries.inbox.$post({
json: {
publishedAfter: pageParam,
read,
limit,
isCollection,
inboxId: params.inboxId,
...params,
},
})
: await apiClient().entries.$post({
json: {
publishedAfter: pageParam,
read,
limit,
isCollection,
excludePrivate,
...params,
},
})
// Mark feed unread dirty, so re-fetch the unread data when view feed unread entires in the next time
if (read === false) {
if (typeof params.view === "number" && !params.feedId) {
clearAllFeedUnreadDirty()
}
if (params.feedId) {
clearFeedUnreadDirty(params.feedId as string)
}
if (params.feedIdList) {
params.feedIdList.forEach((feedId) => {
clearFeedUnreadDirty(feedId)
})
}
}
const entries = honoMorph.toEntryList(res.data)
const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id))
for (const entry of entries) {
const entryInDB = entriesInDB.find((e) => e.id === entry.id)
if (entryInDB) {
entry.content = entryInDB.content
entry.readabilityContent = entryInDB.readabilityContent
}
}
await entryActions.upsertMany(entries)
if (typeof view === "number") {
const { collections, entryIdsNotInCollections } = honoMorph.toCollections(res.data, view)
await collectionActions.upsertMany(collections, {
reset: params.isCollection && !pageParam,
})
await collectionActions.delete(entryIdsNotInCollections)
}
const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === "feed")
const feeds = dataFeeds?.map((f) => honoMorph.toFeed(f)) ?? []
const users = dataFeeds?.flatMap((f) => f.tipUsers).filter((u) => !!u) ?? []
feedActions.upsertMany(feeds)
userActions.upsertMany(
users.map((u) => ({
...u,
isMe: u.id === whoami()?.id,
})),
)
return res
}
async fetchEntryDetail(entryId: EntryId) {
const currentEntry = getEntry(entryId)
const res = currentEntry?.inboxHandle
? await apiClient().entries.inbox.$get({ query: { id: entryId } })
: await apiClient().entries.$get({ query: { id: entryId } })
const entry = honoMorph.toEntry(res.data)
if (!currentEntry && entry) {
await entryActions.upsertMany([entry])
} else {
if (entry?.content && currentEntry?.content !== entry.content) {
await entryActions.updateEntryContent({ entryId, content: entry.content })
}
if (
entry?.readabilityContent &&
currentEntry?.readabilityContent !== entry.readabilityContent
) {
await entryActions.updateEntryContent({
entryId,
readabilityContent: entry.readabilityContent,
})
}
}
return entry
}
async fetchEntryReadabilityContent(
entryId: EntryId,
fallBack?: () => Promise<string | null | undefined>,
) {
const entry = getEntry(entryId)
if (entry?.url && entry?.readabilityContent === null) {
let readabilityContent: string | null | undefined
try {
const { data: contentByFetch } = await apiClient().entries.readability.$get({
query: {
id: entryId,
},
})
readabilityContent = contentByFetch?.content || null
} catch (error) {
if (fallBack) {
readabilityContent = await fallBack()
} else {
throw error
}
}
if (readabilityContent && entry?.readabilityContent !== readabilityContent) {
await entryActions.updateEntryContent({
entryId,
readabilityContent,
})
}
}
return entry
}
async fetchEntryContentByStream(
remoteEntryIds?: string[],
options?: {
fetch?: typeof fetch
cookie?: string
},
) {
if (!remoteEntryIds || remoteEntryIds.length === 0) return
const onlyNoStored = true
const nextIds = [] as string[]
if (onlyNoStored) {
for (const id of remoteEntryIds) {
const entry = getEntry(id)!
if (entry.content) {
continue
}
nextIds.push(id)
}
}
if (nextIds.length === 0) return
const readStream = async () => {
// https://github.com/facebook/react-native/issues/37505
// TODO: And it seems we can not just use fetch from expo for ofetch, need further investigation
const response = await (options?.fetch || fetch)(
apiClient().entries.stream.$url().toString(),
{
method: "POST",
headers: options?.cookie
? {
cookie: options.cookie,
}
: undefined,
credentials: options?.cookie ? "omit" : "include",
body: JSON.stringify({
ids: nextIds,
}),
},
)
if (!response.ok) {
console.error("Failed to fetch stream:", response.statusText, await response.text())
return
}
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
// Process all complete lines
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i]!.trim()) {
const json = JSON.parse(lines[i]!)
// Handle each JSON line here
entryActions.updateEntryContent({ entryId: json.id, content: json.content })
}
}
// Keep the last incomplete line in the buffer
buffer = lines.at(-1) || ""
}
// Process any remaining data
if (buffer.trim()) {
const json = JSON.parse(buffer)
entryActions.updateEntryContent({ entryId: json.id, content: json.content })
}
} catch (error) {
console.error("Error reading stream:", error)
} finally {
reader.releaseLock()
}
}
readStream()
}
async fetchEntryReadHistory(entryId: EntryId, size: number) {
const res = await apiClient().entries["read-histories"][":id"].$get({
param: {
id: entryId,
},
query: {
size,
},
})
await userActions.upsertMany(Object.values(res.data.users))
return res.data
}
async deleteInboxEntry(entryId: string) {
const entry = get().data[entryId]
if (!entry || !entry.inboxHandle) return
const tx = createTransaction()
const currentEntry = cloneDeep(entry)
tx.store(() => {
entryActions.deleteInboxEntryById(entryId)
})
tx.request(async () => {
await apiClient().entries.inbox.$delete({ json: { entryId } })
})
tx.rollback(() => {
entryActions.upsertManyInSession([currentEntry])
})
tx.persist(() => {
return EntryService.deleteMany([entryId])
})
await tx.run()
}
}
export const entrySyncServices = new EntrySyncServices()
export const entryActions = new EntryActions()
export const debouncedFetchEntryContentByStream = debounce(
entrySyncServices.fetchEntryContentByStream,
1000,
)