feat(feed): a masonry view for all feed (#4335)

* feat(feed): a masonry view for all feed (wip)

* fix: adjust color and icons

* feat: all masonry

* feat: item details

* fix: styles

* fix: styles

* fix: default tab to be -1

* fix: selectable view

* fix: safe area

* fix: remove safe area

* fix: styles

* fix: styles

* fix: navigation

* fix: aspect-ratio

* fix: hooks

* fix: styles

* chore: comment

* fix: splash issue

* fix: style

* fix: style

* fix: style

* fix: style

* fix: close entry

* fix: styles

* fix: folder in all

* fix: styles

* fix: styles

* fix: styles

* refactor(desktop): enhance entry data handling in AllItem component

- Updated the AllItem component to destructure additional properties (content, description) from the entry state for improved data accessibility.
- Modified the rendering logic to display title, content, or description in the UI, enhancing user experience by providing more context.

This refactor aims to streamline data management and improve the presentation of entry information.

* refactor(desktop): improve entry display order in AllItem component

- Adjusted the rendering logic in the AllItem component to prioritize displaying the entry title before the description and content. This change enhances the clarity and relevance of the information presented to users.

This update aims to further refine the user experience by ensuring the most important entry information is highlighted first.

* feat: enhance AllItem component with keyword highlighting

- Added support for highlighting keywords in the title using a new `title_keyword` property in the ExtraModel.
- Introduced random card and highlight styles for visual differentiation.
- Updated the rendering logic to display highlighted keywords within the title.

This improves the user experience by making important keywords more prominent in the item display.

* refactor(AllItem): update media rendering logic to include Audios

- Modified the rendering conditions in the AllItem component to include Audios in the media display alongside Articles, Notifications, and Social Media.
- Removed the previously defined separate rendering block for Audios to streamline the component structure.

This update enhances the media display functionality by integrating Audios into the existing media rendering logic.

* fix: height calc

* feat(AllMasonry): add refetch and scroll handling functionality

- Introduced a `refetch` prop to the `AllMasonry` component for improved data fetching capabilities.
- Implemented a `handleScrollTo` function to facilitate scrolling to specific items within the masonry layout.
- Updated the component structure to accommodate these new features, enhancing user interaction and experience.

This update enhances the functionality of the AllMasonry component by allowing for better data management and item navigation.

* feat(AllItem): enhance title rendering and entry read tracking

- Updated the title rendering logic to improve keyword highlighting with case-insensitive matching.
- Integrated the `unreadSyncService` to mark entries as read when hovered, enhancing user interaction.
- Refactored highlight styles to support new underline and background options for better visual distinction.

This update improves the user experience by providing clearer title visibility and tracking read status effectively.

* refactor(AllItem): streamline hover behavior and enhance title keyword matching

- Refactored hover handling to utilize a timer for showing previews, improving performance and user experience.
- Updated title keyword matching logic to ensure proper escaping of special characters, enhancing accuracy in highlighting.
- Cleaned up unused underline-wavy styles for better code maintainability.

This update optimizes the interaction flow and improves the visual representation of keywords in titles.

* feat(timeline): filter views based on AI feature flag

- Introduced a check for the AI feature to conditionally filter out the 'All' view from the timeline list.
- Updated the logic to create view IDs based on the filtered views, enhancing the timeline's adaptability to feature availability.

This update improves the timeline's functionality by ensuring that the view options align with the current feature set.

* refactor(AllItem): simplify highlight styles and improve text decoration handling

- Removed unnecessary text-decoration-skip-ink styles from highlight class names for cleaner code.
- Added inline style to ensure consistent text decoration behavior in highlighted keywords.

This update enhances code maintainability and ensures proper rendering of highlighted text.

* fix(EntryColumnGrid): add refetch prop and update comments in AllItem

- Added the `refetch` prop to the `EntryColumnGrid` component for improved data handling.
- Updated comment in `AllItem` from "Social Media" to "Common views" for better clarity.
- Enhanced the no content display in `AllItem` for improved user experience.

This update improves functionality and clarity in the entry column components.

* refactor(AllItem): clean up media handling and improve preview logic

- Removed unused `firstMedia` variable and adjusted media cover logic for clarity.
- Updated aspect ratio handling to ensure default values are set correctly.
- Enhanced the no media available display for better user experience.
- Refactored preview display logic to improve readability and maintainability.

This update streamlines the media handling in the AllItem component and enhances the user interface.

* fix: index key

* refactor(AllItem): optimize title keyword highlighting logic

- Simplified the keyword highlighting logic in the AllItem component by extracting the regex pattern into a separate variable.
- Improved readability and maintainability of the code by reducing redundancy in the title rendering function.

This update enhances the performance and clarity of the title rendering process.

* fix: button width

* fix: first screen navigation

* fix: video hovering autoplay

* feat(timeline-tabs): implement customizable timeline tabs settings

- Added a new `TimelineTabsSettingsModal` component for managing visible and hidden timeline tabs.
- Introduced drag-and-drop functionality for reordering tabs and customizing their visibility.
- Updated `SubscriptionTabButton` to accept a shortcut prop for improved keyboard navigation.
- Enhanced global shortcuts handling to allow bypassing input restrictions for specific actions.

These changes provide users with greater flexibility in managing their timeline tabs, improving the overall user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: clean up error handling in AllMasonry component and update UnreadSyncService to use new API method

- Removed console log from error handling in the AllMasonry component to streamline the code.
- Updated the UnreadSyncService to utilize the new `markAllAsRead` API method, improving the readability and structure of the request handling.

These changes enhance code clarity and maintainability across the affected components.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: optimize rendering performance in AllMasonry component

- Introduced `startTransition` for setting width in the ResizeObserver to improve rendering performance during layout updates.
- Updated the `LoadingSkeleton` component's class names for better styling consistency.
- Simplified key generation in the `SkeletonGrid` component by using `null` instead of `crypto.randomUUID()`.

These changes enhance the efficiency and maintainability of the AllMasonry component and its related elements.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: simplify icon rendering in TimelineTabsSettingsModal

- Removed conditional rendering for the icon in the TabItem component, directly displaying the icon for improved clarity and performance.
- This change streamlines the code and enhances the maintainability of the TimelineTabsSettingsModal component.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
songkeys 2025-09-05 21:24:42 +08:00 committed by GitHub
parent 335a38262a
commit 7d0a0d1c40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 1494 additions and 135 deletions

View File

@ -23,6 +23,7 @@ type BaseProps = {
blurhash?: string blurhash?: string
inline?: boolean inline?: boolean
fitContent?: boolean fitContent?: boolean
videoClassName?: string
} }
const isImageLoadedSet = new Set<string>() const isImageLoadedSet = new Set<string>()
@ -71,6 +72,7 @@ const MediaImpl: FC<MediaProps> = ({
width, width,
inline, inline,
fitContent, fitContent,
videoClassName,
...rest ...rest
} = props } = props
@ -287,7 +289,12 @@ const MediaImpl: FC<MediaProps> = ({
)} )}
onClick={handleClick} onClick={handleClick}
> >
<VideoPreview src={src!} previewImageUrl={previewImageSrc} thumbnail={thumbnail} /> <VideoPreview
src={src!}
previewImageUrl={previewImageSrc}
thumbnail={thumbnail}
videoClassName={videoClassName}
/>
</span> </span>
) )
} }
@ -487,7 +494,8 @@ const VideoPreview: FC<{
src: string src: string
previewImageUrl?: string previewImageUrl?: string
thumbnail?: boolean thumbnail?: boolean
}> = ({ src, previewImageUrl, thumbnail = false }) => { videoClassName?: string
}> = ({ src, previewImageUrl, thumbnail = false, videoClassName }) => {
const [isInitVideoPlayer, setIsInitVideoPlayer] = useState(!previewImageUrl) const [isInitVideoPlayer, setIsInitVideoPlayer] = useState(!previewImageUrl)
const [videoRef, setVideoRef] = useState<VideoPlayerRef | null>(null) const [videoRef, setVideoRef] = useState<VideoPlayerRef | null>(null)
@ -507,7 +515,7 @@ const VideoPreview: FC<{
{!isInitVideoPlayer ? ( {!isInitVideoPlayer ? (
<img <img
src={previewImageUrl} src={previewImageUrl}
className="size-full object-cover" className={cn("size-full object-cover", videoClassName)}
onMouseEnter={() => { onMouseEnter={() => {
setIsInitVideoPlayer(true) setIsInitVideoPlayer(true)
}} }}
@ -520,7 +528,7 @@ const VideoPreview: FC<{
poster={previewImageUrl} poster={previewImageUrl}
ref={setVideoRef} ref={setVideoRef}
muted muted
className="not-prose relative size-full object-cover" className={cn("not-prose relative size-full object-cover", videoClassName)}
/> />
)} )}

View File

@ -432,7 +432,11 @@ export const useEntryActions = ({
new EntryActionMenuItem({ new EntryActionMenuItem({
id: COMMAND_ID.entry.readability, id: COMMAND_ID.entry.readability,
onClick: runCmdFn(COMMAND_ID.entry.readability, [{ entryId, entryUrl: entry.url! }]), onClick: runCmdFn(COMMAND_ID.entry.readability, [{ entryId, entryUrl: entry.url! }]),
hide: !!entry.readability || compact || (view && views[view]!.wideMode) || !entry.url, hide:
!!entry.readability ||
compact ||
(view && views.find((v) => v.view === view)?.wideMode) ||
!entry.url,
active: isEntryInReadability, active: isEntryInReadability,
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability, notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
entryId, entryId,

View File

@ -62,7 +62,7 @@ const parseNavigateEntryOptions = (options: NavigateEntryOptions): ParsedNavigat
let finalTimelineId = timelineId || params.timelineId || ROUTE_FEED_PENDING let finalTimelineId = timelineId || params.timelineId || ROUTE_FEED_PENDING
const finalEntryId = entryId || ROUTE_ENTRY_PENDING const finalEntryId = entryId || ROUTE_ENTRY_PENDING
const subscription = getSubscriptionByFeedId(finalFeedId) const subscription = getSubscriptionByFeedId(finalFeedId)
const finalView = subscription?.view || view const finalView = view || subscription?.view
if ("feedId" in options && feedId === null) { if ("feedId" in options && feedId === null) {
finalFeedId = ROUTE_FEED_PENDING finalFeedId = ROUTE_FEED_PENDING

View File

@ -1,10 +1,22 @@
import { FeedViewType } from "@follow/constants"
import { useViewWithSubscription } from "@follow/store/subscription/hooks" import { useViewWithSubscription } from "@follow/store/subscription/hooks"
import { useMemo } from "react" import { useMemo } from "react"
import { useFeature } from "~/hooks/biz/useFeature"
export const useTimelineList = () => { export const useTimelineList = () => {
const views = useViewWithSubscription() const views = useViewWithSubscription()
const viewsIds = useMemo(() => views.map((view) => `view-${view}`), [views]) // because the All view is highly tied to the AI
// so we need to filter it out if the AI is not enabled
const aiEnabled = useFeature("ai")
const filteredViews = useMemo(
() => (aiEnabled ? views : views.filter((v) => v !== FeedViewType.All)),
[views, aiEnabled],
)
const viewsIds = useMemo(() => filteredViews.map((view) => `view-${view}`), [filteredViews])
return viewsIds return viewsIds
} }

View File

@ -3,6 +3,7 @@ import { callWindowExposeRenderer } from "@follow/shared/bridge"
interface ShortcutDefinition { interface ShortcutDefinition {
accelerator: string accelerator: string
action: () => void action: () => void
inputBypass?: boolean
} }
const parseAccelerator = ( const parseAccelerator = (
@ -23,6 +24,7 @@ export const registerAppGlobalShortcuts = () => {
{ {
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
action: () => window.router.showSettings(), action: () => window.router.showSettings(),
inputBypass: true,
}, },
{ {
accelerator: "CmdOrCtrl+T", accelerator: "CmdOrCtrl+T",
@ -41,15 +43,16 @@ export const registerAppGlobalShortcuts = () => {
] ]
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
// Prevent on input, textarea, [contenteditable] shortcuts.forEach(({ accelerator, action, inputBypass }) => {
if ( // Prevent on input, textarea, [contenteditable]
["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName) || if (
(e.target as HTMLElement)?.contentEditable === "true" !inputBypass &&
) { (["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName) ||
return (e.target as HTMLElement)?.contentEditable === "true")
} ) {
return
}
shortcuts.forEach(({ accelerator, action }) => {
const { key, ctrl, meta, shift } = parseAccelerator(accelerator) const { key, ctrl, meta, shift } = parseAccelerator(accelerator)
const matchesKey = e.key.toLowerCase() === key.toLowerCase() const matchesKey = e.key.toLowerCase() === key.toLowerCase()

View File

@ -30,7 +30,10 @@ const EntryLayoutContentLegacy = () => {
const settingWideMode = useRealInWideMode() const settingWideMode = useRealInWideMode()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
usePrefetchEntryDetail(realEntryId) usePrefetchEntryDetail(realEntryId)
const showEntryContent = !(views[view]!.wideMode || (settingWideMode && !realEntryId)) const showEntryContent = !(
views.find((v) => v.view === view)?.wideMode ||
(settingWideMode && !realEntryId)
)
const wideMode = !!(settingWideMode && realEntryId) const wideMode = !!(settingWideMode && realEntryId)
const feedColumnTempShow = useTimelineColumnTempShow() const feedColumnTempShow = useTimelineColumnTempShow()
const feedColumnShow = useTimelineColumnShow() const feedColumnShow = useTimelineColumnShow()
@ -75,7 +78,7 @@ export const EntryLayoutContentWithAI = () => {
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const wideMode = !!(settingWideMode && realEntryId) const wideMode = !!(settingWideMode && realEntryId)
const isWideView = views[view]?.wideMode const isWideView = views.find((v) => v.view === view)?.wideMode
return ( return (
<AppLayoutGridContainerProvider> <AppLayoutGridContainerProvider>
<EntryGridContainer wideMode={wideMode}> <EntryGridContainer wideMode={wideMode}>

View File

@ -110,7 +110,7 @@ const FeedResponsiveResizerContainer = ({
const aiEnabled = useFeature("ai") const aiEnabled = useFeature("ai")
const feedColumnShow = useTimelineColumnShow() const feedColumnShow = useTimelineColumnShow()
const feedColumnTempShow = useTimelineColumnTempShow() const feedColumnTempShow = useTimelineColumnTempShow()
const { entryId, isPendingEntry } = useRouteParams() const { entryId, isPendingEntry, view: currentView } = useRouteParams()
const navigate = useNavigateEntry() const navigate = useNavigateEntry()
const t = useI18n() const t = useI18n()
@ -201,7 +201,7 @@ const FeedResponsiveResizerContainer = ({
{entryId && !isPendingEntry && aiEnabled && !isAtTop && ( {entryId && !isPendingEntry && aiEnabled && !isAtTop && (
<m.div <m.div
className="bg-background/80 hover:bg-background/90 center absolute inset-0 z-20 cursor-pointer backdrop-blur-[2px] transition-colors duration-200" className="bg-background/80 hover:bg-background/90 center absolute inset-0 z-20 cursor-pointer backdrop-blur-[2px] transition-colors duration-200"
onClick={() => navigate({ entryId: null })} onClick={() => navigate({ entryId: null, view: currentView })}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}

View File

@ -24,7 +24,8 @@ export function TimelineColumnLayout() {
const settingWideMode = useRealInWideMode() const settingWideMode = useRealInWideMode()
const entryColWidth = useMemo(() => getUISettings().entryColWidth, []) const entryColWidth = useMemo(() => getUISettings().entryColWidth, [])
const { view } = useRouteParams() const { view } = useRouteParams()
const inWideMode = (view ? views[view]!.wideMode : false) || settingWideMode const inWideMode =
(view ? views.find((v) => v.view === view)?.wideMode : false) || settingWideMode
const feedColumnWidth = useUISettingKey("feedColWidth") const feedColumnWidth = useUISettingKey("feedColWidth")
const startDragPosition = useRef(0) const startDragPosition = useRef(0)
const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({ const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({

View File

@ -95,7 +95,7 @@ export const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => {
export function DroppableContainer({ children }: { children: ReactNode }) { export function DroppableContainer({ children }: { children: ReactNode }) {
return ( return (
<div className="border-border bg-material-ultra-thin flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border p-2 pb-6 shadow-sm"> <div className="border-border bg-material-ultra-thin flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border p-2 shadow-sm">
{children} {children}
</div> </div>
) )

View File

@ -12,6 +12,7 @@ import { getUISettings, setUISetting } from "~/atoms/settings/ui"
import { m } from "~/components/common/Motion" import { m } from "~/components/common/Motion"
import { ROUTE_ENTRY_PENDING } from "~/constants" import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout" import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
import { EntryContent } from "~/modules/entry-content/components/entry-content" import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider" import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
@ -33,9 +34,15 @@ const AIEntryLayoutImpl = () => {
const accumulatedDelta = useRef(0) const accumulatedDelta = useRef(0)
const isScrollingAtTop = useRef(false) const isScrollingAtTop = useRef(false)
const { view: currentView } = useRouteParams()
const closeEntry = useCallback(() => {
navigate({ entryId: null, view: currentView })
}, [navigate, currentView])
const handleCloseGesture = useCallback(() => { const handleCloseGesture = useCallback(() => {
navigate({ entryId: null }) closeEntry()
}, [navigate]) }, [closeEntry])
const handleWheel = useCallback( const handleWheel = useCallback(
(e: WheelEvent) => { (e: WheelEvent) => {

View File

@ -28,7 +28,11 @@ export const EntryItemSkeleton: FC<{
} }
return ( return (
<div className={cn(views[view]!.gridMode ? girdClassNames : "flex flex-col")}> <div
className={cn(
views.find((v) => v.view === view)?.gridMode ? girdClassNames : "flex flex-col",
)}
>
{Array.from({ length: count }).map((_, index) => ( {Array.from({ length: count }).map((_, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique // eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique
<div key={index}>{SkeletonItem}</div> <div key={index}>{SkeletonItem}</div>

View File

@ -0,0 +1,495 @@
import { views } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { useEntry } from "@follow/store/entry/hooks"
import { useEntryStore } from "@follow/store/entry/store"
import { useFeedById } from "@follow/store/feed/hooks"
import { cn, formatDuration, transformVideoUrl } from "@follow/utils"
import { FeedViewType } from "@follow-app/client-sdk"
import { useHover } from "@use-gesture/react"
import dayjs from "dayjs"
import { useCallback, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import { Media } from "~/components/ui/media/Media"
import { SwipeMedia } from "~/components/ui/media/SwipeMedia"
import { useEntryIsRead } from "~/hooks/biz/useAsRead"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { StarIcon } from "../star-icon"
import { EntryTranslation } from "../translation"
import type { EntryListItemFC } from "../types"
const cardStylePresets = [
{
card: "bg-[#7C6E63] text-white",
icon: "text-[#B7ADA4]",
},
{
card: "bg-[#D7FED3] text-black",
icon: "text-[#92E58A]",
},
{
card: "bg-[#FDFFDA] text-black",
icon: "text-[#F9EFAA]",
},
{
card: "bg-[#FFE5EE] text-black",
icon: "text-[#FDD1E2]",
},
{
card: "bg-[#CFF0FF] text-black",
icon: "text-[#A7D6F2]",
},
{
card: "bg-[#ECE7FB] text-black",
icon: "text-[#DFCFF0]",
},
]
const highlightStyle = [
{
type: "underline",
className: "underline decoration-blue-500 decoration-4 underline-offset-1",
},
{
type: "underline",
className: "underline decoration-yellow-500 decoration-4 underline-offset-1",
},
{
type: "underline",
className: "underline decoration-green-500 decoration-4 underline-offset-1",
},
{
type: "underline",
className: "underline decoration-orange-500 decoration-4 underline-offset-1",
},
// {
// type: "underline-wavy",
// className:
// "underline decoration-blue-500 decoration-4 underline-offset-1 decoration-wavy",
// },
// {
// type: "underline-wavy",
// className:
// "underline decoration-yellow-500 decoration-4 underline-offset-1 decoration-wavy",
// },
// {
// type: "underline-wavy",
// className:
// "underline decoration-green-500 decoration-4 underline-offset-1 decoration-wavy",
// },
// {
// type: "underline-wavy",
// className:
// "underline decoration-orange-500 decoration-4 underline-offset-1 decoration-wavy",
// },
{
type: "full",
className: "bg-blue-200 text-black",
},
{
type: "full",
className: "bg-yellow-200 text-black",
},
{
type: "full",
className: "bg-green-200 text-black",
},
{
type: "full",
className: "bg-orange-200 text-black",
},
]
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
export const AllItem: EntryListItemFC = ({ entryId, entryPreview, translation }) => {
const view = useViewTypeByEntryId(entryId)
const entry = useEntry(entryId, (state) => {
/// keep-sorted
const {
attachments,
authorAvatar,
content,
description,
extra,
feedId,
id,
publishedAt,
read,
title,
url,
} = state
const media = state.media || []
const photo = media.find((a) => a.type === "photo")
const firstPhotoUrl = photo?.url
const iconEntry: FeedIconEntry = {
firstPhotoUrl,
authorAvatar,
}
const { duration_in_seconds } =
attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}
const seconds = duration_in_seconds
? Number.parseInt(duration_in_seconds.toString())
: undefined
const duration = formatDuration(seconds)
return {
attachments,
duration,
content,
extra,
feedId,
iconEntry,
id,
media,
publishedAt,
read,
title,
description,
url,
}
})
const isInCollection = useIsEntryStarred(entryId)
const feeds = useFeedById(entry?.feedId)
const asRead = useEntryIsRead(entry)
const { t } = useTranslation("common")
const icon = useMemo(() => views.find((v) => v.view === view)?.icon, [view])
const entryMedia = useMemo(
() => entry?.media || entryPreview?.entries?.media || [],
[entry, entryPreview],
)
const randomStyle = useMemo(() => {
// Use a hash of entryId to get a consistent index for card style
// djb2 hash
let hash = 5381
for (let i = 0, len = entryId.length; i < len; ++i) {
hash = (hash << 5) + hash + entryId.codePointAt(i)!
}
const hashShift = (hash >>> 0) + 1
const cardIndex = hashShift % cardStylePresets.length
const highlightIndex = hashShift % highlightStyle.length
return {
card: cardStylePresets[cardIndex]!,
highlight: highlightStyle[highlightIndex]!,
}
}, [entryId])
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)
const entryContent = useMemo(() => <EntryContent entryId={entryId} noMedia compact />, [entryId])
const previewMedia = usePreviewMedia(entryContent)
const [miniIframeSrc] = useMemo(
() => [
transformVideoUrl({
url: entry?.url ?? "",
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
],
[entry?.attachments, entry?.url],
)
const ref = useRef<HTMLDivElement>(null)
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [showPreview, setShowPreview] = useState(false)
useHover(
(event) => {
const hovered = event.active
if (hovered) {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
}
hoverTimerRef.current = setTimeout(() => {
setShowPreview(true)
}, 500)
} else {
setShowPreview(false)
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
}
},
{ target: ref },
)
const title = entry?.title || entry?.description || entry?.content
const titleKeyword = entry?.extra?.title_keyword?.toLowerCase().trim() || ""
const titleWithKeyword = useMemo(() => {
if (!title || !titleKeyword) return title
const regex = new RegExp(`(${titleKeyword.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi")
const renderTitle = ({ type }: { type: "highlight" | "normal" }) => (
<>
{title.split(regex).map((part, index) => {
// Check if this part matches the keyword (case-insensitive)
const normalizedPart = part.toLowerCase().trim()
const normalizedKeyword = titleKeyword.trim()
const isKeyword = normalizedPart === normalizedKeyword && part.trim() !== ""
return isKeyword ? (
<span
key={`keyword-${index}-${part}`}
className={cn(type === "highlight" && randomStyle.highlight.className)}
style={{
textDecorationSkipInk: "none",
}}
>
{part}
</span>
) : (
<span
key={`text-${index}-${part}`}
className={cn(type === "highlight" && "text-transparent")}
>
{part}
</span>
)
})}
</>
)
return (
<div className="relative">
{renderTitle({ type: "normal" })}
<div className="absolute inset-0 z-0">{renderTitle({ type: "highlight" })}</div>
</div>
)
}, [randomStyle.highlight.className, titleKeyword, title])
if (!entry) return null
const mediaCover = entryMedia?.[0] ?? null
const mediaCoverHeight = mediaCover?.height
const mediaCoverWidth = mediaCover?.width
const aspectRatio =
mediaCoverHeight && mediaCoverWidth ? mediaCoverWidth / mediaCoverHeight : undefined
return (
<div className="group" ref={ref}>
{/* Hero */}
<div
className={cn(
"relative flex max-h-[35em] flex-col overflow-hidden rounded-lg",
"before:group-hover:bg-theme-item-hover before:pointer-events-none before:absolute before:inset-0 before:z-10 before:transition-colors before:duration-200",
randomStyle.card.card,
)}
>
{/* Icon */}
{!mediaCover && (
<div
className={cn(
"absolute left-4 top-4 z-[1] flex items-center justify-center text-2xl",
randomStyle.card.icon,
)}
>
{icon}
</div>
)}
{/* Common views */}
{(view === FeedViewType.Articles ||
view === FeedViewType.Notifications ||
view === FeedViewType.SocialMedia ||
view === FeedViewType.Audios) && (
<>
{mediaCover ? (
<Media
src={mediaCover.url}
type={mediaCover.type}
previewImageUrl={mediaCover.preview_image_url}
className="min-h-[10em] w-full overflow-hidden"
mediaContainerClassName="size-full min-h-[10em] object-cover"
videoClassName="size-full min-h-[10em] object-cover"
loading="lazy"
proxy={{
width: mediaCoverWidth ?? 200,
height: mediaCoverHeight ?? 200,
}}
blurhash={mediaCover.blurhash || undefined}
style={{
aspectRatio: aspectRatio ?? 1,
}}
/>
) : (
<div className="flex min-h-[10em] flex-col items-center justify-center overflow-hidden px-4 py-20 text-[1.5rem] font-normal leading-[1.2]">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
</>
)}
{/* Pictures */}
{view === FeedViewType.Pictures && (
<div className="relative flex gap-2 overflow-x-auto">
{entryMedia ? (
<SwipeMedia
media={entryMedia}
className={cn(
"aspect-square",
"w-full shrink-0 rounded-md [&_img]:rounded-md",
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
onPreview={previewMedia}
/>
) : (
<div className="flex min-h-[10em] flex-col items-center justify-center overflow-hidden px-4 py-20 text-[1.5rem] font-normal leading-[1.2]">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
</div>
)}
{/* Videos */}
{view === FeedViewType.Videos && (
<div className="cursor-card w-full">
<div className="relative overflow-x-auto">
{mediaCover ? (
<Media
key={mediaCover.url}
src={mediaCover.url}
type={mediaCover.type}
previewImageUrl={mediaCover.preview_image_url}
className="min-h-[10em] w-full overflow-hidden"
mediaContainerClassName="size-full min-h-[10em] object-cover"
videoClassName="size-full min-h-[10em] object-cover"
loading="lazy"
proxy={{
width: mediaCover.width ?? 640,
height: mediaCover.height ?? 360,
}}
blurhash={mediaCover.blurhash || undefined}
style={{
aspectRatio: aspectRatio ?? 16 / 9,
}}
showFallback={true}
/>
) : (
<div className="flex min-h-[10em] flex-col items-center justify-center overflow-hidden px-4 py-20 text-[1.5rem] font-normal leading-[1.2]">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
{miniIframeSrc && showPreview && (
<div className="pointer-events-none absolute inset-0">
<ViewTag
src={miniIframeSrc}
className={cn("pointer-events-none size-full min-h-[10em] object-cover")}
/>
</div>
)}
{!!entry.duration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className={cn("relative px-1 pb-4 text-sm")}>
<div className="flex items-center">
<div
className={cn(
"bg-accent mr-1 size-1.5 shrink-0 self-center rounded-full duration-200",
asRead && "mr-0 w-0",
)}
/>
<div className={cn("relative mb-1 mt-1.5 flex w-full items-center gap-1 font-medium")}>
<EntryTranslation
source={entry.title}
target={translation?.title}
className="line-clamp-2"
inline={false}
/>
{isInCollection && (
<div className="h-0 shrink-0 -translate-y-2">
<StarIcon />
</div>
)}
</div>
</div>
<div className="flex items-center justify-between truncate text-[13px]">
<div className="flex items-center gap-1">
<FeedIcon
fallback
noMargin
className="flex"
feed={feeds!}
entry={entry.iconEntry}
size={18}
/>
<span className={cn("text-text-secondary min-w-0 truncate pl-1")}>
<FeedTitle feed={feeds} />
</span>
</div>
<div>
<span className={cn("text-zinc-500")}>
{dayjs
.duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute")
.humanize()}
{t("space")}
{t("words.ago")}
</span>
</div>
</div>
</div>
</div>
)
}
AllItem.wrapperClassName = "hover:bg-transparent"
// function AllArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) {
// return <ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} />
// }
// Determine the most appropriate view type for an entry
function useViewTypeByEntryId(entryId: string): FeedViewType {
return useEntryStore(
useCallback(
(state) => {
const certain = Object.entries(state.entryIdByView).find(([_, entryIds]) =>
entryIds.has(entryId),
)?.[0] as FeedViewType | undefined
const fallback = FeedViewType.Articles
return Number(certain ?? fallback)
},
[entryId],
),
)
}

View File

@ -0,0 +1,368 @@
import { Masonry } from "@follow/components/ui/masonry/index.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { FeedViewType } from "@follow/constants"
import { useRefValue } from "@follow/hooks"
import { getEntry } from "@follow/store/entry/getter"
import { clsx } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import type { RenderComponentProps } from "masonic"
import { useInfiniteLoader } from "masonic"
import type { FC, ReactNode } from "react"
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { EntryColumnShortcutHandler } from "../EntryColumnShortcutHandler"
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
import { EntryItem } from "../item"
interface AllMasonryProps {
data: string[]
hasNextPage: boolean
endReached: () => void
Footer?: FC | ReactNode
refetch: () => void
}
const GUTTER = 16
const COLUMN_WIDTH = 250
const OVERSCAN = 2
interface MasonryItem {
entryId: string
}
export const AllMasonry: FC<AllMasonryProps> = ({
data,
hasNextPage,
endReached,
Footer,
refetch,
}) => {
const scrollElement = useScrollViewElement()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [width, setWidth] = useState<number>(0)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const prevDataLengthRef = useRef(data.length)
// Convert entry IDs to masonry items with stable references
const items = useMemo<MasonryItem[]>(
() => data.filter(Boolean).map((entryId) => ({ entryId })),
[data],
)
// Handle loading state when new data arrives
useEffect(() => {
if (data.length > prevDataLengthRef.current) {
setIsLoadingMore(false)
}
prevDataLengthRef.current = data.length
}, [data.length])
// Force remount when errors happen
const [forceRemountCounter, setForceRemountCounter] = useState(0)
const masonryKey = useMemo(() => `masonry-${forceRemountCounter}`, [forceRemountCounter])
// Handle container resize
useEffect(() => {
if (!containerRef) return
const resizeObserver = new ResizeObserver((entries) => {
const [first] = entries
if (first) {
startTransition(() => {
setWidth(first.contentRect.width)
})
}
})
resizeObserver.observe(containerRef)
return () => resizeObserver.disconnect()
}, [containerRef])
const columnCount = useMemo(() => {
if (!width) return 1
return Math.max(1, Math.floor(width / COLUMN_WIDTH))
}, [width])
const columnWidth = useMemo(() => {
if (!width) return COLUMN_WIDTH
const totalGutter = (columnCount - 1) * GUTTER
return Math.floor((width - totalGutter) / columnCount)
}, [width, columnCount])
const maybeLoadMore = useInfiniteLoader(
useCallback(() => {
if (hasNextPage && !isLoadingMore) {
setIsLoadingMore(true)
endReached()
}
}, [hasNextPage, endReached, isLoadingMore]),
{
isItemLoaded: (index, items) => index < items.length && Boolean(items[index]),
minimumBatchSize: 24,
threshold: 6,
},
)
const currentRange = useRef<{ start: number; end: number } | undefined>(undefined)
const handleRender = useCallback(
(startIndex: number, stopIndex: number, items: MasonryItem[]) => {
currentRange.current = { start: startIndex, end: stopIndex }
return maybeLoadMore(startIndex, stopIndex, items as any[])
},
[maybeLoadMore],
)
// Mark as read functionality
const renderMarkRead = useGeneralSettingKey("renderMarkUnread")
const scrollMarkRead = useGeneralSettingKey("scrollMarkUnread")
const dataRef = useRefValue(data)
useEffect(() => {
if (!renderMarkRead && !scrollMarkRead) return
if (!scrollElement) return
const observer = new IntersectionObserver(
(entries) => {
if (renderMarkRead) {
const visibleEntryIds: string[] = []
entries.forEach(
({ isIntersecting, intersectionRatio, boundingClientRect, rootBounds, target }) => {
if (
isIntersecting &&
intersectionRatio >= 0.8 &&
boundingClientRect.top >= (rootBounds?.top ?? 0)
) {
const { entryId } = (target as HTMLElement).dataset
if (entryId) visibleEntryIds.push(entryId)
}
},
)
if (visibleEntryIds.length > 0) {
batchMarkRead(visibleEntryIds)
}
}
if (scrollMarkRead) {
let minIndex = Number.MAX_SAFE_INTEGER
entries.forEach(({ isIntersecting, boundingClientRect, target }) => {
if (!isIntersecting && boundingClientRect.top < 0) {
const { index: datasetIndex } = (target as HTMLElement).dataset
const parsedIndex = Number.parseInt(datasetIndex || "0")
if (parsedIndex > 0 && parsedIndex <= (currentRange.current?.end ?? 0)) {
minIndex = Math.min(minIndex, parsedIndex)
}
}
})
if (minIndex !== Number.MAX_SAFE_INTEGER) {
batchMarkRead(dataRef.current.slice(0, minIndex))
}
}
},
{
root: scrollElement,
rootMargin: "0px",
threshold: [0, 0.8, 1],
},
)
return () => observer.disconnect()
}, [scrollElement, renderMarkRead, scrollMarkRead, dataRef])
const handleScrollTo = useCallback(
(index: number) => {
if (!scrollElement) return
const findTarget = (): HTMLElement | null => {
const byIndex = containerRef?.querySelector<HTMLElement>(`[data-index="${index}"]`)
if (byIndex) return byIndex
const id = dataRef.current[index]
if (!id) return null
return containerRef?.querySelector<HTMLElement>(`[data-entry-id="${id}"]`) ?? null
}
const scrollToEl = (el: HTMLElement) => {
const scRect = scrollElement.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
const centerOffset = (scrollElement.clientHeight - elRect.height) / 2
const targetTop = elRect.top - scRect.top + scrollElement.scrollTop - centerOffset
const nextTop = Math.max(0, Math.round(targetTop))
if (Math.abs(nextTop - scrollElement.scrollTop) > 2) {
scrollElement.scrollTo({ top: nextTop, behavior: "auto" })
}
}
const el = findTarget()
if (el) {
scrollToEl(el)
} else {
// Try once more on the next frame in case virtualization just mounted it
requestAnimationFrame(() => {
const el2 = findTarget()
if (el2) scrollToEl(el2)
})
}
},
[containerRef, dataRef, scrollElement],
)
if (!width) {
return (
<div ref={setContainerRef} className="mx-4 pt-4">
<LoadingSkeleton />
</div>
)
}
return (
<div ref={setContainerRef} className="mx-4 pb-8 pt-4">
<MasonryWrapper
key={masonryKey}
items={items}
columnGutter={GUTTER}
columnWidth={columnWidth}
columnCount={columnCount}
overscanBy={OVERSCAN}
render={MasonryItemRender}
onRender={handleRender}
itemKey={itemKey}
itemHeightEstimate={200}
onError={() => {
setForceRemountCounter((prev) => prev + 1)
}}
/>
<div className="mt-8">
{Footer && <div className="mb-4">{typeof Footer === "function" ? <Footer /> : Footer}</div>}
{(hasNextPage || isLoadingMore) && <SkeletonGrid columnCount={columnCount} />}
</div>
<EntryColumnShortcutHandler
refetch={refetch}
data={dataRef.current}
handleScrollTo={handleScrollTo}
/>
</div>
)
}
const itemKey = (item: MasonryItem, index: number) => {
if (!item || !item.entryId) {
console.warn("Missing item or entryId at index:", index)
return `fallback-${index}`
}
return item.entryId
}
const MasonryItemRender: React.ComponentType<RenderComponentProps<MasonryItem>> = ({
data,
index,
}) => {
if (!data || !data.entryId) return <LoadingSkeleton count={1} />
const entry = getEntry(data.entryId)
if (!entry) return <LoadingSkeleton count={1} />
return (
<div
data-entry-id={data.entryId}
data-index={index}
className={clsx(
"bg-background rounded-lg shadow-sm",
"transition-all duration-200 hover:shadow-md",
)}
>
<EntryItem entryId={data.entryId} view={FeedViewType.All} />
</div>
)
}
const MasonryWrapper: FC<{
items: MasonryItem[]
columnGutter: number
columnWidth: number
columnCount: number
overscanBy: number
render: React.ComponentType<RenderComponentProps<MasonryItem>>
onRender: (startIndex: number, stopIndex: number, items: MasonryItem[]) => void
itemKey: (item: MasonryItem, index: number) => string
itemHeightEstimate?: number
onError?: () => void
}> = (props) => {
const [errorKey, setErrorKey] = useState(0)
const [hasError, setHasError] = useState(false)
useEffect(() => {
if (hasError) {
const timer = setTimeout(() => setHasError(false), 100)
return () => clearTimeout(timer)
}
}, [hasError])
return (
<ErrorBoundary
key={errorKey}
fallback={(errorData) => {
console.error("Masonry error caught:", errorData.error)
setHasError(true)
props.onError?.()
return (
<div className="flex items-center justify-center py-8">
<LoadingSkeleton count={6} />
</div>
)
}}
beforeCapture={() => {
setErrorKey((prev) => prev + 1)
}}
>
{!hasError && (
<Masonry
items={props.items}
columnGutter={props.columnGutter}
columnWidth={props.columnWidth}
columnCount={props.columnCount}
overscanBy={props.overscanBy}
render={props.render}
onRender={props.onRender}
itemKey={props.itemKey}
itemHeightEstimate={props.itemHeightEstimate}
/>
)}
</ErrorBoundary>
)
}
// Loading skeleton component
const LoadingSkeleton: FC<{ count?: number }> = ({ count = 1 }) => {
const keys = useMemo(() => Array.from({ length: count }), [count])
return (
<>
{keys.map((_, index) => (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key
key={index}
className="border-material-ultra-thick bg-background overflow-hidden rounded-lg border"
>
<div className="space-y-3 p-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-32 w-full" />
</div>
</div>
))}
</>
)
}
const SkeletonGrid: FC<{ columnCount: number }> = ({ columnCount }) => {
const keys = useMemo(() => Array.from({ length: columnCount * 2 }, () => null), [columnCount])
return (
<div className="mb-4 grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}>
{keys.map((_, index) => (
<LoadingSkeleton count={1} key={index} />
))}
</div>
)
}

View File

@ -1,5 +1,6 @@
import { FeedViewType } from "@follow/constants" import { FeedViewType } from "@follow/constants"
import { AllItem } from "./all-item"
import { ArticleItem } from "./article-item" import { ArticleItem } from "./article-item"
import { AudioItem } from "./audio-item" import { AudioItem } from "./audio-item"
import { NotificationItem } from "./notification-item" import { NotificationItem } from "./notification-item"
@ -8,6 +9,7 @@ import { SocialMediaItem } from "./social-media-item"
import { VideoItem } from "./video-item" import { VideoItem } from "./video-item"
const ItemMap = { const ItemMap = {
[FeedViewType.All]: AllItem,
[FeedViewType.Articles]: ArticleItem, [FeedViewType.Articles]: ArticleItem,
[FeedViewType.SocialMedia]: SocialMediaItem, [FeedViewType.SocialMedia]: SocialMediaItem,
[FeedViewType.Pictures]: PictureItem, [FeedViewType.Pictures]: PictureItem,

View File

@ -17,7 +17,7 @@ export const FooterMarkItem = ({
if (view === FeedViewType.SocialMedia) { if (view === FeedViewType.SocialMedia) {
return <SocialMediaFooterMarkItem filter={filter} /> return <SocialMediaFooterMarkItem filter={filter} />
} else if (views[view]!.gridMode) { } else if (views.find((v) => v.view === view)?.gridMode) {
return <GridFooterMarkItem filter={filter} /> return <GridFooterMarkItem filter={filter} />
} }
return <CommonFooterMarkItem filter={filter} /> return <CommonFooterMarkItem filter={filter} />

View File

@ -18,7 +18,10 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp
return ( return (
<div className={cn(styles, animationStyles)}> <div className={cn(styles, animationStyles)}>
<ScrollArea <ScrollArea
scrollbarClassName={cn(!views[view]!.wideMode ? "w-[5px] p-0" : "", "z-[3]")} scrollbarClassName={cn(
!views.find((v) => v.view === view)?.wideMode ? "w-[5px] p-0" : "",
"z-[3]",
)}
mask={false} mask={false}
ref={ref} ref={ref}
rootClassName={cn("h-full", isZenMode ? "max-w-[80ch] mx-auto" : "")} rootClassName={cn("h-full", isZenMode ? "max-w-[80ch] mx-auto" : "")}

View File

@ -21,15 +21,29 @@ import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContaine
import { EntryItemSkeleton } from "./EntryItemSkeleton" import { EntryItemSkeleton } from "./EntryItemSkeleton"
import { EntryItem } from "./item" import { EntryItem } from "./item"
import { AllMasonry } from "./Items/all-masonry"
import { PictureMasonry } from "./Items/picture-masonry" import { PictureMasonry } from "./Items/picture-masonry"
import type { EntryListProps } from "./list" import type { EntryListProps } from "./list"
export const EntryColumnGrid: FC<EntryListProps> = (props) => { export const EntryColumnGrid: FC<EntryListProps> = (props) => {
const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props const { entriesIds, feedId, hasNextPage, view, fetchNextPage, refetch } = props
const isMobile = useMobile() const isMobile = useMobile()
const masonry = useUISettingKey("pictureViewMasonry") || isMobile const masonry = useUISettingKey("pictureViewMasonry") || isMobile
if (view === FeedViewType.All) {
return (
<AllMasonry
key={feedId}
hasNextPage={hasNextPage}
endReached={fetchNextPage}
data={entriesIds}
Footer={props.Footer}
refetch={refetch}
/>
)
}
if (masonry && view === FeedViewType.Pictures) { if (masonry && view === FeedViewType.Pictures) {
return ( return (
<PictureMasonry <PictureMasonry

View File

@ -285,7 +285,8 @@ export const useEntriesByView = ({ onReset }: { onReset?: () => void }) => {
const groupByDate = useGeneralSettingKey("groupByDate") const groupByDate = useGeneralSettingKey("groupByDate")
const groupedCounts: number[] | undefined = useMemo(() => { const groupedCounts: number[] | undefined = useMemo(() => {
if (views[view]!.gridMode) { const viewDefinition = views.find((v) => v.view === view)
if (viewDefinition?.gridMode) {
return return
} }
if (!groupByDate) { if (!groupByDate) {

View File

@ -33,7 +33,7 @@ export const useEntryMarkReadHandler = (entriesIds: string[]) => {
) )
return useMemo(() => { return useMemo(() => {
if (views[feedView]!.wideMode && renderAsRead) { if (views.find((v) => v.view === feedView)?.wideMode && renderAsRead) {
return handleRenderAsRead return handleRenderAsRead
} }

View File

@ -103,7 +103,7 @@ function EntryColumnImpl() {
} }
if (!renderAsRead) return if (!renderAsRead) return
if (!views[view]!.wideMode) { if (!views.find((v) => v.view === view)?.wideMode) {
return return
} }
// For gird, render as mark read logic // For gird, render as mark read logic
@ -119,7 +119,7 @@ function EntryColumnImpl() {
}, [entries]) }, [entries])
const isMobile = useMobile() const isMobile = useMobile()
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList const ListComponent = views.find((v) => v.view === view)?.gridMode ? EntryColumnGrid : EntryList
return ( return (
<Focusable <Focusable
scope={HotkeyScope.Timeline} scope={HotkeyScope.Timeline}

View File

@ -146,10 +146,11 @@ export const EntryItemWrapper: FC<
} }
navigate({ navigate({
view,
entryId: entry.id, entryId: entry.id,
}) })
}, },
[asRead, entry?.id, entry?.feedId, navigate], [asRead, entry?.id, entry?.feedId, navigate, view],
) )
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const showContextMenu = useShowContextMenu() const showContextMenu = useShowContextMenu()
@ -208,7 +209,7 @@ export const EntryItemWrapper: FC<
}) })
const aiEnabled = useFeature("ai") const aiEnabled = useFeature("ai")
const isWide = views[view as FeedViewType]?.wideMode || aiEnabled const isWide = views.find((v) => v.view === view)?.wideMode || aiEnabled
const Link = view === FeedViewType.SocialMedia ? "article" : NavLink const Link = view === FeedViewType.SocialMedia ? "article" : NavLink

View File

@ -62,7 +62,16 @@ export const EntryListHeader: FC<{
const feed = useFeedById(feedId) const feed = useFeedById(feedId)
const titleStyleBasedView = ["pl-6", "pl-7", "pl-7", "pl-7", "px-5", "pl-6"] const titleStyleBasedView = {
[FeedViewType.All]: "pl-5",
[FeedViewType.Articles]: "pl-7",
[FeedViewType.Pictures]: "pl-7",
[FeedViewType.Videos]: "pl-7",
[FeedViewType.SocialMedia]: "px-5",
[FeedViewType.Audios]: "pl-6",
[FeedViewType.Notifications]: "pl-6",
}
const feedColumnShow = useTimelineColumnShow() const feedColumnShow = useTimelineColumnShow()
const commandShortcuts = useCommandShortcuts() const commandShortcuts = useCommandShortcuts()
const runCmdFn = useRunCommandFn() const runCmdFn = useRunCommandFn()
@ -89,15 +98,17 @@ export const EntryListHeader: FC<{
)} )}
onClick={stopPropagation} onClick={stopPropagation}
> >
{views[view]!.wideMode && entryId && entryId !== ROUTE_ENTRY_PENDING && !aiEnabled && ( {views.find((v) => v.view === view)?.wideMode &&
<> entryId &&
<EntryHeader entryId={entryId} /> entryId !== ROUTE_ENTRY_PENDING && (
<DividerVertical className="mx-2 w-px" /> <>
</> <EntryHeader entryId={entryId} />
)} <DividerVertical className="mx-2 w-px" />
</>
)}
<AppendTaildingDivider> <AppendTaildingDivider>
{!views[view]!.wideMode && !aiEnabled && <WideModeButton />} {!views.find((v) => v.view === view)?.wideMode && !aiEnabled && <WideModeButton />}
{view === FeedViewType.Pictures && <SwitchToMasonryButton />} {view === FeedViewType.Pictures && <SwitchToMasonryButton />}
</AppendTaildingDivider> </AppendTaildingDivider>

View File

@ -41,7 +41,7 @@ export const EntryTranslation: Component<{
const SourceTag = inline ? "span" : "p" const SourceTag = inline ? "span" : "p"
return ( return (
<div> <>
{isHTML ? ( {isHTML ? (
<HTML as="div" className={cn("prose dark:prose-invert", className)} noMedia> <HTML as="div" className={cn("prose dark:prose-invert", className)} noMedia>
{nextTarget || source} {nextTarget || source}
@ -58,6 +58,6 @@ export const EntryTranslation: Component<{
{nextTarget && !inline && <p>{nextTarget}</p>} {nextTarget && !inline && <p>{nextTarget}</p>}
</div> </div>
)} )}
</div> </>
) )
} }

View File

@ -459,8 +459,8 @@ const FeedListItem = memo(
</div> </div>
<div className="text-text flex items-center gap-1 text-sm opacity-80"> <div className="text-text flex items-center gap-1 text-sm opacity-80">
{views[subscription.view]!.icon} {views.find((v) => v.view === subscription.view)!.icon}
<span>{tCommon(views[subscription.view]!.name)}</span> <span>{tCommon(views.find((v) => v.view === subscription.view)!.name)}</span>
</div> </div>
{!!subscription.createdAt && ( {!!subscription.createdAt && (
<div className="whitespace-nowrap pr-1 text-center text-sm"> <div className="whitespace-nowrap pr-1 text-center text-sm">

View File

@ -157,15 +157,17 @@ export const SettingLists = () => {
<span <span
className={cn( className={cn(
"inline-flex items-center", "inline-flex items-center",
views[row.view]!.className, views.find((v) => v.view === row.view)?.className,
)} )}
> >
{views[row.view]!.icon} {views.find((v) => v.view === row.view)?.icon}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent> <TooltipContent>
{t(views[row.view]!.name, { ns: "common" })} {t(views.find((v) => v.view === row.view)!.name, {
ns: "common",
})}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>

View File

@ -31,36 +31,38 @@ export const ViewSelectorRadioGroup = ({
return ( return (
<Card className={rest.disabled ? "pointer-events-none" : void 0}> <Card className={rest.disabled ? "pointer-events-none" : void 0}>
<CardHeader className={cn("grid grid-cols-6 space-y-0 px-2 py-3", className)}> <CardHeader className={cn("grid grid-cols-6 space-y-0 px-2 py-3", className)}>
{views.map((view) => ( {views
<div key={view.name}> .filter((v) => v.switchable)
<input .map((view) => (
className="peer hidden" <div key={view.name}>
type="radio" <input
id={view.name} className="peer hidden"
value={view.view} type="radio"
ref={ref} id={view.name}
{...rest} value={view.view}
/> ref={ref}
<label {...rest}
htmlFor={view.name} />
className={cn( <label
"hover:text-text", htmlFor={view.name}
view.peerClassName, className={cn(
"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200", "hover:text-text",
"text-text-secondary", view.peerClassName,
"peer-checked:opacity-100", "center flex h-10 flex-col text-xs leading-none opacity-80 duration-200",
"whitespace-nowrap", "text-text-secondary",
)} "peer-checked:opacity-100",
> "whitespace-nowrap",
{cloneElement(view.icon, { )}
className: `text-lg ${view.icon?.props?.className ?? ""}`, >
})} {cloneElement(view.icon, {
<span className="mt-1 hidden text-xs lg:inline"> className: `text-lg ${view.icon?.props?.className ?? ""}`,
{t(view.name, { ns: "common" })} })}
</span> <span className="mt-1 hidden text-xs lg:inline">
</label> {t(view.name, { ns: "common" })}
</div> </span>
))} </label>
</div>
))}
</CardHeader> </CardHeader>
{showPreview && ( {showPreview && (
<CardContent <CardContent

View File

@ -229,7 +229,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
new MenuItemText({ new MenuItemText({
label: t("sidebar.feed_column.context_menu.change_to_other_view"), label: t("sidebar.feed_column.context_menu.change_to_other_view"),
submenu: views submenu: views
.filter((v) => v.view !== view) .filter((v) => v.view !== view && v.switchable)
.map( .map(
(v) => (v) =>
new MenuItemText({ new MenuItemText({

View File

@ -71,6 +71,10 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
const { t } = useTranslation() const { t } = useTranslation()
const subscription = useSubscriptionByFeedId(feedId) const subscription = useSubscriptionByFeedId(feedId)
const navigate = useNavigateEntry() const navigate = useNavigateEntry()
// Use current route view for navigation to stay in current view (e.g., All view)
const currentRouteView = useRouteParamsSelector((s) => s.view)
const navigationView = currentRouteView ?? view
const feed = useFeedById(feedId, (feed) => { const feed = useFeedById(feedId, (feed) => {
return { return {
type: feed.type, type: feed.type,
@ -101,14 +105,14 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
} }
e.stopPropagation() e.stopPropagation()
if (view === undefined) return if (navigationView === undefined) return
navigate({ navigate({
feedId, feedId,
entryId: null, entryId: null,
view, view: navigationView,
}) })
}, },
[feedId, navigate, setSelectedFeedIds, view], [feedId, navigate, setSelectedFeedIds, navigationView],
) )
const feedUnread = useUnreadById(feedId) const feedUnread = useUnreadById(feedId)
@ -271,6 +275,11 @@ const ListItemImpl: Component<ListItemProps> = ({
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const subscription = useSubscriptionByFeedId(listId)! const subscription = useSubscriptionByFeedId(listId)!
const navigate = useNavigateEntry() const navigate = useNavigateEntry()
// Use current route view for navigation to stay in current view (e.g., All view)
const currentRouteView = useRouteParamsSelector((s) => s.view)
const navigationView = currentRouteView ?? view
const handleNavigate = useCallback( const handleNavigate = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
@ -278,10 +287,10 @@ const ListItemImpl: Component<ListItemProps> = ({
navigate({ navigate({
listId, listId,
entryId: null, entryId: null,
view, view: navigationView,
}) })
}, },
[listId, navigate, view], [listId, navigate, navigationView],
) )
const showContextMenu = useShowContextMenu() const showContextMenu = useShowContextMenu()
const { t } = useTranslation() const { t } = useTranslation()
@ -381,6 +390,11 @@ const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, ic
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const navigate = useNavigateEntry() const navigate = useNavigateEntry()
// Use current route view for navigation to stay in current view (e.g., All view)
const currentRouteView = useRouteParamsSelector((s) => s.view)
const navigationView = currentRouteView ?? view
const handleNavigate = useCallback( const handleNavigate = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation() e.stopPropagation()
@ -388,10 +402,10 @@ const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, ic
navigate({ navigate({
inboxId, inboxId,
entryId: null, entryId: null,
view, view: navigationView,
}) })
}, },
[inboxId, navigate, view], [inboxId, navigate, navigationView],
) )
const showContextMenu = useShowContextMenu() const showContextMenu = useShowContextMenu()

View File

@ -16,8 +16,15 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { resetSelectedFeedIds } from "./atom" import { resetSelectedFeedIds } from "./atom"
export function SubscriptionTabButton({ timelineId }: { timelineId: string }) { export function SubscriptionTabButton({
timelineId,
shortcut,
}: {
timelineId: string
shortcut: string
}) {
const activeTimelineId = useRouteParamsSelector((s) => s.timelineId) const activeTimelineId = useRouteParamsSelector((s) => s.timelineId)
const isActive = activeTimelineId === timelineId const isActive = activeTimelineId === timelineId
const navigate = useNavigateEntry() const navigate = useNavigateEntry()
const setActive = useCallback(() => { const setActive = useCallback(() => {
@ -31,7 +38,9 @@ export function SubscriptionTabButton({ timelineId }: { timelineId: string }) {
if (timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) { if (timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType
return <ViewSwitchButton view={id} isActive={isActive} setActive={setActive} /> return (
<ViewSwitchButton view={id} isActive={isActive} setActive={setActive} shortcut={shortcut} />
)
} }
} }
@ -39,7 +48,8 @@ const ViewSwitchButton: FC<{
view: FeedViewType view: FeedViewType
isActive: boolean isActive: boolean
setActive: () => void setActive: () => void
}> = ({ view, isActive, setActive }) => { shortcut: string
}> = ({ view, isActive, setActive, shortcut }) => {
const unreadByView = useUnreadByView(view) const unreadByView = useUnreadByView(view)
const { t } = useTranslation() const { t } = useTranslation()
const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount") const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount")
@ -59,10 +69,10 @@ const ViewSwitchButton: FC<{
ref={setNodeRef} ref={setNodeRef}
key={item.name} key={item.name}
tooltip={t(item.name, { ns: "common" })} tooltip={t(item.name, { ns: "common" })}
shortcut={`${view + 1}`} shortcut={shortcut}
className={cn( className={cn(
isActive && item.className, isActive && item.className,
"flex h-11 w-9 shrink-0 flex-col items-center gap-1 text-[1.375rem]", "flex h-11 w-8 shrink-0 grow flex-col items-center gap-1 text-[1.375rem]",
ELECTRON ? "hover:!bg-theme-item-hover" : "", ELECTRON ? "hover:!bg-theme-item-hover" : "",
isOver && "border-orange-400 bg-orange-400/60", isOver && "border-orange-400 bg-orange-400/60",
)} )}

View File

@ -0,0 +1,271 @@
import type { DragOverEvent, UniqueIdentifier } from "@dnd-kit/core"
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@follow/components/ui/button/index.js"
import { views } from "@follow/constants"
import type { CSSProperties, ReactNode } from "react"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { ROUTE_TIMELINE_OF_VIEW } from "~/constants"
import { useTimelineList } from "~/hooks/biz/useTimelineList"
const MAX_VISIBLE = 4
function ContainerDroppable({ id, children }: { id: "visible" | "hidden"; children: ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } })
return (
<div
ref={setNodeRef}
className={`border-border bg-material-ultra-thin flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border p-2 pb-6 shadow-sm ${
isOver ? "outline outline-1 outline-orange-400" : ""
}`}
>
{children}
</div>
)
}
function getViewMeta(timelineId: string) {
if (!timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) return { name: timelineId, icon: null }
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10)
const item = views.find((v) => v.view === id)
return { name: item?.name ?? String(id), icon: item?.icon ?? null }
}
function TabItem({ id }: { id: UniqueIdentifier }) {
const meta = getViewMeta(String(id))
const { t } = useTranslation()
return (
<div className="hover:bg-material-opaque flex w-full items-center gap-2 rounded-md p-2">
<div className="flex size-6 items-center justify-center text-lg">{meta.icon}</div>
<div className="text-text-secondary text-callout">
{t(meta.name as any, { ns: "common" })}
</div>
</div>
)
}
function SortableTabItem({ id }: { id: UniqueIdentifier }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
const style = useMemo(() => {
return {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 999 : undefined,
} as CSSProperties
}, [transform, transition, isDragging])
return (
<div
ref={setNodeRef}
style={style}
className={isDragging ? "cursor-grabbing" : "cursor-grab"}
{...attributes}
{...listeners}
>
<TabItem id={id} />
</div>
)
}
function useResolvedTimelineTabs() {
const timelineTabs = useUISettingKey("timelineTabs")
const timelineList = useTimelineList()
const first = timelineList[0]
const rest = timelineList.slice(1)
// Resolve visible: keep saved order, ensure they exist in rest, cap at MAX_VISIBLE
const savedVisible = (timelineTabs?.visible ?? []).filter((id) => rest.includes(id))
const filledVisible = [...savedVisible]
for (const id of rest) {
if (filledVisible.length >= MAX_VISIBLE) break
if (!filledVisible.includes(id)) filledVisible.push(id)
}
const hidden = rest.filter((id) => !filledVisible.includes(id))
return { first, visible: filledVisible, hidden }
}
const TimelineTabsSettings = () => {
const { visible, hidden } = useResolvedTimelineTabs()
const timelineListForReset = useTimelineList()
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event
if (!over) return
const activeId = String(active.id)
const overId = String(over.id)
const current = (key: "visible" | "hidden") => (key === "visible" ? visible : hidden)
const isActiveInVisible = visible.includes(activeId)
// Determine hovered list
const overContainer = (over.data?.current as any)?.container as
| "visible"
| "hidden"
| undefined
const overKey: "visible" | "hidden" =
overContainer || (visible.includes(overId) ? "visible" : "hidden")
const isCross = (isActiveInVisible ? "visible" : "hidden") !== overKey
if (isCross) {
const sourceKey = isActiveInVisible ? "visible" : "hidden"
const targetKey = overKey
const sourceList = current(sourceKey)
const targetList = current(targetKey)
// If moving into visible and it's full, replace the hovered item
if (targetKey === "visible" && targetList.length >= MAX_VISIBLE) {
const replaceIndex = targetList.indexOf(overId)
if (replaceIndex === -1) return // container hover; ignore
const replacedId = targetList[replaceIndex]!
const nextVisible = [...targetList]
nextVisible[replaceIndex] = activeId
const activeIndexInSource = sourceList.indexOf(activeId)
const nextSource = sourceList.filter((i) => i !== activeId)
const insertIndex = activeIndexInSource !== -1 ? activeIndexInSource : nextSource.length
nextSource.splice(insertIndex, 0, replacedId)
setUISetting("timelineTabs", {
visible: nextVisible,
hidden: targetKey === "visible" ? nextSource : hidden,
})
return
}
// Normal cross-container insert
const newIndexOfOver = targetList.indexOf(overId)
const insertIndex = newIndexOfOver !== -1 ? newIndexOfOver : targetList.length
const nextSource = sourceList.filter((i) => i !== activeId)
const nextTarget = [
...targetList.slice(0, insertIndex),
activeId,
...targetList.slice(insertIndex),
]
setUISetting("timelineTabs", {
visible: targetKey === "visible" ? nextTarget : nextSource,
hidden: targetKey === "hidden" ? nextTarget : nextSource,
})
return
}
// Reorder within list
const listKey = isActiveInVisible ? "visible" : "hidden"
const items = current(listKey)
const oldIndex = items.indexOf(activeId)
const newIndex = items.indexOf(overId)
if (oldIndex === -1 || newIndex === -1) return
setUISetting("timelineTabs", {
visible: listKey === "visible" ? arrayMove(items, oldIndex, newIndex) : visible,
hidden: listKey === "hidden" ? arrayMove(items, oldIndex, newIndex) : hidden,
})
},
[visible, hidden],
)
return (
<div
className="mx-auto w-[600px] max-w-full space-y-4 overflow-hidden"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="mb-2">
<h2 className="text-text text-title2 font-semibold">Timeline Tabs</h2>
<p className="text-text-secondary text-headline">
First tab is fixed. Drag to choose up to {MAX_VISIBLE} visible tabs.
</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragOver={handleDragOver}
onDragEnd={handleDragOver}
>
<div className="space-y-4">
<div>
<h3 className="text-text text-subheadline mb-2 font-medium">
Visible ({visible.length}/{MAX_VISIBLE})
</h3>
<ContainerDroppable id="visible">
<SortableContext items={visible} strategy={verticalListSortingStrategy}>
{visible.map((id) => (
<SortableTabItem key={id} id={id} />
))}
</SortableContext>
</ContainerDroppable>
</div>
<div>
<h3 className="text-text text-subheadline mb-2 font-medium">Hidden</h3>
<ContainerDroppable id="hidden">
<SortableContext items={hidden} strategy={verticalListSortingStrategy}>
{hidden.map((id) => (
<SortableTabItem key={id} id={id} />
))}
</SortableContext>
</ContainerDroppable>
</div>
</div>
</DndContext>
<div className="flex justify-end">
<Button
variant="outline"
onClick={() => {
// reset to current timeline order: first 4 visible, rest hidden
const rest = timelineListForReset.slice(1)
setUISetting("timelineTabs", {
visible: rest.slice(0, MAX_VISIBLE),
hidden: rest.slice(MAX_VISIBLE),
})
}}
>
Reset to default
</Button>
</div>
</div>
)
}
export const useShowTimelineTabsSettingsModal = () => {
const { present } = useModalStack()
return useCallback(() => {
present({
id: "timeline-tabs-settings",
title: "Customize Timeline Tabs",
content: () => <TimelineTabsSettings />,
overlay: true,
clickOutsideToDismiss: true,
})
}, [present])
}

View File

@ -1,7 +1,7 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { ActionButton } from "@follow/components/ui/button/index.js" import { ActionButton } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js" import { RootPortal } from "@follow/components/ui/portal/index.js"
import type { FeedViewType } from "@follow/constants" import { FeedViewType } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks" import { useTypeScriptHappyCallback } from "@follow/hooks"
import { ELECTRON_BUILD } from "@follow/shared/constants" import { ELECTRON_BUILD } from "@follow/shared/constants"
import { usePrefetchSubscription } from "@follow/store/subscription/hooks" import { usePrefetchSubscription } from "@follow/store/subscription/hooks"
@ -32,6 +32,7 @@ import { useShouldFreeUpSpace } from "./hook"
import { SubscriptionListGuard } from "./subscription-list/SubscriptionListGuard" import { SubscriptionListGuard } from "./subscription-list/SubscriptionListGuard"
import { SubscriptionColumnHeader } from "./SubscriptionColumnHeader" import { SubscriptionColumnHeader } from "./SubscriptionColumnHeader"
import { SubscriptionTabButton } from "./SubscriptionTabButton" import { SubscriptionTabButton } from "./SubscriptionTabButton"
import { useShowTimelineTabsSettingsModal } from "./TimelineTabsSettingsModal"
const lethargy = new Lethargy() const lethargy = new Lethargy()
@ -138,11 +139,7 @@ export function SubscriptionColumn({
)} )}
<div className="relative mb-4 mt-3"> <div className="relative mb-4 mt-3">
<div className="text-text-secondary flex h-11 justify-between gap-0 px-3 text-xl"> <TabsRow />
{timelineList.map((timelineId) => (
<SubscriptionTabButton key={timelineId} timelineId={timelineId} />
))}
</div>
</div> </div>
<div <div
className={cn("relative mt-1 flex size-full", !shouldFreeUpSpace && "overflow-hidden")} className={cn("relative mt-1 flex size-full", !shouldFreeUpSpace && "overflow-hidden")}
@ -239,6 +236,52 @@ const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo
}, },
) )
const TabsRow: FC = () => {
const timelineList = useTimelineList()
const showSettings = useShowTimelineTabsSettingsModal()
const timelineTabs = useUISettingKey("timelineTabs")
if (timelineList.length <= 5) {
return (
<div className="text-text-secondary flex h-11 items-center gap-0 px-3 text-xl">
{timelineList.map((timelineId, index) => (
<SubscriptionTabButton
key={timelineId}
timelineId={timelineId}
shortcut={`${index + 1}`}
/>
))}
</div>
)
}
const first = timelineList[0]
const rest = timelineList.slice(1)
const savedVisible = (timelineTabs?.visible ?? []).filter((id) => rest.includes(id))
const visible: string[] = [...savedVisible]
for (const id of rest) {
if (visible.length >= 4) break
if (!visible.includes(id)) visible.push(id)
}
return (
<div className="text-text-secondary flex h-11 items-center px-3 text-xl">
<SubscriptionTabButton
shortcut="BackQuote"
key={first}
timelineId={`${ROUTE_TIMELINE_OF_VIEW}${FeedViewType.All}`}
/>
{visible.map((timelineId, index) => (
<SubscriptionTabButton key={timelineId} timelineId={timelineId} shortcut={`${index + 1}`} />
))}
<ActionButton tooltip={"Customize Tabs"} onClick={showSettings}>
<i className="i-mingcute-dots-fill" />
</ActionButton>
</div>
)
}
const CommandsHandler = ({ const CommandsHandler = ({
setActive, setActive,
timelineList, timelineList,

View File

@ -36,7 +36,7 @@ export const ListHeader = ({ view }: { view: FeedViewType }) => {
} }
}} }}
> >
{view !== undefined && t(views[view]!.name, { ns: "common" })} {view !== undefined && t(views.find((v) => v.view === view)!.name, { ns: "common" })}
</div> </div>
<div className="text-text-secondary ml-2 flex items-center gap-3 text-base lg:text-sm"> <div className="text-text-secondary ml-2 flex items-center gap-3 text-base lg:text-sm">
<SortButton /> <SortButton />

View File

@ -2,13 +2,14 @@ import { FeedViewType } from "@follow/constants"
import { redirect } from "react-router" import { redirect } from "react-router"
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants" import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants"
import { getFeature } from "~/hooks/biz/useFeature"
export function Component() { export function Component() {
return null return null
} }
export const loader = () => { export const loader = () => {
return redirect( const aiEnabled = getFeature("ai")
`/timeline/view-${FeedViewType.Articles}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`, const view = aiEnabled ? FeedViewType.All : FeedViewType.Articles
) return redirect(`/timeline/view-${view}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`)
} }

View File

@ -43,7 +43,7 @@ export const useFeedHeaderTitle = () => {
switch (currentFeedId) { switch (currentFeedId) {
case ROUTE_FEED_PENDING: { case ROUTE_FEED_PENDING: {
return t(views[view]!.name, { ns: "common" }) return t(views.find((v) => v.view === view)!.name, { ns: "common" })
} }
case FEED_COLLECTION_LIST: { case FEED_COLLECTION_LIST: {
return t("words.starred") return t("words.starred")

View File

@ -200,14 +200,16 @@ const ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {
</Text> </Text>
)} )}
<View className="flex-row items-center gap-1"> <View className="flex-row items-center gap-1">
{!!views[list.view]?.icon && {!!views.find((v) => v.view === list.view)?.icon &&
createElement(views[list.view]!.icon, { createElement(views.find((v) => v.view === list.view)!.icon, {
color: views[list.view]!.activeColor, color: views.find((v) => v.view === list.view)!.activeColor,
height: 16, height: 16,
width: 16, width: 16,
})} })}
{!!views[list.view]?.name && ( {!!views.find((v) => v.view === list.view)?.name && (
<Text className="text-secondary-label text-base">{t(views[list.view]!.name)}</Text> <Text className="text-secondary-label text-base">
{t(views.find((v) => v.view === list.view)!.name)}
</Text>
)} )}
</View> </View>
</View> </View>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M15.34 3.046c-.698.089-1.527.406-2.148.822a5.725 5.725 0 0 0-1.198 1.148c-.311.417-.685 1.18-.814 1.664a4.974 4.974 0 0 0 .337 3.5c.521 1.063 1.297 1.827 2.379 2.344 1.566.747 3.437.594 4.928-.405.405-.271 1.024-.89 1.295-1.295 1.164-1.739 1.164-3.909 0-5.648-.271-.405-.89-1.024-1.295-1.295a5.07 5.07 0 0 0-3.484-.835M6.433 11.04a4.383 4.383 0 0 0-1.718.682c-.326.22-.773.667-.993.993a4.848 4.848 0 0 0-.609 1.347c-.126.509-.126 1.367 0 1.876a4.211 4.211 0 0 0 1.064 1.885 4.211 4.211 0 0 0 1.885 1.064c.509.126 1.367.126 1.876 0a4.211 4.211 0 0 0 1.885-1.064 4.211 4.211 0 0 0 1.064-1.885c.126-.509.126-1.367 0-1.876a4.211 4.211 0 0 0-1.064-1.885 4.186 4.186 0 0 0-1.865-1.057c-.386-.094-1.175-.136-1.525-.08m9.07 4a3.033 3.033 0 0 0-2.205 1.7c-.226.481-.277.714-.277 1.26s.051.779.277 1.26a2.905 2.905 0 0 0 1.442 1.444 2.873 2.873 0 0 0 2.52 0 2.925 2.925 0 0 0 1.444-1.444c.183-.387.296-.867.296-1.26 0-.86-.446-1.781-1.127-2.329a3.668 3.668 0 0 0-1.117-.574c-.265-.073-.986-.106-1.253-.057" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M4.503 3.042a3.045 3.045 0 0 0-2.191 1.662c-.308.64-.288.335-.299 4.436-.009 3.138-.003 3.658.043 3.53a1.05 1.05 0 0 1 .455-.539l.189-.111 3.217-.011c3.632-.012 3.469-.025 3.783.289a.983.983 0 0 1 0 1.404c-.313.313-.152.301-3.764.289l-3.198-.011-.195-.1a1.035 1.035 0 0 1-.486-.545c-.09-.244-.056 2.113.035 2.421a3.098 3.098 0 0 0 1.648 1.948c.479.226.864.296 1.642.296h.613l.012.91c.013.903.014.912.125 1.137.131.269.321.447.626.587.284.131.691.139.962.019.1-.044.953-.659 1.898-1.367L11.334 18h3.449c3.904 0 3.843.004 4.477-.296a2.925 2.925 0 0 0 1.444-1.444c.307-.65.296-.427.296-5.76s.011-5.11-.296-5.76a2.94 2.94 0 0 0-1.408-1.428c-.658-.317.056-.289-7.676-.297-3.806-.003-7.009.009-7.117.027M7.34 7.066c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45C4.036 8.328 4.02 8.256 4.02 8s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064m12 0c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.317.317-.038.299-4.703.299-4.514 0-4.321.009-4.624-.222a1.19 1.19 0 0 1-.243-.289c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.125-.213.318-.375.539-.454.122-.043.94-.054 4.313-.055 3.773-.002 4.181.004 4.357.064m-4 5c.369.126.66.538.66.934s-.291.808-.66.934c-.276.094-2.399.098-2.662.005a.986.986 0 0 1-.547-.45c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.061-.103.173-.236.25-.294.264-.202.352-.213 1.602-.215.981-.002 1.193.008 1.357.064" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12.367 2.92a4.433 4.433 0 0 0-2.472 1.171c-.507.481-.788.874-1.062 1.488-.091.205-.128.246-.24.275-1.009.255-1.687.625-2.313 1.264a4.463 4.463 0 0 0-.888 4.917c.314.71.834 1.345 1.518 1.851.48.356.804.679 1.177 1.175.661.878 1.304 1.363 2.233 1.689.513.179.837.23 1.46.229.608-.002.801-.033 1.503-.244.936-.28 1.089-.3 2.557-.334 1.274-.03 1.358-.036 1.712-.136a4.475 4.475 0 0 0 3.257-3.805c.043-.374.056-.412.196-.58.225-.269.537-.808.675-1.166.232-.602.295-.949.296-1.634 0-.549-.012-.671-.111-1.06-.404-1.59-1.611-2.827-3.164-3.243a5.672 5.672 0 0 0-.921-.14c-.971-.074-1.375-.218-1.997-.71-.613-.486-1.271-.804-1.949-.943-.382-.079-1.131-.111-1.467-.064M4.46 16.044c-.96.135-1.846.728-2.218 1.486-.757 1.54.385 3.206 2.355 3.437 1.65.194 3.17-.78 3.371-2.158.245-1.68-1.492-3.049-3.508-2.765" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 945 B

View File

@ -28,6 +28,7 @@
"feed.follower_one": "follower", "feed.follower_one": "follower",
"feed.follower_other": "followers", "feed.follower_other": "followers",
"feed.updated_at": "Updated", "feed.updated_at": "Updated",
"feed_view_type.all": "All",
"feed_view_type.articles": "Articles", "feed_view_type.articles": "Articles",
"feed_view_type.audios": "Audios", "feed_view_type.audios": "Audios",
"feed_view_type.notifications": "Notifications", "feed_view_type.notifications": "Notifications",

View File

@ -28,6 +28,7 @@
"feed.follower_one": "フォロワー", "feed.follower_one": "フォロワー",
"feed.follower_other": "フォロワー", "feed.follower_other": "フォロワー",
"feed.updated_at": "更新日時", "feed.updated_at": "更新日時",
"feed_view_type.all": "すべて",
"feed_view_type.articles": "記事", "feed_view_type.articles": "記事",
"feed_view_type.audios": "オーディオ", "feed_view_type.audios": "オーディオ",
"feed_view_type.notifications": "通知", "feed_view_type.notifications": "通知",

View File

@ -28,6 +28,7 @@
"feed.follower_one": "订阅者", "feed.follower_one": "订阅者",
"feed.follower_other": "订阅者", "feed.follower_other": "订阅者",
"feed.updated_at": "更新于", "feed.updated_at": "更新于",
"feed_view_type.all": "全部",
"feed_view_type.articles": "文章", "feed_view_type.articles": "文章",
"feed_view_type.audios": "音频", "feed_view_type.audios": "音频",
"feed_view_type.notifications": "通知", "feed_view_type.notifications": "通知",

View File

@ -28,6 +28,7 @@
"feed.follower_one": "跟隨者", "feed.follower_one": "跟隨者",
"feed.follower_other": "跟隨者", "feed.follower_other": "跟隨者",
"feed.updated_at": "已更新", "feed.updated_at": "已更新",
"feed_view_type.all": "全部",
"feed_view_type.articles": "文章", "feed_view_type.articles": "文章",
"feed_view_type.audios": "音訊", "feed_view_type.audios": "音訊",
"feed_view_type.notifications": "通知", "feed_view_type.notifications": "通知",

View File

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

View File

@ -4,6 +4,7 @@ import { FeedViewType } from "./enums"
export interface ViewDefinition { export interface ViewDefinition {
name: name:
| "feed_view_type.all"
| "feed_view_type.articles" | "feed_view_type.articles"
| "feed_view_type.audios" | "feed_view_type.audios"
| "feed_view_type.notifications" | "feed_view_type.notifications"
@ -18,20 +19,35 @@ export interface ViewDefinition {
wideMode?: boolean wideMode?: boolean
gridMode?: boolean gridMode?: boolean
activeColor: string activeColor: string
/** if it's switchable from other views to this view by user */
switchable: boolean
} }
export const views: ViewDefinition[] = [ export const views: ViewDefinition[] = [
{ {
name: "feed_view_type.articles", name: "feed_view_type.all",
icon: <i className="i-mgc-paper-cute-fi" />, icon: <i className="i-mgc-bubble-cute-fi" />,
className: "text-folo", className: "text-folo",
peerClassName: "peer-checked:text-folo dark:peer-checked:text-folo", peerClassName: "peer-checked:text-folo dark:peer-checked:text-folo",
translation: "title,description,content",
view: FeedViewType.All,
gridMode: true,
wideMode: true,
activeColor: "#FF5C00",
switchable: false,
},
{
name: "feed_view_type.articles",
icon: <i className="i-mgc-paper-cute-fi" />,
className: "text-lime-600 dark:text-lime-500",
peerClassName: "peer-checked:text-lime-600 dark:peer-checked:text-lime-500",
translation: "title,description", translation: "title,description",
view: FeedViewType.Articles, view: FeedViewType.Articles,
activeColor: "#FF5C00", activeColor: "#FF5C00",
switchable: true,
}, },
{ {
name: "feed_view_type.social_media", name: "feed_view_type.social_media",
icon: <i className="i-mgc-twitter-cute-fi" />, icon: <i className="i-mgc-thought-cute-fi" />,
className: "text-sky-600 dark:text-sky-500", className: "text-sky-600 dark:text-sky-500",
peerClassName: "peer-checked:text-sky-600 peer-checked:dark:text-sky-500", peerClassName: "peer-checked:text-sky-600 peer-checked:dark:text-sky-500",
wideMode: true, wideMode: true,
@ -39,6 +55,7 @@ export const views: ViewDefinition[] = [
view: FeedViewType.SocialMedia, view: FeedViewType.SocialMedia,
// sky-500 // sky-500
activeColor: "#0ea5e9", activeColor: "#0ea5e9",
switchable: true,
}, },
{ {
name: "feed_view_type.pictures", name: "feed_view_type.pictures",
@ -51,6 +68,7 @@ export const views: ViewDefinition[] = [
view: FeedViewType.Pictures, view: FeedViewType.Pictures,
// green-500 // green-500
activeColor: "#22c55e", activeColor: "#22c55e",
switchable: true,
}, },
{ {
name: "feed_view_type.videos", name: "feed_view_type.videos",
@ -63,6 +81,7 @@ export const views: ViewDefinition[] = [
view: FeedViewType.Videos, view: FeedViewType.Videos,
// red-500 // red-500
activeColor: "#ef4444", activeColor: "#ef4444",
switchable: true,
}, },
{ {
name: "feed_view_type.audios", name: "feed_view_type.audios",
@ -73,6 +92,7 @@ export const views: ViewDefinition[] = [
view: FeedViewType.Audios, view: FeedViewType.Audios,
// purple-500 // purple-500
activeColor: "#a855f7", activeColor: "#a855f7",
switchable: true,
}, },
{ {
name: "feed_view_type.notifications", name: "feed_view_type.notifications",
@ -83,6 +103,7 @@ export const views: ViewDefinition[] = [
view: FeedViewType.Notifications, view: FeedViewType.Notifications,
// yellow-500 // yellow-500
activeColor: "#eab308", activeColor: "#eab308",
switchable: true,
}, },
] ]

View File

@ -57,6 +57,7 @@ export type ExtraModel = {
type: string type: string
content_html?: string content_html?: string
}[] }[]
title_keyword?: string
} }
// export { ImageColorsResult } from "react-native-image-colors" // export { ImageColorsResult } from "react-native-image-colors"

View File

@ -1636,6 +1636,7 @@ type ExtraModel = {
type: string; type: string;
content_html?: string; content_html?: string;
}[]; }[];
title_keyword?: string
}; };
declare const CommonEntryFields: { declare const CommonEntryFields: {
id: drizzle_orm142.HasRuntimeDefault<drizzle_orm142.HasDefault<drizzle_orm142.IsPrimaryKey<drizzle_orm142.NotNull<drizzle_orm_pg_core100.PgTextBuilderInitial<"id", [string, ...string[]]>>>>>; id: drizzle_orm142.HasRuntimeDefault<drizzle_orm142.HasDefault<drizzle_orm142.IsPrimaryKey<drizzle_orm142.NotNull<drizzle_orm_pg_core100.PgTextBuilderInitial<"id", [string, ...string[]]>>>>>;
@ -23279,6 +23280,7 @@ declare const _routes: hono_hono_base42.HonoBase<Env, ({
url: string; url: string;
content_html?: string | undefined; content_html?: string | undefined;
}[] | null | undefined; }[] | null | undefined;
title_keyword?: string | undefined;
} | null | undefined; } | null | undefined;
}; };
feeds: { feeds: {
@ -23548,6 +23550,7 @@ declare const _routes: hono_hono_base42.HonoBase<Env, ({
url: string; url: string;
content_html?: string | undefined; content_html?: string | undefined;
}[] | null | undefined; }[] | null | undefined;
title_keyword?: string | undefined;
} | null | undefined; } | null | undefined;
}; };
feeds: { feeds: {
@ -26100,4 +26103,4 @@ declare const _routes: hono_hono_base42.HonoBase<Env, ({
}, "/referrals">, "/">; }, "/referrals">, "/">;
type AppType = typeof _routes; type AppType = typeof _routes;
//#endregion //#endregion
export { ActionItem, ActionsModel, AirdropActivity, AppType, AttachmentsModel, AuthSession, AuthUser, CommonEntryFields, ConditionItem, DetailModel, EntriesModel, ExtraModel, FEATURE_NAMES, FeatureFlagInsertModel, FeatureFlagModel, FeatureName, FeedModel, InvitationDB, ListModel, MediaModel, MessagingData, MessagingType, ROLLOUT_TYPES, RolloutType, RolloutValue, SettingsModel, UrlReadsModel, UserFeatureOverrideInsertModel, UserFeatureOverrideModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activities, activitiesOpenAPISchema, activityEnum, airdrops, airdropsOpenAPISchema, applePayTransactions, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, featureFlags, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, listAnalytics, listAnalyticsOpenAPISchema, listAnalyticsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubAnalytics, rsshubAnalyticsOpenAPISchema, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, stripeSubscriptions, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, tools, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user$1 as user, userFeatureOverrides, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations }; export { ActionItem, ActionsModel, AirdropActivity, AppType, AttachmentsModel, AuthSession, AuthUser, CommonEntryFields, ConditionItem, DetailModel, EntriesModel, ExtraModel, FEATURE_NAMES, FeatureFlagInsertModel, FeatureFlagModel, FeatureName, FeedModel, InvitationDB, ListModel, MediaModel, MessagingData, MessagingType, ROLLOUT_TYPES, RolloutType, RolloutValue, SettingsModel, UrlReadsModel, UserFeatureOverrideInsertModel, UserFeatureOverrideModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activities, activitiesOpenAPISchema, activityEnum, airdrops, airdropsOpenAPISchema, applePayTransactions, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, featureFlags, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, listAnalytics, listAnalyticsOpenAPISchema, listAnalyticsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubAnalytics, rsshubAnalyticsOpenAPISchema, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, stripeSubscriptions, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, tools, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user$1 as user, userFeatureOverrides, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations };

View File

@ -96,6 +96,12 @@ export const defaultUISettings: UISettings = {
// Discover // Discover
discoverLanguage: "all", discoverLanguage: "all",
// Timeline tabs preset (excluding the first fixed tab)
timelineTabs: {
visible: [],
hidden: [],
},
} }
export const defaultIntegrationSettings: IntegrationSettings = { export const defaultIntegrationSettings: IntegrationSettings = {

View File

@ -91,6 +91,12 @@ export interface UISettings {
// Discover // Discover
discoverLanguage: "all" | "eng" | "cmn" discoverLanguage: "all" | "eng" | "cmn"
// Desktop: Timeline tabs preset (excluding the first fixed tab)
timelineTabs: {
visible: string[]
hidden: string[]
}
} }
export interface IntegrationSettings { export interface IntegrationSettings {

View File

@ -97,8 +97,11 @@ class EntryActions implements Hydratable, Resetable {
(hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) || (hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||
subscription?.hideFromTimeline subscription?.hideFromTimeline
if (typeof subscription?.view === "number" && !ignore) { if (!ignore) {
draft.entryIdByView[subscription.view].add(entryId) if (typeof subscription?.view === "number") {
draft.entryIdByView[subscription.view].add(entryId)
}
draft.entryIdByView[FeedViewType.All].add(entryId)
} }
// lists // lists
@ -108,8 +111,11 @@ class EntryActions implements Hydratable, Resetable {
(hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) || (hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||
subscription?.hideFromTimeline subscription?.hideFromTimeline
if (typeof subscription?.view === "number" && !ignore) { if (!ignore) {
draft.entryIdByView[subscription.view].add(entryId) if (typeof subscription?.view === "number") {
draft.entryIdByView[subscription.view].add(entryId)
}
draft.entryIdByView[FeedViewType.All].add(entryId)
} }
} }
} }
@ -438,6 +444,7 @@ class EntryActions implements Hydratable, Resetable {
delete draft.data[entryId] delete draft.data[entryId]
draft.entryIdSet.delete(entryId) draft.entryIdSet.delete(entryId)
draft.entryIdByInbox[entry.inboxHandle!]?.delete(entryId) draft.entryIdByInbox[entry.inboxHandle!]?.delete(entryId)
draft.entryIdByView[FeedViewType.All].delete(entryId)
}) })
} }

View File

@ -262,6 +262,7 @@ export const useViewWithSubscription = () =>
return views return views
.filter((view) => { .filter((view) => {
if ( if (
view.view === FeedViewType.All ||
view.view === FeedViewType.Articles || view.view === FeedViewType.Articles ||
view.view === FeedViewType.SocialMedia || view.view === FeedViewType.SocialMedia ||
view.view === FeedViewType.Pictures || view.view === FeedViewType.Pictures ||

View File

@ -1,4 +1,4 @@
import type { FeedViewType } from "@follow/constants" import { FeedViewType } from "@follow/constants"
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "../../constants/app" import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "../../constants/app"
import type { SubscriptionState } from "./store" import type { SubscriptionState } from "./store"
@ -21,7 +21,7 @@ export const folderFeedsByFeedIdSelector =
for (const feedId in state.data) { for (const feedId in state.data) {
const subscription = state.data[feedId]! const subscription = state.data[feedId]!
if ( if (
subscription.view === view && (subscription.view === view || view === FeedViewType.All) &&
(subscription.category (subscription.category
? subscription.category === folderName ? subscription.category === folderName
: getDefaultCategory(subscription) === folderName) : getDefaultCategory(subscription) === folderName)

View File

@ -99,12 +99,14 @@ class SubscriptionActions implements Hydratable, Resetable {
if (subscription.feedId && subscription.type === "feed") { if (subscription.feedId && subscription.type === "feed") {
draft.feedIdByView[subscription.view].add(subscription.feedId) draft.feedIdByView[subscription.view].add(subscription.feedId)
draft.feedIdByView[FeedViewType.All].add(subscription.feedId)
if (subscription.category) { if (subscription.category) {
draft.categories[subscription.view].add(subscription.category) draft.categories[subscription.view].add(subscription.category)
} }
} }
if (subscription.listId && subscription.type === "list") { if (subscription.listId && subscription.type === "list") {
draft.listIdByView[subscription.view].add(subscription.listId) draft.listIdByView[subscription.view].add(subscription.listId)
draft.listIdByView[FeedViewType.All].add(subscription.listId)
} }
} }
}) })
@ -323,10 +325,18 @@ class SubscriptionSyncService {
const subscription = draft.data[id] const subscription = draft.data[id]
if (!subscription) continue if (!subscription) continue
draft.subscriptionIdSet.delete(getSubscriptionDBId(subscription)) draft.subscriptionIdSet.delete(getSubscriptionDBId(subscription))
if (subscription.feedId) draft.feedIdByView[subscription.view].delete(subscription.feedId) if (subscription.feedId) {
if (subscription.listId) draft.listIdByView[subscription.view].delete(subscription.listId) draft.feedIdByView[subscription.view].delete(subscription.feedId)
if (subscription.category) draft.feedIdByView[FeedViewType.All].delete(subscription.feedId)
}
if (subscription.listId) {
draft.listIdByView[subscription.view].delete(subscription.listId)
draft.listIdByView[FeedViewType.All].delete(subscription.listId)
}
if (subscription.category) {
draft.categories[subscription.view].delete(subscription.category) draft.categories[subscription.view].delete(subscription.category)
draft.categories[FeedViewType.All].delete(subscription.category)
}
delete draft.data[id] delete draft.data[id]
} }
}) })
@ -349,9 +359,18 @@ class SubscriptionSyncService {
draft.data[id] = subscription draft.data[id] = subscription
draft.subscriptionIdSet.add(getSubscriptionDBId(subscription)) draft.subscriptionIdSet.add(getSubscriptionDBId(subscription))
if (subscription.feedId) draft.feedIdByView[subscription.view].add(subscription.feedId) if (subscription.feedId) {
if (subscription.listId) draft.listIdByView[subscription.view].add(subscription.listId) draft.feedIdByView[subscription.view].add(subscription.feedId)
if (subscription.category) draft.categories[subscription.view].add(subscription.category) draft.feedIdByView[FeedViewType.All].add(subscription.feedId)
}
if (subscription.listId) {
draft.listIdByView[subscription.view].add(subscription.listId)
draft.listIdByView[FeedViewType.All].add(subscription.listId)
}
if (subscription.category) {
draft.categories[subscription.view].add(subscription.category)
draft.categories[FeedViewType.All].add(subscription.category)
}
} }
}) })
}) })

View File

@ -1,10 +1,11 @@
import type { FeedViewType } from "@follow/constants" import { FeedViewType } from "@follow/constants"
import type { UnreadSchema } from "@follow/database/schemas/types" import type { UnreadSchema } from "@follow/database/schemas/types"
import { EntryService } from "@follow/database/services/entry" import { EntryService } from "@follow/database/services/entry"
import { UnreadService } from "@follow/database/services/unread" import { UnreadService } from "@follow/database/services/unread"
import type { MarkAllAsReadRequest } from "@follow-app/client-sdk"
import { isEqual } from "es-toolkit" import { isEqual } from "es-toolkit"
import { apiClient } from "../../context" import { api, apiClient } from "../../context"
import type { Hydratable, Resetable } from "../../lib/base" import type { Hydratable, Resetable } from "../../lib/base"
import { createTransaction, createZustandStore } from "../../lib/helper" import { createTransaction, createZustandStore } from "../../lib/helper"
import { getEntry } from "../entry/getter" import { getEntry } from "../entry/getter"
@ -127,14 +128,17 @@ class UnreadSyncService {
excludePrivate: boolean excludePrivate: boolean
}) { }) {
const request = async () => { const request = async () => {
const res = await apiClient().reads.all.$post({ const args: MarkAllAsReadRequest = {
json: { view: view === FeedViewType.All ? undefined : view,
view, excludePrivate,
excludePrivate, ...filter,
...filter, ...time,
...time, }
}, if (view === FeedViewType.All) {
}) delete args.view
}
const res = await api().reads.markAllAsRead(args)
return res.data.read return res.data.read
} }
@ -161,7 +165,7 @@ class UnreadSyncService {
async markViewAsRead(view: FeedViewType, excludePrivate: boolean) { async markViewAsRead(view: FeedViewType, excludePrivate: boolean) {
await this.markBatchAsRead({ await this.markBatchAsRead({
view, view: view === FeedViewType.All ? undefined : view,
excludePrivate, excludePrivate,
}) })
} }

View File

@ -153,6 +153,7 @@ class APIMorph {
extra: item.entries.extra extra: item.entries.extra
? { ? {
links: item.entries.extra.links ?? undefined, links: item.entries.extra.links ?? undefined,
title_keyword: item.entries.extra.title_keyword ?? undefined,
} }
: null, : null,
language: item.entries.language, language: item.entries.language,

View File

@ -50,6 +50,7 @@ class LegacyHonoMorph {
extra: data.entries.extra extra: data.entries.extra
? { ? {
links: data.entries.extra.links ?? undefined, links: data.entries.extra.links ?? undefined,
title_keyword: data.entries.extra.title_keyword ?? undefined,
} }
: null, : null,
language: data.entries.language, language: data.entries.language,