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:
parent
335a38262a
commit
7d0a0d1c40
|
|
@ -23,6 +23,7 @@ type BaseProps = {
|
|||
blurhash?: string
|
||||
inline?: boolean
|
||||
fitContent?: boolean
|
||||
videoClassName?: string
|
||||
}
|
||||
|
||||
const isImageLoadedSet = new Set<string>()
|
||||
|
|
@ -71,6 +72,7 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
width,
|
||||
inline,
|
||||
fitContent,
|
||||
videoClassName,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
|
|
@ -287,7 +289,12 @@ const MediaImpl: FC<MediaProps> = ({
|
|||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<VideoPreview src={src!} previewImageUrl={previewImageSrc} thumbnail={thumbnail} />
|
||||
<VideoPreview
|
||||
src={src!}
|
||||
previewImageUrl={previewImageSrc}
|
||||
thumbnail={thumbnail}
|
||||
videoClassName={videoClassName}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -487,7 +494,8 @@ const VideoPreview: FC<{
|
|||
src: string
|
||||
previewImageUrl?: string
|
||||
thumbnail?: boolean
|
||||
}> = ({ src, previewImageUrl, thumbnail = false }) => {
|
||||
videoClassName?: string
|
||||
}> = ({ src, previewImageUrl, thumbnail = false, videoClassName }) => {
|
||||
const [isInitVideoPlayer, setIsInitVideoPlayer] = useState(!previewImageUrl)
|
||||
|
||||
const [videoRef, setVideoRef] = useState<VideoPlayerRef | null>(null)
|
||||
|
|
@ -507,7 +515,7 @@ const VideoPreview: FC<{
|
|||
{!isInitVideoPlayer ? (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
className="size-full object-cover"
|
||||
className={cn("size-full object-cover", videoClassName)}
|
||||
onMouseEnter={() => {
|
||||
setIsInitVideoPlayer(true)
|
||||
}}
|
||||
|
|
@ -520,7 +528,7 @@ const VideoPreview: FC<{
|
|||
poster={previewImageUrl}
|
||||
ref={setVideoRef}
|
||||
muted
|
||||
className="not-prose relative size-full object-cover"
|
||||
className={cn("not-prose relative size-full object-cover", videoClassName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -432,7 +432,11 @@ export const useEntryActions = ({
|
|||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.entry.readability,
|
||||
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,
|
||||
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
|
||||
entryId,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const parseNavigateEntryOptions = (options: NavigateEntryOptions): ParsedNavigat
|
|||
let finalTimelineId = timelineId || params.timelineId || ROUTE_FEED_PENDING
|
||||
const finalEntryId = entryId || ROUTE_ENTRY_PENDING
|
||||
const subscription = getSubscriptionByFeedId(finalFeedId)
|
||||
const finalView = subscription?.view || view
|
||||
const finalView = view || subscription?.view
|
||||
|
||||
if ("feedId" in options && feedId === null) {
|
||||
finalFeedId = ROUTE_FEED_PENDING
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { useViewWithSubscription } from "@follow/store/subscription/hooks"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { useFeature } from "~/hooks/biz/useFeature"
|
||||
|
||||
export const useTimelineList = () => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { callWindowExposeRenderer } from "@follow/shared/bridge"
|
|||
interface ShortcutDefinition {
|
||||
accelerator: string
|
||||
action: () => void
|
||||
inputBypass?: boolean
|
||||
}
|
||||
|
||||
const parseAccelerator = (
|
||||
|
|
@ -23,6 +24,7 @@ export const registerAppGlobalShortcuts = () => {
|
|||
{
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
action: () => window.router.showSettings(),
|
||||
inputBypass: true,
|
||||
},
|
||||
{
|
||||
accelerator: "CmdOrCtrl+T",
|
||||
|
|
@ -41,15 +43,16 @@ export const registerAppGlobalShortcuts = () => {
|
|||
]
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Prevent on input, textarea, [contenteditable]
|
||||
if (
|
||||
["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName) ||
|
||||
(e.target as HTMLElement)?.contentEditable === "true"
|
||||
) {
|
||||
return
|
||||
}
|
||||
shortcuts.forEach(({ accelerator, action, inputBypass }) => {
|
||||
// Prevent on input, textarea, [contenteditable]
|
||||
if (
|
||||
!inputBypass &&
|
||||
(["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName) ||
|
||||
(e.target as HTMLElement)?.contentEditable === "true")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
shortcuts.forEach(({ accelerator, action }) => {
|
||||
const { key, ctrl, meta, shift } = parseAccelerator(accelerator)
|
||||
|
||||
const matchesKey = e.key.toLowerCase() === key.toLowerCase()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ const EntryLayoutContentLegacy = () => {
|
|||
const settingWideMode = useRealInWideMode()
|
||||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
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 feedColumnTempShow = useTimelineColumnTempShow()
|
||||
const feedColumnShow = useTimelineColumnShow()
|
||||
|
|
@ -75,7 +78,7 @@ export const EntryLayoutContentWithAI = () => {
|
|||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
const wideMode = !!(settingWideMode && realEntryId)
|
||||
|
||||
const isWideView = views[view]?.wideMode
|
||||
const isWideView = views.find((v) => v.view === view)?.wideMode
|
||||
return (
|
||||
<AppLayoutGridContainerProvider>
|
||||
<EntryGridContainer wideMode={wideMode}>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const FeedResponsiveResizerContainer = ({
|
|||
const aiEnabled = useFeature("ai")
|
||||
const feedColumnShow = useTimelineColumnShow()
|
||||
const feedColumnTempShow = useTimelineColumnTempShow()
|
||||
const { entryId, isPendingEntry } = useRouteParams()
|
||||
const { entryId, isPendingEntry, view: currentView } = useRouteParams()
|
||||
const navigate = useNavigateEntry()
|
||||
const t = useI18n()
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ const FeedResponsiveResizerContainer = ({
|
|||
{entryId && !isPendingEntry && aiEnabled && !isAtTop && (
|
||||
<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"
|
||||
onClick={() => navigate({ entryId: null })}
|
||||
onClick={() => navigate({ entryId: null, view: currentView })}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export function TimelineColumnLayout() {
|
|||
const settingWideMode = useRealInWideMode()
|
||||
const entryColWidth = useMemo(() => getUISettings().entryColWidth, [])
|
||||
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 startDragPosition = useRef(0)
|
||||
const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => {
|
|||
|
||||
export function DroppableContainer({ children }: { children: ReactNode }) {
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
|||
import { m } from "~/components/common/Motion"
|
||||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
|
||||
import { EntryContent } from "~/modules/entry-content/components/entry-content"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
|
@ -33,9 +34,15 @@ const AIEntryLayoutImpl = () => {
|
|||
const accumulatedDelta = useRef(0)
|
||||
const isScrollingAtTop = useRef(false)
|
||||
|
||||
const { view: currentView } = useRouteParams()
|
||||
|
||||
const closeEntry = useCallback(() => {
|
||||
navigate({ entryId: null, view: currentView })
|
||||
}, [navigate, currentView])
|
||||
|
||||
const handleCloseGesture = useCallback(() => {
|
||||
navigate({ entryId: null })
|
||||
}, [navigate])
|
||||
closeEntry()
|
||||
}, [closeEntry])
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ export const EntryItemSkeleton: FC<{
|
|||
}
|
||||
|
||||
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) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- index is unique
|
||||
<div key={index}>{SkeletonItem}</div>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
import { AllItem } from "./all-item"
|
||||
import { ArticleItem } from "./article-item"
|
||||
import { AudioItem } from "./audio-item"
|
||||
import { NotificationItem } from "./notification-item"
|
||||
|
|
@ -8,6 +9,7 @@ import { SocialMediaItem } from "./social-media-item"
|
|||
import { VideoItem } from "./video-item"
|
||||
|
||||
const ItemMap = {
|
||||
[FeedViewType.All]: AllItem,
|
||||
[FeedViewType.Articles]: ArticleItem,
|
||||
[FeedViewType.SocialMedia]: SocialMediaItem,
|
||||
[FeedViewType.Pictures]: PictureItem,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const FooterMarkItem = ({
|
|||
|
||||
if (view === FeedViewType.SocialMedia) {
|
||||
return <SocialMediaFooterMarkItem filter={filter} />
|
||||
} else if (views[view]!.gridMode) {
|
||||
} else if (views.find((v) => v.view === view)?.gridMode) {
|
||||
return <GridFooterMarkItem filter={filter} />
|
||||
}
|
||||
return <CommonFooterMarkItem filter={filter} />
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp
|
|||
return (
|
||||
<div className={cn(styles, animationStyles)}>
|
||||
<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}
|
||||
ref={ref}
|
||||
rootClassName={cn("h-full", isZenMode ? "max-w-[80ch] mx-auto" : "")}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,29 @@ import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContaine
|
|||
|
||||
import { EntryItemSkeleton } from "./EntryItemSkeleton"
|
||||
import { EntryItem } from "./item"
|
||||
import { AllMasonry } from "./Items/all-masonry"
|
||||
import { PictureMasonry } from "./Items/picture-masonry"
|
||||
import type { EntryListProps } from "./list"
|
||||
|
||||
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 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) {
|
||||
return (
|
||||
<PictureMasonry
|
||||
|
|
|
|||
|
|
@ -285,7 +285,8 @@ export const useEntriesByView = ({ onReset }: { onReset?: () => void }) => {
|
|||
|
||||
const groupByDate = useGeneralSettingKey("groupByDate")
|
||||
const groupedCounts: number[] | undefined = useMemo(() => {
|
||||
if (views[view]!.gridMode) {
|
||||
const viewDefinition = views.find((v) => v.view === view)
|
||||
if (viewDefinition?.gridMode) {
|
||||
return
|
||||
}
|
||||
if (!groupByDate) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const useEntryMarkReadHandler = (entriesIds: string[]) => {
|
|||
)
|
||||
|
||||
return useMemo(() => {
|
||||
if (views[feedView]!.wideMode && renderAsRead) {
|
||||
if (views.find((v) => v.view === feedView)?.wideMode && renderAsRead) {
|
||||
return handleRenderAsRead
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ function EntryColumnImpl() {
|
|||
}
|
||||
|
||||
if (!renderAsRead) return
|
||||
if (!views[view]!.wideMode) {
|
||||
if (!views.find((v) => v.view === view)?.wideMode) {
|
||||
return
|
||||
}
|
||||
// For gird, render as mark read logic
|
||||
|
|
@ -119,7 +119,7 @@ function EntryColumnImpl() {
|
|||
}, [entries])
|
||||
const isMobile = useMobile()
|
||||
|
||||
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
|
||||
const ListComponent = views.find((v) => v.view === view)?.gridMode ? EntryColumnGrid : EntryList
|
||||
return (
|
||||
<Focusable
|
||||
scope={HotkeyScope.Timeline}
|
||||
|
|
|
|||
|
|
@ -146,10 +146,11 @@ export const EntryItemWrapper: FC<
|
|||
}
|
||||
|
||||
navigate({
|
||||
view,
|
||||
entryId: entry.id,
|
||||
})
|
||||
},
|
||||
[asRead, entry?.id, entry?.feedId, navigate],
|
||||
[asRead, entry?.id, entry?.feedId, navigate, view],
|
||||
)
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const showContextMenu = useShowContextMenu()
|
||||
|
|
@ -208,7 +209,7 @@ export const EntryItemWrapper: FC<
|
|||
})
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,16 @@ export const EntryListHeader: FC<{
|
|||
|
||||
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 commandShortcuts = useCommandShortcuts()
|
||||
const runCmdFn = useRunCommandFn()
|
||||
|
|
@ -89,15 +98,17 @@ export const EntryListHeader: FC<{
|
|||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{views[view]!.wideMode && entryId && entryId !== ROUTE_ENTRY_PENDING && !aiEnabled && (
|
||||
<>
|
||||
<EntryHeader entryId={entryId} />
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
</>
|
||||
)}
|
||||
{views.find((v) => v.view === view)?.wideMode &&
|
||||
entryId &&
|
||||
entryId !== ROUTE_ENTRY_PENDING && (
|
||||
<>
|
||||
<EntryHeader entryId={entryId} />
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppendTaildingDivider>
|
||||
{!views[view]!.wideMode && !aiEnabled && <WideModeButton />}
|
||||
{!views.find((v) => v.view === view)?.wideMode && !aiEnabled && <WideModeButton />}
|
||||
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
|
||||
</AppendTaildingDivider>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const EntryTranslation: Component<{
|
|||
const SourceTag = inline ? "span" : "p"
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{isHTML ? (
|
||||
<HTML as="div" className={cn("prose dark:prose-invert", className)} noMedia>
|
||||
{nextTarget || source}
|
||||
|
|
@ -58,6 +58,6 @@ export const EntryTranslation: Component<{
|
|||
{nextTarget && !inline && <p>{nextTarget}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -459,8 +459,8 @@ const FeedListItem = memo(
|
|||
</div>
|
||||
|
||||
<div className="text-text flex items-center gap-1 text-sm opacity-80">
|
||||
{views[subscription.view]!.icon}
|
||||
<span>{tCommon(views[subscription.view]!.name)}</span>
|
||||
{views.find((v) => v.view === subscription.view)!.icon}
|
||||
<span>{tCommon(views.find((v) => v.view === subscription.view)!.name)}</span>
|
||||
</div>
|
||||
{!!subscription.createdAt && (
|
||||
<div className="whitespace-nowrap pr-1 text-center text-sm">
|
||||
|
|
|
|||
|
|
@ -157,15 +157,17 @@ export const SettingLists = () => {
|
|||
<span
|
||||
className={cn(
|
||||
"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>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t(views[row.view]!.name, { ns: "common" })}
|
||||
{t(views.find((v) => v.view === row.view)!.name, {
|
||||
ns: "common",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -31,36 +31,38 @@ export const ViewSelectorRadioGroup = ({
|
|||
return (
|
||||
<Card className={rest.disabled ? "pointer-events-none" : void 0}>
|
||||
<CardHeader className={cn("grid grid-cols-6 space-y-0 px-2 py-3", className)}>
|
||||
{views.map((view) => (
|
||||
<div key={view.name}>
|
||||
<input
|
||||
className="peer hidden"
|
||||
type="radio"
|
||||
id={view.name}
|
||||
value={view.view}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
<label
|
||||
htmlFor={view.name}
|
||||
className={cn(
|
||||
"hover:text-text",
|
||||
view.peerClassName,
|
||||
"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200",
|
||||
"text-text-secondary",
|
||||
"peer-checked:opacity-100",
|
||||
"whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
{cloneElement(view.icon, {
|
||||
className: `text-lg ${view.icon?.props?.className ?? ""}`,
|
||||
})}
|
||||
<span className="mt-1 hidden text-xs lg:inline">
|
||||
{t(view.name, { ns: "common" })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{views
|
||||
.filter((v) => v.switchable)
|
||||
.map((view) => (
|
||||
<div key={view.name}>
|
||||
<input
|
||||
className="peer hidden"
|
||||
type="radio"
|
||||
id={view.name}
|
||||
value={view.view}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
<label
|
||||
htmlFor={view.name}
|
||||
className={cn(
|
||||
"hover:text-text",
|
||||
view.peerClassName,
|
||||
"center flex h-10 flex-col text-xs leading-none opacity-80 duration-200",
|
||||
"text-text-secondary",
|
||||
"peer-checked:opacity-100",
|
||||
"whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
{cloneElement(view.icon, {
|
||||
className: `text-lg ${view.icon?.props?.className ?? ""}`,
|
||||
})}
|
||||
<span className="mt-1 hidden text-xs lg:inline">
|
||||
{t(view.name, { ns: "common" })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
{showPreview && (
|
||||
<CardContent
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
new MenuItemText({
|
||||
label: t("sidebar.feed_column.context_menu.change_to_other_view"),
|
||||
submenu: views
|
||||
.filter((v) => v.view !== view)
|
||||
.filter((v) => v.view !== view && v.switchable)
|
||||
.map(
|
||||
(v) =>
|
||||
new MenuItemText({
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
const { t } = useTranslation()
|
||||
const subscription = useSubscriptionByFeedId(feedId)
|
||||
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) => {
|
||||
return {
|
||||
type: feed.type,
|
||||
|
|
@ -101,14 +105,14 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
}
|
||||
|
||||
e.stopPropagation()
|
||||
if (view === undefined) return
|
||||
if (navigationView === undefined) return
|
||||
navigate({
|
||||
feedId,
|
||||
entryId: null,
|
||||
view,
|
||||
view: navigationView,
|
||||
})
|
||||
},
|
||||
[feedId, navigate, setSelectedFeedIds, view],
|
||||
[feedId, navigate, setSelectedFeedIds, navigationView],
|
||||
)
|
||||
|
||||
const feedUnread = useUnreadById(feedId)
|
||||
|
|
@ -271,6 +275,11 @@ const ListItemImpl: Component<ListItemProps> = ({
|
|||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const subscription = useSubscriptionByFeedId(listId)!
|
||||
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(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -278,10 +287,10 @@ const ListItemImpl: Component<ListItemProps> = ({
|
|||
navigate({
|
||||
listId,
|
||||
entryId: null,
|
||||
view,
|
||||
view: navigationView,
|
||||
})
|
||||
},
|
||||
[listId, navigate, view],
|
||||
[listId, navigate, navigationView],
|
||||
)
|
||||
const showContextMenu = useShowContextMenu()
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -381,6 +390,11 @@ const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, ic
|
|||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
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(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -388,10 +402,10 @@ const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, ic
|
|||
navigate({
|
||||
inboxId,
|
||||
entryId: null,
|
||||
view,
|
||||
view: navigationView,
|
||||
})
|
||||
},
|
||||
[inboxId, navigate, view],
|
||||
[inboxId, navigate, navigationView],
|
||||
)
|
||||
const showContextMenu = useShowContextMenu()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,15 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
|||
|
||||
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 isActive = activeTimelineId === timelineId
|
||||
const navigate = useNavigateEntry()
|
||||
const setActive = useCallback(() => {
|
||||
|
|
@ -31,7 +38,9 @@ export function SubscriptionTabButton({ timelineId }: { timelineId: string }) {
|
|||
|
||||
if (timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
|
||||
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
|
||||
isActive: boolean
|
||||
setActive: () => void
|
||||
}> = ({ view, isActive, setActive }) => {
|
||||
shortcut: string
|
||||
}> = ({ view, isActive, setActive, shortcut }) => {
|
||||
const unreadByView = useUnreadByView(view)
|
||||
const { t } = useTranslation()
|
||||
const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount")
|
||||
|
|
@ -59,10 +69,10 @@ const ViewSwitchButton: FC<{
|
|||
ref={setNodeRef}
|
||||
key={item.name}
|
||||
tooltip={t(item.name, { ns: "common" })}
|
||||
shortcut={`${view + 1}`}
|
||||
shortcut={shortcut}
|
||||
className={cn(
|
||||
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" : "",
|
||||
isOver && "border-orange-400 bg-orange-400/60",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/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 { ELECTRON_BUILD } from "@follow/shared/constants"
|
||||
import { usePrefetchSubscription } from "@follow/store/subscription/hooks"
|
||||
|
|
@ -32,6 +32,7 @@ import { useShouldFreeUpSpace } from "./hook"
|
|||
import { SubscriptionListGuard } from "./subscription-list/SubscriptionListGuard"
|
||||
import { SubscriptionColumnHeader } from "./SubscriptionColumnHeader"
|
||||
import { SubscriptionTabButton } from "./SubscriptionTabButton"
|
||||
import { useShowTimelineTabsSettingsModal } from "./TimelineTabsSettingsModal"
|
||||
|
||||
const lethargy = new Lethargy()
|
||||
|
||||
|
|
@ -138,11 +139,7 @@ export function SubscriptionColumn({
|
|||
)}
|
||||
|
||||
<div className="relative mb-4 mt-3">
|
||||
<div className="text-text-secondary flex h-11 justify-between gap-0 px-3 text-xl">
|
||||
{timelineList.map((timelineId) => (
|
||||
<SubscriptionTabButton key={timelineId} timelineId={timelineId} />
|
||||
))}
|
||||
</div>
|
||||
<TabsRow />
|
||||
</div>
|
||||
<div
|
||||
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 = ({
|
||||
setActive,
|
||||
timelineList,
|
||||
|
|
|
|||
|
|
@ -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 className="text-text-secondary ml-2 flex items-center gap-3 text-base lg:text-sm">
|
||||
<SortButton />
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import { FeedViewType } from "@follow/constants"
|
|||
import { redirect } from "react-router"
|
||||
|
||||
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { getFeature } from "~/hooks/biz/useFeature"
|
||||
|
||||
export function Component() {
|
||||
return null
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return redirect(
|
||||
`/timeline/view-${FeedViewType.Articles}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`,
|
||||
)
|
||||
const aiEnabled = getFeature("ai")
|
||||
const view = aiEnabled ? FeedViewType.All : FeedViewType.Articles
|
||||
return redirect(`/timeline/view-${view}/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const useFeedHeaderTitle = () => {
|
|||
|
||||
switch (currentFeedId) {
|
||||
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: {
|
||||
return t("words.starred")
|
||||
|
|
|
|||
|
|
@ -200,14 +200,16 @@ const ListItemCellImpl: ListRenderItem<ListModel> = ({ item: list }) => {
|
|||
</Text>
|
||||
)}
|
||||
<View className="flex-row items-center gap-1">
|
||||
{!!views[list.view]?.icon &&
|
||||
createElement(views[list.view]!.icon, {
|
||||
color: views[list.view]!.activeColor,
|
||||
{!!views.find((v) => v.view === list.view)?.icon &&
|
||||
createElement(views.find((v) => v.view === list.view)!.icon, {
|
||||
color: views.find((v) => v.view === list.view)!.activeColor,
|
||||
height: 16,
|
||||
width: 16,
|
||||
})}
|
||||
{!!views[list.view]?.name && (
|
||||
<Text className="text-secondary-label text-base">{t(views[list.view]!.name)}</Text>
|
||||
{!!views.find((v) => v.view === list.view)?.name && (
|
||||
<Text className="text-secondary-label text-base">
|
||||
{t(views.find((v) => v.view === list.view)!.name)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -28,6 +28,7 @@
|
|||
"feed.follower_one": "follower",
|
||||
"feed.follower_other": "followers",
|
||||
"feed.updated_at": "Updated",
|
||||
"feed_view_type.all": "All",
|
||||
"feed_view_type.articles": "Articles",
|
||||
"feed_view_type.audios": "Audios",
|
||||
"feed_view_type.notifications": "Notifications",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"feed.follower_one": "フォロワー",
|
||||
"feed.follower_other": "フォロワー",
|
||||
"feed.updated_at": "更新日時",
|
||||
"feed_view_type.all": "すべて",
|
||||
"feed_view_type.articles": "記事",
|
||||
"feed_view_type.audios": "オーディオ",
|
||||
"feed_view_type.notifications": "通知",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"feed.follower_one": "订阅者",
|
||||
"feed.follower_other": "订阅者",
|
||||
"feed.updated_at": "更新于",
|
||||
"feed_view_type.all": "全部",
|
||||
"feed_view_type.articles": "文章",
|
||||
"feed_view_type.audios": "音频",
|
||||
"feed_view_type.notifications": "通知",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"feed.follower_one": "跟隨者",
|
||||
"feed.follower_other": "跟隨者",
|
||||
"feed.updated_at": "已更新",
|
||||
"feed_view_type.all": "全部",
|
||||
"feed_view_type.articles": "文章",
|
||||
"feed_view_type.audios": "音訊",
|
||||
"feed_view_type.notifications": "通知",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const styledButtonVariant = cva(
|
|||
],
|
||||
variants: {
|
||||
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",
|
||||
lg: "px-5 py-2 rounded-lg text-base font-semibold",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { FeedViewType } from "./enums"
|
|||
|
||||
export interface ViewDefinition {
|
||||
name:
|
||||
| "feed_view_type.all"
|
||||
| "feed_view_type.articles"
|
||||
| "feed_view_type.audios"
|
||||
| "feed_view_type.notifications"
|
||||
|
|
@ -18,20 +19,35 @@ export interface ViewDefinition {
|
|||
wideMode?: boolean
|
||||
gridMode?: boolean
|
||||
activeColor: string
|
||||
/** if it's switchable from other views to this view by user */
|
||||
switchable: boolean
|
||||
}
|
||||
export const views: ViewDefinition[] = [
|
||||
{
|
||||
name: "feed_view_type.articles",
|
||||
icon: <i className="i-mgc-paper-cute-fi" />,
|
||||
name: "feed_view_type.all",
|
||||
icon: <i className="i-mgc-bubble-cute-fi" />,
|
||||
className: "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",
|
||||
view: FeedViewType.Articles,
|
||||
activeColor: "#FF5C00",
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
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",
|
||||
peerClassName: "peer-checked:text-sky-600 peer-checked:dark:text-sky-500",
|
||||
wideMode: true,
|
||||
|
|
@ -39,6 +55,7 @@ export const views: ViewDefinition[] = [
|
|||
view: FeedViewType.SocialMedia,
|
||||
// sky-500
|
||||
activeColor: "#0ea5e9",
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
name: "feed_view_type.pictures",
|
||||
|
|
@ -51,6 +68,7 @@ export const views: ViewDefinition[] = [
|
|||
view: FeedViewType.Pictures,
|
||||
// green-500
|
||||
activeColor: "#22c55e",
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
name: "feed_view_type.videos",
|
||||
|
|
@ -63,6 +81,7 @@ export const views: ViewDefinition[] = [
|
|||
view: FeedViewType.Videos,
|
||||
// red-500
|
||||
activeColor: "#ef4444",
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
name: "feed_view_type.audios",
|
||||
|
|
@ -73,6 +92,7 @@ export const views: ViewDefinition[] = [
|
|||
view: FeedViewType.Audios,
|
||||
// purple-500
|
||||
activeColor: "#a855f7",
|
||||
switchable: true,
|
||||
},
|
||||
{
|
||||
name: "feed_view_type.notifications",
|
||||
|
|
@ -83,6 +103,7 @@ export const views: ViewDefinition[] = [
|
|||
view: FeedViewType.Notifications,
|
||||
// yellow-500
|
||||
activeColor: "#eab308",
|
||||
switchable: true,
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export type ExtraModel = {
|
|||
type: string
|
||||
content_html?: string
|
||||
}[]
|
||||
title_keyword?: string
|
||||
}
|
||||
|
||||
// export { ImageColorsResult } from "react-native-image-colors"
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,7 @@ type ExtraModel = {
|
|||
type: string;
|
||||
content_html?: string;
|
||||
}[];
|
||||
title_keyword?: string
|
||||
};
|
||||
declare const CommonEntryFields: {
|
||||
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;
|
||||
content_html?: string | undefined;
|
||||
}[] | null | undefined;
|
||||
title_keyword?: string | undefined;
|
||||
} | null | undefined;
|
||||
};
|
||||
feeds: {
|
||||
|
|
@ -23548,6 +23550,7 @@ declare const _routes: hono_hono_base42.HonoBase<Env, ({
|
|||
url: string;
|
||||
content_html?: string | undefined;
|
||||
}[] | null | undefined;
|
||||
title_keyword?: string | undefined;
|
||||
} | null | undefined;
|
||||
};
|
||||
feeds: {
|
||||
|
|
@ -26100,4 +26103,4 @@ declare const _routes: hono_hono_base42.HonoBase<Env, ({
|
|||
}, "/referrals">, "/">;
|
||||
type AppType = typeof _routes;
|
||||
//#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 };
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@ export const defaultUISettings: UISettings = {
|
|||
|
||||
// Discover
|
||||
discoverLanguage: "all",
|
||||
|
||||
// Timeline tabs preset (excluding the first fixed tab)
|
||||
timelineTabs: {
|
||||
visible: [],
|
||||
hidden: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultIntegrationSettings: IntegrationSettings = {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,12 @@ export interface UISettings {
|
|||
|
||||
// Discover
|
||||
discoverLanguage: "all" | "eng" | "cmn"
|
||||
|
||||
// Desktop: Timeline tabs preset (excluding the first fixed tab)
|
||||
timelineTabs: {
|
||||
visible: string[]
|
||||
hidden: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntegrationSettings {
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ class EntryActions implements Hydratable, Resetable {
|
|||
(hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||
|
||||
subscription?.hideFromTimeline
|
||||
|
||||
if (typeof subscription?.view === "number" && !ignore) {
|
||||
draft.entryIdByView[subscription.view].add(entryId)
|
||||
if (!ignore) {
|
||||
if (typeof subscription?.view === "number") {
|
||||
draft.entryIdByView[subscription.view].add(entryId)
|
||||
}
|
||||
draft.entryIdByView[FeedViewType.All].add(entryId)
|
||||
}
|
||||
|
||||
// lists
|
||||
|
|
@ -108,8 +111,11 @@ class EntryActions implements Hydratable, Resetable {
|
|||
(hidePrivateSubscriptionsInTimeline && subscription?.isPrivate) ||
|
||||
subscription?.hideFromTimeline
|
||||
|
||||
if (typeof subscription?.view === "number" && !ignore) {
|
||||
draft.entryIdByView[subscription.view].add(entryId)
|
||||
if (!ignore) {
|
||||
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]
|
||||
draft.entryIdSet.delete(entryId)
|
||||
draft.entryIdByInbox[entry.inboxHandle!]?.delete(entryId)
|
||||
draft.entryIdByView[FeedViewType.All].delete(entryId)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ export const useViewWithSubscription = () =>
|
|||
return views
|
||||
.filter((view) => {
|
||||
if (
|
||||
view.view === FeedViewType.All ||
|
||||
view.view === FeedViewType.Articles ||
|
||||
view.view === FeedViewType.SocialMedia ||
|
||||
view.view === FeedViewType.Pictures ||
|
||||
|
|
|
|||
|
|
@ -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 type { SubscriptionState } from "./store"
|
||||
|
|
@ -21,7 +21,7 @@ export const folderFeedsByFeedIdSelector =
|
|||
for (const feedId in state.data) {
|
||||
const subscription = state.data[feedId]!
|
||||
if (
|
||||
subscription.view === view &&
|
||||
(subscription.view === view || view === FeedViewType.All) &&
|
||||
(subscription.category
|
||||
? subscription.category === folderName
|
||||
: getDefaultCategory(subscription) === folderName)
|
||||
|
|
|
|||
|
|
@ -99,12 +99,14 @@ class SubscriptionActions implements Hydratable, Resetable {
|
|||
|
||||
if (subscription.feedId && subscription.type === "feed") {
|
||||
draft.feedIdByView[subscription.view].add(subscription.feedId)
|
||||
draft.feedIdByView[FeedViewType.All].add(subscription.feedId)
|
||||
if (subscription.category) {
|
||||
draft.categories[subscription.view].add(subscription.category)
|
||||
}
|
||||
}
|
||||
if (subscription.listId && subscription.type === "list") {
|
||||
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]
|
||||
if (!subscription) continue
|
||||
draft.subscriptionIdSet.delete(getSubscriptionDBId(subscription))
|
||||
if (subscription.feedId) draft.feedIdByView[subscription.view].delete(subscription.feedId)
|
||||
if (subscription.listId) draft.listIdByView[subscription.view].delete(subscription.listId)
|
||||
if (subscription.category)
|
||||
if (subscription.feedId) {
|
||||
draft.feedIdByView[subscription.view].delete(subscription.feedId)
|
||||
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[FeedViewType.All].delete(subscription.category)
|
||||
}
|
||||
delete draft.data[id]
|
||||
}
|
||||
})
|
||||
|
|
@ -349,9 +359,18 @@ class SubscriptionSyncService {
|
|||
draft.data[id] = subscription
|
||||
|
||||
draft.subscriptionIdSet.add(getSubscriptionDBId(subscription))
|
||||
if (subscription.feedId) draft.feedIdByView[subscription.view].add(subscription.feedId)
|
||||
if (subscription.listId) draft.listIdByView[subscription.view].add(subscription.listId)
|
||||
if (subscription.category) draft.categories[subscription.view].add(subscription.category)
|
||||
if (subscription.feedId) {
|
||||
draft.feedIdByView[subscription.view].add(subscription.feedId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import type { UnreadSchema } from "@follow/database/schemas/types"
|
||||
import { EntryService } from "@follow/database/services/entry"
|
||||
import { UnreadService } from "@follow/database/services/unread"
|
||||
import type { MarkAllAsReadRequest } from "@follow-app/client-sdk"
|
||||
import { isEqual } from "es-toolkit"
|
||||
|
||||
import { apiClient } from "../../context"
|
||||
import { api, apiClient } from "../../context"
|
||||
import type { Hydratable, Resetable } from "../../lib/base"
|
||||
import { createTransaction, createZustandStore } from "../../lib/helper"
|
||||
import { getEntry } from "../entry/getter"
|
||||
|
|
@ -127,14 +128,17 @@ class UnreadSyncService {
|
|||
excludePrivate: boolean
|
||||
}) {
|
||||
const request = async () => {
|
||||
const res = await apiClient().reads.all.$post({
|
||||
json: {
|
||||
view,
|
||||
excludePrivate,
|
||||
...filter,
|
||||
...time,
|
||||
},
|
||||
})
|
||||
const args: MarkAllAsReadRequest = {
|
||||
view: view === FeedViewType.All ? undefined : view,
|
||||
excludePrivate,
|
||||
...filter,
|
||||
...time,
|
||||
}
|
||||
if (view === FeedViewType.All) {
|
||||
delete args.view
|
||||
}
|
||||
const res = await api().reads.markAllAsRead(args)
|
||||
|
||||
return res.data.read
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +165,7 @@ class UnreadSyncService {
|
|||
|
||||
async markViewAsRead(view: FeedViewType, excludePrivate: boolean) {
|
||||
await this.markBatchAsRead({
|
||||
view,
|
||||
view: view === FeedViewType.All ? undefined : view,
|
||||
excludePrivate,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ class APIMorph {
|
|||
extra: item.entries.extra
|
||||
? {
|
||||
links: item.entries.extra.links ?? undefined,
|
||||
title_keyword: item.entries.extra.title_keyword ?? undefined,
|
||||
}
|
||||
: null,
|
||||
language: item.entries.language,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class LegacyHonoMorph {
|
|||
extra: data.entries.extra
|
||||
? {
|
||||
links: data.entries.extra.links ?? undefined,
|
||||
title_keyword: data.entries.extra.title_keyword ?? undefined,
|
||||
}
|
||||
: null,
|
||||
language: data.entries.language,
|
||||
|
|
|
|||
Loading…
Reference in New Issue