feat: enhance subscription column functionality

- Introduced new components for managing subscription entries, including EntrySubscriptionItem and EntrySubscriptionList, to improve the display and interaction within the subscription column.
- Added hooks for handling entry visibility and data fetching, optimizing performance and user experience.
- Implemented a toolbar for toggling the visibility of the entry list, enhancing user control over the interface.
- Refactored sidebar atoms to support new subscription-related states and actions.

These changes aim to provide a more robust and user-friendly experience in managing subscriptions within the application.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-12 23:32:58 +08:00
parent 6e028b3e68
commit 863bc43f3d
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
23 changed files with 739 additions and 1403 deletions

View File

@ -1,4 +1,5 @@
import { atom } from "jotai"
import type { ReactNode } from "react"
import { createAtomHooks } from "~/lib/jotai"
@ -11,13 +12,9 @@ const [
setTimelineColumnShow,
] = createAtomHooks(atom(true))
export const useSubscriptionColumnShow = () => {
return internal_useSubscriptionColumnShow()
}
export const useSubscriptionColumnShow = internal_useSubscriptionColumnShow
export const getSubscriptionColumnShow = () => {
return internal_getSubscriptionShow()
}
export const getSubscriptionColumnShow = internal_getSubscriptionShow
export { setTimelineColumnShow }
@ -29,3 +26,21 @@ export const [
getSubscriptionColumnTempShow,
setSubscriptionColumnTempShow,
] = createAtomHooks(atom(false))
export const [
,
,
useSubscriptionColumnApronNode,
,
getSubscriptionColumnApronNode,
setSubscriptionColumnApronNode,
] = createAtomHooks(atom<ReactNode | null>(null))
export const [
,
,
useSubscriptionEntryPlaneVisible,
,
getSubscriptionEntryPlaneVisible,
setSubscriptionEntryPlaneVisible,
] = createAtomHooks(atom(true))

View File

@ -3,14 +3,16 @@ import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn } from "@follow/utils"
import { AnimatePresence } from "motion/react"
import { memo, useMemo, useRef } from "react"
import { memo, startTransition, useEffect, useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
import { setSubscriptionColumnApronNode, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useRouteParamsSelector } 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"
@ -18,6 +20,8 @@ import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-cont
import { AIChatRoot } from "../ai-chat/components/layouts/AIChatRoot"
import { AIIndicator } from "../app-layout/ai/AISplineButton"
import { AIEntryHeader } from "../entry-content/components/entry-header"
import { EntryPlaneToolbar } from "./components/EntryPlaneToolbar"
import { EntrySubscriptionList } from "./EntrySubscriptionList"
import { EntryColumn } from "./index"
const AIEntryLayoutImpl = () => {
@ -112,6 +116,7 @@ const AIEntryLayoutImpl = () => {
{/* Floating panel - renders outside layout flow */}
{aiPanelStyle === AIChatPanelStyle.Floating && <AIChatLayout key="ai-chat-layout" />}
<SubscriptionColumnToggler />
</div>
)
}
@ -125,3 +130,57 @@ export const AIEntryLayout = memo(function AIEntryLayout() {
)
})
AIEntryLayout.displayName = "AIEntryLayout"
const SubscriptionColumnToggler = () => {
const isInEntry = useRouteParamsSelector((s) => s.entryId !== ROUTE_ENTRY_PENDING)
useEffect(() => {
if (isInEntry) {
startTransition(() => {
setSubscriptionColumnApronNode(<SubscriptionEntryListPlaneNode />)
})
return () => {
startTransition(() => {
setSubscriptionColumnApronNode(null)
})
}
}
}, [isInEntry])
return null
}
const SubscriptionEntryListPlaneNode = () => {
const entryId = useRouteParamsSelector((s) => s.entryId)
const isVisible = useSubscriptionEntryPlaneVisible()
return (
<m.div
className={cn(
"bg-sidebar backdrop-blur-background absolute left-0 top-12 z-[2] rounded-r-lg",
isVisible ? "w-feed-col bottom-0 flex flex-col" : "w-[40px]",
)}
id="subscription-entry-list-plane-node"
initial={false}
animate={{
width: isVisible ? "var(--fo-feed-col-w, 256px)" : "40px",
}}
transition={Spring.presets.smooth}
>
<EntryPlaneToolbar />
<AnimatePresence mode="popLayout">
{isVisible && (
<m.div
key="entry-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="w-feed-col flex flex-1 flex-col whitespace-pre"
>
<EntrySubscriptionList scrollToEntryId={entryId} />
</m.div>
)}
</AnimatePresence>
</m.div>
)
}

View File

@ -0,0 +1,103 @@
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import type { FeedViewType } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { cn } from "@follow/utils/utils"
import { memo, useCallback } from "react"
import { RelativeTime } from "~/components/ui/datetime"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { feedColumnStyles } from "~/modules/subscription-column/styles"
interface EntrySubscriptionItemProps {
entryId: string
view: FeedViewType
className?: string
isPreview?: boolean
}
const EntrySubscriptionItemImpl = ({ entryId, view, className }: EntrySubscriptionItemProps) => {
const navigate = useNavigateEntry()
// Use current route view for navigation to stay in current view
const currentRouteView = useRouteParamsSelector((s) => s.view)
const navigationView = currentRouteView ?? view
const entry = useEntry(entryId, (entry) => {
if (!entry) return null
return {
id: entry.id,
title: entry.title,
publishedAt: entry.publishedAt,
feedId: entry.feedId,
read: entry.read,
}
})
const feed = useFeedById(entry?.feedId, (feed) => {
if (!feed) return null
return {
id: feed.id,
type: feed.type || "feed",
title: feed.title,
image: feed.image,
siteUrl: feed.siteUrl,
url: feed.url,
}
})
const isActive = useRouteParamsSelector((routerParams) => routerParams.entryId === entryId)
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (navigationView === undefined || !entry) return
navigate({
feedId: entry.feedId,
entryId: entry.id,
view: navigationView,
})
},
[entry, navigate, navigationView],
)
if (!entry || !feed) return null
return (
<div
data-entry-id={entryId}
data-active={isActive}
className={cn(feedColumnStyles.item, "py-1 pl-2.5", !entry.read && "font-medium", className)}
onClick={handleClick}
>
<div className="flex min-w-0 flex-1 items-start gap-2">
<FeedIcon fallback feed={feed} size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<EllipsisHorizontalTextWithTooltip
className={cn("text-text truncate text-sm leading-tight", entry.read && "opacity-90")}
>
{entry.title}
</EllipsisHorizontalTextWithTooltip>
<div className="text-text-secondary flex items-center gap-1 text-xs opacity-80">
<EllipsisHorizontalTextWithTooltip className="max-w-24 truncate">
{feed.title}
</EllipsisHorizontalTextWithTooltip>
{entry.publishedAt && (
<>
<span>·</span>
<RelativeTime date={entry.publishedAt} />
</>
)}
</div>
</div>
</div>
</div>
)
}
export const EntrySubscriptionItem = memo(EntrySubscriptionItemImpl)

View File

@ -0,0 +1,100 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import type { FC, RefObject } from "react"
import { memo, startTransition, useEffect, useRef, useState } from "react"
import { EntryItemSkeleton } from "~/modules/entry-column/EntryItemSkeleton"
import { EntrySubscriptionItem } from "./EntrySubscriptionItem"
import { useEntrySubscriptionData } from "./hooks/subscription/useEntrySubscriptionData"
import { useEntryVirtualizer } from "./hooks/subscription/useEntryVirtualizer"
export interface EntrySubscriptionListProps {
scrollToEntryId?: string
}
// Prevent scroll list move when press up/down key
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
}
}
export const EntrySubscriptionList: FC<EntrySubscriptionListProps> = memo(({ scrollToEntryId }) => {
// Get all data internally
const { entriesIds, hasNextPage, isFetchingNextPage, fetchNextPage, view } =
useEntrySubscriptionData()
const scrollAreaRef = useRef<HTMLDivElement>(null)
// Handle virtualization internally
const { rowVirtualizer, renderData, totalSize } = useEntryVirtualizer({
entriesIds,
scrollToEntryId,
scrollElement: scrollAreaRef as RefObject<HTMLElement>,
})
// Handle infinite loading
useEffect(() => {
const lastRenderItem = renderData.at(-1)
if (!lastRenderItem) return
if (lastRenderItem.isLoaderRow && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [renderData, hasNextPage, isFetchingNextPage, fetchNextPage])
const [ready, setReady] = useState(false)
useEffect(() => {
startTransition(() => {
setReady(true)
})
}, [])
return (
<ScrollArea rootClassName="h-0 grow" ref={scrollAreaRef}>
<div
onKeyDown={handleKeyDown}
className="relative w-full select-none"
style={{
height: `${totalSize}px`,
}}
>
{renderData.map((item) => {
if (!ready) return null
if (item.isLoaderRow) {
const Content = hasNextPage ? <EntryItemSkeleton view={view} count={3} /> : null
return (
<div
ref={rowVirtualizer.measureElement}
className="absolute left-0 top-0 w-full will-change-transform"
key={item.key}
data-index={item.index}
style={{
transform: item.transform,
}}
>
{Content}
</div>
)
}
return (
<div
key={item.key}
className="absolute left-0 top-0 w-full will-change-transform"
style={{ transform: item.transform }}
ref={rowVirtualizer.measureElement}
data-index={item.index}
>
<EntrySubscriptionItem entryId={item.entryId!} view={view} />
</div>
)
})}
</div>
</ScrollArea>
)
})
EntrySubscriptionList.displayName = "EntrySubscriptionList"

View File

@ -0,0 +1,69 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { cn, stopPropagation } from "@follow/utils"
import type { FC, SVGProps } from "react"
import { setSubscriptionEntryPlaneVisible, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar"
interface EntryPlaneToolbarProps {
className?: string
}
export const EntryPlaneToolbar: FC<EntryPlaneToolbarProps> = ({ className }) => {
const isVisible = useSubscriptionEntryPlaneVisible()
const handleToggle = () => {
setSubscriptionEntryPlaneVisible(!isVisible)
}
// When hidden, show only a compact toggle button
if (!isVisible) {
return (
<div className={cn("translate-y-2 p-2", className)} onClick={stopPropagation}>
<ActionButton tooltip="Show Entry List" size="sm" onClick={handleToggle}>
<MaterialSymbolsExpandContent />
</ActionButton>
</div>
)
}
// When visible, show full toolbar
return (
<div
onClick={stopPropagation}
className={cn(
"text-text-secondary flex h-11 items-center justify-between px-2 text-xl",
"backdrop-blur-background shrink-0",
className,
)}
>
<div className="flex items-center gap-2 whitespace-pre">
<div className="text-sm font-medium">Entry List</div>
</div>
<ActionButton tooltip="Hide Entry List" size="sm" onClick={handleToggle}>
<MaterialSymbolsCollapseContentRounded />
</ActionButton>
</div>
)
}
function MaterialSymbolsCollapseContentRounded(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
{/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
<path
fill="currentColor"
d="M9 15H6q-.425 0-.712-.288T5 14t.288-.712T6 13h4q.425 0 .713.288T11 14v4q0 .425-.288.713T10 19t-.712-.288T9 18zm6-6h3q.425 0 .713.288T19 10t-.288.713T18 11h-4q-.425 0-.712-.288T13 10V6q0-.425.288-.712T14 5t.713.288T15 6z"
/>
</svg>
)
}
export function MaterialSymbolsExpandContent(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
{/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
<path fill="currentColor" d="M5 19v-6h2v4h4v2zm12-8V7h-4V5h6v6z" />
</svg>
)
}

View File

@ -1,5 +1,5 @@
export interface EntryColumnWrapperProps extends ComponentType {
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
onPullToRefresh?: () => Promise<any>
ref?: React.Ref<HTMLDivElement | null>
}

View File

@ -0,0 +1,26 @@
import { FeedViewType } from "@follow/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { useEntriesByView } from "../useEntriesByView"
/**
* Hook for managing entry data in subscription column context
* Uses local entries only for better performance in sidebar
*/
export const useEntrySubscriptionData = () => {
const { view = FeedViewType.Articles } = useRouteParams()
// Reuse the same data source logic as EntryColumn's ListComponent
// to ensure entries are identical in order, filtering, and paging.
const entriesData = useEntriesByView({})
return {
entriesIds: entriesData.entriesIds,
hasNextPage: entriesData.hasNextPage,
isFetchingNextPage: entriesData.isFetchingNextPage,
fetchNextPage: entriesData.fetchNextPage,
refetch: entriesData.refetch,
view,
}
}

View File

@ -0,0 +1,71 @@
import type { Range } from "@tanstack/react-virtual"
import type { RefObject } from "react"
import { useCallback, useMemo } from "react"
import { useEntryVirtualization } from "../useEntryVirtualization"
interface UseEntryVirtualizerOptions {
entriesIds: string[]
onRangeChange?: (range: Range) => void
scrollToEntryId?: string
scrollElement?: RefObject<HTMLElement> | (() => HTMLElement | null)
}
export const useEntryVirtualizer = ({
entriesIds,
onRangeChange,
scrollToEntryId,
scrollElement,
}: UseEntryVirtualizerOptions) => {
// Find scroll to index
const scrollToIndex = useMemo(() => {
if (scrollToEntryId) {
const index = entriesIds.indexOf(scrollToEntryId)
return index !== -1 ? { index, align: "start" as const } : undefined
}
return
}, [entriesIds, scrollToEntryId])
const virtualization = useEntryVirtualization({
count: entriesIds.length + 1, // +1 for loading placeholder
estimateSize: () => 48, // Smaller height for subscription column
overscan: 5,
cacheKey: "entry-subscription-list",
onRangeChange,
scrollToIndex,
scrollElement,
})
// Memoized render data with subscription-specific logic
const renderData = useMemo(() => {
return virtualization.renderData.map((item) => {
const isLoaderRow = item.index === entriesIds.length
return {
key: item.key,
index: item.index,
isLoaderRow,
transform: item.transform,
entryId: isLoaderRow ? null : entriesIds[item.index],
}
})
}, [virtualization.renderData, entriesIds])
// Scroll to specific entry programmatically
const scrollToEntry = useCallback(
(entryId: string) => {
const index = entriesIds.indexOf(entryId)
if (index !== -1) {
virtualization.scrollTo(index, "start")
}
},
[entriesIds, virtualization],
)
return {
rowVirtualizer: virtualization.virtualizer,
renderData,
totalSize: virtualization.totalSize,
scrollToEntry,
}
}

View File

@ -0,0 +1,120 @@
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { LRUCache } from "@follow/utils/lru-cache"
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { RefObject } from "react"
import { useCallback, useEffect, useMemo } from "react"
interface UseEntryVirtualizationOptions {
count: number
estimateSize?: () => number
overscan?: number
gap?: number
cacheKey?: string
onRangeChange?: (range: Range) => void
scrollToIndex?: number | { index: number; align?: "start" | "center" | "end" | "auto" }
scrollElement?: RefObject<HTMLElement> | (() => HTMLElement | null)
}
const capacity = 3
const offsetCache = new LRUCache<string, number>(capacity)
const measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)
export const useEntryVirtualization = ({
count,
estimateSize = () => 112,
overscan = 5,
gap,
cacheKey = "entry-list",
onRangeChange,
scrollToIndex,
scrollElement,
}: UseEntryVirtualizationOptions) => {
const defaultScrollRef = useScrollViewElement()
const getScrollElement = useCallback(() => {
if (scrollElement) {
return typeof scrollElement === "function" ? scrollElement() : scrollElement.current
}
return defaultScrollRef
}, [scrollElement, defaultScrollRef])
const rowVirtualizer = useVirtualizer({
count,
estimateSize,
overscan,
gap,
getScrollElement,
initialOffset: offsetCache.get(cacheKey) ?? 0,
initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [],
onChange: useTypeScriptHappyCallback(
(virtualizer: Virtualizer<HTMLElement, Element>) => {
if (!virtualizer.isScrolling) {
measurementsCache.put(cacheKey, virtualizer.measurementsCache)
offsetCache.put(cacheKey, virtualizer.scrollOffset ?? 0)
}
onRangeChange?.(virtualizer.range as Range)
},
[cacheKey],
),
})
// Handle scroll to index with viewport check
useEffect(() => {
if (scrollToIndex !== undefined) {
const targetIndex = typeof scrollToIndex === "number" ? scrollToIndex : scrollToIndex.index
// Check if target index is already in viewport
const { range } = rowVirtualizer
if (range && targetIndex >= range.startIndex && targetIndex <= range.endIndex) {
// Target is already visible, no need to scroll
return
}
if (typeof scrollToIndex === "number") {
rowVirtualizer.scrollToIndex(scrollToIndex)
} else {
rowVirtualizer.scrollToIndex(scrollToIndex.index, { align: scrollToIndex.align })
}
}
}, [scrollToIndex, rowVirtualizer])
const virtualItems = rowVirtualizer.getVirtualItems()
// Create render data with common transformations
const renderData = useMemo(() => {
return virtualItems.map((virtualRow) => ({
key: virtualRow.key,
index: virtualRow.index,
start: virtualRow.start,
size: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}))
}, [virtualItems])
// Scroll to specific index programmatically with viewport check
const scrollTo = useCallback(
(index: number, align?: "start" | "center" | "end" | "auto") => {
// Check if target index is already in viewport
const { range } = rowVirtualizer
if (range && index >= range.startIndex && index <= range.endIndex) {
// Target is already visible, no need to scroll
return
}
rowVirtualizer.scrollToIndex(index, { align })
},
[rowVirtualizer],
)
return {
virtualizer: rowVirtualizer,
virtualItems,
renderData,
totalSize: rowVirtualizer.getTotalSize(),
scrollTo,
measureElement: rowVirtualizer.measureElement,
}
}

View File

@ -0,0 +1,145 @@
import type { FeedViewType } from "@follow/constants"
import { useCollectionEntryList } from "@follow/store/collection/hooks"
import {
useEntryIdsByFeedId,
useEntryIdsByFeedIds,
useEntryIdsByInboxId,
useEntryIdsByListId,
useEntryIdsByView,
} from "@follow/store/entry/hooks"
import { useEntryStore } from "@follow/store/entry/store"
import type { UseEntriesReturn } from "@follow/store/entry/types"
import { useFolderFeedsByFeedId } from "@follow/store/subscription/hooks"
import { debounce } from "es-toolkit/compat"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { ROUTE_FEED_PENDING } from "~/constants/app"
interface UseLocalEntriesOptions {
feedId?: string
view?: FeedViewType
inboxId?: string
listId?: string
isCollection?: boolean
pageSize?: number
}
function getEntryIdsFromMultiplePlace(...entryIds: Array<string[] | undefined | null>) {
return entryIds.find((ids) => ids?.length) ?? []
}
export const useLocalEntries = ({
feedId,
view,
inboxId,
listId,
isCollection,
pageSize = 30,
}: UseLocalEntriesOptions = {}): UseEntriesReturn => {
const unreadOnly = useGeneralSettingKey("unreadOnly")
const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey(
"hidePrivateSubscriptionsInTimeline",
)
const folderIds = useFolderFeedsByFeedId({
feedId,
view,
})
const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline)
const entryIdsByCollections = useCollectionEntryList(view)
const entryIdsByFeedId = useEntryIdsByFeedId(feedId)
const entryIdsByCategory = useEntryIdsByFeedIds(folderIds)
const entryIdsByListId = useEntryIdsByListId(listId)
const entryIdsByInboxId = useEntryIdsByInboxId(inboxId)
const showEntriesByView =
(!feedId || feedId === ROUTE_FEED_PENDING) &&
folderIds.length === 0 &&
!isCollection &&
!inboxId &&
!listId
const allEntries = useEntryStore(
useCallback(
(state) => {
const ids = isCollection
? entryIdsByCollections
: showEntriesByView
? (entryIdsByView ?? [])
: (getEntryIdsFromMultiplePlace(
entryIdsByFeedId,
entryIdsByCategory,
entryIdsByListId,
entryIdsByInboxId,
) ?? [])
return ids
.map((id) => {
const entry = state.data[id]
if (!entry) return null
if (unreadOnly && entry.read) {
return null
}
return entry.id
})
.filter((id) => typeof id === "string")
},
[
entryIdsByCategory,
entryIdsByCollections,
entryIdsByFeedId,
entryIdsByInboxId,
entryIdsByListId,
entryIdsByView,
isCollection,
showEntriesByView,
unreadOnly,
],
),
)
const [page, setPage] = useState(0)
const totalPage = useMemo(
() => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0),
[allEntries, pageSize],
)
const entries = useMemo(() => {
return allEntries?.slice(0, (page + 1) * pageSize) || []
}, [allEntries, page, pageSize])
const hasNext = useMemo(() => {
return entries.length < (allEntries?.length || 0)
}, [entries.length, allEntries])
const refetch = useCallback(async () => {
setPage(0)
}, [])
const fetchNextPage = useCallback(() => {
const debouncedFetch = debounce(() => {
setPage((prev) => prev + 1)
}, 300)
return debouncedFetch()
}, [])
useEffect(() => {
setPage(0)
}, [view, feedId])
return {
entriesIds: entries,
hasNext,
hasUpdate: false,
refetch,
fetchNextPage: fetchNextPage as () => Promise<void>,
isLoading: false,
isRefetching: false,
isReady: true,
isFetchingNextPage: false,
isFetching: false,
hasNextPage: page < totalPage,
error: null,
}
}

View File

@ -1,4 +1,3 @@
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, views } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import { useEntry } from "@follow/store/entry/hooks"
@ -117,21 +116,19 @@ function EntryColumnImpl() {
entries.fetchNextPage()
}
}, [entries])
const isMobile = useMobile()
const ListComponent = views.find((v) => v.view === view)?.gridMode ? EntryColumnGrid : EntryList
return (
<Focusable
scope={HotkeyScope.Timeline}
data-hide-in-print
className="@container relative flex h-full flex-1 flex-col"
onClick={
isMobile
? undefined
: () =>
navigate({
entryId: null,
})
onClick={() =>
navigate({
view,
entryId: null,
})
}
>
{entriesIds.length === 0 &&
@ -145,11 +142,7 @@ function EntryColumnImpl() {
hasUpdate={entries.hasUpdate}
/>
<EntryColumnWrapper
onScroll={handleScroll}
onPullToRefresh={entries.refetch}
key={`${routeFeedId}-${view}`}
>
<EntryColumnWrapper onScroll={handleScroll} key={`${routeFeedId}-${view}`}>
{entriesIds.length === 0 ? (
entries.isLoading ? (
<EntryItemSkeleton view={view} />

View File

@ -17,7 +17,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import { useRootContainerElement } from "~/atoms/dom"
import { useUISettingKey } from "~/atoms/settings/ui"
import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar"
import {
setTimelineColumnShow,
useSubscriptionColumnApronNode,
useSubscriptionColumnShow,
} from "~/atoms/sidebar"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope, ROUTE_TIMELINE_OF_VIEW } from "~/constants"
import { useBackHome } from "~/hooks/biz/useNavigateEntry"
@ -174,11 +178,17 @@ export function SubscriptionColumn({
</SwipeWrapper>
</div>
<ApronNodeContainer />
{children}
</WindowUnderBlur>
)
}
const ApronNodeContainer: FC = () => {
return useSubscriptionColumnApronNode()
}
const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo(
({ children, active }) => {
const reduceMotion = useReduceMotion()

View File

@ -1,186 +0,0 @@
# @follow/changelog-cli
AI-driven changelog generator for the Follow project. This package automatically analyzes git commits and generates user-friendly changelog entries using configurable AI models.
## Features
- 🤖 **AI-Powered Analysis**: Uses OpenAI GPT models to analyze commit messages
- 📊 **Smart Categorization**: Automatically categorizes changes into sections
- 👥 **Contributor Recognition**: Identifies and thanks external contributors
- 🔧 **Configurable**: Supports custom AI endpoints and models
- 🔄 **Fallback Support**: Falls back to keyword-based analysis if AI is unavailable
## Installation
This package is part of the Follow monorepo and is not published to npm. It's designed to be used within the monorepo environment.
## Usage
### As a Workspace Script
From the mobile app directory:
```bash
npm run changelog:generate
```
### Direct Execution
From the package directory:
```bash
pnpm run generate
```
### CLI Command (after building)
```bash
pnpm run build
./bin/cli.js
```
## Configuration
Create or modify `config.json` in the package root:
```json
{
"internalTeamMembers": [
"innei",
"DIYgod",
"hyoban",
"renovate[bot]",
"github-actions[bot]",
"dependabot[bot]",
"vercel[bot]"
],
"aiModel": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.3,
"maxTokens": 1000,
"apiKey": "",
"baseURL": "https://api.openai.com/v1",
"customEndpoint": ""
},
"commitAnalysis": {
"categories": {
"features": {
"keywords": ["feat", "feature", "add", "implement", "introduce"],
"section": "Shiny new things"
},
"improvements": {
"keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"],
"section": "Improvements"
},
"fixes": {
"keywords": ["fix", "bug", "patch", "resolve", "correct"],
"section": "No longer broken"
}
},
"ignorePatterns": [
"^chore:",
"^docs:",
"^test:",
"^ci:",
"^build:",
"Merge pull request",
"Merge branch",
"version bump",
"changelog",
"Update dependencies"
]
},
"changelog": {
"maxCommitsPerSection": 5,
"includeCommitHash": true,
"includePullRequestLinks": true
}
}
```
## Custom OpenAI Endpoints
The tool supports custom OpenAI-compatible endpoints:
### Environment Variables
```bash
export OPENAI_API_KEY="your-api-key"
export OPENAI_BASE_URL="https://your-custom-endpoint.com/v1"
```
### Configuration File
```json
{
"aiModel": {
"apiKey": "your-api-key",
"customEndpoint": "https://your-custom-endpoint.com/v1"
}
}
```
### Supported Providers
- OpenAI API
- Azure OpenAI
- Any OpenAI-compatible API (e.g., Claude via proxy, local models)
## Requirements
- Node.js >= 18.0.0
- Git repository with proper tag structure (`mobile@x.y.z`)
- Must be run on a release branch (`release/mobile/*`)
## How It Works
1. **Branch Validation**: Ensures execution on a release branch
2. **Git Analysis**: Finds commits between latest mobile tag and current HEAD
3. **Commit Filtering**: Removes noise commits (merges, version bumps, etc.)
4. **AI Analysis**: Sends filtered commits to AI for categorization
5. **Contributor Detection**: Identifies external contributors from commit authors
6. **Changelog Generation**: Formats and writes changelog to target file
## Files Structure
- `index.ts` - Main entry point and orchestration logic
- `git-tools.ts` - Git command execution and analysis
- `ai-agent.ts` - AI-powered commit analysis and categorization
- `openai-client.ts` - OpenAI API client with custom endpoint support
- `config.json` - Configuration file
- `bin/cli.js` - CLI executable wrapper
## Development
```bash
# Install dependencies
pnpm install
# Run in development mode
pnpm run dev
# Type checking
pnpm run typecheck
# Build for production
pnpm run build
```
## Integration
This package is designed to integrate with the existing release workflow in the Follow monorepo. It can be called from other apps' package.json scripts or integrated into the bump configuration.
Example integration in mobile app's `package.json`:
```json
{
"scripts": {
"changelog:generate": "tsx ../packages/changelog-cli/index.ts"
}
}
```
## License
This package is part of the Follow project and follows the same license terms.

View File

@ -1,331 +0,0 @@
# Usage Examples
This document provides practical examples of how to use the `@follow/changelog-cli` package in different scenarios.
## Basic Usage
### 1. Generate Changelog for Current Release
Make sure you're on a release branch (e.g., `release/mobile/0.2.5`):
```bash
# From mobile app directory
cd apps/mobile
npm run changelog:generate
# Or from the package directory
cd packages/changelog-cli
pnpm run generate
```
### 2. Using with Custom OpenAI Endpoint
If you're using a custom OpenAI-compatible endpoint (like Azure OpenAI or a local model):
```bash
# Set environment variables
export OPENAI_API_KEY="your-api-key"
export OPENAI_BASE_URL="https://your-endpoint.openai.azure.com/v1"
# Run the generator
npm run changelog:generate
```
### 3. Configuration for Different AI Providers
#### Azure OpenAI
```json
{
"aiModel": {
"provider": "openai",
"model": "gpt-4",
"apiKey": "your-azure-key",
"customEndpoint": "https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview"
}
}
```
#### Local Model (Ollama)
```json
{
"aiModel": {
"provider": "openai",
"model": "llama2",
"apiKey": "not-needed",
"customEndpoint": "http://localhost:11434/v1"
}
}
```
#### Claude via Proxy
```json
{
"aiModel": {
"provider": "openai",
"model": "claude-3-sonnet-20240229",
"apiKey": "your-anthropic-key",
"customEndpoint": "https://api.anthropic.com/v1"
}
}
```
## Integration Examples
### 1. Auto-generate during Release Process
Add to `apps/mobile/bump.config.ts`:
```typescript
export default defineConfig({
leading: [
"git pull --rebase",
"npm run changelog:generate", // Add this line
"tsx scripts/apply-changelog.ts ${NEW_VERSION}",
"git add changelog",
// ... rest of config
],
// ...
})
```
### 2. CI/CD Integration
Example GitHub Actions workflow:
```yaml
name: Generate Changelog
on:
push:
branches:
- "release/mobile/*"
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Important: fetch full history
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Generate changelog
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
cd apps/mobile
npm run changelog:generate
- name: Commit changelog
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add apps/mobile/changelog/next.md
git commit -m "chore: auto-generate changelog" || exit 0
git push
```
### 3. Custom Script Integration
Create a custom script that uses the changelog CLI:
```typescript
// scripts/release-workflow.ts
import { execSync } from "node:child_process"
async function releaseWorkflow() {
console.log("🚀 Starting release workflow...")
// 1. Generate changelog
console.log("📝 Generating changelog...")
execSync("npm run changelog:generate", {
cwd: "apps/mobile",
stdio: "inherit",
})
// 2. Review and edit if needed
console.log("📋 Changelog generated. Please review before continuing.")
// 3. Continue with release process...
}
releaseWorkflow()
```
## Configuration Examples
### 1. Team-specific Configuration
For different teams with different internal members:
```json
{
"internalTeamMembers": [
"innei",
"DIYgod",
"hyoban",
"team-lead-1",
"team-lead-2",
"renovate[bot]",
"dependabot[bot]"
]
}
```
### 2. Project-specific Keywords
Customize keywords for different types of projects:
```json
{
"commitAnalysis": {
"categories": {
"features": {
"keywords": ["feat", "feature", "add", "implement", "new"],
"section": "🎉 New Features"
},
"improvements": {
"keywords": ["improve", "enhance", "optimize", "perf", "refactor"],
"section": "⚡ Improvements"
},
"fixes": {
"keywords": ["fix", "bug", "patch", "resolve", "hotfix"],
"section": "🐛 Bug Fixes"
}
}
}
}
```
### 3. Strict Filtering
For projects with very specific changelog requirements:
```json
{
"commitAnalysis": {
"ignorePatterns": [
"^chore:",
"^docs:",
"^test:",
"^ci:",
"^build:",
"^style:",
"^refactor:",
"Merge pull request",
"Merge branch",
"version bump",
"changelog",
"Update dependencies",
"^deps:",
"^devDeps:"
]
},
"changelog": {
"maxCommitsPerSection": 3,
"includeCommitHash": false,
"includePullRequestLinks": true
}
}
```
## Troubleshooting Examples
### 1. API Rate Limiting
If you hit OpenAI API rate limits:
```json
{
"aiModel": {
"temperature": 0.1,
"maxTokens": 500
}
}
```
Or process commits in smaller batches by modifying the batch size in `ai-agent.ts`.
### 2. Custom Git Tag Format
If your project uses a different tag format, modify `git-tools.ts`:
```typescript
// In git-tools.ts, modify getLatestMobileTag()
getLatestMobileTag(): GitTag | null {
try {
// Change this line to match your tag format
const tags = this.exec('git tag --sort=-version:refname | grep "^v" | head -1')
// ... rest of the method
}
}
```
### 3. Working with Monorepos
For complex monorepo setups, you might need to adjust the working directory:
```typescript
// Create a custom git tools instance
const git = new GitTools("/path/to/your/specific/repo")
```
## Best Practices
### 1. Review Before Committing
Always review the generated changelog before committing:
```bash
# Generate changelog
npm run changelog:generate
# Review the changes
cat apps/mobile/changelog/next.md
# Edit if necessary
vim apps/mobile/changelog/next.md
# Commit when satisfied
git add apps/mobile/changelog/next.md
git commit -m "chore: update changelog"
```
### 2. Backup Configuration
Keep your configuration in version control but sensitive data in environment variables:
```json
{
"aiModel": {
"apiKey": "", // Leave empty in version control
"customEndpoint": "" // Can be committed if not sensitive
}
}
```
### 3. Testing Configuration
Test your configuration with a small batch first:
```json
{
"changelog": {
"maxCommitsPerSection": 1, // Start small
"includeCommitHash": true,
"includePullRequestLinks": true
}
}
```
This way you can verify the output format before processing many commits.

View File

@ -1,250 +0,0 @@
import type { GitCommit } from "./git-tools"
import OpenAIClient from "./openai-client"
export interface ChangelogEntry {
section: "Shiny new things" | "Improvements" | "No longer broken"
description: string
commitHash?: string
pullRequest?: string
}
export interface AIConfig {
provider: string
model: string
temperature: number
maxTokens: number
apiKey?: string
}
export interface CommitCategory {
keywords: string[]
section: "Shiny new things" | "Improvements" | "No longer broken"
}
export class AIAgent {
private config: AIConfig
private categories: Record<string, CommitCategory>
private openaiClient: OpenAIClient
constructor(config: AIConfig, categories: Record<string, CommitCategory>) {
this.config = config
this.categories = categories
this.openaiClient = new OpenAIClient(config)
}
/**
* Analyze commits and generate changelog entries using AI
*/
async analyzeCommits(commits: GitCommit[]): Promise<ChangelogEntry[]> {
console.info(`Analyzing ${commits.length} commits with AI...`)
const filteredCommits = this.filterImportantCommits(commits)
console.info(`Selected ${filteredCommits.length} important commits`)
const changelogEntries: ChangelogEntry[] = []
// Process commits in batches to avoid API limits
const batchSize = 10
for (let i = 0; i < filteredCommits.length; i += batchSize) {
const batch = filteredCommits.slice(i, i + batchSize)
const batchEntries = await this.processBatch(batch)
changelogEntries.push(...batchEntries)
}
return changelogEntries
}
/**
* Filter commits to identify important ones
*/
private filterImportantCommits(commits: GitCommit[]): GitCommit[] {
return commits.filter((commit) => {
const message = `${commit.subject} ${commit.body}`.toLowerCase()
// Skip ignored patterns
const shouldIgnore = [
/^chore:/,
/^docs:/,
/^test:/,
/^ci:/,
/^build:/,
/merge pull request/,
/merge branch/,
/version bump/,
/changelog/,
/update dependencies/,
/^\d+\.\d+\.\d+/, // version numbers
/^bump /,
/^release\(/,
].some((pattern) => pattern.test(message))
if (shouldIgnore) return false
// Include commits with significant changes
const hasSignificantChange = [
/\bfeat\b/,
/\bfeature\b/,
/\badd\b/,
/\bimplement\b/,
/\bintroduce\b/,
/\bfix\b/,
/\bbug\b/,
/\bpatch\b/,
/\bresolve\b/,
/\bcorrect\b/,
/\bimprove\b/,
/\benhance\b/,
/\boptimize\b/,
/\brefactor\b/,
/\bupdate\b/,
/\bperf\b/,
/\bbreaking\b/,
/\bmajor\b/,
/\bminor\b/,
/\bui\b/,
/\bux\b/,
/\bdesign\b/,
].some((pattern) => pattern.test(message))
return hasSignificantChange || commit.pullRequest
})
}
/**
* Process a batch of commits using AI
*/
private async processBatch(commits: GitCommit[]): Promise<ChangelogEntry[]> {
const prompt = this.buildAnalysisPrompt(commits)
try {
const aiResponse = await this.callAI(prompt)
return this.parseAIResponse(aiResponse, commits)
} catch (error) {
console.warn("AI analysis failed, falling back to keyword-based categorization:", error)
return this.fallbackCategorization(commits)
}
}
/**
* Build the analysis prompt for AI
*/
private buildAnalysisPrompt(commits: GitCommit[]): string {
const commitsText = commits
.map(
(commit, index) =>
`${index + 1}. [${commit.shortHash}] ${commit.subject}\n ${commit.body || "No description"}\n ${commit.pullRequest ? `PR #${commit.pullRequest}` : ""}`,
)
.join("\n\n")
return `You are a technical writer creating a changelog for a React Native RSS reader app called "Follow".
Analyze these git commits and categorize the most important user-facing changes into these sections:
**Shiny new things**: New features, major additions that users can see/use
**Improvements**: Enhancements, optimizations, better UX/UI, performance improvements
**No longer broken**: Bug fixes, crash fixes, issues resolved
For each relevant commit, provide:
1. A user-friendly description (not technical jargon)
2. Which section it belongs to
3. Reference the commit hash or PR number if significant
Focus on changes that impact the user experience. Skip internal refactoring, dependency updates, or development-only changes unless they significantly affect users.
Commits to analyze:
${commitsText}
Respond in this JSON format:
{
"entries": [
{
"section": "Shiny new things",
"description": "User-friendly description of what changed",
"commitHash": "abc123",
"pullRequest": "1234"
}
]
}
Only include meaningful user-facing changes. Be selective and focus on quality over quantity.`
}
/**
* Call AI API (mock implementation - you need to implement the actual API call)
*/
private async callAI(prompt: string): Promise<string> {
// This is a mock implementation. You need to implement the actual AI API call
// based on your chosen provider (OpenAI, Anthropic, etc.)
if (this.config.provider === "openai") {
return this.callOpenAI(prompt)
}
throw new Error(`AI provider "${this.config.provider}" not implemented`)
}
/**
* OpenAI API call implementation
*/
private async callOpenAI(prompt: string): Promise<string> {
if (!this.openaiClient.isAvailable()) {
throw new Error("OpenAI API not available")
}
console.info("🤖 Calling OpenAI API...")
return await this.openaiClient.chat(prompt)
}
/**
* Parse AI response into changelog entries
*/
private parseAIResponse(response: string, commits: GitCommit[]): ChangelogEntry[] {
try {
const parsed = JSON.parse(response)
return parsed.entries || []
} catch {
console.warn("Failed to parse AI response, using fallback")
return this.fallbackCategorization(commits)
}
}
/**
* Fallback categorization using keywords when AI fails
*/
private fallbackCategorization(commits: GitCommit[]): ChangelogEntry[] {
return commits.map((commit) => {
const message = `${commit.subject} ${commit.body}`.toLowerCase()
// Determine category based on keywords
let section: ChangelogEntry["section"] = "Improvements"
if (this.categories.features?.keywords.some((keyword) => message.includes(keyword))) {
section = "Shiny new things"
} else if (this.categories.fixes?.keywords.some((keyword) => message.includes(keyword))) {
section = "No longer broken"
}
// Clean up the description
let description = commit.subject
if (
description.startsWith("feat:") ||
description.startsWith("fix:") ||
description.startsWith("chore:")
) {
description = description.slice(Math.max(0, description.indexOf(":") + 1)).trim()
}
// Capitalize first letter
description = description.charAt(0).toUpperCase() + description.slice(1)
return {
section,
description,
commitHash: commit.shortHash,
pullRequest: commit.pullRequest,
}
})
}
}
export default AIAgent

View File

@ -1,18 +0,0 @@
#!/usr/bin/env node
import { execSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { dirname, join } from "pathe"
const __dirname = dirname(fileURLToPath(import.meta.url))
const indexPath = join(__dirname, "..", "index.ts")
try {
execSync(`tsx "${indexPath}"`, {
stdio: "inherit",
cwd: process.cwd(),
})
} catch (error) {
process.exit(error.status || 1)
}

View File

@ -1,54 +0,0 @@
{
"internalTeamMembers": [
"Innei",
"DIYgod",
"hyoban",
"renovate[bot]",
"github-actions[bot]",
"dependabot[bot]",
"vercel[bot]",
"whitewater"
],
"aiModel": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.3,
"maxTokens": 1000,
"apiKey": "",
"baseURL": "https://api.openai.com/v1",
"customEndpoint": ""
},
"commitAnalysis": {
"categories": {
"features": {
"keywords": ["feat", "feature", "add", "implement", "introduce"],
"section": "Shiny new things"
},
"improvements": {
"keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"],
"section": "Improvements"
},
"fixes": {
"keywords": ["fix", "bug", "patch", "resolve", "correct"],
"section": "No longer broken"
}
},
"ignorePatterns": [
"^chore:",
"^docs:",
"^test:",
"^ci:",
"^build:",
"Merge pull request",
"Merge branch",
"version bump",
"changelog",
"Update dependencies"
]
},
"changelog": {
"maxCommitsPerSection": 5,
"includeCommitHash": true,
"includePullRequestLinks": true
}
}

View File

@ -1,179 +0,0 @@
import { execSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { dirname, join } from "pathe"
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, "..", "..")
export interface GitCommit {
hash: string
shortHash: string
subject: string
body: string
author: string
email: string
date: string
pullRequest?: string
}
export interface GitTag {
name: string
hash: string
date: string
}
export class GitTools {
private workingDir: string
constructor(workingDir = projectRoot) {
this.workingDir = workingDir
}
/**
* Execute git command and return output
*/
private exec(command: string): string {
try {
return execSync(command, {
cwd: this.workingDir,
encoding: "utf-8",
}).trim()
} catch (error) {
console.error(`Git command failed: ${command}`)
throw error
}
}
/**
* Get the latest tag for mobile releases
*/
getLatestMobileTag(): GitTag | null {
try {
const tags = this.exec('git tag --sort=-version:refname | grep "^mobile@" | head -1')
if (!tags) return null
const tagName = tags.trim()
const hash = this.exec(`git rev-list -n 1 ${tagName}`)
const date = this.exec(`git log -1 --format=%ai ${hash}`)
return {
name: tagName,
hash,
date,
}
} catch {
return null
}
}
/**
* Get current branch name
*/
getCurrentBranch(): string {
return this.exec("git rev-parse --abbrev-ref HEAD")
}
/**
* Get the latest commit hash on current branch
*/
getLatestCommitHash(): string {
return this.exec("git rev-parse HEAD")
}
/**
* Get commits between two references
*/
getCommitsBetween(fromRef: string, toRef: string): GitCommit[] {
const format = [
"%H", // full hash
"%h", // short hash
"%s", // subject
"%b", // body
"%an", // author name
"%ae", // author email
"%ai", // author date
].join("%x1f") // use ASCII unit separator
const command = `git log --format="${format}%x1e" ${fromRef}..${toRef}`
const output = this.exec(command)
if (!output) return []
return output
.split("\x1e")
.filter(Boolean)
.map((entry) => {
const parts = entry.split("\x1f")
const [hash, shortHash, subject, body, author, email, date] = parts
// Ensure all required fields exist
if (!hash || !shortHash || !subject || !author || !email || !date) {
throw new Error("Invalid git log entry format")
}
// Check if commit is related to a pull request
const pullRequest = this.extractPullRequestNumber(subject, body || "")
return {
hash: hash.trim(),
shortHash: shortHash.trim(),
subject: subject.trim(),
body: (body || "").trim(),
author: author.trim(),
email: email.trim(),
date: date.trim(),
pullRequest,
}
})
}
/**
* Extract pull request number from commit message
*/
private extractPullRequestNumber(subject: string, body: string): string | undefined {
const text = `${subject} ${body}`
const prMatch = text.match(/#(\d+)/)
return prMatch ? prMatch[1] : undefined
}
/**
* Get all unique authors from commits, excluding bots and internal members
*/
getUniqueAuthors(commits: GitCommit[], internalMembers: string[]): string[] {
const authors = new Set<string>()
commits.forEach((commit) => {
const author = commit.author.toLowerCase()
const isBotOrInternal =
author.includes("[bot]") ||
author.includes("bot") ||
internalMembers.some((member) => author.includes(member.toLowerCase()))
if (!isBotOrInternal) {
authors.add(commit.author)
}
})
return Array.from(authors).sort()
}
/**
* Check if we're on a release branch
*/
isOnReleaseBranch(): boolean {
const currentBranch = this.getCurrentBranch()
return currentBranch.startsWith("release/")
}
/**
* Get the version from release branch name
*/
getVersionFromBranch(): string | null {
const currentBranch = this.getCurrentBranch()
const match = currentBranch.match(/release\/mobile\/(.+)/)
return match?.[1] ?? null
}
}
export default GitTools

View File

@ -1,224 +0,0 @@
#!/usr/bin/env tsx
import "dotenv/config"
import { readFileSync, writeFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, join } from "pathe"
import type { ChangelogEntry } from "./ai-agent"
import AIAgent from "./ai-agent"
import GitTools from "./git-tools"
// Get current directory
const __dirname = dirname(fileURLToPath(import.meta.url))
const changelogDir = join(__dirname, "..", "..", "apps", "mobile", "changelog")
const configPath = join(__dirname, "config.json")
interface Config {
internalTeamMembers: string[]
aiModel: {
provider: string
model: string
temperature: number
maxTokens: number
baseURL?: string
customEndpoint?: string
}
commitAnalysis: {
categories: Record<
string,
{
keywords: string[]
section: string
}
>
ignorePatterns: string[]
}
changelog: {
maxCommitsPerSection: number
includeCommitHash: boolean
includePullRequestLinks: boolean
}
}
/**
* Load configuration from file
*/
function loadConfig(): Config {
try {
const configContent = readFileSync(configPath, "utf-8")
return JSON.parse(configContent)
} catch (error) {
console.error("Failed to load config file:", error)
process.exit(1)
}
}
/**
* Format changelog entry for output
*/
function formatChangelogEntry(entry: ChangelogEntry, config: Config): string {
let line = `- ${entry.description}`
if (config.changelog.includePullRequestLinks && entry.pullRequest) {
line += `(#${entry.pullRequest})`
} else if (config.changelog.includeCommitHash && entry.commitHash) {
line += `(${entry.commitHash})`
}
return line
}
/**
* Group changelog entries by section
*/
function groupEntriesBySection(entries: ChangelogEntry[]): Record<string, ChangelogEntry[]> {
const grouped: Record<string, ChangelogEntry[]> = {
"Shiny new things": [],
Improvements: [],
"No longer broken": [],
}
entries.forEach((entry) => {
if (grouped[entry.section]) {
grouped[entry.section]!.push(entry)
}
})
return grouped
}
/**
* Generate the changelog content
*/
function generateChangelogContent(
version: string,
entries: ChangelogEntry[],
contributors: string[],
config: Config,
): string {
const groupedEntries = groupEntriesBySection(entries)
let content = `# What's New in v${version}\n\n`
// Add sections with entries
Object.entries(groupedEntries).forEach(([section, sectionEntries]) => {
content += `## ${section}\n\n`
if (sectionEntries.length > 0) {
// Limit entries per section
const limitedEntries = sectionEntries.slice(0, config.changelog.maxCommitsPerSection)
limitedEntries.forEach((entry) => {
content += `${formatChangelogEntry(entry, config)}\n`
})
}
content += "\n"
})
// Add contributors section
content += "## Thanks\n\n"
if (contributors.length > 0) {
const contributorList = contributors.map((name) => `@${name}`).join(" ")
content += `Special thanks to volunteer contributors ${contributorList} for their valuable contributions\n`
} else {
content += "Special thanks to volunteer contributors @ for their valuable contributions\n"
}
return content
}
/**
* Main function to generate changelog
*/
async function main() {
try {
console.info("🤖 Starting AI-driven changelog generation...")
// Load configuration
const config = loadConfig()
console.info("✅ Configuration loaded")
// Initialize Git tools
const git = new GitTools()
// Check if we're on a release branch
if (!git.isOnReleaseBranch()) {
console.error("❌ This script should only be run on a release branch (release/mobile/*)")
process.exit(1)
}
// Get version from branch name
const version = git.getVersionFromBranch()
if (!version) {
console.error("❌ Could not extract version from branch name")
process.exit(1)
}
console.info(`📝 Generating changelog for version: ${version}`)
// Get the latest mobile tag
const latestTag = git.getLatestMobileTag()
if (!latestTag) {
console.error("❌ No previous mobile tags found")
process.exit(1)
}
console.info(`📌 Latest tag: ${latestTag.name}`)
// Get commits between latest tag and current HEAD
const currentCommit = git.getLatestCommitHash()
const commits = git.getCommitsBetween(latestTag.hash, currentCommit)
console.info(`🔍 Found ${commits.length} commits since last release`)
if (commits.length === 0) {
console.warn("⚠️ No new commits found since last release")
return
}
// Initialize AI agent
const aiAgent = new AIAgent(config.aiModel, config.commitAnalysis.categories as any)
// Analyze commits with AI
const changelogEntries = await aiAgent.analyzeCommits(commits)
console.info(`✨ Generated ${changelogEntries.length} changelog entries`)
// Get contributors
const contributors = git.getUniqueAuthors(commits, config.internalTeamMembers)
console.info(`👥 Found ${contributors.length} external contributors`)
// Generate changelog content
const changelogContent = generateChangelogContent(
version,
changelogEntries,
contributors,
config,
)
// Write to next.md
const nextFilePath = join(changelogDir, "next.md")
writeFileSync(nextFilePath, changelogContent, "utf-8")
console.info("✅ Changelog generated successfully!")
console.info(`📄 Updated: ${nextFilePath}`)
// Show preview
console.info("\n📋 Preview:")
console.info("─".repeat(50))
console.info(changelogContent)
console.info("─".repeat(50))
} catch (error) {
console.error("❌ Failed to generate changelog:", error)
process.exit(1)
}
}
// Run the script if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
main()
}
export default main

View File

@ -1,84 +0,0 @@
export interface OpenAIConfig {
apiKey?: string
model: string
temperature: number
maxTokens: number
baseURL?: string
customEndpoint?: string
}
export class OpenAIClient {
private config: OpenAIConfig
private apiKey: string
private baseURL: string
constructor(config: OpenAIConfig) {
this.config = config
this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || ""
// Support custom endpoints
if (config.customEndpoint) {
this.baseURL = config.customEndpoint
} else if (config.baseURL) {
this.baseURL = config.baseURL
} else {
this.baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"
}
if (!this.apiKey) {
console.warn("⚠️ No OpenAI API key provided. Using fallback analysis.")
}
if (config.customEndpoint) {
console.info(`🔗 Using custom OpenAI endpoint: ${this.baseURL}`)
}
}
async chat(prompt: string): Promise<string> {
if (!this.apiKey) {
throw new Error("OpenAI API key not provided")
}
try {
const response = await fetch(`${this.baseURL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: [
{
role: "user",
content: prompt,
},
],
temperature: this.config.temperature,
max_tokens: this.config.maxTokens,
}),
})
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (!data.choices || data.choices.length === 0) {
throw new Error("No response from OpenAI API")
}
return data.choices[0].message.content
} catch (error) {
console.error("OpenAI API call failed:", error)
throw error
}
}
isAvailable(): boolean {
return Boolean(this.apiKey)
}
}
export default OpenAIClient

View File

@ -1,35 +0,0 @@
{
"name": "@follow/changelog-cli",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "AI-driven changelog generator for Follow project",
"keywords": [
"changelog",
"git",
"ai",
"cli",
"automation"
],
"main": "./index.ts",
"bin": {
"changelog-cli": "./bin/cli.js"
},
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"build": "tsc",
"dev": "tsx index.ts",
"generate": "tsx index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dotenv": "17.2.0",
"pathe": "2.0.3"
},
"devDependencies": {
"tsx": "4.20.3",
"typescript": "catalog:"
}
}

View File

@ -1,17 +0,0 @@
{
"extends": "../configs/tsconfig.extend.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@ -1,6 +1,6 @@
import type * as React from "react"
import type { CSSProperties } from "react"
import { useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { useLayoutEffect, useMemo, useRef } from "react"
import { createPortal } from "react-dom"
type Target = HTMLElement | null | string | (() => HTMLElement | null | undefined) | undefined
@ -40,13 +40,16 @@ export function ReparentPortal({
const hostEl = useMemo(() => {
const el = document.createElement(hostTag)
if (debugName) el.dataset.reparentPortal = debugName
if (hostClassName != null) el.className = hostClassName
if (hostStyle != null) Object.assign(el.style, hostStyle)
return el
}, [hostTag, debugName])
const lastParentRef = useRef<HTMLElement | null>(null)
// Sync styles/classes to hostEl
useEffect(() => {
useLayoutEffect(() => {
if (hostClassName != null) hostEl.className = hostClassName
if (hostStyle != null) Object.assign(hostEl.style, hostStyle)
}, [hostEl, hostClassName, hostStyle])