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:
Stephen Zhou 2025-03-05 21:36:26 +08:00 committed by GitHub
parent 0be6255d0b
commit 7180e00d19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 976 additions and 171 deletions

View File

@ -0,0 +1 @@
ALTER TABLE `lists` DROP COLUMN `entry_ids`;

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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,
},
}

View File

@ -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", {

View File

@ -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 (

View File

@ -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} />
}

View File

@ -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 ? (

View File

@ -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 : "",
)

View File

@ -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

View File

@ -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>
)

View File

@ -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>

View File

@ -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>

View File

@ -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)} />
)
}

View File

@ -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 {

View File

@ -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>
}

View File

@ -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) ?? []
}

View File

@ -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))

View File

@ -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],

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
})
}

View File

@ -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(() => {

View File

@ -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[] => {

View File

@ -230,7 +230,6 @@ class SubscriptionSyncService {
{
...data.list,
userId: data.list.ownerUserId,
entryIds: [],
},
])
}

View File

@ -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)
}