feat(desktop): feed preview

This commit is contained in:
DIYgod 2025-05-05 15:27:58 +08:00
parent 20e63fc194
commit eb26cde97d
No known key found for this signature in database
8 changed files with 251 additions and 31 deletions

View File

@ -1,5 +1,6 @@
import { getReadonlyRoute, useReadonlyRouteSelector } from "@follow/components/atoms/route.js"
import { FeedViewType } from "@follow/constants"
import { isBizId } from "@follow/utils/utils"
import type { Params } from "react-router"
import { useParams } from "react-router"
@ -13,6 +14,7 @@ import {
ROUTE_TIMELINE_OF_VIEW,
} from "~/constants"
import { getListById } from "~/store/list"
import { getSubscriptionByFeedId } from "~/store/subscription"
export const useRouteEntryId = () => {
const { entryId } = useParams()
@ -35,6 +37,7 @@ export interface BizRouteParams {
inboxId?: string
listId?: string
timelineId?: string
isPreview?: boolean
}
const parseRouteParams = (params: Params<any>): BizRouteParams => {
@ -43,6 +46,13 @@ const parseRouteParams = (params: Params<any>): BizRouteParams => {
: undefined
const list = listId ? getListById(listId) : undefined
let isPreview = false
if (listId) {
isPreview = !getSubscriptionByFeedId(listId)
} else if (params.feedId) {
isPreview = isBizId(params.feedId) && !getSubscriptionByFeedId(params.feedId)
}
return {
view: params.timelineId?.startsWith(ROUTE_TIMELINE_OF_VIEW)
? (Number.parseInt(
@ -64,6 +74,7 @@ const parseRouteParams = (params: Params<any>): BizRouteParams => {
: undefined,
listId,
timelineId: params.timelineId,
isPreview,
}
}

View File

@ -1,4 +1,4 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { ActionButton, Button } from "@follow/components/ui/button/index.js"
import { DividerVertical } from "@follow/components/ui/divider/index.js"
import { RotatingRefreshIcon } from "@follow/components/ui/loading/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
@ -14,6 +14,7 @@ import { useTimelineColumnShow } from "~/atoms/sidebar"
import { useWhoami } from "~/atoms/user"
import { FEED_COLLECTION_LIST, ROUTE_ENTRY_PENDING } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { useFollow } from "~/hooks/biz/useFollow"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryHeader } from "~/modules/entry-content/header"
import { useRefreshFeedMutation } from "~/queries/feed"
@ -34,10 +35,11 @@ export const EntryListHeader: FC<{
}> = ({ refetch, isRefreshing, hasUpdate }) => {
const routerParams = useRouteParams()
const { t } = useTranslation()
const { t: tCommon } = useTranslation("common")
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { feedId, entryId, view } = routerParams
const { feedId, entryId, view, isPreview, listId } = routerParams
const headerTitle = useFeedHeaderTitle()
@ -57,6 +59,8 @@ export const EntryListHeader: FC<{
const feed = useFeedById(feedId)
const follow = useFollow()
const titleStyleBasedView = ["pl-6", "pl-7", "pl-7", "pl-7", "px-5", "pl-6"]
const feedColumnShow = useTimelineColumnShow()
@ -139,6 +143,21 @@ export const EntryListHeader: FC<{
<MarkAllReadButton shortcut />
</div>
</div>
{isPreview && (
<Button
size="lg"
buttonClassName="mt-4"
onClick={() => {
follow({
isList: !!listId,
id: listId ?? feedId,
url: feed?.url,
})
}}
>
{tCommon("words.follow")}
</Button>
)}
{/* <TimelineTabs /> */}
</div>

View File

@ -1,5 +1,6 @@
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { OouiUserAnonymous } from "@follow/components/icons/OouiUserAnonymous.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import {
Tooltip,
TooltipContent,
@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next"
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
import { getMainContainerElement } from "~/atoms/dom"
import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions"
import { useFollow } from "~/hooks/biz/useFollow"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useContextMenu } from "~/hooks/common/useContextMenu"
@ -40,6 +42,7 @@ interface FeedItemProps {
feedId: string
view?: number
className?: string
isPreview?: boolean
}
const DraggableItemWrapper: Component<
@ -61,9 +64,9 @@ const DraggableItemWrapper: Component<
</div>
)
}
const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) => {
const { t } = useTranslation()
const subscription = useSubscriptionByFeedId(feedId)!
const subscription = useSubscriptionByFeedId(feedId)
const navigate = useNavigateEntry()
const feed = useFeedById(feedId, (feed) => {
return {
@ -153,6 +156,8 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
setIsContextMenuOpen(false)
},
})
const follow = useFollow()
if (!feed) return null
const isFeed = feed.type === "feed" || !feed.type
@ -205,7 +210,7 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
</TooltipPortal>
</Tooltip>
)}
{subscription.isPrivate && (
{subscription?.isPrivate && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<OouiUserAnonymous className="ml-1 shrink-0 text-base" />
@ -216,6 +221,21 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
</Tooltip>
)}
</div>
{isPreview && (
<Button
size="sm"
variant="ghost"
onClick={() => {
follow({
isList: false,
id: feedId,
url: feed.url,
})
}}
>
<i className="i-mgc-add-cute-re text-accent text-base" />
</Button>
)}
<UnreadNumber unread={feedUnread} className="ml-2" />
</DraggableItemWrapper>
)
@ -227,7 +247,8 @@ const ListItemImpl: Component<{
listId: string
view: FeedViewType
iconSize?: number
}> = ({ view, listId, className, iconSize = 22 }) => {
isPreview?: boolean
}> = ({ view, listId, className, iconSize = 22, isPreview }) => {
const list = useListById(listId)
const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId)
@ -264,6 +285,8 @@ const ListItemImpl: Component<{
setIsContextMenuOpen(false)
},
})
const follow = useFollow()
if (!list) return null
return (
<div
@ -282,7 +305,7 @@ const ListItemImpl: Component<{
{getPreferredTitle(list)}
</EllipsisHorizontalTextWithTooltip>
{subscription.isPrivate && (
{subscription?.isPrivate && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<OouiUserAnonymous className="ml-1 shrink-0 text-base" />
@ -293,6 +316,20 @@ const ListItemImpl: Component<{
</Tooltip>
)}
</div>
{isPreview && (
<Button
size="sm"
variant="ghost"
onClick={() => {
follow({
isList: true,
id: listId,
})
}}
>
<i className="i-mgc-add-cute-re text-accent text-base" />
</Button>
)}
<UnreadNumber unread={listUnread} className="ml-2" />
</div>
)

View File

@ -6,8 +6,11 @@ import { useTranslation } from "react-i18next"
import Selecto from "react-selecto"
import { useEventListener } from "usehooks-ts"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { Queries } from "~/queries"
import { useFeedQuery } from "~/queries/feed"
import { useList } from "~/queries/lists"
import {
useCategoryOpenStateByView,
useFeedsGroupedData,
@ -21,6 +24,7 @@ import {
useSelectedFeedIdsState,
} from "./atom"
import { DraggableContext } from "./context"
import { FeedItem, ListItem } from "./FeedItem"
import { EmptyFeedList, ListHeader, StarredItem } from "./FeedList.shared"
import { useShouldFreeUpSpace } from "./hook"
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
@ -112,6 +116,14 @@ const FeedListImpl = forwardRef<HTMLDivElement, { className?: string; view: numb
const shouldFreeUpSpace = useShouldFreeUpSpace()
const routerParams = useRouteParams()
const { listId, isPreview, feedId } = routerParams
const isFeedPreview = isPreview && !listId
const isListPreview = isPreview && listId
useFeedQuery({ id: isFeedPreview ? feedId : undefined })
useList({ id: isListPreview ? listId : undefined })
return (
<div className={cn(className, "font-medium")}>
<ListHeader view={view} />
@ -213,11 +225,14 @@ const FeedListImpl = forwardRef<HTMLDivElement, { className?: string; view: numb
rootClassName={cn("h-full", shouldFreeUpSpace && "overflow-visible")}
>
<StarredItem view={view} />
{hasListData && (
{(hasListData || (isListPreview && listId)) && (
<>
<div className="text-text-secondary mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold transition-colors">
{t("words.lists")}
</div>
{isListPreview && listId && (
<ListItem listId={listId} view={view} className="pl-2.5 pr-0" isPreview />
)}
<SortByAlphabeticalList view={view} data={listsData} />
</>
)}
@ -240,6 +255,9 @@ const FeedListImpl = forwardRef<HTMLDivElement, { className?: string; view: numb
{t("words.feeds")}
</div>
)}
{isFeedPreview && feedId && (
<FeedItem feedId={feedId} view={view} className="pl-2.5 pr-0.5" isPreview />
)}
<DraggableContext.Provider value={draggableContextValue}>
<div className="space-y-px" id="feeds-area" ref={setNodeRef}>
{hasData ? (

View File

@ -68,9 +68,12 @@ export const useCategoriesByView = (view: FeedViewType) =>
),
)
export const useSubscriptionByFeedId = (feedId: FeedId) =>
export const useSubscriptionByFeedId = (feedId?: FeedId) =>
useSubscriptionStore(
useCallback((state) => subscriptionByFeedIdSelector(feedId)(state) || null, [feedId]),
useCallback(
(state) => (feedId ? subscriptionByFeedIdSelector(feedId)(state) || null : null),
[feedId],
),
)
export const useSubscriptionsByFeedIds = (feedIds: FeedId[]) =>
useSubscriptionStore(

View File

@ -115,6 +115,7 @@ export default ({ mode }) => {
"/forget-password": proxyConfig,
"/reset-password": proxyConfig,
"/register": proxyConfig,
"/share": proxyConfig,
...(env.VITE_DEV_PROXY
? {

View File

@ -36,6 +36,7 @@ export const styledButtonVariant = cva(
],
variants: {
size: {
sm: "px-2 py-1 rounded-md text-xs",
default: "px-3 py-1.5 rounded-lg text-sm",
lg: "px-4 py-2 rounded-lg text-base",
},

View File

@ -2747,6 +2747,121 @@ declare const urlReadsOpenAPISchema: z.ZodObject<{
count: number;
}>;
declare const feedAnalytics: drizzle_orm_pg_core.PgTableWithColumns<{
name: "feed_analytics";
schema: undefined;
columns: {
feedId: drizzle_orm_pg_core.PgColumn<{
name: "feed_id";
tableName: "feed_analytics";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
updatesPerWeek: drizzle_orm_pg_core.PgColumn<{
name: "updates_per_week";
tableName: "feed_analytics";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
subscriptionCount: drizzle_orm_pg_core.PgColumn<{
name: "subscription_count";
tableName: "feed_analytics";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
latestEntryPublishedAt: drizzle_orm_pg_core.PgColumn<{
name: "latest_entry_published_at";
tableName: "feed_analytics";
dataType: "date";
columnType: "PgTimestamp";
data: Date;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
view: drizzle_orm_pg_core.PgColumn<{
name: "view";
tableName: "feed_analytics";
dataType: "number";
columnType: "PgSmallInt";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
declare const feedAnalyticsOpenAPISchema: zod.ZodObject<{
feedId: zod.ZodString;
updatesPerWeek: zod.ZodNullable<zod.ZodNumber>;
subscriptionCount: zod.ZodNullable<zod.ZodNumber>;
latestEntryPublishedAt: zod.ZodNullable<zod.ZodString>;
view: zod.ZodNullable<zod.ZodNumber>;
}, zod.UnknownKeysParam, zod.ZodTypeAny, {
view: number | null;
feedId: string;
updatesPerWeek: number | null;
subscriptionCount: number | null;
latestEntryPublishedAt: string | null;
}, {
view: number | null;
feedId: string;
updatesPerWeek: number | null;
subscriptionCount: number | null;
latestEntryPublishedAt: string | null;
}>;
declare const feedAnalyticsRelations: drizzle_orm.Relations<"feed_analytics", {
feed: drizzle_orm.One<"feeds", true>;
}>;
declare const feeds: drizzle_orm_pg_core.PgTableWithColumns<{
name: "feeds";
schema: undefined;
@ -13711,6 +13826,8 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
}[] | null | undefined;
} | null | undefined;
}[] | undefined;
updatesPerWeek?: number | undefined;
subscriptionCount?: number | undefined;
feed?: {
id: string;
type: "feed";
@ -13798,8 +13915,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
} | null | undefined;
} | undefined;
docs?: string | undefined;
subscriptionCount?: number | undefined;
updatesPerWeek?: number | undefined;
}[];
};
outputFormat: "json";
@ -14447,6 +14562,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
output: {
code: 0;
data: {
subscriptionCount: number;
feed: {
id: string;
type: "feed";
@ -14479,7 +14595,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
suspended: boolean | null;
}[] | null | undefined;
};
subscriptionCount: number;
tipAmount: number;
}[];
};
@ -14533,6 +14648,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
}[] | null | undefined;
} | null | undefined;
}[];
subscriptionCount: number;
feed: {
id: string;
type: "feed";
@ -14565,7 +14681,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
suspended: boolean | null;
}[] | null | undefined;
};
subscriptionCount: number;
readCount: number;
subscription?: {
createdAt: string;
@ -15561,6 +15676,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
}[] | null | undefined;
} | null | undefined;
}[];
subscriptionCount: number;
list: {
id: string;
type: "list";
@ -15615,7 +15731,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
suspended: boolean | null;
} | null | undefined;
};
subscriptionCount: number;
readCount: number;
feedCount: number;
subscription?: {
@ -15789,6 +15904,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
}[] | null | undefined;
}[] | undefined;
ownerUserId?: string | null | undefined;
subscriptionCount?: number | undefined;
owner?: {
id: string;
name: string | null;
@ -15799,7 +15915,6 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
updatedAt: string;
suspended: boolean | null;
} | null | undefined;
subscriptionCount?: number | undefined;
purchaseAmount?: number | undefined;
}[];
};
@ -16465,24 +16580,39 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
data: {
view: number | null;
feedId: string;
subscriptionCount: number;
feed: {
id: string;
image: string | null;
description: string | null;
title: string | null;
type: "feed";
url: string;
siteUrl: string | null;
checkedAt: string;
lastModifiedHeader: string | null;
etagHeader: string | null;
ttl: number | null;
errorMessage: string | null;
errorAt: string | null;
ownerUserId: string | null;
language: string | null;
migrateTo: string | null;
image?: string | null | undefined;
description?: string | null | undefined;
title?: string | null | undefined;
siteUrl?: string | null | undefined;
errorMessage?: string | null | undefined;
errorAt?: string | null | undefined;
ownerUserId?: string | null | undefined;
owner?: {
id: string;
name: string | null;
emailVerified: boolean | null;
image: string | null;
handle: string | null;
createdAt: string;
updatedAt: string;
suspended: boolean | null;
} | null | undefined;
tipUsers?: {
id: string;
name: string | null;
emailVerified: boolean | null;
image: string | null;
handle: string | null;
createdAt: string;
updatedAt: string;
suspended: boolean | null;
}[] | null | undefined;
} | null;
subscriptionCount: number;
}[];
};
outputFormat: "json";
@ -16492,4 +16622,4 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
}, "/trending">, "/">;
type AppType = typeof _routes;
export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations };
export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations };