Folo/apps/mobile/src/modules/discover/recommendations.tsx

266 lines
7.3 KiB
TypeScript

import { RSSHubCategories } from "@follow/constants"
import type { RSSHubRouteDeclaration } from "@follow/models/src/rsshub"
import { isASCII } from "@follow/utils"
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"
import { useHeaderHeight } from "@react-navigation/elements"
import { FlashList } from "@shopify/flash-list"
import { useQuery } from "@tanstack/react-query"
import type { FC } from "react"
import { useCallback, useMemo, useRef } from "react"
import { Text, TouchableOpacity, View } from "react-native"
import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler"
import { PanGestureHandler } from "react-native-gesture-handler"
import type { TabComponent } from "@/src/components/ui/tabview"
import { TabView } from "@/src/components/ui/tabview"
import { apiClient } from "@/src/lib/api-fetch"
import { RSSHubCategoryCopyMap } from "./copy"
import { RecommendationListItem } from "./recommendation-item"
export const Recommendations = () => {
const headerHeight = useHeaderHeight()
return (
<TabView
lazyOnce
lazyTab
Tab={Tab}
tabbarStyle={{ paddingTop: headerHeight }}
tabs={RSSHubCategories.map((category) => ({
name: RSSHubCategoryCopyMap[category],
value: category,
}))}
/>
)
}
const _languageOptions = [
{
label: "All",
value: "all",
},
{
label: "English",
value: "en",
},
{
label: "中文",
value: "zh-CN",
},
] as const
type Language = (typeof _languageOptions)[number]["value"]
type DiscoverCategories = (typeof RSSHubCategories)[number] | string
const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) => {
return apiClient.discover.rsshub.$get({
query: {
category: "popular",
categories: category === "all" ? "popular" : `popular,${category}`,
lang: lang === "all" ? undefined : lang,
},
})
}
const Tab: TabComponent = ({ tab }) => {
const tabHeight = useBottomTabBarHeight()
const { data, isLoading } = useQuery({
queryKey: ["rsshub-popular", tab.value],
queryFn: () => fetchRsshubPopular(tab.value, "all").then((res) => res.data),
})
const keys = useMemo(() => {
if (!data) {
return []
}
return Object.keys(data).sort((a, b) => {
const aname = data[a].name
const bname = data[b].name
const aRouteName = data[a].routes[Object.keys(data[a].routes)[0]].name
const bRouteName = data[b].routes[Object.keys(data[b].routes)[0]].name
const ia = isASCII(aname) && isASCII(aRouteName)
const ib = isASCII(bname) && isASCII(bRouteName)
if (ia && ib) {
return aname.toLowerCase() < bname.toLowerCase() ? -1 : 1
} else if (ia || ib) {
return ia > ib ? -1 : 1
} else {
return 0
}
})
}, [data])
const alphabetGroups = useMemo(() => {
const groups = keys.reduce(
(acc, key) => {
// A-Z -> A-Z, 0-9 -> #, other -> #, # push to the end
const firstChar = key[0].toUpperCase()
if (/[A-Z]/.test(firstChar)) {
acc[firstChar] = acc[firstChar] || []
acc[firstChar].push(key)
} else {
acc["#"] = acc["#"] || []
acc["#"].push(key)
}
return acc
},
{} as Record<string, string[]>,
)
const sortedGroups = Object.entries(groups).sort((a, b) => {
const aLetter = a[0]
const bLetter = b[0]
return aLetter.localeCompare(bLetter)
})
const result = [] as ({ key: string; data: RSSHubRouteDeclaration } | string)[]
for (const [letter, items] of sortedGroups) {
result.push(letter)
for (const item of items) {
if (!data) {
continue
}
result.push({ key: item, data: data[item] })
}
}
return result
}, [data, keys])
// Add ref for FlashList
const listRef = useRef<FlashList<{ key: string; data: RSSHubRouteDeclaration } | string>>(null)
const getItemType = useCallback((item: string | { key: string }) => {
return typeof item === "string" ? "sectionHeader" : "row"
}, [])
const keyExtractor = useCallback((item: string | { key: string }) => {
return typeof item === "string" ? item : item.key
}, [])
if (isLoading) {
return null
}
return (
<View className="bg-system-background flex-1">
<FlashList
ref={listRef}
data={alphabetGroups}
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={ItemRenderer}
contentContainerStyle={{ paddingBottom: tabHeight }}
removeClippedSubviews
/>
{/* Right Sidebar */}
<NavigationSidebar alphabetGroups={alphabetGroups} listRef={listRef} />
</View>
)
}
const ItemRenderer = ({
item,
}: {
item: string | { key: string; data: RSSHubRouteDeclaration }
}) => {
if (typeof item === "string") {
// Rendering header
return (
<View className="border-b-separator border-b-hairline mx-6 mb-1 mt-2 pb-1">
<Text className="text-secondary-label text-base">{item}</Text>
</View>
)
} else {
// Render item
return (
<View className="mr-4">
<RecommendationListItem data={item.data} routePrefix={item.key} />
</View>
)
}
}
const NavigationSidebar: FC<{
alphabetGroups: (string | { key: string; data: RSSHubRouteDeclaration })[]
listRef: React.RefObject<FlashList<string | { key: string; data: RSSHubRouteDeclaration }>>
}> = ({ alphabetGroups, listRef }) => {
const scrollToLetter = useCallback(
(letter: string, animated = true) => {
const index = alphabetGroups.findIndex((group) => {
if (typeof group !== "string") return false
const firstChar = group[0].toUpperCase()
const firstCharIsAlphabet = /[A-Z]/.test(firstChar)
if (firstCharIsAlphabet) {
return firstChar === letter
}
if (letter === "#" && !firstCharIsAlphabet) {
return true
}
return false
})
if (index !== -1) {
listRef.current?.scrollToIndex({
animated,
index,
})
}
},
[alphabetGroups, listRef],
)
const titles = useMemo(() => {
return alphabetGroups.filter((item) => typeof item === "string")
}, [alphabetGroups])
// Replace PanResponder with gesture handler
const handleGesture = useCallback(
(event: PanGestureHandlerGestureEvent) => {
const { y } = event.nativeEvent
const letterHeight = 20 // Approximate height of each letter
const letter = titles[Math.floor(y / letterHeight)]
if (!letter) {
return
}
const firstChar = letter[0].toUpperCase()
const firstCharIsAlphabet = /[A-Z]/.test(firstChar)
if (firstCharIsAlphabet) {
scrollToLetter(letter, false)
} else {
scrollToLetter("#", false)
}
},
[scrollToLetter, titles],
)
return (
<View className="absolute inset-y-0 right-1 h-full items-center justify-center">
<PanGestureHandler onGestureEvent={handleGesture}>
<View className="gap-0.5">
{titles.map((title) => (
<TouchableOpacity
hitSlop={5}
key={title}
onPress={() => {
scrollToLetter(title)
}}
>
<Text className="text-secondary-text/60 text-sm">{title}</Text>
</TouchableOpacity>
))}
</View>
</PanGestureHandler>
</View>
)
}