feat(mobile): list and inbox (#2969)
* feat(mobile): bring list and inbox back * navigate to list * inbox support * fix: query entries * use proxy image * fix list and inbox state * estimatedItemSize * update * update * update * fix list unread count * unread count component * update * fix unread count by view
This commit is contained in:
parent
0be6255d0b
commit
7180e00d19
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `lists` DROP COLUMN `entry_ids`;
|
||||
|
|
@ -0,0 +1,615 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "95c0e828-629d-42da-8708-93156bde1199",
|
||||
"prevId": "9859ecf0-4bcb-4d5a-90f0-66ceb1c133a5",
|
||||
"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
|
||||
},
|
||||
"sources": {
|
||||
"name": "sources",
|
||||
"type": "text",
|
||||
"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": {}
|
||||
},
|
||||
"images": {
|
||||
"name": "images",
|
||||
"columns": {
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"colors": {
|
||||
"name": "colors",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"summaries": {
|
||||
"name": "summaries",
|
||||
"columns": {
|
||||
"entry_id": {
|
||||
"name": "entry_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"language": {
|
||||
"name": "language",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"unq": {
|
||||
"name": "unq",
|
||||
"columns": ["entry_id", "language"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": false,
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,13 @@
|
|||
"when": 1741017801271,
|
||||
"tag": "0013_chunky_stephen_strange",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1741168250902,
|
||||
"tag": "0014_chemical_shocker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import m0010 from "./0010_legal_ben_grimm.sql"
|
|||
import m0011 from "./0011_mysterious_stark_industries.sql"
|
||||
import m0012 from "./0012_magenta_thing.sql"
|
||||
import m0013 from "./0013_chunky_stephen_strange.sql"
|
||||
import m0014 from "./0014_chemical_shocker.sql"
|
||||
import journal from "./meta/_journal.json"
|
||||
|
||||
export default {
|
||||
|
|
@ -33,5 +34,6 @@ export default {
|
|||
m0011,
|
||||
m0012,
|
||||
m0013,
|
||||
m0014,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export const listsTable = sqliteTable("lists", {
|
|||
image: text("image"),
|
||||
fee: integer("fee"),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
entryIds: text("entry_ids", { mode: "json" }).$type<string[]>(),
|
||||
})
|
||||
|
||||
export const unreadTable = sqliteTable("unread", {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import { Pressable, View } from "react-native"
|
|||
|
||||
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
|
||||
import { apiClient } from "@/src/lib/api-fetch"
|
||||
import { useEntry } from "@/src/store/entry/hooks"
|
||||
import { isInboxEntry } from "@/src/store/entry/utils"
|
||||
import { userActions } from "@/src/store/user/store"
|
||||
|
||||
export const EntryReadHistory = ({ entryId }: { entryId: string }) => {
|
||||
const entry = useEntry(entryId)
|
||||
const { data } = useQuery({
|
||||
queryKey: ["entry-read-history", entryId],
|
||||
queryFn: () => {
|
||||
|
|
@ -20,6 +23,7 @@ export const EntryReadHistory = ({ entryId }: { entryId: string }) => {
|
|||
})
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled: !isInboxEntry(entry),
|
||||
})
|
||||
if (!data?.data.entryReadHistories) return null
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import {
|
|||
useEntryIdsByCategory,
|
||||
useEntryIdsByFeedId,
|
||||
useEntryIdsByInboxId,
|
||||
useEntryIdsByListId,
|
||||
useEntryIdsByView,
|
||||
} from "@/src/store/entry/hooks"
|
||||
import { useListEntryIds } from "@/src/store/list/hooks"
|
||||
import { useViewWithSubscription } from "@/src/store/subscription/hooks"
|
||||
|
||||
import { TimelineSelectorProvider } from "../screen/TimelineSelectorProvider"
|
||||
|
|
@ -106,7 +106,7 @@ function CategoryEntryList({ categoryName }: { categoryName: string }) {
|
|||
|
||||
function ListEntryList({ listId }: { listId: string }) {
|
||||
const view = useSelectedView() ?? FeedViewType.Articles
|
||||
const entryIds = useListEntryIds(listId)
|
||||
const entryIds = useEntryIdsByListId(listId)
|
||||
if (!entryIds) return null
|
||||
return <EntryListSelector entryIds={entryIds} viewId={view} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { PauseCuteFiIcon } from "@/src/icons/pause_cute_fi"
|
|||
import { PlayCuteFiIcon } from "@/src/icons/play_cute_fi"
|
||||
import { getAttachmentState, player } from "@/src/lib/player"
|
||||
import { useEntry } from "@/src/store/entry/hooks"
|
||||
import { getInboxFrom } from "@/src/store/entry/utils"
|
||||
import { useFeed } from "@/src/store/feed/hooks"
|
||||
|
||||
import { EntryItemContextMenu } from "../../context-menu/entry"
|
||||
|
|
@ -24,6 +25,7 @@ import { useEntryListContextView } from "../EntryListContext"
|
|||
|
||||
export function EntryNormalItem({ entryId, extraData }: { entryId: string; extraData: string }) {
|
||||
const entry = useEntry(entryId)
|
||||
const from = getInboxFrom(entry)
|
||||
const feed = useFeed(entry?.feedId as string)
|
||||
const view = useEntryListContextView()
|
||||
|
||||
|
|
@ -84,7 +86,7 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra
|
|||
<View className="mb-1 flex-1 flex-row items-center gap-1.5 pr-2">
|
||||
<FeedIcon fallback feed={feed} size={16} />
|
||||
<Text numberOfLines={1} className="text-secondary-label shrink text-sm font-medium">
|
||||
{feed?.title ?? "Unknown feed"}
|
||||
{feed?.title || from || "Unknown feed"}
|
||||
</Text>
|
||||
<Text className="text-secondary-label text-xs font-medium">·</Text>
|
||||
{estimatedMins ? (
|
||||
|
|
|
|||
|
|
@ -44,15 +44,10 @@ export function useSetDrawerSwipeDisabled() {
|
|||
|
||||
// collection panel selected state
|
||||
|
||||
type CollectionPanelState =
|
||||
| {
|
||||
type: "view"
|
||||
viewId: FeedViewType
|
||||
}
|
||||
| {
|
||||
type: "list"
|
||||
listId: string
|
||||
}
|
||||
type CollectionPanelState = {
|
||||
type: "view"
|
||||
viewId: FeedViewType
|
||||
}
|
||||
|
||||
const collectionPanelStateAtom = atom<CollectionPanelState>({
|
||||
type: "view",
|
||||
|
|
@ -71,19 +66,10 @@ export const selectCollection = (state: CollectionPanelState) => {
|
|||
|
||||
// feed panel selected state
|
||||
|
||||
export type SelectedTimeline =
|
||||
| {
|
||||
type: "view"
|
||||
viewId: FeedViewType
|
||||
}
|
||||
| {
|
||||
type: "list"
|
||||
listId: string
|
||||
}
|
||||
| {
|
||||
type: "inbox"
|
||||
inboxId: string
|
||||
}
|
||||
export type SelectedTimeline = {
|
||||
type: "view"
|
||||
viewId: FeedViewType
|
||||
}
|
||||
|
||||
export type SelectedFeed =
|
||||
| {
|
||||
|
|
@ -94,6 +80,14 @@ export type SelectedFeed =
|
|||
type: "category"
|
||||
categoryName: string
|
||||
}
|
||||
| {
|
||||
type: "list"
|
||||
listId: string
|
||||
}
|
||||
| {
|
||||
type: "inbox"
|
||||
inboxId: string
|
||||
}
|
||||
| null
|
||||
|
||||
const selectedTimelineAtom = atom<SelectedTimeline>({
|
||||
|
|
@ -114,7 +108,7 @@ export function useSelectedView() {
|
|||
const selectedTimeLine = useAtomValue(selectedTimelineAtom)
|
||||
const selectedFeed = useAtomValue(selectedFeedAtom)
|
||||
|
||||
const list = useList(selectedTimeLine.type === "list" ? selectedTimeLine.listId : "")
|
||||
const list = useList(selectedFeed?.type === "list" ? selectedFeed.listId : "")
|
||||
const subscription = useSubscription(
|
||||
selectedFeed && selectedFeed.type === "feed" ? selectedFeed.feedId : "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,13 +13,18 @@ import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList"
|
|||
import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils"
|
||||
import {
|
||||
useGroupedSubscription,
|
||||
useInboxSubscription,
|
||||
useListSubscription,
|
||||
useSortedGroupedSubscription,
|
||||
useSortedListSubscription,
|
||||
useSortedUngroupedSubscription,
|
||||
} from "@/src/store/subscription/hooks"
|
||||
import { subscriptionSyncService } from "@/src/store/subscription/store"
|
||||
|
||||
import { useFeedListSortMethod, useFeedListSortOrder } from "./atoms"
|
||||
import { CategoryGrouped } from "./CategoryGrouped"
|
||||
import { InboxItem } from "./items/InboxItem"
|
||||
import { ListSubscriptionItem } from "./items/ListSubscriptionItem"
|
||||
import { SubscriptionItem } from "./items/SubscriptionItem"
|
||||
import { ItemSeparator } from "./ItemSeparator"
|
||||
|
||||
|
|
@ -31,17 +36,46 @@ const keyExtractor = (item: string | { category: string; subscriptionIds: string
|
|||
}
|
||||
|
||||
export const SubscriptionList = ({ view }: { view: FeedViewType }) => {
|
||||
const listIds = useListSubscription(view)
|
||||
const sortedListIds = useSortedListSubscription(listIds, "alphabet")
|
||||
|
||||
const inboxes = useInboxSubscription(view)
|
||||
|
||||
const { grouped, unGrouped } = useGroupedSubscription(view)
|
||||
|
||||
const sortBy = useFeedListSortMethod()
|
||||
const sortOrder = useFeedListSortOrder()
|
||||
const sortedGrouped = useSortedGroupedSubscription(grouped, sortBy, sortOrder)
|
||||
const sortedUnGrouped = useSortedUngroupedSubscription(unGrouped, sortBy, sortOrder)
|
||||
|
||||
const data = useMemo(
|
||||
() => [...sortedGrouped, ...sortedUnGrouped],
|
||||
[sortedGrouped, sortedUnGrouped],
|
||||
() => [
|
||||
"Starred",
|
||||
"Lists",
|
||||
...sortedListIds,
|
||||
"Inbox",
|
||||
...inboxes,
|
||||
"Feeds",
|
||||
...sortedGrouped,
|
||||
...sortedUnGrouped,
|
||||
],
|
||||
[inboxes, sortedListIds, sortedGrouped, sortedUnGrouped],
|
||||
)
|
||||
|
||||
const extraData = useMemo(() => {
|
||||
const listsIndexStart = 2
|
||||
const listsIndexEnd = listsIndexStart + sortedListIds.length - 1
|
||||
const inboxIndexStart = listsIndexEnd + 2
|
||||
const inboxIndexEnd = inboxIndexStart + inboxes.length - 1
|
||||
const feedsIndexStart = inboxIndexEnd + 2
|
||||
const feedsIndexEnd = feedsIndexStart + sortedGrouped.length + sortedUnGrouped.length - 1
|
||||
return {
|
||||
inboxIndexRange: [inboxIndexStart, inboxIndexEnd],
|
||||
feedsIndexRange: [feedsIndexStart, feedsIndexEnd],
|
||||
listsIndexRange: [listsIndexStart, listsIndexEnd],
|
||||
}
|
||||
}, [inboxes.length, sortedGrouped.length, sortedListIds.length, sortedUnGrouped.length])
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const onRefresh = useEventCallback(() => {
|
||||
return subscriptionSyncService.fetch(view)
|
||||
|
|
@ -60,15 +94,12 @@ export const SubscriptionList = ({ view }: { view: FeedViewType }) => {
|
|||
}}
|
||||
className="bg-system-grouped-background"
|
||||
isRefetching={refreshing}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
data={data}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
estimatedItemSize={50}
|
||||
renderItem={ItemRender}
|
||||
keyExtractor={keyExtractor}
|
||||
// itemLayoutAnimation={LinearTransition}
|
||||
extraData={{
|
||||
total: data.length,
|
||||
}}
|
||||
extraData={extraData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -81,31 +112,84 @@ const ItemRender = ({
|
|||
item: string | { category: string; subscriptionIds: string[] }
|
||||
index: number
|
||||
extraData?: {
|
||||
total: number
|
||||
inboxIndexRange: [number, number]
|
||||
feedsIndexRange: [number, number]
|
||||
listsIndexRange: [number, number]
|
||||
}
|
||||
}) => {
|
||||
if (typeof item === "string") {
|
||||
return (
|
||||
<SubscriptionItem
|
||||
id={item}
|
||||
className={extraData && index === extraData.total - 1 ? "border-b-transparent" : ""}
|
||||
/>
|
||||
)
|
||||
switch (item) {
|
||||
case "Starred": {
|
||||
return <StarItem />
|
||||
}
|
||||
case "Inbox": {
|
||||
if (!extraData) return null
|
||||
const { inboxIndexRange } = extraData
|
||||
if (inboxIndexRange[0] > inboxIndexRange[1]) return null
|
||||
return <SectionTitle title={item} />
|
||||
}
|
||||
case "Lists": {
|
||||
if (!extraData) return null
|
||||
const { listsIndexRange } = extraData
|
||||
if (listsIndexRange[0] > listsIndexRange[1]) return null
|
||||
return <SectionTitle title={item} />
|
||||
}
|
||||
case "Feeds": {
|
||||
if (!extraData) return null
|
||||
const { feedsIndexRange } = extraData
|
||||
if (feedsIndexRange[0] > feedsIndexRange[1]) return null
|
||||
return <SectionTitle title={item} />
|
||||
}
|
||||
default: {
|
||||
if (!extraData) return null
|
||||
const { inboxIndexRange, feedsIndexRange, listsIndexRange } = extraData
|
||||
|
||||
if (listsIndexRange[0] <= index && index <= listsIndexRange[1]) {
|
||||
return (
|
||||
<>
|
||||
<ListSubscriptionItem id={item} />
|
||||
{index !== listsIndexRange[1] ? <ItemSeparator /> : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (inboxIndexRange[0] <= index && index <= inboxIndexRange[1]) {
|
||||
return (
|
||||
<>
|
||||
<InboxItem id={item} />
|
||||
{index !== inboxIndexRange[1] ? <ItemSeparator /> : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (feedsIndexRange[0] <= index && index <= feedsIndexRange[1]) {
|
||||
return (
|
||||
<>
|
||||
<SubscriptionItem id={item} />
|
||||
{index !== feedsIndexRange[1] ? <ItemSeparator /> : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { category, subscriptionIds } = item
|
||||
|
||||
return <CategoryGrouped category={category} subscriptionIds={subscriptionIds} />
|
||||
}
|
||||
|
||||
const ListHeaderComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<StarItem />
|
||||
<Text className="text-gray mb-2 ml-3 mt-4 text-sm font-semibold">Feeds</Text>
|
||||
<CategoryGrouped category={category} subscriptionIds={subscriptionIds} />
|
||||
{extraData && index !== extraData.feedsIndexRange[1] ? <ItemSeparator /> : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SectionTitle = ({ title }: { title: string }) => {
|
||||
return <Text className="text-gray mb-2 ml-3 mt-4 text-sm font-semibold">{title}</Text>
|
||||
}
|
||||
|
||||
const StarItem = () => {
|
||||
return (
|
||||
<ItemPressable
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { router } from "expo-router"
|
||||
import { useColorScheme } from "nativewind"
|
||||
import { memo } from "react"
|
||||
import { Text, View } from "react-native"
|
||||
|
|
@ -5,11 +6,13 @@ import Animated, { FadeOutUp } from "react-native-reanimated"
|
|||
|
||||
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
|
||||
import { InboxCuteFiIcon } from "@/src/icons/inbox_cute_fi"
|
||||
import { closeDrawer, selectTimeline } from "@/src/modules/screen/atoms"
|
||||
import { selectFeed } from "@/src/modules/screen/atoms"
|
||||
import { useSubscription } from "@/src/store/subscription/hooks"
|
||||
import { getInboxStoreId } from "@/src/store/subscription/utils"
|
||||
import { useUnreadCount } from "@/src/store/unread/hooks"
|
||||
|
||||
import { UnreadCount } from "./UnreadCount"
|
||||
|
||||
export const InboxItem = memo(({ id }: { id: string }) => {
|
||||
const subscription = useSubscription(getInboxStoreId(id))
|
||||
const unreadCount = useUnreadCount(id)
|
||||
|
|
@ -20,8 +23,8 @@ export const InboxItem = memo(({ id }: { id: string }) => {
|
|||
<ItemPressable
|
||||
className="h-12 flex-row items-center px-3"
|
||||
onPress={() => {
|
||||
selectTimeline({ type: "inbox", inboxId: id })
|
||||
closeDrawer()
|
||||
selectFeed({ type: "inbox", inboxId: id })
|
||||
router.push(`/feeds/${id}`)
|
||||
}}
|
||||
>
|
||||
<View className="ml-0.5 overflow-hidden rounded">
|
||||
|
|
@ -33,9 +36,7 @@ export const InboxItem = memo(({ id }: { id: string }) => {
|
|||
</View>
|
||||
|
||||
<Text className="text-text ml-2.5">{subscription.title}</Text>
|
||||
{!!unreadCount && (
|
||||
<Text className="text-tertiary-label ml-auto text-xs">{unreadCount}</Text>
|
||||
)}
|
||||
<UnreadCount unread={unreadCount} className="ml-auto" />
|
||||
</ItemPressable>
|
||||
</Animated.View>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
import { router } from "expo-router"
|
||||
import { memo } from "react"
|
||||
import { Image, Text, View } from "react-native"
|
||||
import { Text, View } from "react-native"
|
||||
import Animated, { FadeOutUp } from "react-native-reanimated"
|
||||
|
||||
import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon"
|
||||
import { ProxiedImage } from "@/src/components/ui/image/ProxiedImage"
|
||||
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
|
||||
import { closeDrawer, selectTimeline } from "@/src/modules/screen/atoms"
|
||||
import { useList } from "@/src/store/list/hooks"
|
||||
import { useUnreadCount } from "@/src/store/unread/hooks"
|
||||
import { useListUnreadCount } from "@/src/store/unread/hooks"
|
||||
|
||||
import { SubscriptionListItemContextMenu } from "../../context-menu/lists"
|
||||
import { selectFeed } from "../../screen/atoms"
|
||||
import { UnreadCount } from "./UnreadCount"
|
||||
|
||||
export const ListSubscriptionItem = memo(({ id }: { id: string; className?: string }) => {
|
||||
const list = useList(id)
|
||||
const unreadCount = useUnreadCount(id)
|
||||
const unreadCount = useListUnreadCount(id)
|
||||
if (!list) return null
|
||||
return (
|
||||
<Animated.View exiting={FadeOutUp}>
|
||||
|
|
@ -20,22 +23,29 @@ export const ListSubscriptionItem = memo(({ id }: { id: string; className?: stri
|
|||
<ItemPressable
|
||||
className="h-12 flex-row items-center px-3"
|
||||
onPress={() => {
|
||||
selectTimeline({
|
||||
selectFeed({
|
||||
type: "list",
|
||||
listId: id,
|
||||
})
|
||||
closeDrawer()
|
||||
router.push(`/feeds/${id}`)
|
||||
}}
|
||||
>
|
||||
<View className="overflow-hidden rounded">
|
||||
{!!list.image && (
|
||||
<Image source={{ uri: list.image, width: 24, height: 24 }} resizeMode="cover" />
|
||||
<ProxiedImage
|
||||
proxy={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
}}
|
||||
style={{ height: 20, width: 20 }}
|
||||
source={list.image}
|
||||
/>
|
||||
)}
|
||||
{!list.image && <FallbackIcon title={list.title} size={24} />}
|
||||
{!list.image && <FallbackIcon title={list.title} size={20} />}
|
||||
</View>
|
||||
|
||||
<Text className="text-text ml-2">{list.title}</Text>
|
||||
{!!unreadCount && <View className="bg-tertiary-label ml-auto size-1 rounded-full" />}
|
||||
<UnreadCount unread={unreadCount} className="ml-auto" />
|
||||
</ItemPressable>
|
||||
</SubscriptionListItemContextMenu>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useUnreadCount } from "@/src/store/unread/hooks"
|
|||
|
||||
import { SubscriptionFeedItemContextMenu } from "../../context-menu/feeds"
|
||||
import { GroupedContext } from "../ctx"
|
||||
import { UnreadCount } from "./UnreadCount"
|
||||
|
||||
// const renderRightActions = () => {
|
||||
// return (
|
||||
|
|
@ -104,9 +105,7 @@ export const SubscriptionItem = memo(({ id, className }: { id: string; className
|
|||
<Text numberOfLines={1} className="text-text flex-1 font-medium">
|
||||
{subscription?.title || feed.title}
|
||||
</Text>
|
||||
{!!unreadCount && (
|
||||
<Text className="text-tertiary-label ml-auto pl-2 text-xs">{unreadCount}</Text>
|
||||
)}
|
||||
<UnreadCount unread={unreadCount} className="ml-auto" />
|
||||
</ItemPressable>
|
||||
</SubscriptionFeedItemContextMenu>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import { Text, View } from "react-native"
|
||||
|
||||
import { useUISettingKey } from "@/src/atoms/settings/ui"
|
||||
|
||||
export function UnreadCount({ unread, className }: { unread?: number; className?: string }) {
|
||||
const showUnreadCount = useUISettingKey("subscriptionShowUnreadCount")
|
||||
|
||||
if (!unread) return null
|
||||
return showUnreadCount ? (
|
||||
<Text className={cn("text-tertiary-label text-xs", className)}>{unread}</Text>
|
||||
) : (
|
||||
<View className={cn("bg-tertiary-label size-1 rounded-full", className)} />
|
||||
)
|
||||
}
|
||||
|
|
@ -77,7 +77,6 @@ class Morph {
|
|||
ownerUserId: list.owner.id,
|
||||
feedIds: list.feedIds!,
|
||||
fee: list.fee!,
|
||||
entryIds: [],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -97,18 +96,17 @@ class Morph {
|
|||
ownerUserId: data.ownerUserId!,
|
||||
feedIds: data.feedIds!,
|
||||
fee: data.fee!,
|
||||
entryIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
toEntryList(data?: HonoApiClient.Entry_Post): EntryModel[] {
|
||||
toEntryList(data?: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post): EntryModel[] {
|
||||
const entries: EntryModel[] = []
|
||||
for (const item of data ?? []) {
|
||||
entries.push({
|
||||
id: item.entries.id,
|
||||
title: item.entries.title,
|
||||
url: item.entries.url,
|
||||
content: "",
|
||||
content: null,
|
||||
description: item.entries.description,
|
||||
guid: item.entries.guid,
|
||||
author: item.entries.author,
|
||||
|
|
@ -126,16 +124,18 @@ class Morph {
|
|||
: null,
|
||||
language: item.entries.language,
|
||||
feedId: item.feeds.id,
|
||||
// TODO: handle inboxHandle
|
||||
inboxHandle: "",
|
||||
inboxHandle: item.feeds.type === "inbox" ? item.feeds.id : null,
|
||||
read: item.read,
|
||||
sources: item.from ?? null,
|
||||
sources: "from" in item ? (item.from ?? null) : null,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
toCollections(data: HonoApiClient.Entry_Post, view: FeedViewType): CollectionModel[] {
|
||||
toCollections(
|
||||
data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post,
|
||||
view: FeedViewType,
|
||||
): CollectionModel[] {
|
||||
if (!data) return [] satisfies CollectionModel[]
|
||||
return data
|
||||
.map((item) => {
|
||||
|
|
@ -152,7 +152,7 @@ class Morph {
|
|||
.filter((i) => i !== null)
|
||||
}
|
||||
|
||||
toEntry(data?: HonoApiClient.Entry_Get): EntryModel | null {
|
||||
toEntry(data?: HonoApiClient.Entry_Get | HonoApiClient.Entry_Inbox_Get): EntryModel | null {
|
||||
if (!data) return null
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ export namespace HonoApiClient {
|
|||
export type Subscription_Get = ExtractData<typeof apiClient.subscriptions.$get>
|
||||
export type List_Get = ExtractData<typeof apiClient.lists.$get>
|
||||
export type Entry_Post = ExtractData<typeof apiClient.entries.$post>
|
||||
export type Entry_Inbox_Post = ExtractData<typeof apiClient.entries.inbox.$post>
|
||||
export type Entry_Get = ExtractData<typeof apiClient.entries.$get>
|
||||
export type Entry_Inbox_Get = ExtractData<typeof apiClient.entries.inbox.$get>
|
||||
export type List_List_Get = ExtractData<typeof apiClient.lists.list.$get>[number]
|
||||
export type Feed_Get = ExtractData<typeof apiClient.feeds.$get>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,36 @@ import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector"
|
|||
import { EntryListContext, useSelectedView } from "@/src/modules/screen/atoms"
|
||||
import { TimelineSelectorProvider } from "@/src/modules/screen/TimelineSelectorProvider"
|
||||
import { useCollectionEntryList } from "@/src/store/collection/hooks"
|
||||
import { useEntryIdsByCategory, useEntryIdsByFeedId } from "@/src/store/entry/hooks"
|
||||
import {
|
||||
useEntryIdsByCategory,
|
||||
useEntryIdsByFeedId,
|
||||
useEntryIdsByInboxId,
|
||||
useEntryIdsByListId,
|
||||
} from "@/src/store/entry/hooks"
|
||||
import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils"
|
||||
|
||||
export default function Feed() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { feedId: feedIdOrCategory } = useLocalSearchParams()
|
||||
const entryIdsByFeedId = useEntryIdsByFeedId(feedIdOrCategory as string)
|
||||
const entryIdsByCategory = useEntryIdsByCategory(feedIdOrCategory as string)
|
||||
const isCollection = feedIdOrCategory === FEED_COLLECTION_LIST
|
||||
const { feedId } = useLocalSearchParams()
|
||||
const feedIdentifier = feedId as string
|
||||
|
||||
const isCollection = feedIdentifier === FEED_COLLECTION_LIST
|
||||
const view = useSelectedView() ?? FeedViewType.Articles
|
||||
const collectionEntryIds = useCollectionEntryList(view)
|
||||
|
||||
const entryIdsByFeedId = useEntryIdsByFeedId(feedIdentifier)
|
||||
const entryIdsByCategory = useEntryIdsByCategory(feedIdentifier)
|
||||
const entryIdsByListId = useEntryIdsByListId(feedIdentifier)
|
||||
const entryIdsByInboxId = useEntryIdsByInboxId(feedIdentifier)
|
||||
|
||||
const entryIds = isCollection
|
||||
? collectionEntryIds
|
||||
: entryIdsByFeedId.length > 0
|
||||
? entryIdsByFeedId
|
||||
: entryIdsByCategory
|
||||
: getEntryIdsFromMultiplePlace(
|
||||
entryIdsByFeedId,
|
||||
entryIdsByCategory,
|
||||
entryIdsByListId,
|
||||
entryIdsByInboxId,
|
||||
)
|
||||
|
||||
return (
|
||||
<EntryListContext.Provider value={useMemo(() => ({ type: "feed" }), [])}>
|
||||
|
|
@ -36,3 +49,7 @@ export default function Feed() {
|
|||
</EntryListContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined>) {
|
||||
return entryIds.find((ids) => ids?.length) ?? []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,20 +30,6 @@ 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))
|
||||
}
|
||||
|
||||
async deleteList(params: { listId: string }) {
|
||||
await db.delete(listsTable).where(eq(listsTable.id, params.listId))
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ export const usePrefetchEntries = (props: Omit<FetchEntriesProps, "pageParam" |
|
|||
queryKey: ["entries", feedId, inboxId, listId, view, unreadOnly, limit],
|
||||
queryFn: ({ pageParam }) =>
|
||||
entrySyncServices.fetchEntries({ ...props, pageParam, read: unreadOnly ? false : undefined }),
|
||||
getNextPageParam: (lastPage) =>
|
||||
listId
|
||||
? lastPage.data?.at(-1)?.entries.insertedAt
|
||||
: lastPage.data?.at(-1)?.entries.publishedAt,
|
||||
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
|
||||
initialPageParam: undefined as undefined | string,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
|
|
@ -105,6 +102,19 @@ export const useEntryIdsByCategory = (category: string) => {
|
|||
)
|
||||
}
|
||||
|
||||
export const useEntryIdsByListId = (listId: string) => {
|
||||
return useEntryStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const ids = state.entryIdByList[listId]
|
||||
if (!ids) return []
|
||||
return Array.from(ids).sort((a, b) => sortEntryIdsByPublishDate(a, b))
|
||||
},
|
||||
[listId],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFetchEntryContentByStream = (remoteEntryIds?: string[]) => {
|
||||
const { mutate: updateEntryContent } = useMutation({
|
||||
mutationKey: ["stream-entry-content", remoteEntryIds],
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { EntryService } from "@/src/services/entry"
|
|||
import { collectionActions } from "../collection/store"
|
||||
import { feedActions } from "../feed/store"
|
||||
import { createImmerSetter, createTransaction, createZustandStore } from "../internal/helper"
|
||||
import { listActions } from "../list/store"
|
||||
import { getSubscription } from "../subscription/getter"
|
||||
import { getEntry } from "./getter"
|
||||
import type { EntryModel, FetchEntriesProps } from "./types"
|
||||
|
|
@ -21,6 +20,7 @@ type EntryId = string
|
|||
type FeedId = string
|
||||
type InboxId = string
|
||||
type Category = string
|
||||
type ListId = string
|
||||
|
||||
interface EntryState {
|
||||
data: Record<EntryId, EntryModel>
|
||||
|
|
@ -28,6 +28,7 @@ interface EntryState {
|
|||
entryIdByCategory: Record<Category, Set<EntryId>>
|
||||
entryIdByFeed: Record<FeedId, Set<EntryId>>
|
||||
entryIdByInbox: Record<InboxId, Set<EntryId>>
|
||||
entryIdByList: Record<ListId, Set<EntryId>>
|
||||
entryIdSet: Set<EntryId>
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ const defaultState: EntryState = {
|
|||
entryIdByCategory: {},
|
||||
entryIdByFeed: {},
|
||||
entryIdByInbox: {},
|
||||
entryIdByList: {},
|
||||
entryIdSet: new Set(),
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +138,24 @@ class EntryActions {
|
|||
}
|
||||
}
|
||||
|
||||
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?: { unreadOnly?: boolean }) {
|
||||
if (entries.length === 0) return
|
||||
const { unreadOnly } = options ?? {}
|
||||
|
|
@ -148,11 +168,19 @@ class EntryActions {
|
|||
const { feedId, inboxHandle, read, sources } = entry
|
||||
if (unreadOnly && read) continue
|
||||
|
||||
this.addEntryIdToFeed({
|
||||
draft,
|
||||
feedId,
|
||||
entryId: entry.id,
|
||||
})
|
||||
if (inboxHandle) {
|
||||
this.addEntryIdToInbox({
|
||||
draft,
|
||||
inboxHandle,
|
||||
entryId: entry.id,
|
||||
})
|
||||
} else {
|
||||
this.addEntryIdToFeed({
|
||||
draft,
|
||||
feedId,
|
||||
entryId: entry.id,
|
||||
})
|
||||
}
|
||||
|
||||
this.addEntryIdToView({
|
||||
draft,
|
||||
|
|
@ -161,17 +189,21 @@ class EntryActions {
|
|||
sources,
|
||||
})
|
||||
|
||||
this.addEntryIdToInbox({
|
||||
draft,
|
||||
inboxHandle,
|
||||
entryId: entry.id,
|
||||
})
|
||||
|
||||
this.addEntryIdToCategory({
|
||||
draft,
|
||||
feedId,
|
||||
entryId: entry.id,
|
||||
})
|
||||
|
||||
entry.sources
|
||||
?.filter((s) => s !== "feed")
|
||||
.forEach((s) => {
|
||||
this.addEntryIdToList({
|
||||
draft,
|
||||
listId: s,
|
||||
entryId: entry.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -264,6 +296,20 @@ class EntryActions {
|
|||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class EntrySyncServices {
|
||||
|
|
@ -275,15 +321,26 @@ class EntrySyncServices {
|
|||
listId,
|
||||
view,
|
||||
})
|
||||
const res = await apiClient.entries.$post({
|
||||
json: {
|
||||
publishedAfter: pageParam,
|
||||
read,
|
||||
limit,
|
||||
isCollection,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
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,
|
||||
...params,
|
||||
},
|
||||
})
|
||||
|
||||
const entries = honoMorph.toEntryList(res.data)
|
||||
const entriesInDB = await EntryService.getEntryMany(entries.map((e) => e.id))
|
||||
|
|
@ -295,12 +352,6 @@ class EntrySyncServices {
|
|||
}
|
||||
|
||||
await entryActions.upsertMany(entries)
|
||||
if (params.listId) {
|
||||
await listActions.addEntryIds({
|
||||
listId: params.listId,
|
||||
entryIds: entries.map((e) => e.id),
|
||||
})
|
||||
}
|
||||
|
||||
// After initial fetch, we can reset the state to prefer the entries data from the server
|
||||
if (!pageParam) {
|
||||
|
|
@ -318,6 +369,14 @@ class EntrySyncServices {
|
|||
if (params.feedId) {
|
||||
entryActions.resetByFeed({ feedId: params.feedId, entries })
|
||||
}
|
||||
|
||||
if (params.inboxId) {
|
||||
entryActions.resetByInbox({ inboxId: params.inboxId, entries })
|
||||
}
|
||||
|
||||
if (params.listId) {
|
||||
entryActions.resetByList({ listId: params.listId, entries })
|
||||
}
|
||||
}
|
||||
|
||||
if (isCollection && res.data) {
|
||||
|
|
@ -328,13 +387,21 @@ class EntrySyncServices {
|
|||
await collectionActions.upsertMany(collections)
|
||||
}
|
||||
|
||||
await feedActions.upsertMany(res.data?.map((e) => honoMorph.toFeed(e.feeds)) || [])
|
||||
const feeds =
|
||||
res.data
|
||||
?.map((e) => e.feeds)
|
||||
.filter((f) => f.type === "feed")
|
||||
.map((f) => honoMorph.toFeed(f)) ?? []
|
||||
feedActions.upsertMany(feeds)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async fetchEntryContent(entryId: EntryId) {
|
||||
const res = await apiClient.entries.$get({ query: { id: 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 (!entry) return null
|
||||
if (entry.content && getEntry(entryId)?.content !== entry.content) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
import type { EntryModel } from "./types"
|
||||
|
||||
/// Feed
|
||||
export const FEED_COLLECTION_LIST = "collections"
|
||||
|
||||
|
|
@ -54,3 +56,13 @@ export function getEntriesParams({
|
|||
...params,
|
||||
}
|
||||
}
|
||||
|
||||
export function getInboxFrom(entry?: EntryModel) {
|
||||
if (isInboxEntry(entry)) {
|
||||
return entry.authorUrl?.replace("mailto:", "")
|
||||
}
|
||||
}
|
||||
|
||||
export function isInboxEntry(entry?: EntryModel): entry is EntryModel {
|
||||
return !!entry?.inboxHandle
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type { ListModel } from "./store"
|
||||
import { useListStore } from "./store"
|
||||
|
||||
const get = () => useListStore.getState()
|
||||
export const getList = (id: string) => {
|
||||
return get().lists[id] as ListModel | undefined
|
||||
return get().lists[id]
|
||||
}
|
||||
|
||||
export const getListFeedIds = (id: string) => {
|
||||
return get().lists[id]?.feedIds
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ export const useList = (id: string) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const useIsOwnList = (id: string) => {
|
||||
export const useListFeedIds = (id: string) => {
|
||||
return useListStore((state) => {
|
||||
return state.lists[id]?.userId === whoami()?.id
|
||||
return state.lists[id]?.feedIds
|
||||
})
|
||||
}
|
||||
|
||||
export const useListEntryIds = (id: string) => {
|
||||
export const useIsOwnList = (id: string) => {
|
||||
return useListStore((state) => {
|
||||
return state.lists[id]?.entryIds
|
||||
return state.lists[id]?.userId === whoami()?.id
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,26 +50,6 @@ class ListActions {
|
|||
tx.run()
|
||||
}
|
||||
|
||||
addEntryIdsInSession(params: { listId: string; entryIds: string[] }) {
|
||||
immerSet((draft) => {
|
||||
const list = draft.lists[params.listId]
|
||||
if (!list) return
|
||||
list.entryIds = [...new Set([...(list.entryIds ?? []), ...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()
|
||||
}
|
||||
|
||||
deleteList(params: { listId: string }) {
|
||||
const tx = createTransaction()
|
||||
tx.store(() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
|
||||
import { getListFeedIds } from "../list/getters"
|
||||
import type { SubscriptionModel } from "./store"
|
||||
import { useSubscriptionStore } from "./store"
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ export const getSubscriptionByView = (view: FeedViewType): string[] => {
|
|||
const state = get()
|
||||
return Array.from(state.feedIdByView[view])
|
||||
.concat(Array.from(state.inboxIdByView[view]))
|
||||
.concat(Array.from(state.listIdByView[view]))
|
||||
.concat(Array.from(state.listIdByView[view]).flatMap((id) => getListFeedIds(id) ?? []))
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionByView = (view: FeedViewType): string[] => {
|
||||
|
|
|
|||
|
|
@ -230,7 +230,6 @@ class SubscriptionSyncService {
|
|||
{
|
||||
...data.list,
|
||||
userId: data.list.ownerUserId,
|
||||
entryIds: [],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import type { FeedViewType } from "@follow/constants"
|
|||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { useFeedSubscriptionByView } from "../subscription/hooks"
|
||||
import { useListFeedIds } from "../list/hooks"
|
||||
import { useSubscriptionByView } from "../subscription/hooks"
|
||||
import { unreadSyncService, useUnreadStore } from "./store"
|
||||
|
||||
export const usePrefetchUnread = () => {
|
||||
|
|
@ -26,6 +27,11 @@ export const useUnreadCount = (subscriptionId: string) => {
|
|||
return useUnreadStore((state) => state.data[subscriptionId])
|
||||
}
|
||||
|
||||
export const useListUnreadCount = (listId: string) => {
|
||||
const feedIds = useListFeedIds(listId)
|
||||
return useUnreadCounts(feedIds ?? [])
|
||||
}
|
||||
|
||||
export const useUnreadCounts = (subscriptionIds: string[]): number => {
|
||||
return useUnreadStore(
|
||||
useCallback(
|
||||
|
|
@ -42,17 +48,6 @@ export const useUnreadCounts = (subscriptionIds: string[]): number => {
|
|||
}
|
||||
|
||||
export const useUnreadCountByView = (view: FeedViewType) => {
|
||||
const subscriptionIds = useFeedSubscriptionByView(view)
|
||||
return useUnreadStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
let count = 0
|
||||
for (const subscriptionId of subscriptionIds) {
|
||||
count += state.data[subscriptionId] ?? 0
|
||||
}
|
||||
return count
|
||||
},
|
||||
[subscriptionIds],
|
||||
),
|
||||
)
|
||||
const subscriptionIds = useSubscriptionByView(view)
|
||||
return useUnreadCounts(subscriptionIds)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue