feat: add collections table and implement collection management services (#2718)

* feat: add collections table and implement collection management services

* chore: fix typo in star entry hook and add toast for star

* chore: generate drizzle migration script

* chore: clean code
This commit is contained in:
Whitewater 2025-02-11 17:57:05 +08:00 committed by GitHub
parent 43b49d8619
commit 2f1d0485f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 790 additions and 3 deletions

View File

@ -0,0 +1,6 @@
CREATE TABLE `collections` (
`feed_id` text,
`entry_id` text PRIMARY KEY NOT NULL,
`created_at` text,
`view` integer NOT NULL
);

View File

@ -0,0 +1,539 @@
{
"version": "6",
"dialect": "sqlite",
"id": "25bbf165-d945-4ec7-9571-b7bd36a7b9b5",
"prevId": "0d5c6f89-f31c-405c-8c2d-7b59ab03a275",
"tables": {
"collections": {
"name": "collections",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"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": {}
}
}

View File

@ -57,6 +57,13 @@
"when": 1737108493439,
"tag": "0007_curvy_tarantula",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1739265018895,
"tag": "0008_last_the_santerians",
"breakpoints": true
}
]
}

View File

@ -8,6 +8,7 @@ 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 m0008 from "./0008_last_the_santerians.sql"
import journal from "./meta/_journal.json"
export default {
@ -21,5 +22,6 @@ export default {
m0005,
m0006,
m0007,
m0008,
},
}

View File

@ -84,3 +84,10 @@ export const entriesTable = sqliteTable("entries", {
inboxHandle: text("inbox_handle"),
read: integer("read", { mode: "boolean" }),
})
export const collectionsTable = sqliteTable("collections", {
feedId: text("feed_id"),
entryId: text("entry_id").notNull().primaryKey(),
createdAt: text("created_at"),
view: integer("view").notNull().$type<FeedViewType>(),
})

View File

@ -1,4 +1,5 @@
import type {
collectionsTable,
entriesTable,
feedsTable,
inboxesTable,
@ -22,6 +23,8 @@ export type UserSchema = typeof usersTable.$inferSelect
export type EntrySchema = typeof entriesTable.$inferSelect
export type CollectionSchema = typeof collectionsTable.$inferSelect
export type MediaModel = {
url: string
type: "photo" | "video"

View File

@ -10,11 +10,15 @@ import {
setWebViewEntry,
} from "@/src/components/native/webview/EntryContentWebView"
import { openLink } from "@/src/lib/native"
import { toast } from "@/src/lib/toast"
import { useIsEntryStarred } from "@/src/store/collection/hooks"
import { collectionSyncService } from "@/src/store/collection/store"
import { useEntry } from "@/src/store/entry/hooks"
import { unreadSyncService } from "@/src/store/unread/store"
export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: string }>) => {
const entry = useEntry(id)
const isEntryStarred = useIsEntryStarred(id)
const handlePressPreview = useCallback(() => {
if (!entry) return
@ -51,8 +55,33 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s
<ContextMenu.ItemTitle>Mark As Read</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item key="Star">
<ContextMenu.ItemTitle>Star</ContextMenu.ItemTitle>
<ContextMenu.Item
key="Star"
onSelect={() => {
if (isEntryStarred) {
collectionSyncService.unstarEntry({
createdAt: new Date().toISOString(),
feedId: entry.feedId,
entryId: id,
view: 0,
})
toast.info("Unstarred")
} else {
collectionSyncService.starEntry(
{
createdAt: new Date().toISOString(),
feedId: entry.feedId,
entryId: id,
// TODO update view
view: 0,
},
0,
)
toast.info("Starred")
}
}}
>
<ContextMenu.ItemTitle>{isEntryStarred ? "Unstar" : "Star"}</ContextMenu.ItemTitle>
</ContextMenu.Item>
{entry.url && (

View File

@ -1,4 +1,7 @@
import type { FeedViewType } from "@follow/constants"
import type { FeedSchema, InboxSchema } from "../database/schemas/types"
import type { CollectionModel } from "../store/collection/types"
import type { EntryModel } from "../store/entry/types"
import type { FeedModel } from "../store/feed/types"
import type { ListModel } from "../store/list/store"
@ -131,6 +134,23 @@ class Morph {
return entries
}
toCollections(data: HonoApiClient.Entry_Post, view: FeedViewType): CollectionModel[] {
if (!data) return [] satisfies CollectionModel[]
return data
.map((item) => {
if (!item.collections) {
return null
}
return {
createdAt: item.collections.createdAt,
entryId: item.entries.id,
feedId: item.feeds.id,
view,
} satisfies CollectionModel
})
.filter((i) => i !== null)
}
toEntry(data?: HonoApiClient.Entry_Get): EntryModel | null {
if (!data) return null

View File

@ -0,0 +1,40 @@
import { eq, inArray } from "drizzle-orm"
import { db } from "../database"
import { collectionsTable } from "../database/schemas"
import type { CollectionSchema } from "../database/schemas/types"
import { collectionActions } from "../store/collection/store"
import type { Hydratable, Resetable } from "./internal/base"
import { conflictUpdateAllExcept } from "./internal/utils"
class CollectionServiceStatic implements Hydratable, Resetable {
async reset() {
await db.delete(collectionsTable).execute()
}
async upsertMany(collections: CollectionSchema[]) {
if (collections.length === 0) return
await db
.insert(collectionsTable)
.values(collections)
.onConflictDoUpdate({
target: [collectionsTable.entryId],
set: conflictUpdateAllExcept(collectionsTable, ["entryId"]),
})
}
async delete(entryId: string) {
await db.delete(collectionsTable).where(eq(collectionsTable.entryId, entryId))
}
getCollectionMany(entryId: string[]) {
return db.query.collectionsTable.findMany({ where: inArray(collectionsTable.entryId, entryId) })
}
async hydrate() {
const collections = await db.query.collectionsTable.findMany()
collectionActions.upsertManyInSession(collections)
}
}
export const CollectionService = new CollectionServiceStatic()

View File

@ -1,3 +1,4 @@
import { CollectionService } from "./collection"
import { EntryService } from "./entry"
import { FeedService } from "./feed"
import { InboxService } from "./inbox"
@ -15,6 +16,7 @@ const hydrates: Hydratable[] = [
UnreadService,
UserService,
EntryService,
CollectionService,
]
export const hydrateDatabaseToStore = async () => {

View File

@ -0,0 +1,13 @@
import { useCollectionStore } from "./store"
export const useCollectionEntry = (entryId: string) => {
return useCollectionStore((state) => {
return state.collections[entryId]
})
}
export const useIsEntryStarred = (entryId: string) => {
return useCollectionStore((state) => {
return !!state.collections[entryId]
})
}

View File

@ -0,0 +1,104 @@
import type { FeedViewType } from "@follow/constants"
import type { CollectionSchema } from "@/src/database/schemas/types"
import { apiClient } from "@/src/lib/api-fetch"
import { CollectionService } from "@/src/services/collection"
import { createTransaction, createZustandStore } from "../internal/helper"
interface CollectionState {
collections: Record<string, CollectionSchema>
}
const defaultState = {
collections: {},
}
export const useCollectionStore = createZustandStore<CollectionState>("collection")(
() => defaultState,
)
// const get = useCollectionStore.getState
const set = useCollectionStore.setState
class CollectionSyncService {
async starEntry(collection: CollectionSchema, view: FeedViewType) {
await apiClient.collections.$post({
json: {
entryId: collection.entryId,
view,
},
})
await collectionActions.upsertMany([collection])
return
}
async unstarEntry(collection: CollectionSchema) {
await apiClient.collections.$delete({
json: {
entryId: collection.entryId,
},
})
await collectionActions.delete(collection.entryId)
}
}
class CollectionActions {
async upsertManyInSession(collections: CollectionSchema[]) {
const state = useCollectionStore.getState()
const nextCollections: CollectionState["collections"] = {
...state.collections,
}
collections.forEach((collection) => {
if (!collection.entryId) return
nextCollections[collection.entryId] = collection
})
set({
...state,
collections: nextCollections,
})
}
async upsertMany(collections: CollectionSchema[]) {
const tx = createTransaction()
tx.store(() => {
this.upsertManyInSession(collections)
})
tx.persist(() => {
return CollectionService.upsertMany(collections)
})
tx.run()
}
async deleteInSession(entryId: string) {
const state = useCollectionStore.getState()
const nextCollections: CollectionState["collections"] = {
...state.collections,
}
delete nextCollections[entryId]
set({
...state,
collections: nextCollections,
})
}
async delete(entryId: string) {
const tx = createTransaction()
tx.store(() => {
this.deleteInSession(entryId)
})
tx.persist(() => {
return CollectionService.delete(entryId)
})
tx.run()
}
reset() {
set(defaultState)
}
}
export const collectionActions = new CollectionActions()
export const collectionSyncService = new CollectionSyncService()

View File

@ -0,0 +1,3 @@
import type { CollectionSchema } from "@/src/database/schemas/types"
export type CollectionModel = CollectionSchema

View File

@ -8,6 +8,7 @@ import { honoMorph } from "@/src/morph/hono"
import { storeDbMorph } from "@/src/morph/store-db"
import { EntryService } from "@/src/services/entry"
import { collectionActions } from "../collection/store"
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
import { listActions } from "../list/store"
import { getSubscription } from "../subscription/getter"
@ -169,7 +170,8 @@ class EntryActions {
class EntrySyncServices {
async fetchEntries(props: FetchEntriesProps) {
const { feedId, inboxId, listId, view, read, limit, pageParam, isArchived } = props
const { feedId, inboxId, listId, view, read, limit, pageParam, isArchived, isCollection } =
props
const params = getEntriesParams({
feedId,
inboxId,
@ -182,6 +184,7 @@ class EntrySyncServices {
read,
limit,
isArchived,
isCollection,
...params,
},
})
@ -217,6 +220,14 @@ class EntrySyncServices {
}
}
if (isCollection && res.data) {
if (!view) {
console.error("view is required for collection")
}
const collections = honoMorph.toCollections(res.data, view ?? 0)
await collectionActions.upsertMany(collections)
}
return res
}

View File

@ -10,4 +10,5 @@ export type FetchEntriesProps = {
limit?: number
pageParam?: string
isArchived?: boolean
isCollection?: boolean
}