diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 97a45337b..25a094e11 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/components/common/Balance.tsx b/apps/mobile/src/components/common/Balance.tsx new file mode 100644 index 000000000..4d08e09b2 --- /dev/null +++ b/apps/mobile/src/components/common/Balance.tsx @@ -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 {formatted} +} + +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) +} diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 68b85e6d4..02b5bcf27 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -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<{ {label} - + @@ -160,3 +161,29 @@ export const GroupedInsetListActionCell: FC<{ ) } + +export const GroupedInformationCell: FC<{ + title: string + description?: string + icon?: React.ReactNode + iconBackgroundColor?: string +}> = ({ title, description, icon, iconBackgroundColor }) => { + return ( + + {icon && ( + + {icon} + + )} + {title} + {!!description && ( + + {description} + + )} + + ) +} diff --git a/apps/mobile/src/components/ui/loading/index.tsx b/apps/mobile/src/components/ui/loading/index.tsx index 65f7f7e78..67dea6b23 100644 --- a/apps/mobile/src/components/ui/loading/index.tsx +++ b/apps/mobile/src/components/ui/loading/index.tsx @@ -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(() => { diff --git a/apps/mobile/src/icons/user_add_2_cute_fi.tsx b/apps/mobile/src/icons/user_add_2_cute_fi.tsx new file mode 100644 index 000000000..9e9eca46a --- /dev/null +++ b/apps/mobile/src/icons/user_add_2_cute_fi.tsx @@ -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 ( + + + + + ) +} diff --git a/apps/mobile/src/icons/wallet_2_cute_fi.tsx b/apps/mobile/src/icons/wallet_2_cute_fi.tsx new file mode 100644 index 000000000..07c26857c --- /dev/null +++ b/apps/mobile/src/icons/wallet_2_cute_fi.tsx @@ -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 ( + + + + + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx index 0c3e72ab1..39b0e1e7f 100644 --- a/apps/mobile/src/modules/discover/search-tabs/hooks.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/hooks.tsx @@ -15,7 +15,7 @@ export const useDataSkeleton = (isLoading: boolean, data: any) => { return ( - + ) } diff --git a/apps/mobile/src/modules/feed-drawer/feed-panel.tsx b/apps/mobile/src/modules/feed-drawer/feed-panel.tsx index d00948c70..5dc34415b 100644 --- a/apps/mobile/src/modules/feed-drawer/feed-panel.tsx +++ b/apps/mobile/src/modules/feed-drawer/feed-panel.tsx @@ -210,7 +210,7 @@ const SubscriptionItem = memo(({ id, className }: { id: string; className?: stri if (isLoading) { return ( - + ) } diff --git a/apps/mobile/src/modules/feed/FollowFeed.tsx b/apps/mobile/src/modules/feed/FollowFeed.tsx index 8383c1bce..10a496044 100644 --- a/apps/mobile/src/modules/feed/FollowFeed.tsx +++ b/apps/mobile/src/modules/feed/FollowFeed.tsx @@ -40,7 +40,7 @@ export function FollowFeed(props: { id: string }) { if (isLoading) { return ( - + ) } diff --git a/apps/mobile/src/modules/list/FollowList.tsx b/apps/mobile/src/modules/list/FollowList.tsx index 139b45c40..ded5a9e28 100644 --- a/apps/mobile/src/modules/list/FollowList.tsx +++ b/apps/mobile/src/modules/list/FollowList.tsx @@ -38,7 +38,7 @@ export const FollowList = (props: { id: string }) => { if (isLoading) { return ( - + ) } diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index e43722972..8766299e3 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -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, }, ] diff --git a/apps/mobile/src/modules/settings/routes/Lists.tsx b/apps/mobile/src/modules/settings/routes/Lists.tsx index 537f2ec7b..ec098bb3b 100644 --- a/apps/mobile/src/modules/settings/routes/Lists.tsx +++ b/apps/mobile/src/modules/settings/routes/Lists.tsx @@ -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 ( - - Lists Settings + + ( + + ), + [], + )} + /> + + + + } + iconBackgroundColor="#34D399" + /> + + + + + {data && ( + + + + )} + + + {isLoading && ( + + + + )} + + ) +} + +const AddListButton = () => { + const labelColor = useColor("label") + return ( + + + + ) +} + +const ItemSeparatorComponent = () => { + return ( + + ) +} + +const keyExtractor = (item: HonoApiClient.List_List_Get) => item.id + +const ListItemCell: ListRenderItem = ({ item: list }) => { + const { title, description } = list + return ( + + + {list.image ? ( + + ) : ( + + )} + + + + {title} + + + {description} + + + {!!views[list.view]?.icon && + createElement(views[list.view]!.icon, { + color: views[list.view]!.activeColor, + height: 16, + width: 16, + })} + {views[list.view]?.name} + + + + + + + + {list.fee} + + + + + {list.subscriptionCount || 0} + + + {!!list.purchaseAmount && ( + + + + {BigInt(list.purchaseAmount)} + + + )} + ) } + +const styles = StyleSheet.create({ + title: { + fontSize: 20, + fontWeight: "semibold", + }, +}) diff --git a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx index 1c4ec4bce..fe993ac7f 100644 --- a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx +++ b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx @@ -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) diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx index dd6a612f3..957795e05 100644 --- a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -54,7 +54,7 @@ export const SubscriptionItem = memo(({ id, className }: { id: string; className if (isLoading) { return ( - + ) } diff --git a/apps/mobile/src/morph/hono.ts b/apps/mobile/src/morph/hono.ts index 7c3a183f6..0c2622fe3 100644 --- a/apps/mobile/src/morph/hono.ts +++ b/apps/mobile/src/morph/hono.ts @@ -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!, diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index 1112477cc..531537028 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -10,4 +10,5 @@ export namespace HonoApiClient { export type List_Get = ExtractData export type Entry_Post = ExtractData export type Entry_Get = ExtractData + export type List_List_Get = ExtractData[number] } diff --git a/apps/mobile/src/store/list/store.ts b/apps/mobile/src/store/list/store.ts index 35563b1ab..674a3f5b6 100644 --- a/apps/mobile/src/store/list/store.ts +++ b/apps/mobile/src/store/list/store.ts @@ -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() diff --git a/icons/mgc/user_add_2_cute_fi.svg b/icons/mgc/user_add_2_cute_fi.svg new file mode 100644 index 000000000..dec029af7 --- /dev/null +++ b/icons/mgc/user_add_2_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/wallet_2_cute_fi.svg b/icons/mgc/wallet_2_cute_fi.svg new file mode 100644 index 000000000..1627fb013 --- /dev/null +++ b/icons/mgc/wallet_2_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 480463a2d..18fb073fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: