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,