feat(rn): impl list setting pages

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-01-22 22:45:27 +08:00
parent 0d124ffd6b
commit c18da69de1
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
20 changed files with 375 additions and 14 deletions

View File

@ -23,6 +23,7 @@
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@formatjs/intl-numberformat": "8.15.2",
"@gorhom/portal": "1.0.14",
"@hookform/resolvers": "3.9.1",
"@infinite-list/data-model": "2.2.10",
@ -36,6 +37,7 @@
"better-auth": "1.1.9-beta.1",
"cookie-es": "^1.2.2",
"dayjs": "1.11.13",
"dnum": "^2.14.0",
"es-toolkit": "1.29.0",
"expo": "52.0.18",
"expo-apple-authentication": "~7.1.2",

View File

@ -0,0 +1,69 @@
import { cn, toScientificNotation } from "@follow/utils"
import { Text } from "react-native"
export const Balance = ({
children,
className,
precision = 2,
}: {
/** The token balance in wei. */
children: bigint
className?: string
precision?: number
}) => {
const n = BigInt(children || 0n)
const formatted = format(n, { digits: precision, trailingZeros: true })
return <Text className={cn("tabular-nums", className)}>{formatted}</Text>
}
function format(
value: bigint,
options:
| number
| {
digits?: number
compact?: boolean
trailingZeros?: boolean
locale?: string
decimalsRounding?: "ROUND_HALF" | "ROUND_UP" | "ROUND_DOWN"
signDisplay?: "auto" | "always" | "exceptZero" | "negative" | "never"
} = {},
): string {
// Normalize options
const opts = typeof options === "number" ? { digits: options } : options
const {
digits,
compact = false,
trailingZeros = false,
locale = "en-US",
signDisplay = "auto",
} = opts
// Convert bigint to number for formatting
const num = Number(value) / 1e18 // Assuming 18 decimals (wei to ether conversion)
if (compact) {
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: digits,
minimumFractionDigits: trailingZeros ? digits : 0,
signDisplay,
}).format(num)
}
// For very large or small numbers, use scientific notation
if (Math.abs(num) > 1e9 || (Math.abs(num) < 1e-9 && num !== 0)) {
return toScientificNotation(num.toString(), 10)
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: digits,
minimumFractionDigits: trailingZeros ? digits : 0,
signDisplay,
useGrouping: true,
}).format(num)
}

View File

@ -5,6 +5,7 @@ import { Fragment } from "react"
import type { ViewProps } from "react-native"
import { Pressable, StyleSheet, Text, View } from "react-native"
import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
import { RightCuteReIcon } from "@/src/icons/right_cute_re"
import { useColor } from "@/src/theme/colors"
@ -91,7 +92,7 @@ export const GroupedInsetListNavigationLink: FC<{
<Text className={"text-label text-[16px]"}>{label}</Text>
</View>
<View className="-mr-2 ml-4">
<RightCuteReIcon height={18} width={18} color={rightIconColor} />
<MingcuteRightLine height={18} width={18} color={rightIconColor} />
</View>
</View>
</GroupedInsetListBaseCell>
@ -160,3 +161,29 @@ export const GroupedInsetListActionCell: FC<{
</Pressable>
)
}
export const GroupedInformationCell: FC<{
title: string
description?: string
icon?: React.ReactNode
iconBackgroundColor?: string
}> = ({ title, description, icon, iconBackgroundColor }) => {
return (
<GroupedInsetListBaseCell className="flex-1 flex-col items-center justify-center rounded-[16px] p-6">
{icon && (
<View
className="mb-3 size-[64px] items-center justify-center rounded-xl p-1"
style={{ backgroundColor: iconBackgroundColor }}
>
{icon}
</View>
)}
<Text className="text-3xl font-bold">{title}</Text>
{!!description && (
<Text className="text-label mt-3 text-balance text-center text-base leading-tight">
{description}
</Text>
)}
</GroupedInsetListBaseCell>
)
}

View File

@ -20,7 +20,7 @@ export const LoadingIndicator: FC<
color?: string
className?: string
} & PropsWithChildren
> = ({ size = 60, color, children, className }) => {
> = ({ size = 36, color, children, className }) => {
const rotateValue = useSharedValue(0)
const rotation = useDerivedValue(() => {

View File

@ -0,0 +1,24 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface UserAdd2CuteFiIconProps {
width?: number
height?: number
color?: string
}
export const UserAdd2CuteFiIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: UserAdd2CuteFiIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path fill="#fff" fillOpacity={0.01} d="M24 0v24H0V0z" />
<Path
fill={color}
d="M6 7a5 5 0 1 1 10 0A5 5 0 0 1 6 7M4.822 14.672C6.425 13.694 8.605 13 11 13c.447 0 .887.024 1.316.07a1 1 0 0 1 .72 1.557A5.968 5.968 0 0 0 12 18c0 .92.207 1.79.575 2.567a1 1 0 0 1-.89 1.428c-.226.003-.455.005-.685.005-2.229 0-4.335-.14-5.913-.558-.785-.208-1.524-.506-2.084-.956C2.41 20.01 2 19.345 2 18.5c0-.787.358-1.523.844-2.139.494-.625 1.177-1.2 1.978-1.69M18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1"
/>
</Svg>
)
}

View File

@ -0,0 +1,26 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface Wallet2CuteFiIconProps {
width?: number
height?: number
color?: string
}
export const Wallet2CuteFiIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: Wallet2CuteFiIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path fill="#fff" fillOpacity={0.01} d="M24 0v24H0V0z" />
<Path
fill={color}
fillRule="evenodd"
d="M9.06 4.647a.31.31 0 0 0-.06.069c0 .01.008.086.137.231.14.157.375.335.702.504.66.342 1.503.549 2.161.549.658 0 1.502-.207 2.161-.549.327-.169.561-.347.702-.504.129-.145.136-.22.137-.231a.309.309 0 0 0-.06-.069c-.106-.094-.31-.213-.639-.325C13.647 4.102 12.765 4 12 4c-.765 0-1.647.101-2.301.322-.33.112-.533.23-.638.325m6.63 2.214c.242-.17.469-.363.663-.58.353-.395.647-.925.647-1.567 0-.666-.317-1.188-.723-1.553-.386-.348-.872-.577-1.336-.733C14.01 2.113 12.892 2 12 2c-.892 0-2.01.113-2.941.428-.464.156-.95.385-1.336.733C7.317 3.526 7 4.048 7 4.714c0 .642.294 1.172.647 1.566.194.218.42.411.663.581C5.325 8.284 3 11.335 3 15c0 2.556 1.02 4.386 2.766 5.525C7.441 21.617 9.67 22 12 22s4.56-.383 6.234-1.475C19.98 19.386 21 17.555 21 15c0-3.665-2.325-6.716-5.31-8.139m-4.296 4.192a1 1 0 1 0-1.788.894L10.132 13H10a1 1 0 1 0 0 2h1v.5h-1a1 1 0 1 0 0 2h1v.5a1 1 0 1 0 2 0v-.5h1a1 1 0 1 0 0-2h-1V15h1a1 1 0 1 0 0-2h-.132l.526-1.053a1 1 0 1 0-1.788-.894L12 12.263z"
clipRule="evenodd"
/>
</Svg>
)
}

View File

@ -15,7 +15,7 @@ export const useDataSkeleton = (isLoading: boolean, data: any) => {
return (
<BaseSearchPageRootView className="items-center justify-center">
<View className="-mt-72" />
<LoadingIndicator color={withOpacity(textColor, 0.7)} size={32} />
<LoadingIndicator color={withOpacity(textColor, 0.7)} />
</BaseSearchPageRootView>
)
}

View File

@ -210,7 +210,7 @@ const SubscriptionItem = memo(({ id, className }: { id: string; className?: stri
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<LoadingIndicator size={36} />
<LoadingIndicator />
</View>
)
}

View File

@ -40,7 +40,7 @@ export function FollowFeed(props: { id: string }) {
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<LoadingIndicator size={36} />
<LoadingIndicator />
</View>
)
}

View File

@ -38,7 +38,7 @@ export const FollowList = (props: { id: string }) => {
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<LoadingIndicator size={36} />
<LoadingIndicator />
</View>
)
}

View File

@ -60,6 +60,7 @@ const UserGroupNavigationLinks: GroupNavigationLink[] = [
navigation.navigate("Achievement")
},
iconBackgroundColor: "#6366F1",
todo: true,
},
]
@ -107,6 +108,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
navigation.navigate("Actions")
},
iconBackgroundColor: "#059669",
todo: true,
},
{
@ -116,6 +118,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
navigation.navigate("Feeds")
},
iconBackgroundColor: "#10B981",
todo: true,
},
{
label: "Lists",
@ -124,6 +127,7 @@ const DataGroupNavigationLinks: GroupNavigationLink[] = [
navigation.navigate("Lists")
},
iconBackgroundColor: "#34D399",
// todo: true,
},
]

View File

@ -1,9 +1,168 @@
import { Text, View } from "react-native"
import { useQuery } from "@tanstack/react-query"
import { createElement, useCallback } from "react"
import type { ListRenderItem } from "react-native"
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import Animated, { LinearTransition } from "react-native-reanimated"
import { useColor } from "react-native-uikit-colors"
import { Balance } from "@/src/components/common/Balance"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
} from "@/src/components/common/SafeNavigationScrollView"
import {
GroupedInformationCell,
GroupedInsetListCard,
} from "@/src/components/ui/grouped/GroupedList"
import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon"
import { LoadingIndicator } from "@/src/components/ui/loading"
import { views } from "@/src/constants/views"
import { AddCuteReIcon } from "@/src/icons/add_cute_re"
import { PowerIcon } from "@/src/icons/power"
import { RadaCuteFiIcon } from "@/src/icons/rada_cute_fi"
import { UserAdd2CuteFiIcon } from "@/src/icons/user_add_2_cute_fi"
import { Wallet2CuteFiIcon } from "@/src/icons/wallet_2_cute_fi"
import type { HonoApiClient } from "@/src/morph/types"
import { listSyncServices } from "@/src/store/list/store"
import { accentColor } from "@/src/theme/colors"
export const ListsScreen = () => {
const { isLoading, data } = useQuery({
queryKey: ["owned", "lists"],
queryFn: () => listSyncServices.fetchOwnedLists(),
})
return (
<View className="flex-1 items-center justify-center">
<Text>Lists Settings</Text>
<SafeNavigationScrollView nestedScrollEnabled className="bg-system-grouped-background">
<NavigationBlurEffectHeader
title="Lists"
headerRight={useCallback(
() => (
<AddListButton />
),
[],
)}
/>
<View className="mt-6">
<GroupedInsetListCard>
<GroupedInformationCell
title="Lists"
description="Lists are collections of feeds that you can share or sell for others to subscribe to. Subscribers will synchronize and access all feeds in the list."
icon={<RadaCuteFiIcon height={40} width={40} color="#fff" />}
iconBackgroundColor="#34D399"
/>
</GroupedInsetListCard>
</View>
<View className="mt-6">
{data && (
<GroupedInsetListCard>
<Animated.FlatList
keyExtractor={keyExtractor}
itemLayoutAnimation={LinearTransition}
scrollEnabled={false}
data={data}
renderItem={ListItemCell}
ItemSeparatorComponent={ItemSeparatorComponent}
/>
</GroupedInsetListCard>
)}
</View>
{isLoading && (
<View className="flex-1 items-center justify-center">
<LoadingIndicator />
</View>
)}
</SafeNavigationScrollView>
)
}
const AddListButton = () => {
const labelColor = useColor("label")
return (
<TouchableOpacity hitSlop={10}>
<AddCuteReIcon height={20} width={20} color={labelColor} />
</TouchableOpacity>
)
}
const ItemSeparatorComponent = () => {
return (
<View
className="bg-opaque-separator ml-24 flex-1"
style={{ height: StyleSheet.hairlineWidth }}
/>
)
}
const keyExtractor = (item: HonoApiClient.List_List_Get) => item.id
const ListItemCell: ListRenderItem<HonoApiClient.List_List_Get> = ({ item: list }) => {
const { title, description } = list
return (
<View className="flex-row p-4">
<View className="size-16 overflow-hidden rounded-lg">
{list.image ? (
<Image source={{ uri: list.image }} resizeMode="cover" className="size-full" />
) : (
<FallbackIcon title={list.title || ""} size="100%" textStyle={styles.title} />
)}
</View>
<View className="ml-4 flex-1">
<Text
className="text-label text-lg font-semibold leading-tight"
numberOfLines={1}
ellipsizeMode="middle"
>
{title}
</Text>
<Text className="text-secondary-label text-base" numberOfLines={4}>
{description}
</Text>
<View className="flex-row items-center gap-1">
{!!views[list.view]?.icon &&
createElement(views[list.view]!.icon, {
color: views[list.view]!.activeColor,
height: 16,
width: 16,
})}
<Text className="text-secondary-label text-base">{views[list.view]?.name}</Text>
</View>
</View>
<View
className="bg-opaque-separator mx-4 h-full"
style={{ width: StyleSheet.hairlineWidth }}
/>
<View className="w-16 gap-1">
<View className="flex-row items-center gap-1">
<PowerIcon height={16} width={16} color={accentColor} />
<Text className="text-secondary-label text-sm">{list.fee}</Text>
</View>
<View className="flex-row items-center gap-1">
<UserAdd2CuteFiIcon height={16} width={16} color={accentColor} />
<Text className="text-secondary-label text-sm">{list.subscriptionCount || 0}</Text>
</View>
{!!list.purchaseAmount && (
<View className="flex-row items-center gap-1">
<Wallet2CuteFiIcon height={16} width={16} color={accentColor} />
<Balance className="text-secondary-label text-sm">
{BigInt(list.purchaseAmount)}
</Balance>
</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
title: {
fontSize: 20,
fontWeight: "semibold",
},
})

View File

@ -22,6 +22,7 @@ import { UnGroupedList } from "./UnGroupedList"
// })
// }
export const CategoryGrouped = memo(
// eslint-disable-next-line @eslint-react/no-unstable-context-value
({ category, subscriptionIds }: { category: string; subscriptionIds: string[] }) => {
const unreadCounts = useUnreadCounts(subscriptionIds)
const [expanded, setExpanded] = useState(false)

View File

@ -54,7 +54,7 @@ export const SubscriptionItem = memo(({ id, className }: { id: string; className
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<LoadingIndicator size={36} />
<LoadingIndicator />
</View>
)
}

View File

@ -82,7 +82,7 @@ class Morph {
return { subscriptions, ...collections }
}
toList({ list: data }: HonoApiClient.List_Get): ListModel {
toList(data: HonoApiClient.List_Get["list"] | HonoApiClient.List_List_Get): ListModel {
return {
id: data.id,
title: data.title!,

View File

@ -10,4 +10,5 @@ export namespace HonoApiClient {
export type List_Get = ExtractData<typeof apiClient.lists.$get>
export type Entry_Post = ExtractData<typeof apiClient.entries.$post>
export type Entry_Get = ExtractData<typeof apiClient.entries.$get>
export type List_List_Get = ExtractData<typeof apiClient.lists.list.$get>[number]
}

View File

@ -84,10 +84,17 @@ class ListSyncServices {
async fetchListById(params: { id: string }) {
const list = await apiClient.lists.$get({ query: { listId: params.id } })
listActions.upsertMany([honoMorph.toList(list.data)])
listActions.upsertMany([honoMorph.toList(list.data.list)])
return list.data
}
async fetchOwnedLists() {
const res = await apiClient.lists.list.$get()
listActions.upsertMany(res.data.map((list) => honoMorph.toList(list)))
return res.data
}
}
export const listSyncServices = new ListSyncServices()

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" d="M6 7a5 5 0 1 1 10 0A5 5 0 0 1 6 7M4.822 14.672C6.425 13.694 8.605 13 11 13c.447 0 .887.024 1.316.07a1 1 0 0 1 .72 1.557A5.968 5.968 0 0 0 12 18c0 .92.207 1.79.575 2.567a1 1 0 0 1-.89 1.428c-.226.003-.455.005-.685.005-2.229 0-4.335-.14-5.913-.558-.785-.208-1.524-.506-2.084-.956C2.41 20.01 2 19.345 2 18.5c0-.787.358-1.523.844-2.139.494-.625 1.177-1.2 1.978-1.69M18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1"/></svg>

After

Width:  |  Height:  |  Size: 621 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" fill-rule="evenodd" d="M9.06 4.647a.31.31 0 0 0-.06.069c0 .01.008.086.137.231.14.157.375.335.702.504.66.342 1.503.549 2.161.549.658 0 1.502-.207 2.161-.549.327-.169.561-.347.702-.504.129-.145.136-.22.137-.231a.309.309 0 0 0-.06-.069c-.106-.094-.31-.213-.639-.325C13.647 4.102 12.765 4 12 4c-.765 0-1.647.101-2.301.322-.33.112-.533.23-.638.325m6.63 2.214c.242-.17.469-.363.663-.58.353-.395.647-.925.647-1.567 0-.666-.317-1.188-.723-1.553-.386-.348-.872-.577-1.336-.733C14.01 2.113 12.892 2 12 2c-.892 0-2.01.113-2.941.428-.464.156-.95.385-1.336.733C7.317 3.526 7 4.048 7 4.714c0 .642.294 1.172.647 1.566.194.218.42.411.663.581C5.325 8.284 3 11.335 3 15c0 2.556 1.02 4.386 2.766 5.525C7.441 21.617 9.67 22 12 22s4.56-.383 6.234-1.475C19.98 19.386 21 17.555 21 15c0-3.665-2.325-6.716-5.31-8.139m-4.296 4.192a1 1 0 1 0-1.788.894L10.132 13H10a1 1 0 1 0 0 2h1v.5h-1a1 1 0 1 0 0 2h1v.5a1 1 0 1 0 2 0v-.5h1a1 1 0 1 0 0-2h-1V15h1a1 1 0 1 0 0-2h-.132l.526-1.053a1 1 0 1 0-1.788-.894L12 12.263z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -436,6 +436,9 @@ importers:
'@follow/utils':
specifier: workspace:*
version: link:../../packages/utils
'@formatjs/intl-numberformat':
specifier: 8.15.2
version: 8.15.2
'@gorhom/portal':
specifier: 1.0.14
version: 1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
@ -475,6 +478,9 @@ importers:
dayjs:
specifier: 1.11.13
version: 1.11.13
dnum:
specifier: ^2.14.0
version: 2.14.0
es-toolkit:
specifier: 1.29.0
version: 1.29.0
@ -3777,6 +3783,18 @@ packages:
'@fontsource/sn-pro@5.1.0':
resolution: {integrity: sha512-k7cdU1hftD/pyrnrmQg+egKAssbuPORbtgQM/9JG5b76EchEKZdl/juO8xBjN3O/H5BQ16iu8/9vNPgzyExZeg==}
'@formatjs/ecma402-abstract@2.3.2':
resolution: {integrity: sha512-6sE5nyvDloULiyOMbOTJEEgWL32w+VHkZQs8S02Lnn8Y/O5aQhjOEXwWzvR7SsBE/exxlSpY2EsWZgqHbtLatg==}
'@formatjs/fast-memoize@2.2.6':
resolution: {integrity: sha512-luIXeE2LJbQnnzotY1f2U2m7xuQNj2DA8Vq4ce1BY9ebRZaoPB1+8eZ6nXpLzsxuW5spQxr7LdCg+CApZwkqkw==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-numberformat@8.15.2':
resolution: {integrity: sha512-8rdXSBKXb0nyfEX98/Sh3+ML4JXK2vm7Slnrjikx1svXoBKhiw8kZKwQMpAcGIKtEiQA0conUW3LMPXzv/YlSg==}
'@freakycoder/react-native-bounceable@1.0.3':
resolution: {integrity: sha512-+iMq2tnqxCwFjitbPUz9nZ+VfJ8OU9waIlDJAAsoq1229QEwCmERCNy5zVtDsz75q3i4FLXX/n7fimdMzmP21A==}
@ -18338,6 +18356,28 @@ snapshots:
'@fontsource/sn-pro@5.1.0': {}
'@formatjs/ecma402-abstract@2.3.2':
dependencies:
'@formatjs/fast-memoize': 2.2.6
'@formatjs/intl-localematcher': 0.5.10
decimal.js: 10.4.3
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.6':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-numberformat@8.15.2':
dependencies:
'@formatjs/ecma402-abstract': 2.3.2
'@formatjs/intl-localematcher': 0.5.10
decimal.js: 10.4.3
tslib: 2.8.1
'@freakycoder/react-native-bounceable@1.0.3': {}
'@gar/promisify@1.1.3': {}
@ -23274,8 +23314,7 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
decimal.js@10.4.3:
optional: true
decimal.js@10.4.3: {}
decode-bmp@0.2.1:
dependencies: