diff --git a/apps/mobile/drizzle/0007_curvy_tarantula.sql b/apps/mobile/drizzle/0007_curvy_tarantula.sql new file mode 100644 index 000000000..d780cd4f0 --- /dev/null +++ b/apps/mobile/drizzle/0007_curvy_tarantula.sql @@ -0,0 +1 @@ +ALTER TABLE `lists` ADD `entry_ids` text; \ No newline at end of file diff --git a/apps/mobile/drizzle/meta/0007_snapshot.json b/apps/mobile/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..843555bf5 --- /dev/null +++ b/apps/mobile/drizzle/meta/0007_snapshot.json @@ -0,0 +1,501 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d5c6f89-f31c-405c-8c2d-7b59ab03a275", + "prevId": "9f943ca5-ddaf-4916-98cd-d3e3f7ead201", + "tables": { + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_url": { + "name": "author_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_avatar": { + "name": "author_avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media": { + "name": "media", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra": { + "name": "extra", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_handle": { + "name": "inbox_handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feeds": { + "name": "feeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_at": { + "name": "error_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inboxes": { + "name": "inboxes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feed_ids": { + "name": "feed_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fee": { + "name": "fee", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_ids": { + "name": "entry_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "view": { + "name": "view", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unread": { + "name": "unread", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_me": { + "name": "is_me", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/mobile/drizzle/meta/_journal.json b/apps/mobile/drizzle/meta/_journal.json index e6a3090c5..9ec202076 100644 --- a/apps/mobile/drizzle/meta/_journal.json +++ b/apps/mobile/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1737080887634, "tag": "0006_exotic_kid_colt", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1737108493439, + "tag": "0007_curvy_tarantula", + "breakpoints": true } ] } diff --git a/apps/mobile/drizzle/migrations.js b/apps/mobile/drizzle/migrations.js index 49ecb4cf9..48decb123 100644 --- a/apps/mobile/drizzle/migrations.js +++ b/apps/mobile/drizzle/migrations.js @@ -7,6 +7,7 @@ import m0003 from "./0003_known_roland_deschain.sql" import m0004 from "./0004_majestic_thunderbolt_ross.sql" import m0005 from "./0005_tense_sleepwalker.sql" import m0006 from "./0006_exotic_kid_colt.sql" +import m0007 from "./0007_curvy_tarantula.sql" import journal from "./meta/_journal.json" export default { @@ -19,5 +20,6 @@ export default { m0004, m0005, m0006, + m0007, }, } diff --git a/apps/mobile/src/database/schemas/index.ts b/apps/mobile/src/database/schemas/index.ts index 7b65634fd..bab071cfa 100644 --- a/apps/mobile/src/database/schemas/index.ts +++ b/apps/mobile/src/database/schemas/index.ts @@ -44,6 +44,7 @@ export const listsTable = sqliteTable("lists", { image: text("image"), fee: integer("fee"), ownerUserId: text("owner_user_id"), + entryIds: text("entry_ids", { mode: "json" }).$type(), }) export const unreadTable = sqliteTable("unread", { diff --git a/apps/mobile/src/morph/db-store.ts b/apps/mobile/src/morph/db-store.ts index e54d126c5..19e678882 100644 --- a/apps/mobile/src/morph/db-store.ts +++ b/apps/mobile/src/morph/db-store.ts @@ -1,4 +1,5 @@ -import type { SubscriptionSchema } from "../database/schemas/types" +import type { EntrySchema, SubscriptionSchema } from "../database/schemas/types" +import type { EntryModel } from "../store/entry/types" import type { SubscriptionModel } from "../store/subscription/store" class DbStoreMorph { @@ -8,6 +9,10 @@ class DbStoreMorph { isPrivate: subscription.isPrivate ? true : false, } } + + toEntryModel(entry: EntrySchema): EntryModel { + return entry + } } export const dbStoreMorph = new DbStoreMorph() diff --git a/apps/mobile/src/morph/hono.ts b/apps/mobile/src/morph/hono.ts index c881ca0e3..cb7d6f89c 100644 --- a/apps/mobile/src/morph/hono.ts +++ b/apps/mobile/src/morph/hono.ts @@ -1,4 +1,5 @@ import type { FeedSchema, InboxSchema } from "../database/schemas/types" +import type { EntryModel } from "../store/entry/types" import type { ListModel } from "../store/list/store" import type { SubscriptionModel } from "../store/subscription/store" import type { HonoApiClient } from "./types" @@ -72,6 +73,7 @@ class Morph { ownerUserId: list.owner.id, feedIds: list.feedIds!, fee: list.fee!, + entryIds: [], }) } @@ -91,8 +93,42 @@ class Morph { ownerUserId: data.ownerUserId!, feedIds: data.feedIds!, fee: data.fee!, + entryIds: [], } } + + toEntry(data?: HonoApiClient.Entry_Get): EntryModel[] { + const entries: EntryModel[] = [] + for (const item of data ?? []) { + entries.push({ + id: item.entries.id, + title: item.entries.title, + url: item.entries.url, + content: "", + description: item.entries.description, + guid: item.entries.guid, + author: item.entries.author, + authorUrl: item.entries.authorUrl, + authorAvatar: item.entries.authorAvatar, + insertedAt: new Date(item.entries.insertedAt), + publishedAt: new Date(item.entries.publishedAt), + media: item.entries.media ?? null, + categories: item.entries.categories ?? null, + attachments: item.entries.attachments ?? null, + extra: item.entries.extra + ? { + links: item.entries.extra.links ?? undefined, + } + : null, + language: item.entries.language, + feedId: item.feeds.id, + // TODO: handle inboxHandle + inboxHandle: "", + read: false, + }) + } + return entries + } } export const honoMorph = new Morph() diff --git a/apps/mobile/src/morph/store-db.ts b/apps/mobile/src/morph/store-db.ts index e1ab6b0f3..f6ff8f190 100644 --- a/apps/mobile/src/morph/store-db.ts +++ b/apps/mobile/src/morph/store-db.ts @@ -1,4 +1,5 @@ -import type { ListSchema, SubscriptionSchema } from "../database/schemas/types" +import type { EntrySchema, ListSchema, SubscriptionSchema } from "../database/schemas/types" +import type { EntryModel } from "../store/entry/types" import type { ListModel } from "../store/list/store" import type { SubscriptionModel } from "../store/subscription/store" @@ -16,6 +17,9 @@ class StoreDbMorph { isPrivate: subscription.isPrivate ? 1 : 0, } } + toEntrySchema(entry: EntryModel): EntrySchema { + return entry + } } export const storeDbMorph = new StoreDbMorph() diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index 4f06217f3..375f38d5a 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -3,9 +3,10 @@ import type { apiClient } from "../lib/api-fetch" // Add ExtractData type utility type ExtractData any> = - Awaited> extends { data: infer D } ? D : never + Awaited> extends { data?: infer D } ? D : never export namespace HonoApiClient { export type Subscription_Get = ExtractData export type List_Get = ExtractData + export type Entry_Get = ExtractData } diff --git a/apps/mobile/src/services/entry.ts b/apps/mobile/src/services/entry.ts new file mode 100644 index 000000000..b1da6eaf5 --- /dev/null +++ b/apps/mobile/src/services/entry.ts @@ -0,0 +1,37 @@ +import { eq } from "drizzle-orm" + +import { db } from "../database" +import { entriesTable } from "../database/schemas" +import type { EntrySchema } from "../database/schemas/types" +import { dbStoreMorph } from "../morph/db-store" +import { entryActions } from "../store/entry/store" +import type { Hydratable, Resetable } from "./internal/base" +import { conflictUpdateAllExcept } from "./internal/utils" + +class EntryServiceStatic implements Hydratable, Resetable { + async reset() { + await db.delete(entriesTable).execute() + } + + async upsertMany(entries: EntrySchema[]) { + if (entries.length === 0) return + await db + .insert(entriesTable) + .values(entries) + .onConflictDoUpdate({ + target: [entriesTable.id], + set: conflictUpdateAllExcept(entriesTable, ["id"]), + }) + } + + async patch(entry: Partial & { id: string }) { + await db.update(entriesTable).set(entry).where(eq(entriesTable.id, entry.id)) + } + + async hydrate() { + const entries = await db.query.entriesTable.findMany() + entryActions.upsertManyInSession(entries.map((e) => dbStoreMorph.toEntryModel(e))) + } +} + +export const EntryService = new EntryServiceStatic() diff --git a/apps/mobile/src/services/list.ts b/apps/mobile/src/services/list.ts index 035bf7383..f54242e71 100644 --- a/apps/mobile/src/services/list.ts +++ b/apps/mobile/src/services/list.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm" + import { db } from "../database" import { listsTable } from "../database/schemas" import type { ListSchema } from "../database/schemas/types" @@ -28,6 +30,20 @@ class ListServiceStatic implements Hydratable, Resetable { set: conflictUpdateAllExcept(listsTable, ["id"]), }) } + async addEntryIds(params: { listId: string; entryIds: string[] }) { + const list = await db.query.listsTable.findFirst({ + where: eq(listsTable.id, params.listId), + }) + + if (!list) return + + await db + .update(listsTable) + .set({ + entryIds: [...new Set([...(list.entryIds ?? []), ...params.entryIds])], + }) + .where(eq(listsTable.id, params.listId)) + } } export const ListService = new ListServiceStatic() diff --git a/apps/mobile/src/store/entry/getter.ts b/apps/mobile/src/store/entry/getter.ts new file mode 100644 index 000000000..142fca1bd --- /dev/null +++ b/apps/mobile/src/store/entry/getter.ts @@ -0,0 +1,8 @@ +import { useEntryStore } from "./store" +import type { EntryModel } from "./types" + +const get = useEntryStore.getState + +export const getEntry = (id: string): EntryModel | undefined => { + return get().data[id] +} diff --git a/apps/mobile/src/store/entry/hooks.ts b/apps/mobile/src/store/entry/hooks.ts new file mode 100644 index 000000000..c324f79a0 --- /dev/null +++ b/apps/mobile/src/store/entry/hooks.ts @@ -0,0 +1,78 @@ +import type { FeedViewType } from "@follow/constants" +import { useQuery } from "@tanstack/react-query" +import { useCallback } from "react" + +import { getEntry } from "./getter" +import { entrySyncServices, useEntryStore } from "./store" +import type { EntryModel, FetchEntriesProps } from "./types" + +export const usePrefetchEntries = (props: FetchEntriesProps) => { + const { feedId, inboxId, listId, view, read, limit, pageParam, isArchived } = props + return useQuery({ + queryKey: ["entries", feedId, inboxId, listId, view, read, limit, pageParam, isArchived], + queryFn: () => entrySyncServices.fetchEntries(props), + }) +} + +export const useEntry = (id: string): EntryModel | undefined => { + return useEntryStore((state) => state.data[id]) +} + +function sortEntryIdsByPublishDate(a: string, b: string) { + const entryA = getEntry(a) + const entryB = getEntry(b) + if (!entryA || !entryB) return 0 + return entryB.publishedAt.getTime() - entryA.publishedAt.getTime() +} + +export const useEntryIdsByView = (view: FeedViewType) => { + return useEntryStore( + useCallback( + (state) => { + const ids = state.entryIdByView[view] + if (!ids) return [] + return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b)) + }, + [view], + ), + ) +} + +export const useEntryIdsByFeedId = (feedId: string) => { + return useEntryStore( + useCallback( + (state) => { + const ids = state.entryIdByFeed[feedId] + if (!ids) return [] + return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b)) + }, + [feedId], + ), + ) +} + +export const useEntryIdsByInboxId = (inboxId: string) => { + return useEntryStore( + useCallback( + (state) => { + const ids = state.entryIdByInbox[inboxId] + if (!ids) return [] + return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b)) + }, + [inboxId], + ), + ) +} + +export const useEntryIdsByCategory = (category: string) => { + return useEntryStore( + useCallback( + (state) => { + const ids = state.entryIdByCategory[category] + if (!ids) return [] + return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b)) + }, + [category], + ), + ) +} diff --git a/apps/mobile/src/store/entry/store.ts b/apps/mobile/src/store/entry/store.ts new file mode 100644 index 000000000..41a8d01b7 --- /dev/null +++ b/apps/mobile/src/store/entry/store.ts @@ -0,0 +1,174 @@ +import { FeedViewType } from "@follow/constants" + +import { apiClient } from "@/src/lib/api-fetch" +import { honoMorph } from "@/src/morph/hono" +import { storeDbMorph } from "@/src/morph/store-db" +import { EntryService } from "@/src/services/entry" + +import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper" +import { listActions } from "../list/store" +import { getSubscription } from "../subscription/getter" +import type { EntryModel, FetchEntriesProps } from "./types" +import { getEntriesParams } from "./utils" + +type EntryId = string +type FeedId = string +type InboxId = string +type Category = string + +interface EntryState { + data: Record + entryIdByView: Record> + entryIdByCategory: Record> + entryIdByFeed: Record> + entryIdByInbox: Record> +} + +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: {}, +} + +export const useEntryStore = createZustandStore("entry")(() => defaultState) + +const set = useEntryStore.setState +const immerSet = createImmerSetter(useEntryStore) + +class EntryActions { + 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) + } + + const subscription = getSubscription(feedId) + if (subscription?.view) { + draft.entryIdByView[subscription.view].add(entryId) + } + if (subscription?.category) { + const entryIdSetByCategory = draft.entryIdByCategory[subscription.category] + if (!entryIdSetByCategory) { + draft.entryIdByCategory[subscription.category] = new Set([entryId]) + } else { + entryIdSetByCategory.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) + } + } + + upsertManyInSession(entries: EntryModel[]) { + if (entries.length === 0) return + + immerSet((draft) => { + for (const entry of entries) { + draft.data[entry.id] = entry + + const { feedId, inboxHandle } = entry + this.addEntryIdToFeed({ + draft, + feedId, + entryId: entry.id, + }) + + this.addEntryIdToInbox({ + draft, + inboxHandle, + 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() + } + + reset() { + set(defaultState) + } +} + +class EntrySyncServices { + async fetchEntries(props: FetchEntriesProps) { + const { feedId, inboxId, listId, view, read, limit, pageParam, isArchived } = props + const params = getEntriesParams({ + feedId, + inboxId, + listId, + view, + }) + const res = await apiClient.entries.$post({ + json: { + publishedAfter: pageParam, + read, + limit, + isArchived, + ...params, + }, + }) + + if (!pageParam) { + entryActions.reset() + } + + const entries = honoMorph.toEntry(res.data) + await entryActions.upsertMany(entries) + if (params.listId) { + await listActions.addEntryIds({ + listId: params.listId, + entryIds: entries.map((e) => e.id), + }) + } + return entries + } +} + +export const entrySyncServices = new EntrySyncServices() +export const entryActions = new EntryActions() diff --git a/apps/mobile/src/store/entry/types.ts b/apps/mobile/src/store/entry/types.ts new file mode 100644 index 000000000..30c7eb98e --- /dev/null +++ b/apps/mobile/src/store/entry/types.ts @@ -0,0 +1,13 @@ +import type { EntrySchema } from "@/src/database/schemas/types" + +export type EntryModel = EntrySchema +export type FetchEntriesProps = { + feedId?: number | string + inboxId?: number | string + listId?: number | string + view?: number + read?: boolean + limit?: number + pageParam?: string + isArchived?: boolean +} diff --git a/apps/mobile/src/store/entry/utils.ts b/apps/mobile/src/store/entry/utils.ts new file mode 100644 index 000000000..a4fefeea5 --- /dev/null +++ b/apps/mobile/src/store/entry/utils.ts @@ -0,0 +1,56 @@ +import { FeedViewType } from "@follow/constants" + +/// Feed +export const FEED_COLLECTION_LIST = "collections" + +/// Route Keys +export const ROUTE_FEED_PENDING = "all" +export const ROUTE_ENTRY_PENDING = "pending" +export const ROUTE_FEED_IN_FOLDER = "folder-" +export const ROUTE_FEED_IN_LIST = "list-" +export const ROUTE_FEED_IN_INBOX = "inbox-" + +export const INBOX_PREFIX_ID = "inbox-" + +export function getEntriesParams({ + feedId, + inboxId, + listId, + view, +}: { + feedId?: number | string + inboxId?: number | string + listId?: number | string + view?: number +}) { + const params: { + feedId?: string + feedIdList?: string[] + isCollection?: boolean + withContent?: boolean + inboxId?: string + listId?: string + } = {} + if (inboxId) { + params.inboxId = `${inboxId}` + } else if (listId) { + params.listId = `${listId}` + } else if (feedId) { + if (feedId === FEED_COLLECTION_LIST) { + params.isCollection = true + } else if (feedId !== ROUTE_FEED_PENDING) { + if (feedId.toString().includes(",")) { + params.feedIdList = `${feedId}`.split(",") + } else { + params.feedId = `${feedId}` + } + } + } + if (view === FeedViewType.SocialMedia) { + params.withContent = true + } + return { + view, + ...params, + } +} diff --git a/apps/mobile/src/store/list/hooks.ts b/apps/mobile/src/store/list/hooks.ts index f86dd3bad..630e2df7b 100644 --- a/apps/mobile/src/store/list/hooks.ts +++ b/apps/mobile/src/store/list/hooks.ts @@ -12,3 +12,9 @@ export const useIsOwnList = (id: string) => { return state.lists[id]?.userId === whoami()?.id }) } + +export const useListEntryIds = (id: string) => { + return useListStore((state) => { + return state.lists[id]?.entryIds || [] + }) +} diff --git a/apps/mobile/src/store/list/store.ts b/apps/mobile/src/store/list/store.ts index 8390d0f16..35563b1ab 100644 --- a/apps/mobile/src/store/list/store.ts +++ b/apps/mobile/src/store/list/store.ts @@ -45,6 +45,34 @@ class ListActions { }) tx.run() } + + addEntryIdsInSession(params: { listId: string; entryIds: string[] }) { + const state = get() + const list = state.lists[params.listId] + + if (!list) return + + set({ + ...state, + lists: { + ...state.lists, + [params.listId]: { ...list, feedIds: [...list.feedIds, ...params.entryIds] }, + }, + }) + } + + async addEntryIds(params: { listId: string; entryIds: string[] }) { + const tx = createTransaction() + tx.store(() => { + this.addEntryIdsInSession(params) + }) + + tx.persist(() => { + return ListService.addEntryIds(params) + }) + await tx.run() + } + reset() { set(defaultState) } diff --git a/apps/mobile/src/store/subscription/store.ts b/apps/mobile/src/store/subscription/store.ts index 470337c87..7e2c3366d 100644 --- a/apps/mobile/src/store/subscription/store.ts +++ b/apps/mobile/src/store/subscription/store.ts @@ -211,6 +211,7 @@ class SubscriptionSyncService { { ...data.list, userId: data.list.ownerUserId, + entryIds: [], }, ]) }