= memo(
style={useMemo(
() => ({
transform,
- paddingTop: isStickyItem ? "1.75rem" : undefined,
+ paddingTop: isStickyItem ? "2.75rem" : undefined,
}),
[transform, isStickyItem],
)}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx
index e935198ff..2d9ab776d 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx
@@ -1,13 +1,13 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
+import { Spring } from "@follow/components/constants/spring.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
-import type { FeedViewType } from "@follow/constants"
-import { views } from "@follow/constants"
+import { FeedViewType, views } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
-import { EventBus } from "@follow/utils/event-bus"
import { cn } from "@follow/utils/utils"
+import { AnimatePresence, m } from "motion/react"
import type { FC, MouseEvent, MouseEventHandler, PropsWithChildren, TouchEvent } from "react"
-import { useCallback, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { NavLink } from "react-router"
import { useDebounceCallback } from "usehooks-ts"
@@ -22,13 +22,20 @@ import { useGeneralSettingKey } from "~/atoms/settings/general"
import { FocusablePresets } from "~/components/common/Focusable"
import { useEntryIsRead } from "~/hooks/biz/useAsRead"
import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger"
-import { HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, useEntryActions } from "~/hooks/biz/useEntryActions"
+import {
+ HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU,
+ useEntryActions,
+ useSortedEntryActions,
+} from "~/hooks/biz/useEntryActions"
+import { useFeature } from "~/hooks/biz/useFeature"
import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { getNavigateEntryPath, useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
-import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
+import { getRouteParams, useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { copyToClipboard } from "~/lib/clipboard"
import { COMMAND_ID } from "~/modules/command/commands/id"
+import { EntryHeaderActions } from "~/modules/entry-content/actions/header-actions"
+import { MoreActions } from "~/modules/entry-content/actions/more-actions"
export const EntryItemWrapper: FC<
{
@@ -61,7 +68,8 @@ export const EntryItemWrapper: FC<
const asRead = useEntryIsRead(entry)
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
- const handleMouseEnter = useDebounceCallback(
+ const [showAction, setShowAction] = useState(false)
+ const handleMouseEnterMarkRead = useDebounceCallback(
() => {
if (!hoverMarkUnread) return
if (!document.hasFocus()) return
@@ -76,14 +84,43 @@ export const EntryItemWrapper: FC<
},
)
- const navigate = useNavigateEntry()
+ const handleMouseEnter = useMemo(() => {
+ return () => {
+ setShowAction(true)
+ handleMouseEnterMarkRead()
+ }
+ }, [handleMouseEnterMarkRead])
+ const handleMouseLeave = useMemo(() => {
+ return (e: React.MouseEvent) => {
+ handleMouseEnterMarkRead.cancel()
+ // If the mouse is over the action bar, don't hide the action bar
+ const { relatedTarget, currentTarget } = e
+ if (relatedTarget && relatedTarget instanceof Node && currentTarget.contains(relatedTarget)) {
+ return
+ }
+ setShowAction(false)
+ }
+ }, [handleMouseEnterMarkRead])
+ const isDropdownMenuOpen = useGlobalFocusableScopeSelector(
+ FocusablePresets.isNotFloatingLayerScope,
+ )
+
+ useEffect(() => {
+ // Hide the action bar when dropdown menu is open and click outside
+ if (isDropdownMenuOpen) {
+ setShowAction(false)
+ }
+ }, [isDropdownMenuOpen])
+
+ const navigate = useNavigateEntry()
const navigationPath = useMemo(() => {
if (!entry?.id) return "#"
return getNavigateEntryPath({
entryId: entry?.id,
})
}, [entry?.id])
+
const handleClick = useCallback(
(e: TouchEvent
| MouseEvent) => {
e.preventDefault()
@@ -97,10 +134,11 @@ export const EntryItemWrapper: FC<
unreadSyncService.markEntryAsRead(entry.id)
}
- setTimeout(
- () => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }),
- 60,
- )
+ // TODO
+ // setTimeout(
+ // () => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }),
+ // 60,
+ // )
navigate({
entryId: entry.id,
@@ -168,25 +206,55 @@ export const EntryItemWrapper: FC<
},
})
+ const aiEnabled = useFeature("ai")
+ const isWide = views[view as FeedViewType]?.wideMode || aiEnabled
+
return (
{children}
+ {showAction && isWide && }
)
}
+
+const ActionBar = ({ entryId }: { entryId: string }) => {
+ const { mainAction: entryActions } = useSortedEntryActions({
+ entryId,
+ view: FeedViewType.SocialMedia,
+ })
+ const { view } = useRouteParams()
+
+ if (entryActions.length === 0) return null
+
+ return (
+ e.stopPropagation()}
+ >
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts
index c0bf10de4..c53559357 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts
@@ -1 +1,4 @@
export const girdClassNames = tw`grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 gap-1.5`
+
+// Shared max-width styles for readable content
+export const readableContentMaxWidth = tw`max-w-[65ch] mx-auto px-4`
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx
new file mode 100644
index 000000000..426536a83
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx
@@ -0,0 +1,348 @@
+import { useMobile } from "@follow/components/hooks/useMobile.js"
+import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
+import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks"
+import { useEntry } from "@follow/store/entry/hooks"
+import type { EntryModel } from "@follow/store/entry/types"
+import { useFeedById } from "@follow/store/feed/hooks"
+import { useInboxById } from "@follow/store/inbox/hooks"
+import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils"
+import { useMemo } from "react"
+import { titleCase } from "title-case"
+
+import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
+import { useGeneralSettingKey } from "~/atoms/settings/general"
+import { useUISettingKey } from "~/atoms/settings/ui"
+import { RelativeTime } from "~/components/ui/datetime"
+import { Media } from "~/components/ui/media/Media"
+import { FEED_COLLECTION_LIST } from "~/constants"
+import { useEntryIsRead } from "~/hooks/biz/useAsRead"
+import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
+import { EntryTranslation } from "~/modules/entry-column/translation"
+import type { FeedIconEntry } from "~/modules/feed/feed-icon"
+import { FeedIcon } from "~/modules/feed/feed-icon"
+import { FeedTitle } from "~/modules/feed/feed-title"
+import { getPreferredTitle } from "~/store/feed/hooks"
+
+import { StarIcon } from "../star-icon"
+import type { UniversalItemProps } from "../types"
+
+const entrySelector = (state: EntryModel) => {
+ const { feedId, inboxHandle, read } = state
+ const { authorAvatar, authorUrl, description, publishedAt, title } = state
+
+ const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url)
+ const firstAudio = audios?.[0]
+ const media = state.media || []
+ const firstMedia = media?.[0]
+ const photo = media.find((a) => a.type === "photo")
+ const firstPhotoUrl = photo?.url
+ const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }
+
+ const titleEntry = { authorUrl }
+
+ return {
+ description,
+ feedId,
+ firstAudio,
+ firstMedia,
+ iconEntry,
+ inboxId: inboxHandle,
+ publishedAt,
+ read,
+ title,
+ titleEntry,
+ }
+}
+export function ListItem({
+ entryId,
+ entryPreview,
+ translation,
+ simple,
+}: UniversalItemProps & {
+ simple?: boolean
+}) {
+ const isMobile = useMobile()
+ const entry = useEntry(entryId, entrySelector)
+
+ const isInCollection = useIsEntryStarred(entryId)
+ const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt
+
+ const isRead = useEntryIsRead(entry)
+
+ const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)
+
+ const feed =
+ useFeedById(entry?.feedId, (feed) => {
+ return {
+ type: feed.type,
+ ownerUserId: feed.ownerUserId,
+ id: feed.id,
+ title: feed.title,
+ url: (feed as any).url || "",
+ image: feed.image,
+ siteUrl: feed.siteUrl,
+ }
+ }) || entryPreview?.feeds
+
+ const inbox = useInboxById(entry?.inboxId)
+
+ const thumbnailRatio = useUISettingKey("thumbnailRatio")
+ const rid = `list-item-${entryId}`
+
+ const bilingual = useGeneralSettingKey("translationMode") === "bilingual"
+ const lineClamp = useMemo(() => {
+ const envIsSafari = isSafari()
+ let lineClampTitle = 1
+ let lineClampDescription = 2
+
+ if (translation?.title && !simple && bilingual) {
+ lineClampTitle += 1
+ }
+ if (translation?.description && !simple && bilingual) {
+ lineClampDescription += 1
+ }
+
+ // FIXME: Safari bug, not support line-clamp cross elements
+ return {
+ global: !envIsSafari
+ ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]`
+ : "",
+ title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "",
+ description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "",
+ }
+ }, [simple, translation?.description, translation?.title, bilingual])
+
+ const dimRead = useGeneralSettingKey("dimRead")
+ // NOTE: prevent 0 height element, react virtuoso will not stop render any more
+ if (!entry || !(feed || inbox)) return null
+
+ const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt
+
+ const related = feed || inbox
+
+ const hasAudio = simple ? false : !!entry.firstAudio?.url
+ const hasMedia = simple ? false : !!entry.firstMedia?.url
+
+ const marginWidth = 8 * (isMobile ? 1.125 : 1)
+ // calculate the max width to have a correct truncation
+ // FIXME: this is not easy to maintain, need to refactor
+ const feedIconWidth = 20 + marginWidth
+ const audioCoverWidth = 80 + marginWidth
+ const mediaWidth = 80 * (isMobile ? 1.125 : 1) + marginWidth
+
+ let savedWidth = 0
+
+ savedWidth += feedIconWidth
+
+ if (hasAudio) {
+ savedWidth += audioCoverWidth
+ }
+ if (hasMedia && !hasAudio) {
+ savedWidth += mediaWidth
+ }
+
+ return (
+
+
+
+
+
+
+
+ ·
+ {!!displayTime && }
+
+
+ {entry?.title ? (
+
+ ) : (
+
+ )}
+ {!!isInCollection && }
+
+ {!simple && (
+
+
+
+ )}
+
+
+ {hasAudio && entry.firstAudio && (
+
+ }
+ feed={feed || inbox}
+ entry={entry?.iconEntry}
+ size={80}
+ className="m-0 rounded"
+ useMedia
+ noMargin
+ />
+ }
+ />
+ )}
+
+ {!simple && !hasAudio && entry.firstMedia && (
+
+ )}
+
+ )
+}
+
+function AudioCover({
+ entryId,
+ src,
+ durationInSeconds,
+ feedIcon,
+}: {
+ entryId: string
+ src: string
+ durationInSeconds?: number | string
+ feedIcon: React.ReactNode
+}) {
+ const isMobile = useMobile()
+ const playStatus = useAudioPlayerAtomSelector((playerValue) =>
+ playerValue.src === src && playerValue.show ? playerValue.status : false,
+ )
+
+ const language = useGeneralSettingKey("language")
+ const isChinese = useMemo(() => {
+ return language === "zh-CN"
+ }, [language])
+
+ const seconds = formatTimeToSeconds(durationInSeconds)
+ const estimatedMins = seconds && Math.floor(seconds / 60)
+
+ const handleClickPlay = (e: React.MouseEvent) => {
+ if (isMobile) e.stopPropagation()
+ if (!playStatus) {
+ // switch this to play
+ AudioPlayer.mount({
+ type: "audio",
+ entryId,
+ src,
+ currentTime: 0,
+ })
+ } else {
+ // switch between play and pause
+ AudioPlayer.togglePlayAndPause()
+ }
+ }
+
+ return (
+
+ {feedIcon}
+
+
+
+
+
+ {!!estimatedMins && (
+
+
+
+ {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
+
+
+ )}
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx
new file mode 100644
index 000000000..4c66fe7c2
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx
@@ -0,0 +1,359 @@
+import { useMobile } from "@follow/components/hooks/useMobile.js"
+import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
+import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks"
+import { useEntry } from "@follow/store/entry/hooks"
+import type { EntryModel } from "@follow/store/entry/types"
+import { useFeedById } from "@follow/store/feed/hooks"
+import { useInboxById } from "@follow/store/inbox/hooks"
+import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils"
+import { useMemo } from "react"
+import { titleCase } from "title-case"
+
+import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
+import { useGeneralSettingKey } from "~/atoms/settings/general"
+import { useRealInWideMode, useUISettingKey } from "~/atoms/settings/ui"
+import { RelativeTime } from "~/components/ui/datetime"
+import { Media } from "~/components/ui/media/Media"
+import { FEED_COLLECTION_LIST } from "~/constants"
+import { useEntryIsRead } from "~/hooks/biz/useAsRead"
+import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
+import { EntryTranslation } from "~/modules/entry-column/translation"
+import type { FeedIconEntry } from "~/modules/feed/feed-icon"
+import { FeedIcon } from "~/modules/feed/feed-icon"
+import { FeedTitle } from "~/modules/feed/feed-title"
+import { getPreferredTitle } from "~/store/feed/hooks"
+
+import { StarIcon } from "../star-icon"
+import type { UniversalItemProps } from "../types"
+
+const entrySelector = (state: EntryModel) => {
+ const { feedId, inboxHandle, read } = state
+ const { authorAvatar, authorUrl, description, publishedAt, title } = state
+
+ const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url)
+ const firstAudio = audios?.[0]
+ const media = state.media || []
+ const firstMedia = media?.[0]
+ const photo = media.find((a) => a.type === "photo")
+ const firstPhotoUrl = photo?.url
+ const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }
+
+ const titleEntry = { authorUrl }
+
+ return {
+ description,
+ feedId,
+ firstAudio,
+ firstMedia,
+ iconEntry,
+ inboxId: inboxHandle,
+ publishedAt,
+ read,
+ title,
+ titleEntry,
+ }
+}
+export function ListItem({
+ entryId,
+ entryPreview,
+ translation,
+ simple,
+}: UniversalItemProps & {
+ simple?: boolean
+}) {
+ const isMobile = useMobile()
+ const entry = useEntry(entryId, entrySelector)
+
+ const isInCollection = useIsEntryStarred(entryId)
+ const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt
+
+ const isRead = useEntryIsRead(entry)
+
+ const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)
+
+ const feed =
+ useFeedById(entry?.feedId, (feed) => {
+ return {
+ type: feed.type,
+ ownerUserId: feed.ownerUserId,
+ id: feed.id,
+ title: feed.title,
+ url: (feed as any).url || "",
+ image: feed.image,
+ siteUrl: feed.siteUrl,
+ }
+ }) || entryPreview?.feeds
+
+ const inbox = useInboxById(entry?.inboxId)
+
+ const settingWideMode = useRealInWideMode()
+ const thumbnailRatio = useUISettingKey("thumbnailRatio")
+ const rid = `list-item-${entryId}`
+
+ const bilingual = useGeneralSettingKey("translationMode") === "bilingual"
+ const lineClamp = useMemo(() => {
+ const envIsSafari = isSafari()
+ let lineClampTitle = settingWideMode ? 1 : 2
+ let lineClampDescription = settingWideMode ? 1 : 2
+
+ if (translation?.title && !simple && bilingual) {
+ lineClampTitle += 1
+ }
+ if (translation?.description && !simple && bilingual) {
+ lineClampDescription += 1
+ }
+
+ // FIXME: Safari bug, not support line-clamp cross elements
+ return {
+ global: !envIsSafari
+ ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]`
+ : "",
+ title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "",
+ description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "",
+ }
+ }, [settingWideMode, simple, translation?.description, translation?.title, bilingual])
+
+ const dimRead = useGeneralSettingKey("dimRead")
+ // NOTE: prevent 0 height element, react virtuoso will not stop render any more
+ if (!entry || !(feed || inbox)) return null
+
+ const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt
+
+ const related = feed || inbox
+
+ const hasAudio = simple ? false : !!entry.firstAudio?.url
+ const hasMedia = simple ? false : !!entry.firstMedia?.url
+
+ const marginWidth = 8 * (isMobile ? 1.125 : 1)
+ // calculate the max width to have a correct truncation
+ // FIXME: this is not easy to maintain, need to refactor
+ const feedIconWidth = 20 + marginWidth
+ const audioCoverWidth = settingWideMode ? 65 : 80 + marginWidth
+ const mediaWidth = (settingWideMode ? 48 : 80) * (isMobile ? 1.125 : 1) + marginWidth
+
+ let savedWidth = 0
+
+ savedWidth += feedIconWidth
+
+ if (hasAudio) {
+ savedWidth += audioCoverWidth
+ }
+ if (hasMedia && !hasAudio) {
+ savedWidth += mediaWidth
+ }
+
+ return (
+
+
+
+
+
+
+
+ ·
+ {!!displayTime && }
+
+
+ {entry?.title ? (
+
+ ) : (
+
+ )}
+ {!!isInCollection && }
+
+ {!simple && (
+
+
+
+ )}
+
+
+ {hasAudio && entry.firstAudio && (
+
+ }
+ feed={feed || inbox}
+ entry={entry?.iconEntry}
+ size={settingWideMode ? 65 : 80}
+ className="m-0 rounded"
+ useMedia
+ noMargin
+ />
+ }
+ />
+ )}
+
+ {!simple && !hasAudio && entry.firstMedia && (
+
+ )}
+
+ )
+}
+
+function AudioCover({
+ entryId,
+ src,
+ durationInSeconds,
+ feedIcon,
+}: {
+ entryId: string
+ src: string
+ durationInSeconds?: number | string
+ feedIcon: React.ReactNode
+}) {
+ const isMobile = useMobile()
+ const playStatus = useAudioPlayerAtomSelector((playerValue) =>
+ playerValue.src === src && playerValue.show ? playerValue.status : false,
+ )
+
+ const language = useGeneralSettingKey("language")
+ const isChinese = useMemo(() => {
+ return language === "zh-CN"
+ }, [language])
+
+ const seconds = formatTimeToSeconds(durationInSeconds)
+ const estimatedMins = seconds && Math.floor(seconds / 60)
+
+ const handleClickPlay = (e: React.MouseEvent) => {
+ if (isMobile) e.stopPropagation()
+ if (!playStatus) {
+ // switch this to play
+ AudioPlayer.mount({
+ type: "audio",
+ entryId,
+ src,
+ currentTime: 0,
+ })
+ } else {
+ // switch between play and pause
+ AudioPlayer.togglePlayAndPause()
+ }
+ }
+
+ return (
+
+ {feedIcon}
+
+
+
+
+
+ {!!estimatedMins && (
+
+
+
+ {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
+
+
+ )}
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx
index 2f5a6577b..fa9dbb971 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx
@@ -1,359 +1,6 @@
-import { useMobile } from "@follow/components/hooks/useMobile.js"
-import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
-import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks"
-import { useEntry } from "@follow/store/entry/hooks"
-import type { EntryModel } from "@follow/store/entry/types"
-import { useFeedById } from "@follow/store/feed/hooks"
-import { useInboxById } from "@follow/store/inbox/hooks"
-import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils"
-import { useMemo } from "react"
-import { titleCase } from "title-case"
+import { withFeature } from "~/lib/features"
-import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
-import { useGeneralSettingKey } from "~/atoms/settings/general"
-import { useRealInWideMode, useUISettingKey } from "~/atoms/settings/ui"
-import { RelativeTime } from "~/components/ui/datetime"
-import { Media } from "~/components/ui/media/Media"
-import { FEED_COLLECTION_LIST } from "~/constants"
-import { useEntryIsRead } from "~/hooks/biz/useAsRead"
-import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
-import { EntryTranslation } from "~/modules/entry-column/translation"
-import type { FeedIconEntry } from "~/modules/feed/feed-icon"
-import { FeedIcon } from "~/modules/feed/feed-icon"
-import { FeedTitle } from "~/modules/feed/feed-title"
-import { getPreferredTitle } from "~/store/feed/hooks"
+import { ListItem as ListItemAI } from "./list-item-template.ai"
+import { ListItem as ListItemLegacy } from "./list-item-template.legacy"
-import { StarIcon } from "../star-icon"
-import type { UniversalItemProps } from "../types"
-
-const entrySelector = (state: EntryModel) => {
- const { feedId, inboxHandle, read } = state
- const { authorAvatar, authorUrl, description, publishedAt, title } = state
-
- const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url)
- const firstAudio = audios?.[0]
- const media = state.media || []
- const firstMedia = media?.[0]
- const photo = media.find((a) => a.type === "photo")
- const firstPhotoUrl = photo?.url
- const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar }
-
- const titleEntry = { authorUrl }
-
- return {
- description,
- feedId,
- firstAudio,
- firstMedia,
- iconEntry,
- inboxId: inboxHandle,
- publishedAt,
- read,
- title,
- titleEntry,
- }
-}
-export function ListItem({
- entryId,
- entryPreview,
- translation,
- simple,
-}: UniversalItemProps & {
- simple?: boolean
-}) {
- const isMobile = useMobile()
- const entry = useEntry(entryId, entrySelector)
-
- const isInCollection = useIsEntryStarred(entryId)
- const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt
-
- const isRead = useEntryIsRead(entry)
-
- const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)
-
- const feed =
- useFeedById(entry?.feedId, (feed) => {
- return {
- type: feed.type,
- ownerUserId: feed.ownerUserId,
- id: feed.id,
- title: feed.title,
- url: (feed as any).url || "",
- image: feed.image,
- siteUrl: feed.siteUrl,
- }
- }) || entryPreview?.feeds
-
- const inbox = useInboxById(entry?.inboxId)
-
- const settingWideMode = useRealInWideMode()
- const thumbnailRatio = useUISettingKey("thumbnailRatio")
- const rid = `list-item-${entryId}`
-
- const bilingual = useGeneralSettingKey("translationMode") === "bilingual"
- const lineClamp = useMemo(() => {
- const envIsSafari = isSafari()
- let lineClampTitle = settingWideMode ? 1 : 2
- let lineClampDescription = settingWideMode ? 1 : 2
-
- if (translation?.title && !simple && bilingual) {
- lineClampTitle += 1
- }
- if (translation?.description && !simple && bilingual) {
- lineClampDescription += 1
- }
-
- // FIXME: Safari bug, not support line-clamp cross elements
- return {
- global: !envIsSafari
- ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]`
- : "",
- title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "",
- description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "",
- }
- }, [settingWideMode, simple, translation?.description, translation?.title, bilingual])
-
- const dimRead = useGeneralSettingKey("dimRead")
- // NOTE: prevent 0 height element, react virtuoso will not stop render any more
- if (!entry || !(feed || inbox)) return null
-
- const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt
-
- const related = feed || inbox
-
- const hasAudio = simple ? false : !!entry.firstAudio?.url
- const hasMedia = simple ? false : !!entry.firstMedia?.url
-
- const marginWidth = 8 * (isMobile ? 1.125 : 1)
- // calculate the max width to have a correct truncation
- // FIXME: this is not easy to maintain, need to refactor
- const feedIconWidth = 20 + marginWidth
- const audioCoverWidth = settingWideMode ? 65 : 80 + marginWidth
- const mediaWidth = (settingWideMode ? 48 : 80) * (isMobile ? 1.125 : 1) + marginWidth
-
- let savedWidth = 0
-
- savedWidth += feedIconWidth
-
- if (hasAudio) {
- savedWidth += audioCoverWidth
- }
- if (hasMedia && !hasAudio) {
- savedWidth += mediaWidth
- }
-
- return (
-
-
-
-
-
-
-
- ·
- {!!displayTime && }
-
-
- {entry?.title ? (
-
- ) : (
-
- )}
- {!!isInCollection && }
-
- {!simple && (
-
-
-
- )}
-
-
- {hasAudio && entry.firstAudio && (
-
- }
- feed={feed || inbox}
- entry={entry?.iconEntry}
- size={settingWideMode ? 65 : 80}
- className="m-0 rounded"
- useMedia
- noMargin
- />
- }
- />
- )}
-
- {!simple && !hasAudio && entry.firstMedia && (
-
- )}
-
- )
-}
-
-function AudioCover({
- entryId,
- src,
- durationInSeconds,
- feedIcon,
-}: {
- entryId: string
- src: string
- durationInSeconds?: number | string
- feedIcon: React.ReactNode
-}) {
- const isMobile = useMobile()
- const playStatus = useAudioPlayerAtomSelector((playerValue) =>
- playerValue.src === src && playerValue.show ? playerValue.status : false,
- )
-
- const language = useGeneralSettingKey("language")
- const isChinese = useMemo(() => {
- return language === "zh-CN"
- }, [language])
-
- const seconds = formatTimeToSeconds(durationInSeconds)
- const estimatedMins = seconds && Math.floor(seconds / 60)
-
- const handleClickPlay = (e: React.MouseEvent) => {
- if (isMobile) e.stopPropagation()
- if (!playStatus) {
- // switch this to play
- AudioPlayer.mount({
- type: "audio",
- entryId,
- src,
- currentTime: 0,
- })
- } else {
- // switch between play and pause
- AudioPlayer.togglePlayAndPause()
- }
- }
-
- return (
-
- {feedIcon}
-
-
-
-
-
- {!!estimatedMins && (
-
-
-
- {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
-
-
- )}
-
- )
-}
+export const ListItem = withFeature("ai")(ListItemAI, ListItemLegacy)
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx
index 821e442f0..9f53c80a8 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx
@@ -30,6 +30,7 @@ export const EntryHeaderActions = ({
clickableDisabled={config.disabled}
highlightMotion={config.notice}
id={`${config.entryId}/${config.id}`}
+ size={compact ? "sm" : "base"}
/>
)
})
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
index 7fc4abf65..2f7a3e17a 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
@@ -16,7 +16,15 @@ import { COMMAND_ID } from "~/modules/command/commands/id"
import { hasCommand, useCommand } from "~/modules/command/hooks/use-command"
import type { FollowCommandId } from "~/modules/command/types"
-export const MoreActions = ({ entryId, view }: { entryId: string; view: FeedViewType }) => {
+export const MoreActions = ({
+ entryId,
+ view,
+ compact,
+}: {
+ entryId: string
+ view: FeedViewType
+ compact?: boolean
+}) => {
const { moreAction } = useSortedEntryActions({ entryId, view })
const actionConfigs = useMemo(
@@ -47,7 +55,10 @@ export const MoreActions = ({ entryId, view }: { entryId: string; view: FeedView
return (
- } />
+ }
+ size={compact ? "sm" : "base"}
+ />
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx
index 92ee607c3..0e8f05596 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx
@@ -255,6 +255,7 @@ const EntryScrollArea: Component<{
{
@@ -49,7 +49,7 @@ const useReadPercent = () => {
const BackTopIndicator: Component = memo(({ className }) => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
- const aiEnabled = useServerConfigs()?.AI_CHAT_ENABLED
+ const aiEnabled = useFeature("ai")
const isAiPanelOpen = useAIChatPinned()
diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx
index 673123dde..0cbaccecb 100644
--- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx
+++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx
@@ -1 +1,5 @@
-export { CenterColumnLayout as Component } from "~/modules/app-layout/timeline-column/index"
+import { withFeature } from "~/lib/features"
+import { CenterColumnLayout } from "~/modules/app-layout/timeline-column/index"
+import { EntryColumnLayout } from "~/modules/entry-column/EntryColumnLayout"
+
+export const Component = withFeature("ai")(EntryColumnLayout, CenterColumnLayout)
diff --git a/icons/mgc/up_cute_re.svg b/icons/mgc/up_cute_re.svg
new file mode 100644
index 000000000..5bd96c2d7
--- /dev/null
+++ b/icons/mgc/up_cute_re.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/internal/components/package.json b/packages/internal/components/package.json
index 22e240ea0..a229a5802 100644
--- a/packages/internal/components/package.json
+++ b/packages/internal/components/package.json
@@ -24,6 +24,7 @@
"dependencies": {
"@essentials/request-timeout": "1.3.0",
"@follow/hooks": "workspace:*",
+ "@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@headlessui/react": "2.2.4",
diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx
index 5959eb22f..a3701d164 100644
--- a/packages/internal/components/src/ui/button/action-button.tsx
+++ b/packages/internal/components/src/ui/button/action-button.tsx
@@ -47,7 +47,7 @@ const actionButtonStyleVariant = {
size: {
lg: tw`text-xl size-10`,
base: tw`text-xl size-8`,
- sm: tw`text-sm size-6`,
+ sm: tw`text-lg size-7`,
},
}
diff --git a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx
index acfd490ee..88b0d9b9e 100644
--- a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx
+++ b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx
@@ -146,6 +146,7 @@ export const ScrollArea = ({
asChild = false,
onUpdateMaxScroll,
focusable = true,
+ stopWheelPropagation = true,
}: React.PropsWithChildren & {
rootClassName?: string
viewportClassName?: string
@@ -157,6 +158,7 @@ export const ScrollArea = ({
orientation?: "vertical" | "horizontal"
asChild?: boolean
focusable?: boolean
+ stopWheelPropagation?: boolean
} & { ref?: React.Ref }) => {
const [viewportRef, setViewportRef] = React.useState(null)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
@@ -169,7 +171,7 @@ export const ScrollArea = ({
div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
mask={mask}
asChild={asChild}
diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts
index c68b69db8..9cc885b92 100644
--- a/packages/internal/shared/src/settings/defaults.ts
+++ b/packages/internal/shared/src/settings/defaults.ts
@@ -49,6 +49,7 @@ export const defaultUISettings: UISettings = {
// Sidebar
entryColWidth: 356,
+ aiColWidth: 384,
feedColWidth: 256,
hideExtraBadge: false,
diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts
index 5dae0fe53..fa61d4111 100644
--- a/packages/internal/shared/src/settings/interface.ts
+++ b/packages/internal/shared/src/settings/interface.ts
@@ -51,6 +51,7 @@ export type AccentColor =
export interface UISettings {
accentColor: AccentColor
entryColWidth: number
+ aiColWidth: number
feedColWidth: number
opaqueSidebar: boolean
sidebarShowUnreadCount: boolean
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 033187bd2..ee01e78de 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1395,6 +1395,9 @@ importers:
'@follow/hooks':
specifier: workspace:*
version: link:../hooks
+ '@follow/models':
+ specifier: workspace:*
+ version: link:../models
'@follow/types':
specifier: workspace:*
version: link:../types