diff --git a/package-lock.json b/package-lock.json index f50889c3e53..c2426d11b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5928,6 +5928,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -146304,7 +146305,7 @@ }, "packages/mobile": { "name": "@audius/mobile", - "version": "1.5.169", + "version": "1.5.170", "dependencies": { "@amplitude/analytics-react-native": "1.4.11", "@audius/common": "*", diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 2f788cd6add..2ce7ca509c2 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -18,7 +18,7 @@ PODS: - ffmpeg-kit-ios-full-gpl (= 6.0) - React-Core - FingerprintPro (2.11.0) - - fmt (11.0.2) + - fmt (12.1.0) - glog (0.3.5) - google-cast-sdk-dynamic-xcframework-no-bluetooth (4.7.1) - hermes-engine (0.78.3): @@ -55,20 +55,20 @@ PODS: - boost - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Default (= 2024.11.18.00) - RCT-Folly/Default (2024.11.18.00): - boost - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Fabric (2024.11.18.00): - boost - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCTDeprecation (0.78.3) - RCTRequired (0.78.3) @@ -331,7 +331,7 @@ PODS: - React-CoreModules (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - RCT-Folly (= 2024.11.18.00) - RCTTypeSafety (= 0.78.3) - React-Core/CoreModulesHeaders (= 0.78.3) @@ -347,7 +347,7 @@ PODS: - boost - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -384,7 +384,7 @@ PODS: - React-Fabric (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -421,7 +421,7 @@ PODS: - React-Fabric/animations (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -442,7 +442,7 @@ PODS: - React-Fabric/attributedstring (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -463,7 +463,7 @@ PODS: - React-Fabric/componentregistry (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -484,7 +484,7 @@ PODS: - React-Fabric/componentregistrynative (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -505,7 +505,7 @@ PODS: - React-Fabric/components (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -529,7 +529,7 @@ PODS: - React-Fabric/components/legacyviewmanagerinterop (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -550,7 +550,7 @@ PODS: - React-Fabric/components/root (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -571,7 +571,7 @@ PODS: - React-Fabric/components/view (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -593,7 +593,7 @@ PODS: - React-Fabric/consistency (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -614,7 +614,7 @@ PODS: - React-Fabric/core (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -635,7 +635,7 @@ PODS: - React-Fabric/dom (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -656,7 +656,7 @@ PODS: - React-Fabric/imagemanager (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -677,7 +677,7 @@ PODS: - React-Fabric/leakchecker (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -698,7 +698,7 @@ PODS: - React-Fabric/mounting (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -719,7 +719,7 @@ PODS: - React-Fabric/observers (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -741,7 +741,7 @@ PODS: - React-Fabric/observers/events (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -762,7 +762,7 @@ PODS: - React-Fabric/scheduler (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -785,7 +785,7 @@ PODS: - React-Fabric/telemetry (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -806,7 +806,7 @@ PODS: - React-Fabric/templateprocessor (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -827,7 +827,7 @@ PODS: - React-Fabric/uimanager (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -850,7 +850,7 @@ PODS: - React-Fabric/uimanager/consistency (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -872,7 +872,7 @@ PODS: - React-FabricComponents (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -897,7 +897,7 @@ PODS: - React-FabricComponents/components (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -929,7 +929,7 @@ PODS: - React-FabricComponents/components/inputaccessory (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -952,7 +952,7 @@ PODS: - React-FabricComponents/components/iostextinput (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -975,7 +975,7 @@ PODS: - React-FabricComponents/components/modal (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -998,7 +998,7 @@ PODS: - React-FabricComponents/components/rncore (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1021,7 +1021,7 @@ PODS: - React-FabricComponents/components/safeareaview (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1044,7 +1044,7 @@ PODS: - React-FabricComponents/components/scrollview (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1067,7 +1067,7 @@ PODS: - React-FabricComponents/components/text (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1090,7 +1090,7 @@ PODS: - React-FabricComponents/components/textinput (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1113,7 +1113,7 @@ PODS: - React-FabricComponents/components/unimplementedview (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1136,7 +1136,7 @@ PODS: - React-FabricComponents/textlayoutmanager (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1159,7 +1159,7 @@ PODS: - React-FabricImage (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1189,7 +1189,7 @@ PODS: - React-graphics (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly/Fabric (= 2024.11.18.00) @@ -1199,7 +1199,7 @@ PODS: - React-hermes (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -1240,14 +1240,14 @@ PODS: - boost - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) - React-jsiexecutor (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -1337,7 +1337,7 @@ PODS: - React - react-native-google-cast/NoBluetoothArm (= 4.6.2) - react-native-google-cast/NoBluetoothArm (4.6.2): - - google-cast-sdk-dynamic-xcframework-no-bluetooth (= 4.7.1) + - google-cast-sdk-dynamic-xcframework-no-bluetooth - React - react-native-google-cast/RNGoogleCast - react-native-google-cast/RNGoogleCast (4.6.2): @@ -1567,7 +1567,7 @@ PODS: - React-RCTBlob (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - hermes-engine - RCT-Folly (= 2024.11.18.00) - React-Core/RCTBlobHeaders @@ -1657,7 +1657,7 @@ PODS: - React-rendererdebug (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - RCT-Folly (= 2024.11.18.00) - React-debug - React-rncore (0.78.3) @@ -1759,7 +1759,7 @@ PODS: - ReactCommon/turbomodule (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -1773,7 +1773,7 @@ PODS: - ReactCommon/turbomodule/bridging (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -1785,7 +1785,7 @@ PODS: - ReactCommon/turbomodule/core (0.78.3): - DoubleConversion - fast_float (= 6.1.4) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - hermes-engine - RCT-Folly (= 2024.11.18.00) @@ -2503,7 +2503,7 @@ SPEC CHECKSUMS: ffmpeg-kit-ios-full-gpl: 7d416729f7b3604b64cee2a752c42608a8d228e0 ffmpeg-kit-react-native: 3cea88c9c5cfad62e1465279ea7d800dfbba3b00 FingerprintPro: 2fbd0cda75fbab32055f7cb38bc5fc9d17168979 - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 google-cast-sdk-dynamic-xcframework-no-bluetooth: 1fa9e267df3fd6f8a1c6e3345142ca5286297968 hermes-engine: b5c9cfbe6415f1b0b24759f2942c8f33e9af6347 @@ -2512,30 +2512,30 @@ SPEC CHECKSUMS: lottie-react-native: 04061d06c966a4179c9c1352aac63b699642c77e nSure: 2fc3fc973c44aa0be9a3446f84cb514adc475205 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 + RCT-Folly: d533c1d21249a85e67a4be97f4d793665de0222e RCTDeprecation: cf39863b43871c2031050605fb884019b6193910 RCTRequired: 8fdd66f4a97f352b66f38cfef13fc11b12d2c884 RCTTypeSafety: c9c9e64389bc545fc137030615b387ef1654dcee React: 14a80ea4f13387cfdaa4250b46fbfe19754c220c React-callinvoker: fed1dad5d6cf992c7b4b5fdbf1bf67fe2e8fb6c5 React-Core: f703e7a56fcedc3e959b8b7899995e57fd58539a - React-CoreModules: 6e87c904cc257058c271708eef1719b5b3039131 - React-cxxreact: 4153beeff710944832cd90ccb141e299ee16b7d3 + React-CoreModules: 945ed8a2827720af6852b97c8ddd2ac2ceb04157 + React-cxxreact: 26c744a099a29f2f995f9bb4b755d8ecbf7c5392 React-debug: aea2894f3f71697ec8724c11db9c46c1574b21e4 React-defaultsnativemodule: de00ba37b23205eca31cee5a6a8756a48f0a38f9 React-domnativemodule: b8dd2af2fcd232da5ea679e439a95a827a852a21 - React-Fabric: a63a42788096362a4840d827dab426c17a15a644 - React-FabricComponents: 8dd7bea45626350fe7bd052ea7c9fd657a1977cc - React-FabricImage: de4c10c3319b916ca4d0b6a6fd53c3ecb4da0f83 + React-Fabric: 31765f67f16bec0e85e89be0a544da0d91b8fdc2 + React-FabricComponents: ad41201e8e20a4f57d9f62ded0506ebc67e89cff + React-FabricImage: 77b5157315924901911fdaa586df5b5fbf754366 React-featureflags: c6de2182d6065b3239245820347947e2c1309feb React-featureflagsnativemodule: 47c7a5814a027eb262c2709852d1b1a597599cca - React-graphics: bf16f4f74e3bc342b58b71797674a4eafda467a9 - React-hermes: a942bebef5e9fcc31f51c6fb814e96c260a2a20d + React-graphics: f9371eec432c7cf095238eaca8d2d3deaa95989f + React-hermes: 1ee5a6b835bc939b7aba500cb716a1095b3e0959 React-idlecallbacksnativemodule: d53c466886963cc325172bc9100b21e8511bce97 React-ImageManager: 4e2d837df0bf6b4cc42238122dc4577545befed5 React-jserrorhandler: 41f26e9e5559bda0549f27410d685a8f21bdbb94 - React-jsi: b2de88284fc2cc69466a34d8b794160216d3bd2c - React-jsiexecutor: e947af1c9e42610affd9f4178cd4b649e8ac889b + React-jsi: 0810ff01e7f73a1cfba83d176e21c29302f6f3aa + React-jsiexecutor: 70f6865d195eba534f39ac9504a9aa178e5389f7 React-jsinspector: 7efabcd0a393fed8e3bd4b9c8c461c1b4d5d2b90 React-jsinspectortracing: 8cddf7d93b8aa43a14b5d92e8a83de083a1158e6 React-jsitracing: 1d636f7da6f2d4f9c2d3d7e9d50f469eb2356ddb @@ -2548,7 +2548,7 @@ SPEC CHECKSUMS: react-native-document-picker: 4f90a074d1eb269e32d5563c53b70d469180cece react-native-fast-crypto: b30594570dab23aca7e74e206b2c03e28a006216 react-native-get-random-values: 384787fd76976f5aec9465aff6fa9e9129af1e74 - react-native-google-cast: 0a82cf63114470403e41e04ffa2b13d6448b6112 + react-native-google-cast: 18b9b2fc518caabfa65d309409e160b3fc6d1733 react-native-image-picker: f104798044ef2c9211c42a48025d0693b5f34981 react-native-in-app-review: db8bb167a5f238e7ceca5c242d6b36ce8c4404a4 react-native-keyboard-controller: 7c1271a9fe703b7ee588b75d6c486eda79e5081b @@ -2570,7 +2570,7 @@ SPEC CHECKSUMS: React-RCTActionSheet: a078d5008632fed31b0024c420ee02e612b317d5 React-RCTAnimation: 82e31d191af4175e0c2df5bdac2c8569a5f3ab54 React-RCTAppDelegate: 183c991d0be0b05a4e1587bb2740cf954782a56b - React-RCTBlob: c462b8b7de6ce44ddc56dd96eebe1da0a6e54c77 + React-RCTBlob: 605c283c68fee9e095206fefbfe69d0d23df1a00 React-RCTFabric: 8f6eead6e48b1126ec325964040d175c9163e35f React-RCTFBReactNativeSpec: bc4feee6ad7e6cce73d38000473fe8702a38e76a React-RCTImage: 10fad63f1bb8adbd519c4c2ef6bec3c0d95fdd32 @@ -2580,7 +2580,7 @@ SPEC CHECKSUMS: React-RCTText: d97cfb9c89b06de9530577dd43f178c47ea07853 React-RCTVibration: 2fcefee071a4f0d416e4368416bb073ea6893451 React-rendererconsistency: 259dede0b0b9b46bcc2fcdc94465a5fa01a66ef9 - React-rendererdebug: 3f7600015d8ce3a4c97149f3660fe30dba17c0fd + React-rendererdebug: 61d39f5756b6fad82d52c25e87ef71e35e4d4254 React-rncore: 93b049aef62762732c06413616b0f033d47b9a95 React-RuntimeApple: 85a29d8805ace62a2db360cc46e3100435b0dd2b React-RuntimeCore: a81ea64fb5578c0367736c91fb85d6844950b7c2 @@ -2591,7 +2591,7 @@ SPEC CHECKSUMS: React-utils: 21356bf3cdd7a337587165fea57563a077993864 ReactAppDependencyProvider: ad88c80e06f29900f2e6f9ccf1d4cb0bfc3e1bbc ReactCodegen: 8e673528b686d334cfa785515d9c9b14bcb272d8 - ReactCommon: 7ea8ee50e489e9cc75922f19a06ea45c1b59b4bd + ReactCommon: 2a8b95bef15a281871b6bae12de1624f80e3a209 RNBootSplash: c2ae3f80f6c90979ee57977672f57e74981f85a5 RNCAsyncStorage: 23e56519cc41d3bade3c8d4479f7760cb1c11996 RNCClipboard: dfeb43751adff21e588657b5b6c888c72f3aa68e diff --git a/packages/mobile/src/components/artist-coin-details-drawer/ArtistCoinDetailsDrawer.tsx b/packages/mobile/src/components/artist-coin-details-drawer/ArtistCoinDetailsDrawer.tsx index 2aa4b2603de..f44a31d82d5 100644 --- a/packages/mobile/src/components/artist-coin-details-drawer/ArtistCoinDetailsDrawer.tsx +++ b/packages/mobile/src/components/artist-coin-details-drawer/ArtistCoinDetailsDrawer.tsx @@ -1,12 +1,26 @@ -import { useCallback } from 'react' +import { useCallback, type ReactNode } from 'react' -import { useArtistCoin, useCoinGeckoCoin } from '@audius/common/api' +import { useArtistCoin, useCoinGeckoCoin, useUser } from '@audius/common/api' import { coinDetailsMessages } from '@audius/common/messages' import { useArtistCoinDetailsModal } from '@audius/common/store' -import { formatCurrencyWithSubscript } from '@audius/common/utils' +import { + formatCurrencyWithSubscript, + getTokenDecimalPlaces +} from '@audius/common/utils' +import { FixedDecimal, wAUDIO } from '@audius/fixed-decimal' import Clipboard from '@react-native-clipboard/clipboard' +import { ScrollView } from 'react-native' -import { Flex, Text, Divider, Button, useTheme } from '@audius/harmony-native' +import { + Flex, + Text, + Divider, + Button, + IconCopy, + PlainButton, + useTheme +} from '@audius/harmony-native' +import { TooltipInfoIcon } from 'app/components/buy-sell/TooltipInfoIcon' import { TokenIcon } from 'app/components/core' import Drawer from 'app/components/drawer/Drawer' import { useToast } from 'app/hooks/useToast' @@ -14,7 +28,79 @@ import { env } from 'app/services/env' import { DrawerHeader } from '../drawer/DrawerHeader' -const { artistCoinDetails } = coinDetailsMessages +const { artistCoinDetails, overflowMenu } = coinDetailsMessages + +const LAUNCHPAD_COIN_DESCRIPTION = (handle: string, ticker: string) => + `{${ticker}} is an artist coin created by {${handle}} on Audius. Learn more at https://audius.co/coin/{${ticker}}` + +const formatTokenAmount = (balance: number, coinDecimals: number) => { + const decimals = getTokenDecimalPlaces(balance) + const maxFractionDigits = Math.min(decimals, coinDecimals) + return new FixedDecimal(BigInt(balance), coinDecimals).toLocaleString( + 'en-US', + { + maximumFractionDigits: maxFractionDigits + } + ) +} + +const formatFeeNumber = (input: number) => { + const value = wAUDIO(BigInt(input)) + const decimalPlaces = getTokenDecimalPlaces(Number(value.toString())) + return formatCurrencyWithSubscript( + Number(value.trunc(decimalPlaces).toString()), + 'en-US', + '' + ) +} + +const DetailRow = ({ + label, + tooltip, + children +}: { + label: string + tooltip?: string + children: ReactNode +}) => ( + + + + {label} + + {tooltip ? : null} + + {children} + +) + +const StatRow = ({ left, right }: { left: ReactNode; right: ReactNode }) => ( + + {left} + {right} + +) + +const CopyableAddress = ({ + address, + onCopy +}: { + address: string + onCopy: () => void +}) => ( + + + {address} + + + +) export const ArtistCoinDetailsDrawer = () => { const { spacing } = useTheme() @@ -22,6 +108,7 @@ export const ArtistCoinDetailsDrawer = () => { const { isOpen, onClose, data: modalData } = useArtistCoinDetailsModal() const mint = modalData?.mint const { data: artistCoin } = useArtistCoin(mint) + const { data: artist } = useUser(artistCoin?.ownerId) const isAudio = mint === env.WAUDIO_MINT_ADDRESS const { data: coingeckoResponse } = useCoinGeckoCoin( { coinId: 'audius' }, @@ -35,160 +122,258 @@ export const ArtistCoinDetailsDrawer = () => { } }, [artistCoin?.mint, toast]) + const handleCopyRewardsPoolAddress = useCallback(() => { + if (artistCoin?.rewardPool?.address) { + Clipboard.setString(artistCoin.rewardPool.address) + toast({ content: artistCoinDetails.copied, type: 'info' }) + } + }, [artistCoin?.rewardPool?.address, toast]) + if (!artistCoin?.mint) { return null } - const renderHeader = () => { - return ( - - - - - ) - } + const decimals = artistCoin.decimals ?? 9 + const hasGraduated = artistCoin.dynamicBondingCurve?.isMigrated ?? false + const locker = artistCoin.artistLocker + const showLockerStats = !isAudio && hasGraduated && !!locker + const ticker = artistCoin.ticker ? `$${artistCoin.ticker}` : '' + + // Market data + const totalSupply = isAudio + ? coingeckoResponse?.market_data?.total_supply + : artistCoin.totalSupply + const marketCap = isAudio + ? coingeckoResponse?.market_data?.market_cap?.usd + : artistCoin.displayMarketCap + const price = isAudio + ? coingeckoResponse?.market_data?.current_price?.usd + : artistCoin.displayPrice + const liquidity = isAudio + ? coingeckoResponse?.market_data?.total_volume?.usd + : artistCoin.liquidity + + const formattedArtistEarnings = artistCoin.artistFees?.totalFees + ? formatFeeNumber(Math.trunc(artistCoin.artistFees.totalFees)) + : null + + const rewardsPoolBalance = + artistCoin.rewardPool?.balance != null + ? formatTokenAmount(artistCoin.rewardPool.balance, decimals) + : null + + const renderHeader = () => ( + + + + + ) return ( - - - {/* Token Info Section */} - + + + + {/* Token Info with avatar */} - {/* Token Icon */} - + - {artistCoin?.name} + {artistCoin.name} - ${artistCoin?.ticker} + {ticker} - - - - {/* Coin Address */} - {artistCoin?.mint ? ( - - - {artistCoinDetails.coinAddress} - - - {artistCoin.mint} - - - ) : null} - - {/* On-Chain Description */} - {artistCoin?.description ? ( - - - {artistCoinDetails.onChainDescription} - - - {artistCoin.description} - - - ) : null} - - {/* Token Details */} - - {( - isAudio - ? coingeckoResponse?.market_data?.total_supply - : artistCoin?.totalSupply - ) ? ( - - - {artistCoinDetails.totalSupply} - - - {isAudio - ? coingeckoResponse?.market_data?.total_supply?.toLocaleString() - : artistCoin?.totalSupply?.toLocaleString()} - - - ) : null} - {( - isAudio - ? coingeckoResponse?.market_data?.market_cap?.usd - : artistCoin?.marketCap - ) ? ( - - - {artistCoinDetails.marketCap} - - - $ - {isAudio - ? coingeckoResponse?.market_data?.market_cap?.usd?.toLocaleString() - : artistCoin?.marketCap?.toLocaleString()} - - - ) : null} + - {( - isAudio - ? coingeckoResponse?.market_data?.current_price?.usd - : artistCoin?.price - ) ? ( - - - {artistCoinDetails.price} - + {/* Coin Address */} + + + + + {/* On-Chain Description */} + {!isAudio && artist?.handle ? ( + - {formatCurrencyWithSubscript( - isAudio - ? (coingeckoResponse?.market_data?.current_price?.usd ?? 0) - : (artistCoin?.price ?? 0) + {LAUNCHPAD_COIN_DESCRIPTION( + artist.handle, + artistCoin.ticker ?? '' )} - + ) : null} - {( - isAudio - ? coingeckoResponse?.market_data?.total_volume?.usd - : artistCoin?.liquidity - ) ? ( - - - {artistCoinDetails.liquidity} - - - $ - {isAudio - ? coingeckoResponse?.market_data?.total_volume?.usd?.toLocaleString() - : artistCoin?.liquidity?.toLocaleString()} - - - ) : null} + - {artistCoin?.circulatingSupply ? ( - - - {artistCoinDetails.circulatingSupply} - - - {artistCoin.circulatingSupply.toLocaleString()} - - + {/* Market Stats */} + + + + {totalSupply.toLocaleString()} + + + ) : null + } + right={ + marketCap != null ? ( + + + ${marketCap.toLocaleString()} + + + ) : null + } + /> + + + {formatCurrencyWithSubscript(price)} + + + ) : null + } + right={ + liquidity != null ? ( + + + ${liquidity.toLocaleString()} + + + ) : null + } + /> + + + {/* Vesting / Locker Stats - Non-wAUDIO only */} + {!isAudio ? ( + <> + + + {/* Unlock Schedule */} + + + {overflowMenu.vestingScheduleValue} + + + + {/* Artist Earnings */} + {formattedArtistEarnings ? ( + + + {formattedArtistEarnings} {overflowMenu.$audio} + + + ) : null} + + {/* Locked / Unlocked */} + {showLockerStats && locker ? ( + <> + + + {formatTokenAmount(locker.locked ?? 0, decimals)}{' '} + {ticker} + + + + + {formatTokenAmount(locker.unlocked ?? 0, decimals)}{' '} + {ticker} + + + + ) : null} + + {/* Reward Pool */} + {rewardsPoolBalance != null ? ( + + + {rewardsPoolBalance} {ticker} + + + ) : null} + + {/* Rewards Pool Address */} + {artistCoin.rewardPool?.address ? ( + + + + ) : null} + + ) : null} - - {/* Close Button */} - - + {/* Close Button */} + + + + + ) } diff --git a/packages/mobile/src/components/user-link/UserLink.tsx b/packages/mobile/src/components/user-link/UserLink.tsx index 18c3dcb7d6e..044a5a22e26 100644 --- a/packages/mobile/src/components/user-link/UserLink.tsx +++ b/packages/mobile/src/components/user-link/UserLink.tsx @@ -26,6 +26,7 @@ type UserLinkProps = Omit, 'to' | 'children'> & { textLinkStyle?: StyleProp disabled?: boolean hideArtistCoinBadge?: boolean + mint?: string } export const UserLink = (props: UserLinkProps) => { @@ -36,6 +37,7 @@ export const UserLink = (props: UserLinkProps) => { textLinkStyle, disabled, hideArtistCoinBadge, + mint, ...other } = props const navigation = useNavigation() @@ -90,6 +92,7 @@ export const UserLink = (props: UserLinkProps) => { diff --git a/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinExploreCard.tsx b/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinExploreCard.tsx new file mode 100644 index 00000000000..0191c1d34ff --- /dev/null +++ b/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinExploreCard.tsx @@ -0,0 +1,286 @@ +import { useEffect, useMemo, useState } from 'react' + +import type { Coin } from '@audius/common/adapters' +import { useUser } from '@audius/common/api' +import { walletMessages } from '@audius/common/messages' +import type { ID } from '@audius/common/models' +import { WidthSizes } from '@audius/common/models' +import { formatCount } from '@audius/common/utils' +import type { ImageSourcePropType } from 'react-native' +import { Image, StyleSheet, TouchableOpacity, View } from 'react-native' + +import { + Divider, + Flex, + Paper, + Skeleton, + Text, + spacing as harmonySpacing +} from '@audius/harmony-native' +import { ProfilePicture, TokenIcon } from 'app/components/core' +import { useCoverPhoto } from 'app/components/image/CoverPhoto' +import { primitiveToImageSource } from 'app/components/image/primitiveToImageSource' +import { UserLink } from 'app/components/user-link' +import { useThemeColors } from 'app/utils/theme' + +const COVER_HEIGHT = 96 +const AVATAR_OVERLAP = -harmonySpacing.unit9 + +type ArtistCoinExploreCardProps = { + coin: Coin + onPress: () => void +} + +const resolveDisplaySource = ( + bannerTrim: string, + ownerCoverSource: ImageSourcePropType | undefined +): ImageSourcePropType | undefined => { + if (bannerTrim) { + return primitiveToImageSource(bannerTrim) + } + if (!ownerCoverSource) { + return undefined + } + if (typeof ownerCoverSource === 'number') { + return ownerCoverSource + } + if (typeof ownerCoverSource === 'object' && ownerCoverSource !== null) { + if ( + 'uri' in ownerCoverSource && + ownerCoverSource.uri != null && + String(ownerCoverSource.uri).length > 0 + ) { + return ownerCoverSource + } + return undefined + } + return ownerCoverSource +} + +const sourceTrackKey = ( + bannerTrim: string, + ownerCoverSource: ImageSourcePropType | undefined +) => { + if (bannerTrim) { + return `b:${bannerTrim}` + } + if (typeof ownerCoverSource === 'number') { + return `n:${ownerCoverSource}` + } + if ( + typeof ownerCoverSource === 'object' && + ownerCoverSource !== null && + 'uri' in ownerCoverSource && + ownerCoverSource.uri != null + ) { + return `c:${String(ownerCoverSource.uri)}` + } + return '' +} + +/** + * Isolated per `mint` via parent `key` so FlashList recycling does not mix + * cover hooks between rows. Shimmers until the banner or owner cover loads. + */ +const ArtistCoinExploreCardCover = ({ + bannerImageUrl, + ownerId +}: { + bannerImageUrl?: string + ownerId: ID +}) => { + const { borderDefault, neutralLight8 } = useThemeColors() + const { source: ownerCoverSource } = useCoverPhoto({ + userId: ownerId, + size: WidthSizes.SIZE_640 + }) + const { isPending: isUserPending } = useUser(ownerId) + + const bannerTrim = bannerImageUrl?.trim() ?? '' + const displaySource = useMemo( + () => resolveDisplaySource(bannerTrim, ownerCoverSource), + [bannerTrim, ownerCoverSource] + ) + + const trackKey = useMemo( + () => sourceTrackKey(bannerTrim, ownerCoverSource), + [bannerTrim, ownerCoverSource] + ) + + const [imageReady, setImageReady] = useState(false) + + useEffect(() => { + setImageReady(false) + }, [trackKey]) + + useEffect(() => { + if (typeof displaySource === 'number') { + setImageReady(true) + } + }, [displaySource]) + + useEffect(() => { + if (!trackKey && !isUserPending) { + setImageReady(true) + } + }, [trackKey, isUserPending]) + + const waitingOnRemoteImage = + displaySource != null && typeof displaySource !== 'number' + + const showShimmer = + !imageReady && (waitingOnRemoteImage || (!bannerTrim && isUserPending)) + + return ( + + {showShimmer ? : null} + {displaySource ? ( + { + setImageReady(true) + }} + onError={() => { + setImageReady(true) + }} + /> + ) : null} + + ) +} + +export const ArtistCoinExploreCard = ({ + coin, + onPress +}: ArtistCoinExploreCardProps) => { + const ownerId = coin.ownerId + + const fanClubLabel = walletMessages.artistCoins.fanClubLabel + const membersLabel = walletMessages.artistCoins.members + const marketCapLabel = walletMessages.artistCoins.marketCap + + const membersDisplay = + coin.holder != null && !Number.isNaN(coin.holder) + ? coin.holder.toLocaleString('en-US') + : '—' + + const marketCapDisplay = `$${formatCount(coin.displayMarketCap ?? 0, 2)}` + + return ( + + + + + + + + + + + + {fanClubLabel} + + + + + + + + + + + {walletMessages.poweredBy} + + + {coin.ticker ?? coin.name} + + + + + + + + + + {membersLabel} + + + {membersDisplay} + + + + + {marketCapLabel} + + + {marketCapDisplay} + + + + + + + + ) +} diff --git a/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinsExploreScreen.tsx b/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinsExploreScreen.tsx index f0e37c82382..0e393e2955b 100644 --- a/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinsExploreScreen.tsx +++ b/packages/mobile/src/screens/artist-coins-explore-screen/ArtistCoinsExploreScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import type { Coin } from '@audius/common/adapters' import { @@ -10,7 +10,18 @@ import { walletMessages } from '@audius/common/messages' import { useRoute } from '@react-navigation/native' import type { ListRenderItem } from '@shopify/flash-list' import { FlashList } from '@shopify/flash-list' -import { ImageBackground, TouchableOpacity } from 'react-native' +import { + ImageBackground, + TouchableOpacity, + useWindowDimensions, + View +} from 'react-native' +import { + TabView, + TabBar, + type Route as TabRoute, + type SceneRendererProps +} from 'react-native-tab-view' import { useDebounce } from 'react-use' import { @@ -26,20 +37,23 @@ import { useTheme } from '@audius/harmony-native' import imageSearchHeaderBackground from 'app/assets/images/imageCoinsBackgroundImage.webp' +import { GradientText, Screen, TokenIcon } from 'app/components/core' import { PlayBarChin } from 'app/components/core/PlayBarChin' import { UserLink } from 'app/components/user-link' import { useNavigation } from 'app/hooks/useNavigation' import { useStatusBarStyle } from 'app/hooks/useStatusBarStyle' import { env } from 'app/services/env' +import { makeStyles } from 'app/styles' +import { useThemeColors } from 'app/utils/theme' -import { - GradientText, - TokenIcon, - Screen, - VirtualizedScrollView -} from '../../components/core' +import { ArtistCoinExploreCard } from './ArtistCoinExploreCard' + +const COIN_ROW_HEIGHT = 56 +/** FlashList: slightly conservative height avoids mis-recycling tall cards when scrolling. */ +const COIN_CARD_ESTIMATED_HEIGHT = 400 +const EXPLORE_PAGE_SIZE = 12 -const COIN_ROW_HEIGHT = 50 // Estimated height for FlashList optimization +const COIN_ROW_MESSAGES = walletMessages.artistCoins type CoinRowProps = { coin: Coin @@ -53,7 +67,6 @@ const CoinRow = ({ coin, onPress }: CoinRowProps) => { - @@ -69,6 +82,7 @@ const CoinRow = ({ coin, onPress }: CoinRowProps) => { size='xs' badgeSize='2xs' hideArtistCoinBadge + mint={coin.mint} /> @@ -81,10 +95,10 @@ const NoCoinsContent = () => { - {walletMessages.artistCoins.noCoins} + {COIN_ROW_MESSAGES.noCoins} - {walletMessages.artistCoins.noCoinsDescription} + {COIN_ROW_MESSAGES.noCoinsDescription} ) @@ -98,6 +112,8 @@ const Header = ({ setSearchValue: (value: string) => void }) => { const navigation = useNavigation() + const { focus, backgroundSurface2 } = useThemeColors() + return ( @@ -106,11 +122,19 @@ const Header = ({ color='staticWhite' onPress={() => navigation.goBack()} /> - + ({ + tabBar: { + backgroundColor: palette.white, + height: spacing(10), + elevation: 0, + shadowOpacity: 0, + borderBottomWidth: 1, + borderBottomColor: palette.borderDefault + }, + tabIndicator: { + backgroundColor: palette.focus, + borderBottomLeftRadius: 20, + borderBottomRightRadius: 20, + height: 3, + bottom: -3 + } +})) + +const tabRoutes: TabRoute[] = [ + { key: 'cards', title: COIN_ROW_MESSAGES.cardView }, + { key: 'leaderboard', title: COIN_ROW_MESSAGES.leaderboardView } +] + export const ArtistCoinsExploreScreen = () => { const { typography } = useTheme() const route = useRoute() const navigation = useNavigation() + const layout = useWindowDimensions() + const tabStyles = useTabStyles() + const { textIconSubdued, neutral, backgroundSurface2 } = useThemeColors() + const [searchValue, setSearchValue] = useState('') const [debouncedSearchValue, setDebouncedSearchValue] = useState('') + const [tabIndex, setTabIndex] = useState(0) const [sortMethod, setSortMethod] = useState( GetCoinsSortMethodEnum.MarketCap ) @@ -135,10 +187,8 @@ export const ArtistCoinsExploreScreen = () => { GetCoinsSortDirectionEnum.Desc ) - // Set status bar to light content for dark header useStatusBarStyle('light-content') - // Debounce search value to avoid excessive API calls useDebounce( () => { setDebouncedSearchValue(searchValue) @@ -155,14 +205,16 @@ export const ArtistCoinsExploreScreen = () => { hasNextPage, isFetchingNextPage } = useArtistCoins({ + pageSize: EXPLORE_PAGE_SIZE, sortMethod, sortDirection, query: debouncedSearchValue }) - // Filter out WAUDIO - const allCoins = - coins?.filter((coin) => coin.mint !== env.WAUDIO_MINT_ADDRESS) ?? [] + const allCoins = useMemo( + () => coins?.filter((coin) => coin.mint !== env.WAUDIO_MINT_ADDRESS) ?? [], + [coins] + ) const handleCoinPress = useCallback( (ticker?: string) => { @@ -187,7 +239,10 @@ export const ArtistCoinsExploreScreen = () => { }, [isFetchingNextPage, hasNextPage, fetchNextPage]) useEffect(() => { - const routeParams = route.params as any + const routeParams = route.params as { + sortMethod?: GetCoinsSortMethodEnum + sortDirection?: GetCoinsSortDirectionEnum + } if (routeParams?.sortMethod) { setSortMethod(routeParams.sortMethod) } @@ -206,6 +261,16 @@ export const ArtistCoinsExploreScreen = () => { [handleCoinPress] ) + const renderCoinCard: ListRenderItem = useCallback( + ({ item }) => ( + handleCoinPress(item.ticker)} + /> + ), + [handleCoinPress] + ) + const keyExtractor = useCallback((coin: Coin) => coin.mint, []) const renderFooter = useCallback(() => { @@ -218,68 +283,174 @@ export const ArtistCoinsExploreScreen = () => { } return null }, [isFetchingNextPage]) - return ( - ( -
- )} - > - - ( + + ), + [tabStyles.tabBar, tabStyles.tabIndicator, neutral, textIconSubdued] + ) + + const tabCommonOptions = useMemo( + () => ({ + label: ({ + focused, + route: tabRoute + }: { + focused: boolean + route: TabRoute + }) => ( + - - - {walletMessages.artistCoins.title} - - + {tabRoute.title} + + ) + }), + [] + ) + + const renderScene = useCallback( + ({ route: sceneRoute }: SceneRendererProps & { route: TabRoute }) => { + if (sceneRoute.key === 'cards') { + return ( + + {isPending ? ( + + + ) : shouldShowNoCoinsContent ? ( + + ) : ( + } + contentContainerStyle={{ + paddingHorizontal: 16, + paddingVertical: 16 + }} + /> + )} + + ) + } + + return ( + + + + - + + {COIN_ROW_MESSAGES.title} + + + + + + - - - - {isPending ? ( - - + + {isPending ? ( + + + + ) : shouldShowNoCoinsContent ? ( + + ) : ( + + )} - ) : shouldShowNoCoinsContent ? ( - - ) : ( - - )} - - + + + ) + }, + [ + allCoins, + backgroundSurface2, + handleLoadMore, + handleSortPress, + isPending, + keyExtractor, + renderCoinCard, + renderCoinRow, + renderFooter, + shouldShowNoCoinsContent, + typography.fontByWeight.bold, + typography.size.l + ] + ) + + return ( + ( +
+ )} + > + + + ) diff --git a/packages/mobile/src/screens/coin-details-screen/CoinDetailsScreen.tsx b/packages/mobile/src/screens/coin-details-screen/CoinDetailsScreen.tsx index e5a633dd904..bb1aa8ab8d1 100644 --- a/packages/mobile/src/screens/coin-details-screen/CoinDetailsScreen.tsx +++ b/packages/mobile/src/screens/coin-details-screen/CoinDetailsScreen.tsx @@ -1,26 +1,76 @@ +import { useState, useCallback, useMemo } from 'react' + import { useArtistCoinByTicker } from '@audius/common/api' import { route } from '@audius/common/utils' import { useRoute } from '@react-navigation/native' +import { useWindowDimensions } from 'react-native' +import { + TabView, + TabBar, + type Route as TabRoute, + type SceneRendererProps +} from 'react-native-tab-view' -import { Flex, IconButton, IconKebabHorizontal } from '@audius/harmony-native' +import { + Flex, + IconButton, + IconKebabHorizontal, + Text +} from '@audius/harmony-native' import { Screen, ScreenContent, ScrollView } from 'app/components/core' import { useDrawer } from 'app/hooks/useDrawer' +import { makeStyles } from 'app/styles' +import { useThemeColors } from 'app/utils/theme' + +import { CoinTab } from './components/CoinTab' +import { FanClubTab } from './components/FanClubTab' + +const messages = { + fanClub: 'Fan Club', + coin: 'Coin' +} -import { BalanceCard } from './components/BalanceCard' -import { CoinInfoCard } from './components/CoinInfoCard' -import { CoinInsightsCard } from './components/CoinInsightsCard' -import { CoinLeaderboardCard } from './components/CoinLeaderboardCard' -import { ExclusiveTracksSection } from './components/ExclusiveTracksSection' +const useStyles = makeStyles(({ palette, spacing }) => ({ + tabBar: { + backgroundColor: palette.white, + height: spacing(10), + elevation: 0, + shadowOpacity: 0, + borderBottomWidth: 1, + borderBottomColor: palette.borderDefault + }, + tabIndicator: { + backgroundColor: palette.focus, + borderBottomLeftRadius: 20, + borderBottomRightRadius: 20, + height: 3, + bottom: -3 + } +})) + +const tabRoutes: TabRoute[] = [ + { key: 'fanClub', title: messages.fanClub }, + { key: 'coin', title: messages.coin } +] export const CoinDetailsScreen = () => { const { ticker } = useRoute().params as { ticker: string } const { data: coin } = useArtistCoinByTicker({ ticker }) const { onOpen } = useDrawer('CoinInsightsOverflowMenu') const mint = coin?.mint ?? '' + const layout = useWindowDimensions() + const styles = useStyles() + const { textIconSubdued, neutral } = useThemeColors() - const handleOpenOverflowMenu = () => { + const [tabIndex, setTabIndex] = useState(0) + + const handleOpenOverflowMenu = useCallback(() => { onOpen({ mint }) - } + }, [onOpen, mint]) + + const handleSwitchToCoinTab = useCallback(() => { + setTabIndex(1) + }, []) const topbarRight = ( { /> ) + const coinName = coin?.name ?? (ticker ? `$${ticker}` : 'Coin Details') + + const renderScene = useCallback( + ({ route: tabRoute }: SceneRendererProps & { route: TabRoute }) => { + switch (tabRoute.key) { + case 'fanClub': + return ( + + + + + + ) + case 'coin': + return ( + + + + + + ) + default: + return null + } + }, + [mint, handleSwitchToCoinTab] + ) + + const tabCommonOptions = useMemo( + () => ({ + label: ({ + focused, + route: tabRoute + }: { + focused: boolean + route: TabRoute + }) => ( + + {tabRoute.title} + + ) + }), + [] + ) + + const renderTabBar = useCallback( + (props: any) => ( + + ), + [styles.tabBar, styles.tabIndicator, neutral, textIconSubdued] + ) + return ( - - - - - - - - - + ) diff --git a/packages/mobile/src/screens/coin-details-screen/components/CoinInsightsCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/CoinInsightsCard.tsx index c61acaeb6c8..f65491db62a 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/CoinInsightsCard.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/CoinInsightsCard.tsx @@ -1,27 +1,35 @@ +import { useCallback } from 'react' + import type { Coin } from '@audius/common/adapters' import { useArtistCoin, useCoinGeckoCoin } from '@audius/common/api' import { coinDetailsMessages } from '@audius/common/messages' import { createAudioCoinMetrics, createCoinMetrics, + shortenSPLAddress, type MetricData } from '@audius/common/utils' +import Clipboard from '@react-native-clipboard/clipboard' import { Flex, IconCaretDown, IconCaretUp, + IconCopy, Paper, + PlainButton, Text, spacing } from '@audius/harmony-native' import { TooltipInfoIcon } from 'app/components/buy-sell/TooltipInfoIcon' +import { useToast } from 'app/hooks/useToast' import { env } from 'app/services/env' import { isIos } from 'app/utils/os' import { GraduationProgressBar } from './GraduationProgressBar' const messages = coinDetailsMessages.coinInsights +const overflowMessages = coinDetailsMessages.overflowMenu const GraduatedPill = () => { return ( @@ -152,6 +160,34 @@ const MetricRow = ({ metric, coin }: { metric: MetricData; coin?: Coin }) => { ) } +const InsightsCopyMintRow = ({ mint }: { mint: string }) => { + const { toast } = useToast() + + const handleCopyAddress = useCallback(() => { + Clipboard.setString(mint) + toast({ content: overflowMessages.copiedToClipboard, type: 'info' }) + }, [mint, toast]) + + return ( + + + {overflowMessages.copyCoinAddress} + + + {shortenSPLAddress(mint)} + + + ) +} + export const CoinInsightsCard = ({ mint }: { mint: string }) => { const isAudio = mint === env.WAUDIO_MINT_ADDRESS const { @@ -198,7 +234,7 @@ export const CoinInsightsCard = ({ mint }: { mint: string }) => { - {isError || !coin ? ( + {isError ? ( {messages.unableToLoad} @@ -209,6 +245,7 @@ export const CoinInsightsCard = ({ mint }: { mint: string }) => { )) )} + {mint ? : null} ) } diff --git a/packages/mobile/src/screens/coin-details-screen/components/CoinLeaderboardCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/CoinLeaderboardCard.tsx index 2888e7d5b7d..bdcef34c58e 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/CoinLeaderboardCard.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/CoinLeaderboardCard.tsx @@ -38,7 +38,14 @@ export const CoinLeaderboardCard = ({ mint }: { mint: string }) => { }) }, [mint, navigation]) - if (!mint || !users?.length) return null + if (!mint) { + return null + } + + const memberCount = leaderboardUsers?.length ?? 0 + if (!isLeaderboardPending && memberCount === 0) { + return null + } return ( { w='100%' > {isPending ? ( - + ) : ( diff --git a/packages/mobile/src/screens/coin-details-screen/components/CoinTab.tsx b/packages/mobile/src/screens/coin-details-screen/components/CoinTab.tsx new file mode 100644 index 00000000000..46f3942538e --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/CoinTab.tsx @@ -0,0 +1,180 @@ +import type { ReactNode } from 'react' + +import type { Coin } from '@audius/common/adapters' +import { useArtistCoin, useCurrentUserId } from '@audius/common/api' +import { coinDetailsMessages } from '@audius/common/messages' +import { + getTokenDecimalPlaces, + formatCurrencyWithSubscript +} from '@audius/common/utils' +import { FixedDecimal, wAUDIO } from '@audius/fixed-decimal' + +import { Divider, Flex, Paper, Text } from '@audius/harmony-native' +import { TooltipInfoIcon } from 'app/components/buy-sell/TooltipInfoIcon' +import { env } from 'app/services/env' + +import { BalanceCard } from './BalanceCard' +import { CoinInsightsCard } from './CoinInsightsCard' + +const overflowMessages = coinDetailsMessages.overflowMenu + +type CoinTabProps = { + mint: string +} + +const formatTokenAmount = (balance: number, coinDecimals: number) => { + const decimals = getTokenDecimalPlaces(balance) + const maxFractionDigits = Math.min(decimals, coinDecimals) + return new FixedDecimal(BigInt(balance), coinDecimals).toLocaleString( + 'en-US', + { + maximumFractionDigits: maxFractionDigits + } + ) +} + +const formatFeeNumber = (input: number) => { + const value = wAUDIO(BigInt(input)) + const decimalPlaces = getTokenDecimalPlaces(Number(value.toString())) + return formatCurrencyWithSubscript( + Number(value.trunc(decimalPlaces).toString()), + 'en-US', + '' + ) +} + +const CoinDetailRow = ({ + label, + tooltip, + children +}: { + label: string + tooltip?: string + children: ReactNode +}) => ( + + + + {label} + + {tooltip ? : null} + + {children} + +) + +const CoinDetailsSection = ({ coin }: { coin: Coin }) => { + const isAudio = coin.mint === env.WAUDIO_MINT_ADDRESS + if (isAudio) return null + + const hasGraduated = coin.dynamicBondingCurve?.isMigrated ?? false + const locker = coin.artistLocker + const showLockerStats = hasGraduated && !!locker + const decimals = coin.decimals ?? 9 + const ticker = coin.ticker ? `$${coin.ticker}` : '' + + const formattedArtistEarnings = coin.artistFees?.totalFees + ? formatFeeNumber(Math.trunc(coin.artistFees.totalFees)) + : null + + const rewardsPoolBalance = + coin.rewardPool?.balance != null + ? formatTokenAmount(coin.rewardPool.balance, decimals) + : null + + return ( + + + + Coin Details + + + + + + {/* Artist Earnings */} + {formattedArtistEarnings ? ( + + + {formattedArtistEarnings} {overflowMessages.$audio} + + + ) : null} + + {/* Unlock Schedule */} + + + {overflowMessages.vestingScheduleValue} + + + + {/* Locked / Unlocked */} + {showLockerStats && locker ? ( + <> + + + {formatTokenAmount(locker.locked ?? 0, decimals)} {ticker} + + + + + + {formatTokenAmount(locker.unlocked ?? 0, decimals)} {ticker} + + + + ) : null} + + {/* Reward Pool */} + {rewardsPoolBalance != null ? ( + + + {rewardsPoolBalance} {ticker} + + + ) : null} + + + ) +} + +export const CoinTab = ({ mint }: CoinTabProps) => { + const { data: coin } = useArtistCoin(mint) + const { data: currentUserId } = useCurrentUserId() + const isOwner = currentUserId === coin?.ownerId + + return ( + + {/* Balance section */} + + + {/* Insights section (includes copy mint row) */} + + + {/* Coin Details - Owner only */} + {isOwner && coin ? : null} + + ) +} diff --git a/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx new file mode 100644 index 00000000000..f8104fff461 --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx @@ -0,0 +1,390 @@ +import { useCallback, useMemo } from 'react' + +import { + useArtistCoin, + useCoinBalance, + useCurrentUserId, + useExclusiveTracks, + useExclusiveTracksCount +} from '@audius/common/api' +import { useBuySellInitialTab, useIsManagedAccount } from '@audius/common/hooks' +import { coinDetailsMessages, walletMessages } from '@audius/common/messages' +import { WidthSizes } from '@audius/common/models' +import { + exclusiveTracksPageLineupActions as exclusiveTracksActions, + receiveTokensModalActions +} from '@audius/common/store' +import { Image, StyleSheet, TouchableOpacity, View } from 'react-native' +import { useDispatch } from 'react-redux' + +import { + Button, + Flex, + IconCaretRight, + IconCloudUpload, + LoadingSpinner, + Paper, + Text, + spacing as harmonySpacing +} from '@audius/harmony-native' +import { ProfilePicture, TokenIcon } from 'app/components/core' +import { useCoverPhoto } from 'app/components/image/CoverPhoto' +import { primitiveToImageSource } from 'app/components/image/primitiveToImageSource' +import { TanQueryLineup } from 'app/components/lineup/TanQueryLineup' +import { UserLink } from 'app/components/user-link' +import { useNavigation } from 'app/hooks/useNavigation' +import { useThemeColors } from 'app/utils/theme' + +import { CoinLeaderboardCard } from './CoinLeaderboardCard' + +const FAN_CLUB_COVER_HEIGHT = 96 +const FAN_CLUB_AVATAR_OVERLAP = -harmonySpacing.unit9 + +const messages = { + uploadExclusiveTrack: coinDetailsMessages.coinInfo.uploadExclusiveTrack, + becomeAMember: coinDetailsMessages.balance.becomeAMember, + hintDescription: coinDetailsMessages.balance.hintDescription, + fanClubFeed: 'Fan Club Feed' +} + +const MAX_PREVIEW_TRACKS = 3 + +const itemStyles = { + paddingHorizontal: 0 +} + +type FanClubTabProps = { + mint: string + onSwitchToCoinTab: () => void +} + +const BecomeAMemberCard = ({ + ticker, + mint, + coinTicker +}: { + ticker: string + mint: string + coinTicker?: string +}) => { + const dispatch = useDispatch() + const navigation = useNavigation() + const isManagerMode = useIsManagedAccount() + const initialTab = useBuySellInitialTab() + + const handleBuy = useCallback(() => { + navigation.navigate('BuySell', { + initialTab, + coinTicker + }) + }, [navigation, initialTab, coinTicker]) + + const handleReceive = useCallback(() => { + dispatch(receiveTokensModalActions.open({ mint, isOpen: true })) + }, [dispatch, mint]) + + return ( + + + + {messages.becomeAMember} + + {messages.hintDescription(ticker)} + + + + + + + ) +} + +type FanClubHeroTileProps = { + mint: string + onPoweredByPress: () => void +} + +const FanClubHeroTile = ({ mint, onPoweredByPress }: FanClubHeroTileProps) => { + const { borderDefault } = useThemeColors() + const { data: coin, isLoading } = useArtistCoin(mint) + const ownerId = coin?.ownerId + + const { source: coverPhotoSource } = useCoverPhoto({ + userId: ownerId, + size: WidthSizes.SIZE_640 + }) + + const bannerImageSource = useMemo(() => { + if (coin?.bannerImageUrl) { + return primitiveToImageSource(coin.bannerImageUrl) + } + return coverPhotoSource + }, [coin?.bannerImageUrl, coverPhotoSource]) + + if (isLoading || !coin || !ownerId) { + return null + } + + const fanClubLabel = walletMessages.artistCoins.fanClubLabel + + return ( + + + {bannerImageSource ? ( + + ) : null} + + + + + + + + {fanClubLabel} + + + + + + + + + + + {walletMessages.poweredBy} + + + {coin.ticker ?? coin.name} + + + + + + + {coin.description ? ( + + {coin.description} + + ) : null} + + + ) +} + +const FanClubFeed = ({ + mint, + forMemberView +}: { + mint: string + forMemberView?: boolean +}) => { + const { data: coin } = useArtistCoin(mint) + const ownerId = coin?.ownerId + + const { data, lineup, pageSize, isFetching, loadNextPage, isPending } = + useExclusiveTracks({ + userId: ownerId, + pageSize: MAX_PREVIEW_TRACKS + }) + + const { data: totalCount = 0, isPending: isCountPending } = + useExclusiveTracksCount({ + userId: ownerId + }) + + if (!ownerId) { + return null + } + + if (forMemberView) { + if (isCountPending) { + return ( + + + + {messages.fanClubFeed} + + + + + + + ) + } + if (totalCount === 0) { + return null + } + } else if (totalCount === 0) { + return null + } + + return ( + + + + {messages.fanClubFeed} + + {totalCount > 0 ? ( + + ({totalCount}) + + ) : null} + + + + ) +} + +export const FanClubTab = ({ mint, onSwitchToCoinTab }: FanClubTabProps) => { + const { data: coin } = useArtistCoin(mint) + const { data: currentUserId, isPending: isCurrentUserPending } = + useCurrentUserId() + const { data: tokenBalance, isPending: isBalancePending } = useCoinBalance({ + mint + }) + const navigation = useNavigation() + + const isOwner = + !isCurrentUserPending && + currentUserId != null && + coin != null && + currentUserId === coin.ownerId + + const hasBalance = + !isBalancePending && + !!(tokenBalance?.balance && Number(tokenBalance.balance.toString()) > 0) + + const membershipKnown = + coin != null && !isCurrentUserPending && (isOwner || !isBalancePending) + + const isMemberOrOwner = isOwner || hasBalance + + const handleUploadExclusive = useCallback(() => { + navigation.navigate('Upload', {}) + }, [navigation]) + + if (!coin) return null + + const ticker = coin.ticker ?? '' + + return ( + + + + {/* Upload Exclusive Track - Artist only */} + {isOwner ? ( + + ) : null} + + {/* Membership CTA / leaderboard: wait until account + balance are known to avoid CLS */} + {!membershipKnown ? ( + + + + ) : !isMemberOrOwner ? ( + + ) : null} + + {membershipKnown && isMemberOrOwner ? ( + + ) : null} + + {membershipKnown ? ( + + ) : null} + + ) +}