From 7d0a0d1c40bde59e022a4c65b8fda446503665d3 Mon Sep 17 00:00:00 2001 From: songkeys Date: Fri, 5 Sep 2025 21:24:42 +0800 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Signed-off-by: Innei Co-authored-by: Innei --- .../src/components/ui/media/Media.tsx | 16 +- .../src/hooks/biz/useEntryActions.tsx | 6 +- .../src/hooks/biz/useNavigateEntry.ts | 2 +- .../renderer/src/hooks/biz/useTimelineList.ts | 14 +- .../src/initialize/global-shortcuts.ts | 19 +- .../entry-content/EntryLayoutContent.tsx | 7 +- .../SubscriptionColumn.tsx | 4 +- .../timeline-column/TimelineColumnLayout.tsx | 3 +- .../src/modules/customize-toolbar/dnd.tsx | 2 +- .../modules/entry-column/AIEntryLayout.tsx | 11 +- .../entry-column/EntryItemSkeleton.tsx | 6 +- .../modules/entry-column/Items/all-item.tsx | 495 ++++++++++++++++++ .../entry-column/Items/all-masonry.tsx | 368 +++++++++++++ .../Items/getItemComponentByView.ts | 2 + .../components/FooterMarkItem.tsx | 2 +- .../EntryColumnWrapper.tsx | 5 +- .../src/modules/entry-column/grid.tsx | 16 +- .../entry-column/hooks/useEntriesByView.ts | 3 +- .../hooks/useEntryMarkReadHandler.tsx | 2 +- .../src/modules/entry-column/index.tsx | 4 +- .../entry-column/layouts/EntryItemWrapper.tsx | 5 +- .../entry-column/layouts/EntryListHeader.tsx | 27 +- .../src/modules/entry-column/translation.tsx | 4 +- .../src/modules/settings/tabs/feeds.tsx | 4 +- .../src/modules/settings/tabs/lists/index.tsx | 8 +- .../modules/shared/ViewSelectorRadioGroup.tsx | 62 +-- .../subscription-column/FeedCategory.tsx | 2 +- .../modules/subscription-column/FeedItem.tsx | 28 +- .../SubscriptionTabButton.tsx | 20 +- .../TimelineTabsSettingsModal.tsx | 271 ++++++++++ .../src/modules/subscription-column/index.tsx | 55 +- .../subscription-list/ListHeader.tsx | 2 +- .../renderer/src/pages/(main)/index.sync.tsx | 7 +- .../layer/renderer/src/store/feed/hooks.ts | 2 +- .../src/modules/settings/routes/Lists.tsx | 12 +- icons/mgc/bubble_cute_fi.svg | 1 + icons/mgc/danmaku_cute_fi.svg | 1 + icons/mgc/thought_cute_fi.svg | 1 + locales/common/en.json | 1 + locales/common/ja.json | 1 + locales/common/zh-CN.json | 1 + locales/common/zh-TW.json | 1 + .../components/src/ui/button/variants.tsx | 2 +- packages/internal/constants/src/tabs.tsx | 27 +- .../internal/database/src/schemas/types.ts | 1 + packages/internal/shared/src/hono.ts | 5 +- .../internal/shared/src/settings/defaults.ts | 6 + .../internal/shared/src/settings/interface.ts | 6 + .../internal/store/src/modules/entry/store.ts | 15 +- .../store/src/modules/subscription/hooks.ts | 1 + .../src/modules/subscription/selectors.ts | 4 +- .../store/src/modules/subscription/store.ts | 31 +- .../store/src/modules/unread/store.ts | 26 +- packages/internal/store/src/morph/api.ts | 1 + packages/internal/store/src/morph/hono.ts | 1 + 55 files changed, 1494 insertions(+), 135 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx create mode 100644 icons/mgc/bubble_cute_fi.svg create mode 100644 icons/mgc/danmaku_cute_fi.svg create mode 100644 icons/mgc/thought_cute_fi.svg diff --git a/apps/desktop/layer/renderer/src/components/ui/media/Media.tsx b/apps/desktop/layer/renderer/src/components/ui/media/Media.tsx index 1b2805488..272a146b2 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/Media.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/Media.tsx @@ -23,6 +23,7 @@ type BaseProps = { blurhash?: string inline?: boolean fitContent?: boolean + videoClassName?: string } const isImageLoadedSet = new Set() @@ -71,6 +72,7 @@ const MediaImpl: FC = ({ width, inline, fitContent, + videoClassName, ...rest } = props @@ -287,7 +289,12 @@ const MediaImpl: FC = ({ )} onClick={handleClick} > - + ) } @@ -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(null) @@ -507,7 +515,7 @@ const VideoPreview: FC<{ {!isInitVideoPlayer ? ( { 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)} /> )} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index 7b5e78bda..2091e2e8e 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -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, diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts index 278c08129..053dbf659 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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 diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts b/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts index 6437d5adf..61bb8bf5c 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useTimelineList.ts @@ -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 } diff --git a/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts b/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts index 1acc10eb5..c982dcb35 100644 --- a/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts +++ b/apps/desktop/layer/renderer/src/initialize/global-shortcuts.ts @@ -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() diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx index 4e85d6919..e8af08e5f 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx @@ -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 ( diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx index e06ebe4f8..c4e80f099 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/SubscriptionColumn.tsx @@ -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 && ( navigate({ entryId: null })} + onClick={() => navigate({ entryId: null, view: currentView })} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/timeline-column/TimelineColumnLayout.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/timeline-column/TimelineColumnLayout.tsx index 3b32a00f6..b490e994b 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/timeline-column/TimelineColumnLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/timeline-column/TimelineColumnLayout.tsx @@ -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({ diff --git a/apps/desktop/layer/renderer/src/modules/customize-toolbar/dnd.tsx b/apps/desktop/layer/renderer/src/modules/customize-toolbar/dnd.tsx index c51257c90..6dfeff4aa 100644 --- a/apps/desktop/layer/renderer/src/modules/customize-toolbar/dnd.tsx +++ b/apps/desktop/layer/renderer/src/modules/customize-toolbar/dnd.tsx @@ -95,7 +95,7 @@ export const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => { export function DroppableContainer({ children }: { children: ReactNode }) { return ( -
+
{children}
) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx index 5988f9fe4..b30d8676c 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx @@ -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) => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx index 23ced348e..3bfe6b710 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryItemSkeleton.tsx @@ -28,7 +28,11 @@ export const EntryItemSkeleton: FC<{ } return ( -
+
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
{SkeletonItem}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx new file mode 100644 index 000000000..bacc42fd6 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx @@ -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(() => , [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(null) + const hoverTimerRef = useRef | 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 ? ( + + {part} + + ) : ( + + {part} + + ) + })} + + ) + + return ( +
+ {renderTitle({ type: "normal" })} +
{renderTitle({ type: "highlight" })}
+
+ ) + }, [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 ( +
+ {/* Hero */} +
+ {/* Icon */} + {!mediaCover && ( +
+ {icon} +
+ )} + + {/* Common views */} + {(view === FeedViewType.Articles || + view === FeedViewType.Notifications || + view === FeedViewType.SocialMedia || + view === FeedViewType.Audios) && ( + <> + {mediaCover ? ( + + ) : ( +
+
{titleWithKeyword}
+
+ )} + + )} + + {/* Pictures */} + {view === FeedViewType.Pictures && ( +
+ {entryMedia ? ( + + ) : ( +
+
{titleWithKeyword}
+
+ )} +
+ )} + + {/* Videos */} + {view === FeedViewType.Videos && ( +
+
+ {mediaCover ? ( + + ) : ( +
+
{titleWithKeyword}
+
+ )} + {miniIframeSrc && showPreview && ( +
+ +
+ )} + {!!entry.duration && ( +
+ {entry.duration} +
+ )} +
+
+ )} +
+ + {/* Footer */} +
+
+
+
+ + + {isInCollection && ( +
+ +
+ )} +
+
+
+
+ + + + +
+ +
+ + {dayjs + .duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute") + .humanize()} + {t("space")} + {t("words.ago")} + +
+
+
+
+ ) +} + +AllItem.wrapperClassName = "hover:bg-transparent" + +// function AllArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) { +// return +// } + +// 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], + ), + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx new file mode 100644 index 000000000..91734329b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx @@ -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 = ({ + data, + hasNextPage, + endReached, + Footer, + refetch, +}) => { + const scrollElement = useScrollViewElement() + const [containerRef, setContainerRef] = useState(null) + const [width, setWidth] = useState(0) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const prevDataLengthRef = useRef(data.length) + + // Convert entry IDs to masonry items with stable references + const items = useMemo( + () => 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(`[data-index="${index}"]`) + if (byIndex) return byIndex + const id = dataRef.current[index] + if (!id) return null + return containerRef?.querySelector(`[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 ( +
+ +
+ ) + } + + return ( +
+ { + setForceRemountCounter((prev) => prev + 1) + }} + /> +
+ {Footer &&
{typeof Footer === "function" ?
: Footer}
} + {(hasNextPage || isLoadingMore) && } +
+ + +
+ ) +} + +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> = ({ + data, + index, +}) => { + if (!data || !data.entryId) return + + const entry = getEntry(data.entryId) + if (!entry) return + + return ( +
+ +
+ ) +} + +const MasonryWrapper: FC<{ + items: MasonryItem[] + columnGutter: number + columnWidth: number + columnCount: number + overscanBy: number + render: React.ComponentType> + 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 ( + { + console.error("Masonry error caught:", errorData.error) + setHasError(true) + props.onError?.() + return ( +
+ +
+ ) + }} + beforeCapture={() => { + setErrorKey((prev) => prev + 1) + }} + > + {!hasError && ( + + )} +
+ ) +} + +// Loading skeleton component +const LoadingSkeleton: FC<{ count?: number }> = ({ count = 1 }) => { + const keys = useMemo(() => Array.from({ length: count }), [count]) + return ( + <> + {keys.map((_, index) => ( +
+
+ + + +
+
+ ))} + + ) +} + +const SkeletonGrid: FC<{ columnCount: number }> = ({ columnCount }) => { + const keys = useMemo(() => Array.from({ length: columnCount * 2 }, () => null), [columnCount]) + return ( +
+ {keys.map((_, index) => ( + + ))} +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/getItemComponentByView.ts b/apps/desktop/layer/renderer/src/modules/entry-column/Items/getItemComponentByView.ts index ce1e4f35a..d54efe740 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/getItemComponentByView.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/getItemComponentByView.ts @@ -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, diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/FooterMarkItem.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/FooterMarkItem.tsx index e8d3c7c7a..0b2cc228a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/FooterMarkItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/FooterMarkItem.tsx @@ -17,7 +17,7 @@ export const FooterMarkItem = ({ if (view === FeedViewType.SocialMedia) { return - } else if (views[view]!.gridMode) { + } else if (views.find((v) => v.view === view)?.gridMode) { return } return diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/EntryColumnWrapper.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/EntryColumnWrapper.tsx index 644266f27..d2ccf3752 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/EntryColumnWrapper.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/EntryColumnWrapper.tsx @@ -18,7 +18,10 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp return (
v.view === view)?.wideMode ? "w-[5px] p-0" : "", + "z-[3]", + )} mask={false} ref={ref} rootClassName={cn("h-full", isZenMode ? "max-w-[80ch] mx-auto" : "")} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx index a4ad29aed..100177231 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx @@ -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 = (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 ( + + ) + } + if (masonry && view === FeedViewType.Pictures) { return ( 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) { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx index d47737fa9..a9a1bb185 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 92842bd6a..72a2846c7 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -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 ( v.view === view)?.wideMode || aiEnabled const Link = view === FeedViewType.SocialMedia ? "article" : NavLink diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx index b5927018c..27c100104 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx @@ -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 && ( - <> - - - - )} + {views.find((v) => v.view === view)?.wideMode && + entryId && + entryId !== ROUTE_ENTRY_PENDING && ( + <> + + + + )} - {!views[view]!.wideMode && !aiEnabled && } + {!views.find((v) => v.view === view)?.wideMode && !aiEnabled && } {view === FeedViewType.Pictures && } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/translation.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/translation.tsx index 7c09fbc76..57889a7af 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/translation.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/translation.tsx @@ -41,7 +41,7 @@ export const EntryTranslation: Component<{ const SourceTag = inline ? "span" : "p" return ( -
+ <> {isHTML ? ( {nextTarget || source} @@ -58,6 +58,6 @@ export const EntryTranslation: Component<{ {nextTarget && !inline &&

{nextTarget}

}
)} -
+ ) } diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/feeds.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/feeds.tsx index 1e5308552..63f3d64b4 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/feeds.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/feeds.tsx @@ -459,8 +459,8 @@ const FeedListItem = memo(
- {views[subscription.view]!.icon} - {tCommon(views[subscription.view]!.name)} + {views.find((v) => v.view === subscription.view)!.icon} + {tCommon(views.find((v) => v.view === subscription.view)!.name)}
{!!subscription.createdAt && (
diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx index cc912cc4c..3a9ae89b0 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/index.tsx @@ -157,15 +157,17 @@ export const SettingLists = () => { v.view === row.view)?.className, )} > - {views[row.view]!.icon} + {views.find((v) => v.view === row.view)?.icon} - {t(views[row.view]!.name, { ns: "common" })} + {t(views.find((v) => v.view === row.view)!.name, { + ns: "common", + })} diff --git a/apps/desktop/layer/renderer/src/modules/shared/ViewSelectorRadioGroup.tsx b/apps/desktop/layer/renderer/src/modules/shared/ViewSelectorRadioGroup.tsx index 29f187bbc..2da369be4 100644 --- a/apps/desktop/layer/renderer/src/modules/shared/ViewSelectorRadioGroup.tsx +++ b/apps/desktop/layer/renderer/src/modules/shared/ViewSelectorRadioGroup.tsx @@ -31,36 +31,38 @@ export const ViewSelectorRadioGroup = ({ return ( - {views.map((view) => ( -
- - -
- ))} + {views + .filter((v) => v.switchable) + .map((view) => ( +
+ + +
+ ))}
{showPreview && ( v.view !== view) + .filter((v) => v.view !== view && v.switchable) .map( (v) => new MenuItemText({ diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx index c7b3562a5..10c2aa7b5 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx @@ -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 = ({ 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) => { e.stopPropagation() @@ -278,10 +287,10 @@ const ListItemImpl: Component = ({ 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 = ({ 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) => { e.stopPropagation() @@ -388,10 +402,10 @@ const InboxItemImpl: Component = ({ view, inboxId, className, ic navigate({ inboxId, entryId: null, - view, + view: navigationView, }) }, - [inboxId, navigate, view], + [inboxId, navigate, navigationView], ) const showContextMenu = useShowContextMenu() diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx index c32ca3183..a9663d356 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx @@ -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 + return ( + + ) } } @@ -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", )} diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx new file mode 100644 index 000000000..834e89bea --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineTabsSettingsModal.tsx @@ -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 ( +
+ {children} +
+ ) +} + +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 ( +
+
{meta.icon}
+
+ {t(meta.name as any, { ns: "common" })} +
+
+ ) +} + +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 ( +
+ +
+ ) +} + +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 ( +
e.stopPropagation()} + > +
+

Timeline Tabs

+

+ First tab is fixed. Drag to choose up to {MAX_VISIBLE} visible tabs. +

+
+ +
+
+

+ Visible ({visible.length}/{MAX_VISIBLE}) +

+ + + {visible.map((id) => ( + + ))} + + +
+ +
+

Hidden

+ + + {hidden.map((id) => ( + + ))} + + +
+
+
+ +
+ +
+
+ ) +} + +export const useShowTimelineTabsSettingsModal = () => { + const { present } = useModalStack() + return useCallback(() => { + present({ + id: "timeline-tabs-settings", + title: "Customize Timeline Tabs", + content: () => , + overlay: true, + clickOutsideToDismiss: true, + }) + }, [present]) +} diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index 5aafc97d3..f31fa0c60 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -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({ )}
-
- {timelineList.map((timelineId) => ( - - ))} -
+
= memo }, ) +const TabsRow: FC = () => { + const timelineList = useTimelineList() + const showSettings = useShowTimelineTabsSettingsModal() + const timelineTabs = useUISettingKey("timelineTabs") + + if (timelineList.length <= 5) { + return ( +
+ {timelineList.map((timelineId, index) => ( + + ))} +
+ ) + } + + 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 ( +
+ + {visible.map((timelineId, index) => ( + + ))} + + + + +
+ ) +} + const CommandsHandler = ({ setActive, timelineList, diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/ListHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/ListHeader.tsx index e09f4c2be..0981cf44f 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/ListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/subscription-list/ListHeader.tsx @@ -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" })}
diff --git a/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx b/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx index a2b1bc274..c4ae0deb5 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/index.sync.tsx @@ -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}`) } diff --git a/apps/desktop/layer/renderer/src/store/feed/hooks.ts b/apps/desktop/layer/renderer/src/store/feed/hooks.ts index 5fc795573..2c8d1ff61 100644 --- a/apps/desktop/layer/renderer/src/store/feed/hooks.ts +++ b/apps/desktop/layer/renderer/src/store/feed/hooks.ts @@ -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") diff --git a/apps/mobile/src/modules/settings/routes/Lists.tsx b/apps/mobile/src/modules/settings/routes/Lists.tsx index 843761cab..5f89e6116 100644 --- a/apps/mobile/src/modules/settings/routes/Lists.tsx +++ b/apps/mobile/src/modules/settings/routes/Lists.tsx @@ -200,14 +200,16 @@ const ListItemCellImpl: ListRenderItem = ({ item: list }) => { )} - {!!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 && ( - {t(views[list.view]!.name)} + {!!views.find((v) => v.view === list.view)?.name && ( + + {t(views.find((v) => v.view === list.view)!.name)} + )} diff --git a/icons/mgc/bubble_cute_fi.svg b/icons/mgc/bubble_cute_fi.svg new file mode 100644 index 000000000..ba8e57f4b --- /dev/null +++ b/icons/mgc/bubble_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/danmaku_cute_fi.svg b/icons/mgc/danmaku_cute_fi.svg new file mode 100644 index 000000000..24edef5f0 --- /dev/null +++ b/icons/mgc/danmaku_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/thought_cute_fi.svg b/icons/mgc/thought_cute_fi.svg new file mode 100644 index 000000000..d67e4c9bf --- /dev/null +++ b/icons/mgc/thought_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/locales/common/en.json b/locales/common/en.json index 38991ffc4..ac2e8ec43 100644 --- a/locales/common/en.json +++ b/locales/common/en.json @@ -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", diff --git a/locales/common/ja.json b/locales/common/ja.json index 936846066..33b952adc 100644 --- a/locales/common/ja.json +++ b/locales/common/ja.json @@ -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": "通知", diff --git a/locales/common/zh-CN.json b/locales/common/zh-CN.json index 8fe956ac8..509aaf575 100644 --- a/locales/common/zh-CN.json +++ b/locales/common/zh-CN.json @@ -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": "通知", diff --git a/locales/common/zh-TW.json b/locales/common/zh-TW.json index 2ac94e163..1cab0928d 100644 --- a/locales/common/zh-TW.json +++ b/locales/common/zh-TW.json @@ -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": "通知", diff --git a/packages/internal/components/src/ui/button/variants.tsx b/packages/internal/components/src/ui/button/variants.tsx index fb308dd42..4b00f2f2d 100644 --- a/packages/internal/components/src/ui/button/variants.tsx +++ b/packages/internal/components/src/ui/button/variants.tsx @@ -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", }, diff --git a/packages/internal/constants/src/tabs.tsx b/packages/internal/constants/src/tabs.tsx index f1ffaf081..1a6ebb20f 100644 --- a/packages/internal/constants/src/tabs.tsx +++ b/packages/internal/constants/src/tabs.tsx @@ -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: , + name: "feed_view_type.all", + icon: , 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: , + 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: , + icon: , 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, }, ] diff --git a/packages/internal/database/src/schemas/types.ts b/packages/internal/database/src/schemas/types.ts index e2f2d1ce8..d6972d2cb 100644 --- a/packages/internal/database/src/schemas/types.ts +++ b/packages/internal/database/src/schemas/types.ts @@ -57,6 +57,7 @@ export type ExtraModel = { type: string content_html?: string }[] + title_keyword?: string } // export { ImageColorsResult } from "react-native-image-colors" diff --git a/packages/internal/shared/src/hono.ts b/packages/internal/shared/src/hono.ts index 7296b13de..801b0049e 100644 --- a/packages/internal/shared/src/hono.ts +++ b/packages/internal/shared/src/hono.ts @@ -1636,6 +1636,7 @@ type ExtraModel = { type: string; content_html?: string; }[]; + title_keyword?: string }; declare const CommonEntryFields: { id: drizzle_orm142.HasRuntimeDefault>>>>; @@ -23279,6 +23280,7 @@ declare const _routes: hono_hono_base42.HonoBase, "/">; 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 }; \ No newline at end of file +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 }; diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts index 6c6bcf73b..740a58d9e 100644 --- a/packages/internal/shared/src/settings/defaults.ts +++ b/packages/internal/shared/src/settings/defaults.ts @@ -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 = { diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts index e659d3863..eb47c4b4f 100644 --- a/packages/internal/shared/src/settings/interface.ts +++ b/packages/internal/shared/src/settings/interface.ts @@ -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 { diff --git a/packages/internal/store/src/modules/entry/store.ts b/packages/internal/store/src/modules/entry/store.ts index c2a2af2b3..c1e3636c3 100644 --- a/packages/internal/store/src/modules/entry/store.ts +++ b/packages/internal/store/src/modules/entry/store.ts @@ -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) }) } diff --git a/packages/internal/store/src/modules/subscription/hooks.ts b/packages/internal/store/src/modules/subscription/hooks.ts index 8f009cf30..95e0cf4bf 100644 --- a/packages/internal/store/src/modules/subscription/hooks.ts +++ b/packages/internal/store/src/modules/subscription/hooks.ts @@ -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 || diff --git a/packages/internal/store/src/modules/subscription/selectors.ts b/packages/internal/store/src/modules/subscription/selectors.ts index 59c552414..97ce5df57 100644 --- a/packages/internal/store/src/modules/subscription/selectors.ts +++ b/packages/internal/store/src/modules/subscription/selectors.ts @@ -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) diff --git a/packages/internal/store/src/modules/subscription/store.ts b/packages/internal/store/src/modules/subscription/store.ts index 23f727eec..c71cf24f6 100644 --- a/packages/internal/store/src/modules/subscription/store.ts +++ b/packages/internal/store/src/modules/subscription/store.ts @@ -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) + } } }) }) diff --git a/packages/internal/store/src/modules/unread/store.ts b/packages/internal/store/src/modules/unread/store.ts index acf0a0197..d0274afd7 100644 --- a/packages/internal/store/src/modules/unread/store.ts +++ b/packages/internal/store/src/modules/unread/store.ts @@ -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, }) } diff --git a/packages/internal/store/src/morph/api.ts b/packages/internal/store/src/morph/api.ts index 1a98c84d7..9fdf7bfb8 100644 --- a/packages/internal/store/src/morph/api.ts +++ b/packages/internal/store/src/morph/api.ts @@ -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, diff --git a/packages/internal/store/src/morph/hono.ts b/packages/internal/store/src/morph/hono.ts index b9a9e6541..da3c5869c 100644 --- a/packages/internal/store/src/morph/hono.ts +++ b/packages/internal/store/src/morph/hono.ts @@ -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,