diff --git a/package-lock.json b/package-lock.json index b6b6292ce57..636d2f9b676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5770,6 +5770,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -48774,13 +48775,6 @@ "node": ">=0.4.0" } }, - "node_modules/add-line-numbers": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "pad-left": "^1.0.2" - } - }, "node_modules/add-px-to-style": { "version": "1.0.0", "license": "MIT" @@ -50079,10 +50073,6 @@ "dtype": "^1.0.0" } }, - "node_modules/array-range": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/array-union": { "version": "2.1.0", "license": "MIT", @@ -50458,10 +50448,6 @@ "node": ">= 4.5.0" } }, - "node_modules/atob-lite": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "license": "MIT", @@ -52673,10 +52659,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/bit-twiddle": { - "version": "1.0.2", - "license": "MIT" - }, "node_modules/bl": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -54221,25 +54203,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camera-picking-ray": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "camera-unproject": "^1.0.1", - "gl-vec3": "^1.0.3" - } - }, - "node_modules/camera-project": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "gl-vec4": "^1.0.1" - } - }, - "node_modules/camera-unproject": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/can-use-dom": { "version": "0.1.0", "license": "MIT" @@ -54279,21 +54242,6 @@ "version": "2.0.0", "license": "Apache-2.0" }, - "node_modules/canvas-fit": { - "version": "1.5.0", - "license": "MIT", - "dependencies": { - "element-size": "^1.1.1" - } - }, - "node_modules/canvas-loop": { - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "canvas-fit": "^1.4.0", - "raf-loop": "^1.1.0" - } - }, "node_modules/capital-case": { "version": "1.0.4", "dev": true, @@ -56991,13 +56939,6 @@ "node": ">=6" } }, - "node_modules/cwise-compiler": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "uniq": "^1.0.0" - } - }, "node_modules/cyclist": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.2.tgz", @@ -58412,13 +58353,6 @@ "node": ">=0.10.0" } }, - "node_modules/defined": { - "version": "1.0.1", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/defu": { "version": "6.1.4", "license": "MIT" @@ -59225,10 +59159,6 @@ "version": "2.1.0-0", "license": "MIT" }, - "node_modules/dprop": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/drbg.js": { "version": "1.0.1", "license": "MIT", @@ -59272,10 +59202,6 @@ "node": ">= 0.4" } }, - "node_modules/dup": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/duplexer": { "version": "0.1.2", "dev": true, @@ -59890,10 +59816,6 @@ "node": ">= 10.0.0" } }, - "node_modules/element-size": { - "version": "1.1.1", - "license": "MIT" - }, "node_modules/elliptic": { "version": "6.6.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", @@ -65835,10 +65757,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-canvas-context": { - "version": "1.0.2", - "license": "MIT" - }, "node_modules/get-func-name": { "version": "2.0.2", "dev": true, @@ -66119,103 +66037,6 @@ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "license": "ISC" }, - "node_modules/gl-audio-analyser": { - "version": "1.0.3", - "license": "MIT", - "dependencies": { - "gl-texture2d": "^2.0.8", - "ndarray": "^1.0.16", - "web-audio-analyser": "^2.0.0" - } - }, - "node_modules/gl-buffer": { - "version": "2.1.2", - "license": "MIT", - "dependencies": { - "ndarray": "^1.0.15", - "ndarray-ops": "^1.1.0", - "typedarray-pool": "^1.0.0" - } - }, - "node_modules/gl-constants": { - "version": "1.0.0", - "license": "MIT" - }, - "node_modules/gl-format-compiler-error": { - "version": "1.0.3", - "license": "Unlicense", - "dependencies": { - "add-line-numbers": "^1.0.1", - "gl-constants": "^1.0.0", - "glsl-shader-name": "^1.0.0", - "sprintf-js": "^1.0.3" - } - }, - "node_modules/gl-mat3": { - "version": "1.0.0", - "license": "zlib" - }, - "node_modules/gl-mat4": { - "version": "1.2.0", - "license": "Zlib" - }, - "node_modules/gl-quad": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "gl-buffer": "^2.0.8", - "gl-vao": "^1.1.3" - } - }, - "node_modules/gl-quat": { - "version": "1.0.0", - "license": "Zlib", - "dependencies": { - "gl-mat3": "^1.0.0", - "gl-vec3": "^1.0.3", - "gl-vec4": "^1.0.0" - } - }, - "node_modules/gl-shader": { - "version": "4.2.1", - "license": "MIT", - "dependencies": { - "gl-format-compiler-error": "^1.0.2", - "weakmap-shim": "^1.1.0" - } - }, - "node_modules/gl-shader-core": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "dup": "^1.0.0" - } - }, - "node_modules/gl-texture2d": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "ndarray": "^1.0.15", - "ndarray-ops": "^1.2.2", - "typedarray-pool": "^1.1.0" - } - }, - "node_modules/gl-vao": { - "version": "1.3.0", - "license": "MIT" - }, - "node_modules/gl-vec2": { - "version": "1.3.0", - "license": "zlib" - }, - "node_modules/gl-vec3": { - "version": "1.1.3", - "license": "zlib" - }, - "node_modules/gl-vec4": { - "version": "1.0.1", - "license": "Zlib" - }, "node_modules/glob": { "version": "7.2.3", "license": "ISC", @@ -66477,14 +66298,6 @@ "glsl-tokenizer": "^2.0.2" } }, - "node_modules/glsl-noise": { - "version": "0.0.0", - "license": "MIT" - }, - "node_modules/glsl-random": { - "version": "0.0.4", - "license": "BSD-3-Clause" - }, "node_modules/glsl-resolve": { "version": "0.0.1", "dev": true, @@ -66506,14 +66319,6 @@ "node": ">=0.4" } }, - "node_modules/glsl-shader-name": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "atob-lite": "^1.0.0", - "glsl-tokenizer": "^2.0.2" - } - }, "node_modules/glsl-token-assignments": { "version": "2.0.2", "dev": true, @@ -66570,6 +66375,7 @@ }, "node_modules/glsl-tokenizer": { "version": "2.1.5", + "dev": true, "license": "MIT", "dependencies": { "through2": "^0.6.3" @@ -69546,13 +69352,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hex-rgb": { - "version": "1.0.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/hex-rgba": { "version": "1.0.2", "dev": true, @@ -70446,10 +70245,6 @@ "ios-deploy": "build/Release/ios-deploy" } }, - "node_modules/iota-array": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/ip": { "version": "1.1.8", "license": "MIT" @@ -75723,10 +75518,6 @@ "nanoevents": "^8.0.0" } }, - "node_modules/lerp": { - "version": "1.0.3", - "license": "MIT" - }, "node_modules/less": { "version": "4.2.0", "dev": true, @@ -83685,19 +83476,6 @@ "@motionone/vue": "^10.16.2" } }, - "node_modules/mouse-event-offset": { - "version": "3.0.2", - "license": "MIT" - }, - "node_modules/mouse-wheel": { - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "right-now": "^1.0.0", - "signum": "^1.0.0", - "to-px": "^1.0.1" - } - }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -84551,21 +84329,6 @@ "ncp": "bin/ncp" } }, - "node_modules/ndarray": { - "version": "1.0.19", - "license": "MIT", - "dependencies": { - "iota-array": "^1.0.0", - "is-buffer": "^1.0.2" - } - }, - "node_modules/ndarray-ops": { - "version": "1.2.2", - "license": "MIT", - "dependencies": { - "cwise-compiler": "^1.0.0" - } - }, "node_modules/needle": { "version": "3.2.0", "dev": true, @@ -87765,20 +87528,6 @@ "node": ">=8" } }, - "node_modules/orbit-controls": { - "version": "0.0.1", - "license": "MIT", - "dependencies": { - "clamp": "^1.0.1", - "defined": "^1.0.0", - "gl-quat": "^1.0.0", - "gl-vec3": "^1.0.3", - "mouse-event-offset": "^3.0.2", - "mouse-wheel": "^1.0.2", - "quat-from-unit-vec3": "^1.0.0", - "touch-pinch": "^1.0.0" - } - }, "node_modules/os-browserify": { "version": "0.3.0", "license": "MIT" @@ -88202,16 +87951,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/pad-left": { - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "repeat-string": "^1.3.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pako": { "version": "2.1.0", "license": "(MIT AND Zlib)" @@ -88404,10 +88143,6 @@ "node": ">=10" } }, - "node_modules/parse-unit": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/parse5": { "version": "7.1.2", "license": "MIT", @@ -88753,27 +88488,6 @@ "qs": "^6.11.2" } }, - "node_modules/perspective-camera": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "camera-picking-ray": "^1.0.0", - "camera-project": "^1.0.2", - "camera-unproject": "^1.0.1", - "defined": "^1.0.0", - "gl-mat4": "^1.1.3", - "gl-vec3": "^1.0.3", - "object-assign": "^2.0.0", - "ray-3d": "^1.0.2" - } - }, - "node_modules/perspective-camera/node_modules/object-assign": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pg": { "version": "8.8.0", "license": "MIT", @@ -91130,14 +90844,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/quat-from-unit-vec3": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "gl-quat": "^1.0.0", - "gl-vec3": "^1.0.3" - } - }, "node_modules/query-string": { "version": "8.1.0", "license": "MIT", @@ -91460,16 +91166,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/raf-loop": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "events": "^1.0.2", - "inherits": "^2.0.1", - "raf": "^3.0.0", - "right-now": "^1.0.0" - } - }, "node_modules/raf-schd": { "version": "4.0.3", "license": "MIT" @@ -91526,42 +91222,6 @@ "node": ">= 0.8" } }, - "node_modules/ray-3d": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "gl-vec3": "^1.0.3", - "ray-aabb-intersection": "^1.0.1", - "ray-plane-intersection": "^1.0.0", - "ray-sphere-intersection": "^1.0.0", - "ray-triangle-intersection": "^1.0.3" - } - }, - "node_modules/ray-aabb-intersection": { - "version": "1.0.1", - "license": "MIT" - }, - "node_modules/ray-plane-intersection": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "gl-vec3": "^1.0.3" - } - }, - "node_modules/ray-sphere-intersection": { - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "gl-vec3": "^1.0.3" - } - }, - "node_modules/ray-triangle-intersection": { - "version": "1.0.3", - "license": "MIT", - "dependencies": { - "gl-vec3": "^1.0.2" - } - }, "node_modules/rc": { "version": "1.2.8", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", @@ -96108,6 +95768,7 @@ }, "node_modules/repeat-string": { "version": "1.6.1", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10" @@ -96536,10 +96197,6 @@ "node": ">=0.10.0" } }, - "node_modules/right-now": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/right-pad": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/right-pad/-/right-pad-1.1.1.tgz", @@ -99488,10 +99145,6 @@ "version": "3.0.7", "license": "ISC" }, - "node_modules/signum": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/simple-concat": { "version": "1.0.1", "funding": [ @@ -103662,6 +103315,7 @@ }, "node_modules/through2": { "version": "0.6.5", + "dev": true, "license": "MIT", "dependencies": { "readable-stream": ">=1.0.33-1 <1.1.0-0", @@ -103670,10 +103324,12 @@ }, "node_modules/through2/node_modules/isarray": { "version": "0.0.1", + "dev": true, "license": "MIT" }, "node_modules/through2/node_modules/readable-stream": { "version": "1.0.34", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -103684,6 +103340,7 @@ }, "node_modules/through2/node_modules/string_decoder": { "version": "0.10.31", + "dev": true, "license": "MIT" }, "node_modules/tiktok-opensdk-react-native": { @@ -103932,13 +103589,6 @@ "node": ">=0.10.0" } }, - "node_modules/to-px": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "parse-unit": "^1.0.1" - } - }, "node_modules/to-readable-stream": { "version": "1.0.0", "license": "MIT", @@ -104199,16 +103849,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/touch-pinch": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "dprop": "^1.0.0", - "events": "^1.0.2", - "gl-vec2": "^1.0.0", - "mouse-event-offset": "^3.0.2" - } - }, "node_modules/touch/node_modules/nopt": { "version": "1.0.10", "dev": true, @@ -106731,14 +106371,6 @@ "version": "0.0.6", "license": "MIT" }, - "node_modules/typedarray-pool": { - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "bit-twiddle": "^1.0.0", - "dup": "^1.0.0" - } - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "license": "MIT", @@ -107336,10 +106968,6 @@ "node": ">=0.10.0" } }, - "node_modules/uniq": { - "version": "1.0.1", - "license": "MIT" - }, "node_modules/unique-filename": { "version": "3.0.0", "license": "ISC", @@ -110795,13 +110423,6 @@ "defaults": "^1.0.3" } }, - "node_modules/weakmap-shim": { - "version": "1.1.1" - }, - "node_modules/web-audio-analyser": { - "version": "2.0.1", - "license": "MIT" - }, "node_modules/web-encoding": { "version": "1.1.5", "dev": true, @@ -113223,13 +112844,6 @@ "integrity": "sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==", "license": "MPL-2.0" }, - "node_modules/webgl-context": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "get-canvas-context": "^1.0.1" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "dev": true, @@ -148438,12 +148052,10 @@ "@tanstack/react-query-devtools": "5.62.7", "@wagmi/connectors": "5.7.11", "array-pack-2d": "0.1.1", - "array-range": "1.0.1", "body-scroll-lock": "4.0.0-beta.0", "bs58": "5.0.0", - "butterchurn": "^2.6.7", - "butterchurn-presets": "^2.4.7", - "canvas-loop": "1.0.7", + "butterchurn": "2.6.7", + "butterchurn-presets": "2.4.7", "chart.js": "2.9.3", "clamp": "1.0.1", "classnames": "2.2.6", @@ -148453,23 +148065,12 @@ "electron-updater": "6.3.9", "exif-parser": "0.1.12", "formik": "2.4.6", - "gl-audio-analyser": "1.0.3", - "gl-buffer": "2.1.2", - "gl-mat4": "1.2.0", - "gl-quad": "1.1.3", - "gl-shader": "4.2.1", - "gl-shader-core": "2.2.0", - "gl-vao": "1.3.0", - "glsl-noise": "0.0.0", - "glsl-random": "0.0.4", - "hex-rgb": "1.0.0", "history": "4.10.1", "inherits": "2.0.4", "jimp": "0.6.8", "js-sha3": "0.8.0", "js-yaml": "3.13.1", "jsmediatags": "3.8.1", - "lerp": "1.0.3", "linkify-react": "4.1.0", "linkifyjs": "4.1.0", "localforage": "1.10.0", @@ -148477,9 +148078,7 @@ "lottie-react": "2.4.0", "markdown-to-jsx": "7.4.3", "numeral": "2.0.6", - "orbit-controls": "0.0.1", "persona": "5.5.0", - "perspective-camera": "2.0.1", "prop-types": "15.7.2", "query-string": "6.13.5", "react": "19.0.0", @@ -148521,7 +148120,6 @@ "vike": "0.4.247", "vike-react": "^0.3.7", "wagmi": "2.14.15", - "webgl-context": "2.2.0", "zod-formik-adapter": "1.2.0" }, "devDependencies": { diff --git a/packages/common/src/api/tan-query/comments/index.ts b/packages/common/src/api/tan-query/comments/index.ts index 3c66393818d..b2a5ad9372f 100644 --- a/packages/common/src/api/tan-query/comments/index.ts +++ b/packages/common/src/api/tan-query/comments/index.ts @@ -16,3 +16,5 @@ export * from './useMuteUser' export * from './useGetTrackCommentNotificationSetting' export * from './useUpdateTrackCommentNotificationSetting' export * from './useUpdateCommentNotificationSetting' +export * from './useFanClubFeed' +export * from './usePostTextUpdate' diff --git a/packages/common/src/api/tan-query/comments/useFanClubFeed.ts b/packages/common/src/api/tan-query/comments/useFanClubFeed.ts new file mode 100644 index 00000000000..4e0bda99b98 --- /dev/null +++ b/packages/common/src/api/tan-query/comments/useFanClubFeed.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react' + +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' +import { useDispatch } from 'react-redux' + +import { commentFromSDK } from '~/adapters' +import { useQueryContext } from '~/api/tan-query/utils' +import { Feature, ID } from '~/models' +import { toast } from '~/store/ui/toast/slice' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey } from '../types' +import { useCurrentUserId } from '../users/account/useCurrentUserId' +import { primeCommentData } from '../utils/primeCommentData' +import { primeRelatedData } from '../utils/primeRelatedData' + +export type FanClubFeedItem = + | { itemType: 'text_post'; commentId: ID } + | { itemType: 'track'; trackId: ID } + +const FAN_CLUB_FEED_PAGE_SIZE = 20 + +export const getFanClubFeedQueryKey = ({ + mint, + sortMethod +}: { + mint: string + sortMethod?: string +}) => { + return [QUERY_KEYS.fanClubFeed, mint, { sortMethod }] as unknown as QueryKey< + FanClubFeedItem[] + > +} + +type UseFanClubFeedArgs = { + mint: string + sortMethod?: 'top' | 'newest' | 'timestamp' + pageSize?: number + enabled?: boolean +} + +export const useFanClubFeed = ({ + mint, + sortMethod = 'newest', + pageSize = FAN_CLUB_FEED_PAGE_SIZE, + enabled = true +}: UseFanClubFeedArgs) => { + const { audiusSdk, reportToSentry } = useQueryContext() + const queryClient = useQueryClient() + const dispatch = useDispatch() + const { data: currentUserId } = useCurrentUserId() + + const queryRes = useInfiniteQuery({ + initialPageParam: 0, + getNextPageParam: (lastPage: FanClubFeedItem[], pages) => { + if (lastPage?.length < pageSize) return undefined + return (pages.length ?? 0) * pageSize + }, + queryKey: getFanClubFeedQueryKey({ mint, sortMethod }), + queryFn: async ({ pageParam }): Promise => { + const sdk = await audiusSdk() + const response = await sdk.comments.getFanClubFeed({ + mint, + userId: currentUserId?.toString(), + offset: pageParam, + limit: pageSize, + sortMethod + }) + + // Prime related data (users, tracks) in cache + primeRelatedData({ related: response.related, queryClient }) + + // Prime individual comment data and build feed items + const feedItems: FanClubFeedItem[] = [] + + for (const item of response.data) { + if (item.item_type === 'text_post') { + const comment = commentFromSDK(item.comment) + if (comment) { + primeCommentData({ comments: [comment], queryClient }) + feedItems.push({ itemType: 'text_post', commentId: comment.id }) + } + } else if (item.item_type === 'track') { + const trackId = item.track?.id + if (trackId) { + feedItems.push({ itemType: 'track', trackId: Number(trackId) }) + } + } + } + + return feedItems + }, + select: (data) => data.pages.flat(), + enabled: enabled && !!mint + }) + + const { error } = queryRes + + useEffect(() => { + if (error) { + reportToSentry({ + error, + name: 'Comments', + feature: Feature.Comments + }) + dispatch( + toast({ + content: 'There was an error loading the feed. Please try again.' + }) + ) + } + }, [error, dispatch, reportToSentry]) + + return queryRes +} diff --git a/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts new file mode 100644 index 00000000000..4a189282cb1 --- /dev/null +++ b/packages/common/src/api/tan-query/comments/usePostTextUpdate.ts @@ -0,0 +1,108 @@ +import { Id } from '@audius/sdk' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { useQueryContext } from '~/api/tan-query/utils' +import { Comment, Feature, ID } from '~/models' +import { toast } from '~/store/ui/toast/slice' + +import { getFanClubFeedQueryKey } from './useFanClubFeed' +import { getCommentQueryKey } from './utils' + +export type PostTextUpdateArgs = { + userId: ID + entityId: ID // artist user_id (coin owner) + body: string + mint: string +} + +export const usePostTextUpdate = () => { + const { audiusSdk, reportToSentry } = useQueryContext() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (args: PostTextUpdateArgs & { newId?: ID }) => { + const sdk = await audiusSdk() + return await sdk.comments.createComment({ + userId: Id.parse(args.userId)!, + metadata: { + commentId: args.newId, + entityId: args.entityId, + entityType: 'FanClub', + body: args.body, + mentions: [] + } + }) + }, + onMutate: async (args: PostTextUpdateArgs & { newId?: ID }) => { + const { userId, body, entityId, mint } = args + const sdk = await audiusSdk() + const newId = await sdk.comments.generateCommentId() + args.newId = newId + + const newComment: Comment = { + id: newId, + entityId, + entityType: 'FanClub', + userId, + message: body, + mentions: [], + isEdited: false, + trackTimestampS: undefined, + reactCount: 0, + replyCount: 0, + replies: undefined, + createdAt: new Date().toISOString(), + updatedAt: undefined + } + + // Prime the individual comment cache + queryClient.setQueryData(getCommentQueryKey(newId), newComment) + + // Optimistically prepend to the fan club feed + const feedQueryKey = getFanClubFeedQueryKey({ + mint, + sortMethod: 'newest' + }) + queryClient.setQueryData(feedQueryKey, (prevData: any) => { + if (!prevData) return prevData + const newState = structuredClone(prevData) + if (newState.pages?.[0]) { + newState.pages[0].unshift({ + itemType: 'text_post' as const, + commentId: newId + }) + } + return newState + }) + + return { newId } + }, + onSuccess: (_data, args) => { + // Invalidate the fan club feed to get fresh data from server + queryClient.invalidateQueries({ + queryKey: getFanClubFeedQueryKey({ + mint: args.mint, + sortMethod: 'newest' + }) + }) + }, + onError: (error: Error, args) => { + reportToSentry({ + error, + additionalInfo: args, + name: 'Comments', + feature: Feature.Comments + }) + toast({ + content: 'There was an error posting your update. Please try again.' + }) + // Invalidate to reset to server state + queryClient.invalidateQueries({ + queryKey: getFanClubFeedQueryKey({ + mint: args.mint, + sortMethod: 'newest' + }) + }) + } + }) +} diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 4709ed463ee..f778a39a2c4 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -128,5 +128,6 @@ export const QUERY_KEYS = { exclusiveTracksCount: 'exclusiveTracksCount', coinRedeemAmount: 'coinRedeemAmount', coinRedeemCodeAmount: 'coinRedeemCodeAmount', - uploadStatus: 'uploadStatus' + uploadStatus: 'uploadStatus', + fanClubFeed: 'fanClubFeed' } as const diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py new file mode 100644 index 00000000000..10613de269f --- /dev/null +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_fan_club_post.py @@ -0,0 +1,691 @@ +""" +Tests for fan club text posts (entity_type FanClub) in the entity manager indexer. + +entity_id is the artist's user_id; artist_coins must exist for validation. +""" + +import json +import logging +from typing import List + +from sqlalchemy import text +from web3.datastructures import AttributeDict + +from integration_tests.challenges.index_helpers import UpdateTask +from integration_tests.utils import populate_mock_db +from src.challenges.challenge_event_bus import ChallengeEventBus, setup_challenge_bus +from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE, Comment +from src.models.comments.comment_mention import CommentMention +from src.models.comments.comment_reaction import CommentReaction +from src.models.comments.comment_thread import CommentThread +from src.models.notifications.notification import Notification +from src.tasks.entity_manager.entity_manager import entity_manager_update +from src.utils.db_session import get_db + +from web3 import Web3 + +logger = logging.getLogger(__name__) + +# Base entities shared across fan club post (text post) tests. +# User 1 = artist / coin owner, User 2 = fan, User 3 = another fan. +fan_club_post_entities = { + "users": [ + {"user_id": 1, "wallet": "user1wallet"}, + {"user_id": 2, "wallet": "user2wallet"}, + {"user_id": 3, "wallet": "user3wallet"}, + ], + "tracks": [{"track_id": 1, "owner_id": 1}], +} + +COIN_MINT = "CoinMintTestXXXXXXXXXXXXXXXXXXXXXXXXXX" + +fan_club_post_metadata = { + "entity_id": 1, # artist user_id + "entity_type": "FanClub", + "body": "gm fan club", + "parent_comment_id": None, +} + +fan_club_post_metadata_json = json.dumps(fan_club_post_metadata) + + +def _seed_artist_coin(session, user_id=1, mint=COIN_MINT): + """Insert an artist_coins row so the indexer's validation passes.""" + session.execute( + text( + "INSERT INTO artist_coins (mint, ticker, user_id, decimals) " + "VALUES (:mint, :ticker, :user_id, :decimals) " + "ON CONFLICT DO NOTHING" + ), + {"mint": mint, "ticker": "TEST", "user_id": user_id, "decimals": 6}, + ) + session.flush() + + +def setup_test(app, mocker, entities, tx_receipts): + with app.app_context(): + db = get_db() + web3 = Web3() + challenge_event_bus: ChallengeEventBus = setup_challenge_bus() + update_task = UpdateTask(web3, challenge_event_bus) + + entity_manager_txs = [ + AttributeDict({"transactionHash": update_task.web3.to_bytes(text=tx_receipt)}) + for tx_receipt in tx_receipts + ] + + def get_events_side_effect(_, tx_receipt): + return tx_receipts[tx_receipt["transactionHash"].decode("utf-8")] + + mocker.patch( + "src.tasks.entity_manager.entity_manager.get_entity_manager_events_tx", + side_effect=get_events_side_effect, + autospec=True, + ) + + if isinstance(entities, list): + for entity_set in entities: + populate_mock_db(db, entity_set) + else: + populate_mock_db(db, entities) + + # Seed artist_coins after populate_mock_db so the table exists from migrations. + with db.scoped_session() as session: + _seed_artist_coin(session) + + def index_transaction(session): + return entity_manager_update( + update_task, + session, + entity_manager_txs, + block_number=0, + block_timestamp=1585336422, + block_hash=hex(0), + ) + + return db, index_transaction + + +# --------------------------------------------------------------------------- +# CREATE +# --------------------------------------------------------------------------- + + +def test_create_fan_club_post(app, mocker): + """ + Non-owner fan club create is skipped; only the artist (entity owner) may post. + Owner self-post does not trigger a root comment notification. + """ + tx_receipts = { + "FanPostRejected": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 2, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {fan_club_post_metadata_json}}}', + "_signer": "user2wallet", + } + ) + }, + ], + "ArtistPost": [ + { + "args": AttributeDict( + { + "_entityId": 2, + "_entityType": "Comment", + "_userId": 1, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {fan_club_post_metadata_json}}}', + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, fan_club_post_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + comments: List[Comment] = session.query(Comment).all() + assert len(comments) == 1 + assert comments[0].entity_type == FAN_CLUB_ENTITY_TYPE + assert comments[0].entity_id == 1 + assert comments[0].user_id == 1 + + notifications = session.query(Notification).filter( + Notification.type == "comment" + ).all() + assert len(notifications) == 0 + + +def test_create_fan_club_post_invalid_no_artist_coin(app, mocker): + """ + Creating a fan club post for a user_id that has no artist coin should be + silently skipped (validation error). + """ + bad_metadata = json.dumps({ + "entity_id": 99, # user 99 doesn't exist and has no coin + "entity_type": "FanClub", + "body": "should fail", + "parent_comment_id": None, + }) + + tx_receipts = { + "BadFanClubPost": [ + { + "args": AttributeDict( + { + "_entityId": 10, + "_entityType": "Comment", + "_userId": 2, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {bad_metadata}}}', + "_signer": "user2wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, fan_club_post_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + comments = session.query(Comment).all() + assert len(comments) == 0 + + +# --------------------------------------------------------------------------- +# REPLY +# --------------------------------------------------------------------------- + + +def test_fan_club_post_reply(app, mocker): + """ + Only the fan club owner may reply. Owner replying to their own root post + creates a thread row but no comment_thread notification (self-reply). + """ + reply_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 1, + "entity_id": 1, + "entity_type": "FanClub", + } + ], + } + + reply_metadata = json.dumps({ + **fan_club_post_metadata, + "body": "replying to your post", + "parent_comment_id": 1, + }) + + tx_receipts = { + "FanClubPostReply": [ + { + "args": AttributeDict( + { + "_entityId": 2, + "_entityType": "Comment", + "_userId": 1, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {reply_metadata}}}', + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, reply_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + assert session.query(Comment).count() == 2 + + threads = session.query(CommentThread).all() + assert len(threads) == 1 + assert threads[0].comment_id == 2 + assert threads[0].parent_comment_id == 1 + + thread_notifs = ( + session.query(Notification) + .filter(Notification.type == "comment_thread") + .all() + ) + assert len(thread_notifs) == 0 + + +def test_fan_club_post_reply_non_owner_rejected(app, mocker): + """ + A non-owner cannot create a fan club comment (including replies). + """ + # Parent exists but replier is not the fan club owner; indexing skips the tx. + reply_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 2, + "entity_id": 1, + "entity_type": "Track", + } + ], + } + + cross_thread_metadata = json.dumps({ + "entity_id": 1, + "entity_type": "FanClub", + "body": "cross-thread reply", + "parent_comment_id": 1, # parent is a Track comment, not fan club + }) + + tx_receipts = { + "CrossThreadReply": [ + { + "args": AttributeDict( + { + "_entityId": 2, + "_entityType": "Comment", + "_userId": 3, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {cross_thread_metadata}}}', + "_signer": "user3wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, reply_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + # Only the pre-existing comment should remain; the invalid reply is skipped + assert session.query(Comment).count() == 1 + assert session.query(CommentThread).count() == 0 + + +# --------------------------------------------------------------------------- +# DELETE +# --------------------------------------------------------------------------- + + +def test_delete_fan_club_post_by_author(app, mocker): + """Fan club owner (author) can delete their own fan club text post.""" + delete_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 1, + "entity_id": 1, + "entity_type": "FanClub", + } + ], + } + + tx_receipts = { + "DeleteOwnComment": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, + "_action": "Delete", + "_metadata": "", + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, delete_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + comment = session.query(Comment).filter(Comment.comment_id == 1).first() + assert comment.is_delete is True + + +def test_delete_fan_club_post_by_artist(app, mocker): + """ + The artist (entity owner / entity_id) can delete any fan club text post in their + thread, even if they didn't author it. + """ + delete_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 2, # fan authored + "entity_id": 1, # artist's fan club thread + "entity_type": "FanClub", + } + ], + } + + tx_receipts = { + "ArtistDeletesFanPost": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, # artist deletes + "_action": "Delete", + "_metadata": "", + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, delete_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + comment = session.query(Comment).filter(Comment.comment_id == 1).first() + assert comment.is_delete is True + + +def test_delete_fan_club_post_by_other_fan_rejected(app, mocker): + """ + A fan who is not the artist cannot delete the owner's fan club post + (seeded row simulates legacy or direct DB; delete still denied for user 3). + """ + delete_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 1, + "entity_id": 1, + "entity_type": "FanClub", + } + ], + } + + tx_receipts = { + "OtherFanTriesToDelete": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 3, # user 3 is neither author nor artist + "_action": "Delete", + "_metadata": "", + "_signer": "user3wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, delete_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + comment = session.query(Comment).filter(Comment.comment_id == 1).first() + assert comment.is_delete is False # unchanged + + +# --------------------------------------------------------------------------- +# REACT +# --------------------------------------------------------------------------- + + +def test_react_to_fan_club_post(app, mocker): + """ + Reacting to a fan club text post creates a reaction record and notifies the + comment author. Self-reactions do not notify. + """ + react_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 1, + "entity_id": 1, + "entity_type": "FanClub", + } + ], + } + + react_metadata = json.dumps({"entity_type": "FanClub", "entity_id": 1}) + + tx_receipts = { + "OwnerSelfReact": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, + "_action": "React", + "_metadata": f'{{"cid": "", "data": {react_metadata}}}', + "_signer": "user1wallet", + } + ) + }, + ], + "FanReact": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 2, + "_action": "React", + "_metadata": f'{{"cid": "", "data": {react_metadata}}}', + "_signer": "user2wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, react_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + reactions = session.query(CommentReaction).all() + assert len(reactions) == 2 + + # Owner self-react does not notify; fan reaction notifies comment author (owner). + react_notifs = ( + session.query(Notification) + .filter(Notification.type == "comment_reaction") + .all() + ) + assert len(react_notifs) == 1 + assert react_notifs[0].user_ids == [1] + + +# --------------------------------------------------------------------------- +# UPDATE (edit) +# --------------------------------------------------------------------------- + + +def test_update_fan_club_post(app, mocker): + """Editing a fan club text post updates the message and sets is_edited.""" + update_entities = { + **fan_club_post_entities, + "comments": [ + { + "comment_id": 1, + "user_id": 1, + "entity_id": 1, + "entity_type": "FanClub", + "text": "original", + } + ], + } + + update_metadata = json.dumps({ + "entity_id": 1, + "entity_type": "FanClub", + "body": "edited text", + }) + + tx_receipts = { + "EditFanClubPost": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, + "_action": "Update", + "_metadata": f'{{"cid": "", "data": {update_metadata}}}', + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, update_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + comment = session.query(Comment).filter(Comment.comment_id == 1).first() + assert comment.text == "edited text" + assert comment.is_edited is True + assert comment.entity_type == FAN_CLUB_ENTITY_TYPE + + +# --------------------------------------------------------------------------- +# MENTION +# --------------------------------------------------------------------------- + + +def test_fan_club_post_with_mention(app, mocker): + """Mentions in a fan club text post generate mention notifications.""" + mention_metadata = json.dumps({ + **fan_club_post_metadata, + "body": "hey @user3 check this out", + "mentions": [3], + }) + + tx_receipts = { + "FanClubPostMention": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {mention_metadata}}}', + "_signer": "user1wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, fan_club_post_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + mentions = session.query(CommentMention).all() + assert len(mentions) == 1 + assert mentions[0].user_id == 3 + + mention_notifs = ( + session.query(Notification) + .filter(Notification.type == "comment_mention") + .all() + ) + assert len(mention_notifs) == 1 + assert mention_notifs[0].user_ids == [3] + + +# --------------------------------------------------------------------------- +# MIXED: Fan club post + Track comments don't interfere +# --------------------------------------------------------------------------- + + +def test_fan_club_post_and_track_comments_coexist(app, mocker): + """ + Creating both a fan club post and a Track comment in the same block + produces the correct entity_type on each and independent notifications. + """ + track_comment_metadata = json.dumps({ + "entity_id": 1, + "entity_type": "Track", + "body": "great track!", + "parent_comment_id": None, + }) + + tx_receipts = { + "FanClubPost": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": "Comment", + "_userId": 1, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {fan_club_post_metadata_json}}}', + "_signer": "user1wallet", + } + ) + }, + ], + "TrackComment": [ + { + "args": AttributeDict( + { + "_entityId": 2, + "_entityType": "Comment", + "_userId": 3, + "_action": "Create", + "_metadata": f'{{"cid": "", "data": {track_comment_metadata}}}', + "_signer": "user3wallet", + } + ) + }, + ], + } + + db, index_transaction = setup_test(app, mocker, fan_club_post_entities, tx_receipts) + + with db.scoped_session() as session: + index_transaction(session) + + comments = session.query(Comment).order_by(Comment.comment_id).all() + assert len(comments) == 2 + + fan_club_post = comments[0] + assert fan_club_post.entity_type == FAN_CLUB_ENTITY_TYPE + assert fan_club_post.entity_id == 1 + + track_comment = comments[1] + assert track_comment.entity_type == "Track" + assert track_comment.entity_id == 1 + + # Fan club owner self-post does not notify; track comment notifies track owner. + notifs = session.query(Notification).filter( + Notification.type == "comment" + ).all() + assert len(notifs) == 1 + assert notifs[0].group_id == "comment:1:type:Track" diff --git a/packages/discovery-provider/src/models/comments/comment.py b/packages/discovery-provider/src/models/comments/comment.py index 1ac0f2a8e6d..5d9353f3bbe 100644 --- a/packages/discovery-provider/src/models/comments/comment.py +++ b/packages/discovery-provider/src/models/comments/comment.py @@ -3,6 +3,8 @@ from src.models.base import Base from src.models.model_utils import RepresentableMixin +FAN_CLUB_ENTITY_TYPE = "FanClub" + class Comment(Base, RepresentableMixin): __tablename__ = "comments" diff --git a/packages/discovery-provider/src/queries/comments/utils.py b/packages/discovery-provider/src/queries/comments/utils.py index 79459eaa4c6..f969c456c45 100644 --- a/packages/discovery-provider/src/queries/comments/utils.py +++ b/packages/discovery-provider/src/queries/comments/utils.py @@ -7,6 +7,7 @@ from src.models.comments.comment_reaction import CommentReaction from src.models.comments.comment_report import COMMENT_KARMA_THRESHOLD from src.models.moderation.muted_user import MutedUser +from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE from src.models.users.aggregate_user import AggregateUser from src.models.users.user import User from src.queries.query_helpers import get_tracks, get_users @@ -428,6 +429,25 @@ def build_comments_query( query = query.having( (func.count(ReplyCountAlias.comment_id) > 0) | (Comment.is_delete == False), ) + elif query_type == "coin": + query = query.filter( + Comment.entity_id == entity_id, + Comment.entity_type == FAN_CLUB_ENTITY_TYPE, + CommentThreadAlias.parent_comment_id == None, + ) + + ReplyCountAlias = aliased(CommentThread) + query = query.outerjoin( + ReplyCountAlias, Comment.comment_id == ReplyCountAlias.parent_comment_id + ) + query = query.group_by( + Comment.comment_id, + react_count_subquery.c.react_count, + CommentNotificationSetting.is_muted, + ) + query = query.having( + (func.count(ReplyCountAlias.comment_id) > 0) | (Comment.is_delete == False), + ) elif query_type == "user": # For user comments query = query.filter( @@ -701,6 +721,11 @@ def extract_ids_from_comments(formatted_comments): if comment.get("entity_type") == "Track" and comment.get("entity_id"): track_ids.add(decode_string_id(comment["entity_id"])) + if comment.get("entity_type") == FAN_CLUB_ENTITY_TYPE and comment.get( + "entity_id" + ): + user_ids.add(decode_string_id(comment["entity_id"])) + # If we have replies, collect their IDs too if comment.get("replies"): for reply in comment["replies"]: @@ -721,6 +746,11 @@ def extract_ids_from_comments(formatted_comments): if reply.get("entity_type") == "Track" and reply_entity_id: track_ids.add(reply_entity_id) + if reply.get("entity_type") == FAN_CLUB_ENTITY_TYPE and reply.get( + "entity_id" + ): + user_ids.add(decode_string_id(reply["entity_id"])) + return list(user_ids), list(track_ids) diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py index fdbb7affe65..cb75a7371e7 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/comment.py @@ -1,8 +1,8 @@ -from sqlalchemy import func +from sqlalchemy import func, text from src.challenges.challenge_event import ChallengeEvent from src.exceptions import IndexingValidationError -from src.models.comments.comment import Comment +from src.models.comments.comment import FAN_CLUB_ENTITY_TYPE, Comment from src.models.comments.comment_mention import CommentMention from src.models.comments.comment_notification_setting import CommentNotificationSetting from src.models.comments.comment_reaction import CommentReaction @@ -27,6 +27,14 @@ logger = StructuredLogger(__name__) +def get_coin_owner_user_id(session, artist_user_id: int): + row = session.execute( + text("SELECT user_id FROM artist_coins WHERE user_id = :u LIMIT 1"), + {"u": artist_user_id}, + ).scalar() + return int(row) if row is not None else None + + def validate_delete_comment_tx(params: ManageEntityParameters): validate_signer(params) comment_id = params.entity_id @@ -36,15 +44,18 @@ def validate_delete_comment_tx(params: ManageEntityParameters): f"Cannot delete comment {comment_id} that does not exist" ) comment = params.existing_records[EntityType.COMMENT.value][comment_id] - track_owner_id = ( - params.session.query(Track.owner_id) - .filter(Track.track_id == comment.entity_id) - .scalar() - ) + if comment.entity_type == FAN_CLUB_ENTITY_TYPE: + entity_owner_id = comment.entity_id + else: + entity_owner_id = ( + params.session.query(Track.owner_id) + .filter(Track.track_id == comment.entity_id) + .scalar() + ) - if user_id != comment.user_id and user_id != track_owner_id: + if user_id != comment.user_id and user_id != entity_owner_id: raise IndexingValidationError( - f"Only track owner or comment owner can delete comment {comment_id}" + f"Only entity owner or comment owner can delete comment {comment_id}" ) @@ -63,29 +74,62 @@ def validate_write_comment_tx(params: ManageEntityParameters): f"Cannot update comment {comment_id} that does not exist" ) - # Entity type only supports track at the moment - if "entity_type" in params.metadata and params.metadata["entity_type"] != "Track": - raise IndexingValidationError( - f"Entity type {params.metadata['entity_type']} does not exist" - ) - if params.metadata["entity_id"] is None: - raise IndexingValidationError( - "Entitiy id for a track is required to create comment" - ) - if ( - params.metadata["entity_id"] - not in params.existing_records[EntityType.TRACK.value] - ): + entity_type_meta = params.metadata.get("entity_type", EntityType.TRACK.value) + if entity_type_meta not in (EntityType.TRACK.value, FAN_CLUB_ENTITY_TYPE): raise IndexingValidationError( - f"Track {params.metadata['entity_id']} does not exist" + f"Entity type {entity_type_meta} is not supported for comments" ) + raw_entity_id = params.metadata.get("entity_id") + if raw_entity_id is None: + raise IndexingValidationError("entity_id is required to create or update comment") + + track_entity_id = None + coin_entity_id = None + if entity_type_meta == EntityType.TRACK.value: + if not isinstance(raw_entity_id, int): + raise IndexingValidationError("Track entity_id must be an integer") + track_entity_id = raw_entity_id + if track_entity_id not in params.existing_records[EntityType.TRACK.value]: + raise IndexingValidationError(f"Track {track_entity_id} does not exist") + else: + if not isinstance(raw_entity_id, int): + raise IndexingValidationError("Fan club entity_id must be an integer") + coin_entity_id = raw_entity_id + if get_coin_owner_user_id(params.session, coin_entity_id) is None: + raise IndexingValidationError( + f"Unknown artist coin owner user id {coin_entity_id}" + ) + # Fan club threads: only the artist (entity_id) may create or update comments. + if params.user_id != coin_entity_id: + raise IndexingValidationError( + "Only the fan club owner may comment on fan club threads" + ) + + if params.action == Action.UPDATE: + existing_c = params.existing_records[EntityType.COMMENT.value][comment_id] + if existing_c.entity_type == EntityType.TRACK.value: + if entity_type_meta != EntityType.TRACK.value or track_entity_id != existing_c.entity_id: + raise IndexingValidationError( + "Cannot change comment entity from metadata on update" + ) + elif existing_c.entity_type == FAN_CLUB_ENTITY_TYPE: + if ( + entity_type_meta != FAN_CLUB_ENTITY_TYPE + or coin_entity_id != existing_c.entity_id + ): + raise IndexingValidationError( + "Cannot change comment fan club entity_id from metadata on update" + ) + if params.metadata["body"] is None or params.metadata["body"] == "": raise IndexingValidationError("Comment body is empty") if len(params.metadata["body"]) > COMMENT_BODY_LIMIT: raise IndexingValidationError("Comment body is empty") - # Validate parent_comment_id if it exists - parent_comment_id = params.metadata.get("parent_comment_id") + # Validate parent_comment_id if it exists (API may send parent_id) + parent_comment_id = params.metadata.get("parent_comment_id") or params.metadata.get( + "parent_id" + ) if parent_comment_id: if not isinstance(parent_comment_id, int): raise IndexingValidationError( @@ -101,6 +145,23 @@ def validate_write_comment_tx(params: ManageEntityParameters): raise IndexingValidationError( f"comment_thread {(parent_comment_id, comment_id)} already exists" ) + parent_c = params.existing_records[EntityType.COMMENT.value][parent_comment_id] + if entity_type_meta == EntityType.TRACK.value: + if ( + parent_c.entity_type != EntityType.TRACK.value + or parent_c.entity_id != track_entity_id + ): + raise IndexingValidationError( + "parent_comment_id does not belong to the same track thread" + ) + else: + if ( + parent_c.entity_type != FAN_CLUB_ENTITY_TYPE + or parent_c.entity_id != coin_entity_id + ): + raise IndexingValidationError( + "parent_comment_id does not belong to the same fan club thread" + ) mentions = params.metadata.get("mentions") if mentions and not all(isinstance(i, int) for i in mentions): @@ -113,14 +174,29 @@ def create_comment(params: ManageEntityParameters): existing_records = params.existing_records metadata = params.metadata user_id = params.user_id - entity_id = metadata.get("entity_id") - entity_type = metadata.get("entity_type", EntityType.TRACK.value) - entity_user_id = existing_records[EntityType.TRACK.value][entity_id].owner_id + raw_entity_type = metadata.get("entity_type", EntityType.TRACK.value) + if raw_entity_type == FAN_CLUB_ENTITY_TYPE: + entity_type = FAN_CLUB_ENTITY_TYPE + entity_user_id = int(metadata.get("entity_id")) + stored_entity_id = entity_user_id + notification_entity_type = FAN_CLUB_ENTITY_TYPE + notification_entity_id = entity_user_id + data_entity_ref = entity_user_id + else: + entity_type = EntityType.TRACK.value + track_entity_id = metadata.get("entity_id") + entity_user_id = existing_records[EntityType.TRACK.value][ + track_entity_id + ].owner_id + stored_entity_id = track_entity_id + notification_entity_type = "Track" + notification_entity_id = track_entity_id + data_entity_ref = track_entity_id mentions = set( list(metadata.get("mentions") or [])[:10] ) # Only persist the first 10 mentions is_owner_mentioned = entity_user_id in mentions - parent_comment_id = metadata.get("parent_comment_id") + parent_comment_id = metadata.get("parent_comment_id") or metadata.get("parent_id") parent_comment = ( existing_records[EntityType.COMMENT.value][parent_comment_id] if parent_comment_id @@ -142,8 +218,8 @@ def create_comment(params: ManageEntityParameters): track_owner_notifications_off = params.session.query( params.session.query(CommentNotificationSetting) .filter( - CommentNotificationSetting.entity_type == "Track", - CommentNotificationSetting.entity_id == entity_id, + CommentNotificationSetting.entity_type == notification_entity_type, + CommentNotificationSetting.entity_id == notification_entity_id, CommentNotificationSetting.user_id == entity_user_id, CommentNotificationSetting.is_muted == True, ) @@ -163,7 +239,7 @@ def create_comment(params: ManageEntityParameters): user_id=user_id, text=metadata["body"], entity_type=entity_type, - entity_id=entity_id, + entity_id=stored_entity_id, track_timestamp_s=metadata["track_timestamp_s"], txhash=params.txhash, blockhash=params.event_blockhash, @@ -196,10 +272,10 @@ def create_comment(params: ManageEntityParameters): timestamp=params.block_datetime, type="comment", specifier=str(comment_id), - group_id=f"comment:{entity_id}:type:{entity_type}", + group_id=f"comment:{data_entity_ref}:type:{entity_type}", data={ "type": entity_type, - "entity_id": entity_id, + "entity_id": data_entity_ref, "comment_user_id": user_id, "comment_id": comment_id, }, @@ -250,7 +326,7 @@ def create_comment(params: ManageEntityParameters): group_id=f"comment_mention:{comment_id}", data={ "type": entity_type, - "entity_id": entity_id, + "entity_id": data_entity_ref, "entity_user_id": entity_user_id, "comment_user_id": user_id, "comment_id": comment_id, @@ -314,7 +390,7 @@ def create_comment(params: ManageEntityParameters): group_id=f"comment_thread:{parent_comment_id}", data={ "type": entity_type, - "entity_id": entity_id, + "entity_id": data_entity_ref, "entity_user_id": entity_user_id, "comment_user_id": user_id, "comment_id": comment_id, @@ -335,9 +411,20 @@ def update_comment(params: ManageEntityParameters): comment_id = params.entity_id existing_comment = existing_records[EntityType.COMMENT.value][comment_id] user_id = params.user_id - entity_id = metadata.get("entity_id") entity_type = metadata.get("entity_type", EntityType.TRACK.value) - entity_user_id = existing_records[EntityType.TRACK.value][entity_id].owner_id + if existing_comment.entity_type == FAN_CLUB_ENTITY_TYPE: + entity_user_id = existing_comment.entity_id + notification_entity_type = FAN_CLUB_ENTITY_TYPE + notification_entity_id = entity_user_id + data_entity_ref = existing_comment.entity_id + else: + track_entity_id = metadata.get("entity_id") + entity_user_id = existing_records[EntityType.TRACK.value][ + track_entity_id + ].owner_id + notification_entity_type = "Track" + notification_entity_id = track_entity_id + data_entity_ref = track_entity_id comment_reports = ( params.session.query(CommentReport) @@ -358,7 +445,7 @@ def update_comment(params: ManageEntityParameters): mentions = set( list(metadata.get("mentions") or [])[:10] ) # Only persist the first 10 mentions - parent_comment_id = metadata.get("parent_comment_id") + parent_comment_id = metadata.get("parent_comment_id") or metadata.get("parent_id") parent_comment = existing_records[EntityType.COMMENT.value].get(parent_comment_id) parent_comment_user_id = parent_comment.user_id if parent_comment else None @@ -374,8 +461,8 @@ def update_comment(params: ManageEntityParameters): track_owner_notifications_off = params.session.query( params.session.query(CommentNotificationSetting) .filter( - CommentNotificationSetting.entity_type == "Track", - CommentNotificationSetting.entity_id == entity_id, + CommentNotificationSetting.entity_type == notification_entity_type, + CommentNotificationSetting.entity_id == notification_entity_id, CommentNotificationSetting.user_id == entity_user_id, CommentNotificationSetting.is_muted == True, ) @@ -485,7 +572,7 @@ def update_comment(params: ManageEntityParameters): group_id=f"comment_mention:{comment_id}", data={ "type": entity_type, - "entity_id": entity_id, + "entity_id": data_entity_ref, "entity_user_id": entity_user_id, "comment_user_id": user_id, "comment_id": comment_id, @@ -555,7 +642,6 @@ def react_comment(params: ManageEntityParameters): comment_id = params.entity_id user_id = params.user_id metadata = params.metadata - entity_id = metadata.get("entity_id") entity_type = metadata.get("entity_type", EntityType.TRACK.value) existing_reaction = params.existing_records[EntityType.COMMENT_REACTION.value].get( @@ -591,86 +677,95 @@ def react_comment(params: ManageEntityParameters): (user_id, comment_id), comment_reaction_record, EntityType.COMMENT_REACTION ) - if entity_id: + comment_row = params.existing_records[EntityType.COMMENT.value][comment_id] + if comment_row.entity_type == FAN_CLUB_ENTITY_TYPE: + entity_user_id = comment_row.entity_id + data_entity_ref = comment_row.entity_id + notification_entity_type = FAN_CLUB_ENTITY_TYPE + notification_entity_id = entity_user_id + else: + track_entity_id = comment_row.entity_id entity_user_id = params.existing_records[EntityType.TRACK.value][ - entity_id + track_entity_id ].owner_id - comment_user_id = params.existing_records[EntityType.COMMENT.value][ - comment_id - ].user_id + data_entity_ref = track_entity_id + notification_entity_type = "Track" + notification_entity_id = track_entity_id - comment_owner_notifications_off = params.session.query( - params.session.query(CommentNotificationSetting) - .filter( - CommentNotificationSetting.entity_type == EntityType.COMMENT.value, - CommentNotificationSetting.entity_id == comment_id, - CommentNotificationSetting.user_id == comment_user_id, - CommentNotificationSetting.is_muted == True, - ) - .exists() - | params.session.query(MutedUser) - .filter( - MutedUser.muted_user_id == user_id, - MutedUser.user_id == comment_user_id, - MutedUser.is_delete == False, - ) - .exists() - ).scalar() + comment_user_id = comment_row.user_id - is_muted_by_karma = ( - params.session.query(MutedUser.muted_user_id) - .join(AggregateUser, MutedUser.user_id == AggregateUser.user_id) - .filter(MutedUser.muted_user_id == user_id) - .group_by(MutedUser.muted_user_id) - .having(func.sum(AggregateUser.follower_count) > COMMENT_KARMA_THRESHOLD) - .scalar() - ) is not None + comment_owner_notifications_off = params.session.query( + params.session.query(CommentNotificationSetting) + .filter( + CommentNotificationSetting.entity_type == EntityType.COMMENT.value, + CommentNotificationSetting.entity_id == comment_id, + CommentNotificationSetting.user_id == comment_user_id, + CommentNotificationSetting.is_muted == True, + ) + .exists() + | params.session.query(MutedUser) + .filter( + MutedUser.muted_user_id == user_id, + MutedUser.user_id == comment_user_id, + MutedUser.is_delete == False, + ) + .exists() + ).scalar() - track_owner_notifications_off = params.session.query( - params.session.query(CommentNotificationSetting) - .filter( - CommentNotificationSetting.entity_type == "Track", - CommentNotificationSetting.entity_id == entity_id, - CommentNotificationSetting.user_id == entity_user_id, - CommentNotificationSetting.is_muted == True, - ) - .exists() - | params.session.query(MutedUser) - .filter( - MutedUser.muted_user_id == user_id, - MutedUser.user_id == entity_user_id, - MutedUser.is_delete == False, - ) - .exists() - ).scalar() + is_muted_by_karma = ( + params.session.query(MutedUser.muted_user_id) + .join(AggregateUser, MutedUser.user_id == AggregateUser.user_id) + .filter(MutedUser.muted_user_id == user_id) + .group_by(MutedUser.muted_user_id) + .having(func.sum(AggregateUser.follower_count) > COMMENT_KARMA_THRESHOLD) + .scalar() + ) is not None - track_owner_mention_mute = ( - comment_user_id == entity_user_id and track_owner_notifications_off + track_owner_notifications_off = params.session.query( + params.session.query(CommentNotificationSetting) + .filter( + CommentNotificationSetting.entity_type == notification_entity_type, + CommentNotificationSetting.entity_id == notification_entity_id, + CommentNotificationSetting.user_id == entity_user_id, + CommentNotificationSetting.is_muted == True, ) + .exists() + | params.session.query(MutedUser) + .filter( + MutedUser.muted_user_id == user_id, + MutedUser.user_id == entity_user_id, + MutedUser.is_delete == False, + ) + .exists() + ).scalar() - if ( - user_id != comment_user_id - and not comment_owner_notifications_off - and not is_muted_by_karma - and not track_owner_mention_mute - ): - comment_reaction_notification = Notification( - blocknumber=params.block_number, - user_ids=[comment_user_id], - timestamp=params.block_datetime, - type="comment_reaction", - specifier=str(user_id), - group_id=f"comment_reaction:{comment_id}", - data={ - "type": entity_type, - "entity_id": entity_id, - "entity_user_id": entity_user_id, - "comment_id": comment_id, - "reacter_user_id": user_id, - }, - ) + track_owner_mention_mute = ( + comment_user_id == entity_user_id and track_owner_notifications_off + ) + + if ( + user_id != comment_user_id + and not comment_owner_notifications_off + and not is_muted_by_karma + and not track_owner_mention_mute + ): + comment_reaction_notification = Notification( + blocknumber=params.block_number, + user_ids=[comment_user_id], + timestamp=params.block_datetime, + type="comment_reaction", + specifier=str(user_id), + group_id=f"comment_reaction:{comment_id}", + data={ + "type": entity_type, + "entity_id": data_entity_ref, + "entity_user_id": entity_user_id, + "comment_id": comment_id, + "reacter_user_id": user_id, + }, + ) - safe_add_notification(params.session, comment_reaction_notification) + safe_add_notification(params.session, comment_reaction_notification) def unreact_comment(params: ManageEntityParameters): diff --git a/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx index ff9201adf85..b0cf16e0359 100644 --- a/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx +++ b/packages/mobile/src/screens/coin-details-screen/components/FanClubTab.tsx @@ -5,7 +5,9 @@ import { useCoinBalance, useCurrentUserId, useExclusiveTracks, - useExclusiveTracksCount + useExclusiveTracksCount, + useFanClubFeed, + type FanClubFeedItem } from '@audius/common/api' import { useBuySellInitialTab, useIsManagedAccount } from '@audius/common/hooks' import { coinDetailsMessages, walletMessages } from '@audius/common/messages' @@ -36,6 +38,8 @@ import { useNavigation } from 'app/hooks/useNavigation' import { useThemeColors } from 'app/utils/theme' import { CoinLeaderboardCard } from './CoinLeaderboardCard' +import { PostUpdateCard } from './PostUpdateCard' +import { TextPostCard } from './TextPostCard' const FAN_CLUB_COVER_HEIGHT = 96 const FAN_CLUB_AVATAR_OVERLAP = -harmonySpacing.unit9 @@ -250,12 +254,25 @@ const FanClubFeed = ({ userId: ownerId }) + const { data: feedItems, isPending: isFeedPending } = useFanClubFeed({ + mint, + sortMethod: 'newest', + enabled: !!mint + }) + + const textPosts = feedItems?.filter( + (item): item is Extract => + item.itemType === 'text_post' + ) + + const hasTextPosts = textPosts && textPosts.length > 0 + if (!ownerId) { return null } if (forMemberView) { - if (isCountPending) { + if (isCountPending && isFeedPending) { return ( @@ -269,40 +286,46 @@ const FanClubFeed = ({ ) } - if (totalCount === 0) { + if (totalCount === 0 && !hasTextPosts) { return null } - } else if (totalCount === 0) { + } else if (totalCount === 0 && !hasTextPosts) { return null } return ( - + {messages.title} - {totalCount > 0 ? ( - - ({totalCount}) - - ) : null} - + + + + {hasTextPosts + ? textPosts.map((item) => ( + + )) + : null} + + {totalCount > 0 ? ( + + ) : null} ) } diff --git a/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx new file mode 100644 index 00000000000..883e81c510c --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/PostUpdateCard.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react' + +import { + useArtistCoin, + useCurrentUserId, + usePostTextUpdate +} from '@audius/common/api' + +import { Flex, Paper, Text } from '@audius/harmony-native' +import { ComposerInput } from 'app/components/composer-input' + +const messages = { + postUpdate: 'Post Update', + placeholder: 'Update your fans', + membersOnly: 'Members Only' +} + +type PostUpdateCardProps = { + mint: string +} + +export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { + const [messageId, setMessageId] = useState(0) + const { data: currentUserId } = useCurrentUserId() + const { data: coin } = useArtistCoin(mint) + const { mutate: postTextUpdate } = usePostTextUpdate() + + const isOwner = currentUserId != null && coin?.ownerId === currentUserId + + const handleSubmit = useCallback( + (value: string) => { + if (!value.trim() || !currentUserId || !coin?.ownerId) return + + postTextUpdate({ + userId: currentUserId, + entityId: coin.ownerId, + body: value.trim(), + mint + }) + setMessageId((prev) => prev + 1) + }, + [currentUserId, coin?.ownerId, mint, postTextUpdate] + ) + + if (!isOwner) return null + + return ( + + + + {messages.postUpdate} + + + handleSubmit(value)} + maxLength={2000} + /> + + + + {messages.membersOnly} + + + + + ) +} diff --git a/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx new file mode 100644 index 00000000000..76c9ee6acd0 --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/TextPostCard.tsx @@ -0,0 +1,101 @@ +import { useComment } from '@audius/common/api' +import type { ID } from '@audius/common/models' +import { getLargestTimeUnitText } from '@audius/common/utils' + +import { + Flex, + IconLock, + Paper, + Skeleton, + Text, + useTheme +} from '@audius/harmony-native' +import { ProfilePicture } from 'app/components/core' +import { UserLink } from 'app/components/user-link' + +const messages = { + locked: 'Hold this coin to unlock' +} + +type TextPostCardProps = { + commentId: ID +} + +export const TextPostCard = ({ commentId }: TextPostCardProps) => { + const { data: comment, isPending } = useComment(commentId) + const { color, spacing, cornerRadius } = useTheme() + + if (isPending) { + return ( + + + + + + + + ) + } + + if (!comment) return null + + const isLocked = comment.message === null + + return ( + + + {comment.userId ? ( + + ) : null} + + {comment.userId ? ( + + ) : null} + {comment.createdAt ? ( + + {getLargestTimeUnitText(new Date(comment.createdAt))} + + ) : null} + + + + {isLocked ? ( + + + + {messages.locked} + + + ) : ( + + {comment.message} + + )} + + ) +} diff --git a/packages/sdk/src/sdk/api/comments/CommentsAPI.ts b/packages/sdk/src/sdk/api/comments/CommentsAPI.ts index 38ba113a950..ffae3e97737 100644 --- a/packages/sdk/src/sdk/api/comments/CommentsAPI.ts +++ b/packages/sdk/src/sdk/api/comments/CommentsAPI.ts @@ -18,8 +18,17 @@ import { type UnpinCommentRequest, type ReactToCommentRequest, type UnreactToCommentRequest, - type ReportCommentRequest + type ReportCommentRequest, + type CreateCommentRequestBody, + type ReactCommentRequestBody, + type UpdateCommentRequestBody } from '../generated/default' +import { + CommentFromJSON, + TrackFromJSON, + UserFromJSON +} from '../generated/default/models' +import * as runtime from '../generated/default/runtime' import { CreateCommentSchema, @@ -34,7 +43,8 @@ import { EntityManagerPinCommentRequest, EntityManagerReactCommentRequest, EntityManagerReportCommentRequest, - type CommentsApiServicesConfig + type CommentsApiServicesConfig, + type FanClubFeedResponse } from './types' export class CommentsApi extends GeneratedCommentsApi { @@ -66,12 +76,36 @@ export class CommentsApi extends GeneratedCommentsApi { 'createComment', CreateCommentSchema )(params) - const { userId, entityType = EntityType.TRACK, commentId } = metadata + const { + userId, + entityType = 'Track', + commentId, + body, + mentions, + parentCommentId, + trackTimestampS, + entityId + } = metadata const newCommentId = commentId ?? (await this.generateCommentId()) if (!this.entityManager) { throw new UninitializedEntityManagerError() } + const data: Record = { + entity_type: entityType === 'FanClub' ? 'FanClub' : 'Track', + body, + entity_id: entityId + } + if (mentions !== undefined && mentions.length > 0) { + data.mentions = mentions + } + if (parentCommentId !== undefined) { + data.parent_id = parentCommentId + data.parent_comment_id = parentCommentId + } + if (trackTimestampS !== undefined) { + data.track_timestamp_s = trackTimestampS + } const res = await this.entityManager.manageEntity({ userId, entityType: EntityType.COMMENT, @@ -79,7 +113,7 @@ export class CommentsApi extends GeneratedCommentsApi { action: Action.CREATE, metadata: JSON.stringify({ cid: '', - data: snakecaseKeys({ entityType, ...metadata }) + data }) }) return { @@ -94,10 +128,23 @@ export class CommentsApi extends GeneratedCommentsApi { ) { if (this.entityManager) { const { metadata, userId } = params + const md = metadata as CreateCommentRequestBody + if (md.entityType === 'FanClub') { + return await this.createCommentWithEntityManager({ + userId, + entityType: 'FanClub', + entityId: encodeHashId(metadata.entityId) ?? '', + body: md.body, + commentId: md.commentId, + parentCommentId: md.parentId, + trackTimestampS: md.trackTimestampS, + mentions: md.mentions + }) + } return await this.createCommentWithEntityManager({ userId, entityId: encodeHashId(metadata.entityId) ?? '', - entityType: metadata.entityType, + entityType: 'Track', body: metadata.body, commentId: metadata.commentId, parentCommentId: metadata.parentId, @@ -118,10 +165,25 @@ export class CommentsApi extends GeneratedCommentsApi { 'updateComment', UpdateCommentSchema )(params) - const { userId, entityId, trackId, body } = metadata + const { + userId, + entityId, + trackId, + body, + entityType = 'Track', + mentions + } = metadata if (!this.entityManager) { throw new UninitializedEntityManagerError() } + const data: Record = { + entity_type: entityType === 'FanClub' ? 'FanClub' : 'Track', + body, + entity_id: trackId + } + if (mentions !== undefined && mentions.length > 0) { + data.mentions = mentions + } return await this.entityManager.manageEntity({ userId, entityType: EntityType.COMMENT, @@ -129,7 +191,7 @@ export class CommentsApi extends GeneratedCommentsApi { action: Action.UPDATE, metadata: JSON.stringify({ cid: '', - data: snakecaseKeys({ body, entityId: trackId }) + data }) }) } @@ -140,11 +202,24 @@ export class CommentsApi extends GeneratedCommentsApi { ) { if (this.entityManager) { const { metadata, userId, commentId } = params + const md = metadata as UpdateCommentRequestBody + if (md.entityType === 'FanClub') { + return await this.updateCommentWithEntityManager({ + userId, + entityId: commentId, + entityType: 'FanClub', + trackId: encodeHashId(md.entityId) ?? '', + body: md.body, + mentions: md.mentions + }) + } return await this.updateCommentWithEntityManager({ userId, entityId: commentId, + entityType: 'Track', trackId: encodeHashId(metadata.entityId) ?? '', - body: metadata.body + body: metadata.body, + mentions: md.mentions }) } return super.updateComment(params, requestInit) @@ -197,10 +272,20 @@ export class CommentsApi extends GeneratedCommentsApi { 'reactComment', ReactCommentSchema )(params) - const { userId, commentId, isLiked, trackId } = metadata + const { + userId, + commentId, + isLiked, + trackId, + entityType = 'Track' + } = metadata if (!this.entityManager) { throw new UninitializedEntityManagerError() } + const data: Record = { + entity_type: entityType === 'FanClub' ? 'FanClub' : 'Track', + entity_id: trackId + } return await this.entityManager.manageEntity({ userId, entityType: EntityType.COMMENT, @@ -208,7 +293,7 @@ export class CommentsApi extends GeneratedCommentsApi { action: isLiked ? Action.REACT : Action.UNREACT, metadata: JSON.stringify({ cid: '', - data: snakecaseKeys({ entityId: trackId, entityType: EntityType.TRACK }) + data }) }) } @@ -218,11 +303,13 @@ export class CommentsApi extends GeneratedCommentsApi { requestInit?: RequestInit ) { if (this.entityManager) { + const md = params.metadata as ReactCommentRequestBody const metadata: EntityManagerReactCommentRequest = { userId: params.userId, commentId: params.commentId, isLiked: true, - trackId: params.commentId // trackId represents the entity being commented on + entityType: md.entityType === 'FanClub' ? 'FanClub' : 'Track', + trackId: encodeHashId(md.entityId) ?? '' } return await this.reactToCommentWithEntityManager(metadata) } @@ -234,11 +321,13 @@ export class CommentsApi extends GeneratedCommentsApi { requestInit?: RequestInit ) { if (this.entityManager) { + const md = params.metadata as ReactCommentRequestBody const metadata: EntityManagerReactCommentRequest = { userId: params.userId, commentId: params.commentId, isLiked: false, - trackId: params.commentId + entityType: md.entityType === 'FanClub' ? 'FanClub' : 'Track', + trackId: encodeHashId(md.entityId) ?? '' } return await this.reactToCommentWithEntityManager(metadata) } @@ -368,4 +457,91 @@ export class CommentsApi extends GeneratedCommentsApi { metadata: '' }) } + + /** + * Fan-club feed: text posts (root `Coin` comments) + tracks stream-gated on this mint + * (`stream_conditions.token_gate.token_mint`). API enforces holder gate. + */ + async getFanClubFeed( + params: { + mint: string + userId?: string + limit?: number + offset?: number + sortMethod?: 'top' | 'newest' | 'timestamp' + }, + initOverrides?: RequestInit | runtime.InitOverrideFunction + ): Promise { + const queryParameters: Record = { + mint: params.mint + } + if (params.offset !== undefined) { + queryParameters.offset = params.offset + } + if (params.limit !== undefined) { + queryParameters.limit = params.limit + } + if (params.userId !== undefined) { + queryParameters.user_id = params.userId + } + if (params.sortMethod !== undefined) { + queryParameters.sort_method = params.sortMethod + } + + const headerParameters: runtime.HTTPHeaders = {} + if ( + !headerParameters.Authorization && + this.configuration && + this.configuration.accessToken + ) { + const token = await this.configuration.accessToken('OAuth2', ['read']) + if (token) { + headerParameters.Authorization = token + } + } + + const response = await this.request( + { + path: '/fan_club/feed', + method: 'GET', + headers: headerParameters, + query: queryParameters + }, + initOverrides + ) + + return await new runtime.JSONApiResponse( + response, + (jsonValue): FanClubFeedResponse => { + const o = jsonValue as Record + const rawData = (o.data as unknown[]) ?? [] + const data = rawData.map((item) => { + const it = item as Record + if (it.item_type === 'text_post') { + return { + item_type: 'text_post' as const, + comment: CommentFromJSON(it.comment) + } + } + if (it.item_type === 'track') { + return { + item_type: 'track' as const, + track: TrackFromJSON(it.track) + } + } + return it as FanClubFeedResponse['data'][number] + }) + const rel = (o.related as Record) ?? {} + return { + data, + related: { + users: ((rel.users as unknown[]) ?? []).map((u) => UserFromJSON(u)), + tracks: ((rel.tracks as unknown[]) ?? []).map((t) => + TrackFromJSON(t) + ) + } + } + } + ).value() + } } diff --git a/packages/sdk/src/sdk/api/comments/types.ts b/packages/sdk/src/sdk/api/comments/types.ts index 2c0fb2fb49a..633b8b2eba6 100644 --- a/packages/sdk/src/sdk/api/comments/types.ts +++ b/packages/sdk/src/sdk/api/comments/types.ts @@ -3,17 +3,41 @@ import { z } from 'zod' import type { EntityManagerService } from '../../services' import { HashId } from '../../types/HashId' import type { CommentEntityType } from '../generated/default' +import type { Comment } from '../generated/default/models/Comment' +import type { Track } from '../generated/default/models/Track' +import type { User } from '../generated/default/models/User' export type CommentsApiServicesConfig = { entityManager?: EntityManagerService } +/** Fan-club feed item: root coin-thread comment (text post) or artist track. */ +export type FanClubFeedTextPostItem = { + item_type: 'text_post' + comment: Comment +} + +export type FanClubFeedTrackItem = { + item_type: 'track' + track: Track +} + +export type FanClubFeedItem = FanClubFeedTextPostItem | FanClubFeedTrackItem + +export type FanClubFeedResponse = { + data: FanClubFeedItem[] + related: { + users: User[] + tracks: Track[] + } +} + export type CommentMetadata = { body?: string commentId?: number userId: number - entityId: number - entityType?: CommentEntityType // For now just tracks are supported, but we left the door open for more + entityId?: number + entityType?: CommentEntityType parentCommentId?: number trackTimestampS?: number mentions?: number[] @@ -23,8 +47,8 @@ export type CommentMetadata = { export const CreateCommentSchema = z .object({ userId: HashId, - entityId: HashId, - entityType: z.optional(z.string()), + entityId: z.optional(HashId), + entityType: z.optional(z.enum(['Track', 'FanClub'])), body: z.optional(z.string()), commentId: z.optional(z.number()), parentCommentId: z.optional(z.number()), @@ -32,6 +56,14 @@ export const CreateCommentSchema = z mentions: z.optional(z.array(z.number())) }) .strict() + .refine( + (data) => { + return data.entityId !== undefined + }, + { + message: 'Comments require entityId' + } + ) export type EntityManagerCreateCommentRequest = z.input< typeof CreateCommentSchema @@ -41,10 +73,20 @@ export const UpdateCommentSchema = z .object({ userId: HashId, entityId: HashId, - trackId: HashId, - body: z.string() + trackId: z.optional(HashId), + entityType: z.optional(z.enum(['Track', 'FanClub'])), + body: z.string(), + mentions: z.optional(z.array(z.number())) }) .strict() + .refine( + (data) => { + return data.trackId !== undefined + }, + { + message: 'Comment updates require trackId' + } + ) export type EntityManagerUpdateCommentRequest = z.input< typeof UpdateCommentSchema @@ -77,9 +119,18 @@ export const ReactCommentSchema = z userId: HashId, commentId: HashId, isLiked: z.boolean(), - trackId: HashId + trackId: z.optional(HashId), + entityType: z.optional(z.enum(['Track', 'FanClub'])) }) .strict() + .refine( + (data) => { + return data.trackId !== undefined + }, + { + message: 'Reactions require trackId' + } + ) export type EntityManagerReactCommentRequest = z.input< typeof ReactCommentSchema diff --git a/packages/sdk/src/sdk/api/generated/default/models/CommentEntityType.ts b/packages/sdk/src/sdk/api/generated/default/models/CommentEntityType.ts index 448189c00c1..e6fde5e5e8f 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/CommentEntityType.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/CommentEntityType.ts @@ -18,7 +18,8 @@ * @export */ export const CommentEntityType = { - Track: 'Track' + Track: 'Track', + FanClub: 'FanClub' } as const; export type CommentEntityType = typeof CommentEntityType[keyof typeof CommentEntityType]; diff --git a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts index 35b2ee4d6ce..9252b2bb289 100644 --- a/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts +++ b/packages/sdk/src/sdk/api/playlists/PlaylistsApi.ts @@ -695,10 +695,10 @@ export class PlaylistsApi extends GeneratedPlaylistsApi { UpdatePlaylistMetadataSchema.shape ) - const picked = pick(playlist as unknown as Record, supportedUpdateFields) as Record< - string, - unknown - > + const picked = pick( + playlist as unknown as Record, + supportedUpdateFields + ) as Record const metadataForUpdate: EntityManagerUpdatePlaylistRequest['metadata'] = { ...picked, ...(picked.releaseDate != null diff --git a/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx b/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx index e44bf58b6d7..8db156c132f 100644 --- a/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx +++ b/packages/web/src/pages/fan-club-detail-page/components/FanClubFeedSection.tsx @@ -1,16 +1,24 @@ +import { useCallback } from 'react' + import { useExclusiveTracks, useExclusiveTracksCount, - useArtistCoin + useArtistCoin, + useFanClubFeed, + type FanClubFeedItem } from '@audius/common/api' import { exclusiveTracksPageLineupActions } from '@audius/common/store' -import { Flex, Text } from '@audius/harmony' +import { Button, Flex, LoadingSpinner, Text } from '@audius/harmony' import { TanQueryLineup } from 'components/lineup/TanQueryLineup' import { LineupVariant } from 'components/lineup/types' +import { PostUpdateCard } from './PostUpdateCard' +import { TextPostCard } from './TextPostCard' + const messages = { - title: 'Fan Club Feed' + title: 'Fan Club Feed', + loadMore: 'Load More' } const FEED_PAGE_SIZE = 10 @@ -23,15 +31,16 @@ export const FanClubFeedSection = ({ mint }: FanClubFeedSectionProps) => { const { data: coin } = useArtistCoin(mint) const ownerId = coin?.ownerId + // Exclusive tracks lineup const { - data, - isFetching, - isPending, - isError, - hasNextPage, + data: tracksData, + isFetching: isTracksFetching, + isPending: isTracksPending, + isError: isTracksError, + hasNextPage: hasNextTracksPage, play, pause, - loadNextPage, + loadNextPage: loadNextTracksPage, isPlaying, lineup, pageSize @@ -41,11 +50,38 @@ export const FanClubFeedSection = ({ mint }: FanClubFeedSectionProps) => { initialPageSize: FEED_PAGE_SIZE }) - const { data: totalCount = 0 } = useExclusiveTracksCount({ + const { data: totalTrackCount = 0 } = useExclusiveTracksCount({ userId: ownerId }) - const shouldShowSection = totalCount > 0 && ownerId + // Fan club feed (text posts) + const { + data: feedItems, + isPending: isFeedPending, + hasNextPage: hasNextFeedPage, + fetchNextPage: fetchNextFeedPage, + isFetchingNextPage + } = useFanClubFeed({ + mint, + sortMethod: 'newest', + pageSize: FEED_PAGE_SIZE, + enabled: !!mint + }) + + const textPosts = feedItems?.filter( + (item): item is Extract => + item.itemType === 'text_post' + ) + + const handleLoadMoreFeed = useCallback(() => { + if (hasNextFeedPage) { + fetchNextFeedPage() + } + }, [hasNextFeedPage, fetchNextFeedPage]) + + const hasTextPosts = textPosts && textPosts.length > 0 + const hasTracks = totalTrackCount > 0 + const shouldShowSection = hasTextPosts || hasTracks || !!ownerId if (!shouldShowSection) return null @@ -55,28 +91,53 @@ export const FanClubFeedSection = ({ mint }: FanClubFeedSectionProps) => { {messages.title} - - ({totalCount}) - - + + + {/* Text posts */} + {isFeedPending ? ( + + + + ) : hasTextPosts ? ( + + {textPosts.map((item) => ( + + ))} + {hasNextFeedPage ? ( + + ) : null} + + ) : null} + + {/* Exclusive tracks */} + {hasTracks && ownerId ? ( + + ) : null} ) } diff --git a/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx b/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx new file mode 100644 index 00000000000..0cefae928d9 --- /dev/null +++ b/packages/web/src/pages/fan-club-detail-page/components/PostUpdateCard.tsx @@ -0,0 +1,79 @@ +import { useCallback, useState } from 'react' + +import { + useArtistCoin, + useCurrentUserId, + usePostTextUpdate +} from '@audius/common/api' +import { Checkbox, Flex, Paper, Text } from '@audius/harmony' + +import { ComposerInput } from 'components/composer-input/ComposerInput' + +const messages = { + postUpdate: 'Post Update', + placeholder: 'Update your fans', + membersOnly: 'Members Only' +} + +type PostUpdateCardProps = { + mint: string +} + +export const PostUpdateCard = ({ mint }: PostUpdateCardProps) => { + const [messageId, setMessageId] = useState(0) + const { data: currentUserId } = useCurrentUserId() + const { data: coin } = useArtistCoin(mint) + const { mutate: postTextUpdate, isPending } = usePostTextUpdate() + + const isOwner = currentUserId != null && coin?.ownerId === currentUserId + + const handleSubmit = useCallback( + (value: string) => { + if (!value.trim() || !currentUserId || !coin?.ownerId) return + + postTextUpdate({ + userId: currentUserId, + entityId: coin.ownerId, + body: value.trim(), + mint + }) + setMessageId((prev) => prev + 1) + }, + [currentUserId, coin?.ownerId, mint, postTextUpdate] + ) + + if (!isOwner) return null + + return ( + + + + {messages.postUpdate} + + + handleSubmit(value)} + maxLength={2000} + disabled={isPending} + blurOnSubmit + /> + + + + {messages.membersOnly} + + + + + + ) +} diff --git a/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx b/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx new file mode 100644 index 00000000000..067de3c168f --- /dev/null +++ b/packages/web/src/pages/fan-club-detail-page/components/TextPostCard.tsx @@ -0,0 +1,82 @@ +import { useComment } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { getLargestTimeUnitText } from '@audius/common/utils' +import { Flex, IconLock, Paper, Skeleton, Text } from '@audius/harmony' + +import { Avatar } from 'components/avatar' +import { UserLink } from 'components/link' + +const messages = { + locked: 'Hold this coin to unlock' +} + +type TextPostCardProps = { + commentId: ID +} + +export const TextPostCard = ({ commentId }: TextPostCardProps) => { + const { data: comment, isPending } = useComment(commentId) + + if (isPending) { + return ( + + + + + + + + ) + } + + if (!comment) return null + + const isLocked = comment.message === null + + return ( + + + + + {comment.userId ? ( + + ) : null} + {comment.createdAt ? ( + + {getLargestTimeUnitText(new Date(comment.createdAt))} + + ) : null} + + + + {isLocked ? ( + ({ + padding: theme.spacing.m, + borderRadius: theme.cornerRadius.s, + backgroundColor: theme.color.background.surface2 + })} + > + + + {messages.locked} + + + ) : ( + + {comment.message} + + )} + + ) +}