Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PAY-2365] Update logic to access nft gated tracks #7471

Merged
merged 6 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/common/src/models/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export type AccessSignature = {
signature: string
}

export type NFTAccessSignature = {
mp3: AccessSignature
original: AccessSignature
}

export type EthCollectionMap = {
[slug: string]: {
name: string
Expand Down
6 changes: 3 additions & 3 deletions packages/common/src/services/audius-api-client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
Supporting,
UserTip,
AccessConditions,
AccessSignature,
ID,
AccessPermissions
AccessPermissions,
NFTAccessSignature
} from '../../models'
import { License, Nullable } from '../../utils'

Expand Down Expand Up @@ -289,5 +289,5 @@ export type GetTipsResponse = Omit<UserTip, UserTipOmitIds> & {
}

export type GetNFTGatedTrackSignaturesResponse = {
[id: ID]: AccessSignature
[id: ID]: NFTAccessSignature
}
194 changes: 139 additions & 55 deletions packages/common/src/store/gated-content/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import {
ID,
Kind,
Name,
AccessSignature,
GatedTrackStatus,
Track,
isContentCollectibleGated,
isContentFollowGated,
isContentTipGated,
isContentUSDCPurchaseGated
isContentUSDCPurchaseGated,
NFTAccessSignature
} from '~/models'
import { User } from '~/models/User'
import { IntKeys } from '~/services/remote-config'
Expand Down Expand Up @@ -132,29 +132,22 @@ function* getTokenIdMap({

// skip this track entry if it is not gated on an nft collection
const {
is_stream_gated: isStreamGated,
stream_conditions: streamConditions
stream_conditions: streamConditions,
download_conditions: downloadConditions
} = tracks[trackId]
if (
!isStreamGated ||
!streamConditions ||
!isContentCollectibleGated(streamConditions)
)
return
const conditions = streamConditions ?? downloadConditions
if (!conditions || !isContentCollectibleGated(conditions)) return

// Set the token ids for ERC1155 nfts as the balanceOf contract method
// which will be used to determine ownership requires the user's
// wallet address and token ids

// todo: fix the string nft_collection to be an object
// temporarily parse it into object here for now
let { nft_collection: nftCollection } = streamConditions
let { nft_collection: nftCollection } = conditions
if (typeof nftCollection === 'string') {
nftCollection = JSON.parse(
(streamConditions.nft_collection as unknown as string).replaceAll(
"'",
'"'
)
(nftCollection as unknown as string).replaceAll("'", '"')
)
}

Expand Down Expand Up @@ -197,26 +190,41 @@ function* handleSpecialAccessTrackSubscriptions(tracks: Track[]) {
track_id: trackId,
owner_id: ownerId,
stream_conditions: streamConditions,
access,
permalink
download_conditions: downloadConditions,
access
} = track

// Ignore updates that are nft access signature only,
// i.e. make sure the above properties exist before proceeding.
if (!trackId || !ownerId || !streamConditions || !permalink) {
if (!trackId || !ownerId || !(streamConditions || downloadConditions)) {
return false
}

const hasNoStreamAccess = !access?.stream
const isFollowGated = isContentFollowGated(streamConditions)
const isTipGated = isContentTipGated(streamConditions)
const shouldHaveStreamAccess =
(isFollowGated && followeeIds.includes(ownerId)) ||
(isTipGated && tippedUserIds.includes(ownerId))

if (hasNoStreamAccess && shouldHaveStreamAccess) {
statusMap[trackId] = 'UNLOCKING'
return true
if (streamConditions) {
const hasNoStreamAccess = !access?.stream
const isFollowGated = isContentFollowGated(streamConditions)
const isTipGated = isContentTipGated(streamConditions)
const shouldHaveStreamAccess =
(isFollowGated && followeeIds.includes(ownerId)) ||
(isTipGated && tippedUserIds.includes(ownerId))

if (hasNoStreamAccess && shouldHaveStreamAccess) {
statusMap[trackId] = 'UNLOCKING'
// TODO: if necessary, update some ui status to show that the track download is unlocking
return true
}
} else if (downloadConditions) {
const hasNoDownloadAccess = !access?.download
const isFollowGated = isContentFollowGated(downloadConditions)
const isTipGated = isContentTipGated(downloadConditions)
const shouldHaveDownloadAccess =
(isFollowGated && followeeIds.includes(ownerId)) ||
(isTipGated && tippedUserIds.includes(ownerId))

if (hasNoDownloadAccess && shouldHaveDownloadAccess) {
// TODO: if necessary, update some ui status to show that the track download is unlocking
return true
}
}
return false
})
Expand Down Expand Up @@ -257,7 +265,7 @@ function* updateCollectibleGatedTracks(trackMap: { [id: ID]: string[] }) {
let numTrackIdsWithSignature = 0

const nftGatedTrackSignatureMap: {
[id: ID]: Nullable<AccessSignature>
[id: ID]: Nullable<NFTAccessSignature>
} = { ...nftGatedTrackSignatureResponse }
// Set null for tracks for which signatures did not get returned
// to signal that an attempt was made but the user does not have access.
Expand Down Expand Up @@ -303,12 +311,12 @@ function* updateCollectibleGatedTracks(trackMap: { [id: ID]: string[] }) {
/**
* This function runs when new tracks have been added to the cache or when eth or sol nfts are fetched.
* It does a bunch of things (getting gradually larger and should now be broken up):
* - Updates the store with new stream signatures.
* - Updates the store with new stream and download signatures.
* - Skips tracks whose signatures have already been previously obtained.
* - Handles newly loading special access tracks that should have a signature but do not yet.
* - Builds a map of nft-gated track ids (and potentially their respective nft token ids) to
* make a request to DN which confirms that user owns the corresponding nft collections by
* returning corresponding stream signatures.
* returning corresponding stream and download signatures.
*/
function* updateGatedTrackAccess(
action:
Expand Down Expand Up @@ -369,14 +377,20 @@ function* updateGatedTrackAccess(
})

const updatedNftAccessSignatureMap: {
[id: ID]: Nullable<AccessSignature>
[id: ID]: Nullable<NFTAccessSignature>
} = {}
Object.keys(allTracks).forEach((trackId) => {
const id = parseInt(trackId)
if (skipped.has(id)) return

const { stream_conditions: streamConditions } = allTracks[trackId]
if (streamConditions?.nft_collection && !trackMap[id]) {
const {
stream_conditions: streamConditions,
download_conditions: downloadConditions
} = allTracks[trackId]
const isCollectibleGated =
isContentCollectibleGated(streamConditions) ||
isContentCollectibleGated(downloadConditions)
if (isCollectibleGated && !trackMap[id]) {
// Set null for collectible gated track signatures as
// the user does not have nfts for those collections
// and therefore does not have access.
Expand Down Expand Up @@ -410,12 +424,24 @@ export function* pollGatedTrack({
remoteConfigInstance.getRemoteVar(IntKeys.GATED_TRACK_POLL_INTERVAL_MS) ??
DEFAULT_GATED_TRACK_POLL_INTERVAL_MS

// get initial track metadata to determine whether we are polling for stream or download access
const cachedTracks = yield* select(getTracks, {
ids: [trackId]
})
const initialTrack = cachedTracks[trackId]
const initiallyHadNoStreamAccess = !initialTrack?.access.stream
const initiallyHadNoDownloadAccess = !initialTrack?.access.download

// poll for access until it is granted
while (true) {
const track = yield* call([apiClient, 'getTrack'], {
id: trackId,
currentUserId
})
if (track?.access?.stream) {
const currentlyHasStreamAccess = !!track?.access?.stream
const currentlyHasDownloadAccess = !!track?.access?.download

if (initiallyHadNoStreamAccess && currentlyHasStreamAccess) {
yield* put(
cacheActions.update(Kind.TRACKS, [
{
Expand All @@ -427,6 +453,7 @@ export function* pollGatedTrack({
])
)
yield* put(updateGatedTrackStatus({ trackId, status: 'UNLOCKED' }))
// TODO: if necessary, update some ui status to show that the track download is unlocked
yield* put(removeFolloweeId({ id: track.owner_id }))
yield* put(removeTippedUserId({ id: track.owner_id }))

Expand All @@ -453,7 +480,45 @@ export function* pollGatedTrack({
}
})
}
break
} else if (initiallyHadNoDownloadAccess && currentlyHasDownloadAccess) {
yield* put(
cacheActions.update(Kind.TRACKS, [
{
id: trackId,
metadata: {
access: track.access
}
}
])
)
// TODO: if necessary, update some ui status to show that the track download is unlocked
yield* put(removeFolloweeId({ id: track.owner_id }))
yield* put(removeTippedUserId({ id: track.owner_id }))

// Show confetti if track is unlocked from the how to unlock section on track page or modal
if (isSourceTrack) {
yield* put(showConfetti())
}

if (!track.download_conditions) {
return
}
const eventName = isContentUSDCPurchaseGated(track.download_conditions)
? Name.USDC_PURCHASE_GATED_TRACK_UNLOCKED
: isContentFollowGated(track.download_conditions)
? Name.FOLLOW_GATED_TRACK_UNLOCKED
: isContentTipGated(track.download_conditions)
? Name.TIP_GATED_TRACK_UNLOCKED
: null
if (eventName) {
analytics.track({
eventName,
properties: {
trackId
}
})
}
break
}
yield* delay(frequency)
Expand All @@ -463,8 +528,8 @@ export function* pollGatedTrack({
/**
* 1. Get follow or tip gated tracks of user
* 2. Set those track statuses to 'UNLOCKING'
* 3. Poll for stream signatures for those tracks
* 4. When the signatures are returned, set those track statuses as 'UNLOCKED'
* 3. Poll for access for those tracks
* 4. When access is returned, set those track statuses as 'UNLOCKED'
*/
function* updateSpecialAccessTracks(
trackOwnerId: ID,
Expand All @@ -488,14 +553,25 @@ function* updateSpecialAccessTracks(

Object.keys(cachedTracks).forEach((trackId) => {
const id = parseInt(trackId)
const { owner_id: ownerId, stream_conditions: streamConditions } =
cachedTracks[id]
const isGated =
const {
owner_id: ownerId,
stream_conditions: streamConditions,
download_conditions: downloadConditions
} = cachedTracks[id]
const isTrackStreamGated =
gate === 'follow'
? isContentFollowGated(streamConditions)
: isContentTipGated(streamConditions)
if (isGated && ownerId === trackOwnerId) {
const isTrackDownloadGated =
gate === 'follow'
? isContentFollowGated(downloadConditions)
: isContentTipGated(downloadConditions)
if (isTrackStreamGated && ownerId === trackOwnerId) {
statusMap[id] = 'UNLOCKING'
// TODO: if necessary, update some ui status to show that the track download is unlocking
tracksToPoll.add(id)
} else if (isTrackDownloadGated && ownerId === trackOwnerId) {
// TODO: if necessary, update some ui status to show that the track download is unlocking
tracksToPoll.add(id)
}
})
Expand Down Expand Up @@ -526,22 +602,30 @@ function* handleUnfollowUser(
yield* put(removeFolloweeId({ id: action.userId }))

const statusMap: { [id: ID]: GatedTrackStatus } = {}
const revokeAccessMap: { [id: ID]: 'stream' | 'download' } = {}
const cachedTracks = yield* select(getTracks, {})

Object.keys(cachedTracks).forEach((trackId) => {
const id = parseInt(trackId)
const { owner_id: ownerId, stream_conditions: streamConditions } =
cachedTracks[id]
const isFollowGated = isContentFollowGated(streamConditions)
if (isFollowGated && ownerId === action.userId) {
const {
owner_id: ownerId,
stream_conditions: streamConditions,
download_conditions: downloadConditions
} = cachedTracks[id]
const isStreamFollowGated = isContentFollowGated(streamConditions)
const isDownloadFollowGated = isContentFollowGated(downloadConditions)
if (isStreamFollowGated && ownerId === action.userId) {
statusMap[id] = 'LOCKED'
// TODO: if necessary, update some ui status to show that the track download is locked
revokeAccessMap[id] = 'stream'
} else if (isDownloadFollowGated && ownerId === action.userId) {
// TODO: if necessary, update some ui status to show that the track download is locked
revokeAccessMap[id] = 'download'
}
})

yield* put(updateGatedTrackStatuses(statusMap))

const trackIds = Object.keys(statusMap).map((trackId) => parseInt(trackId))
yield* put(revokeAccess({ trackIds }))
yield* put(revokeAccess({ revokeAccessMap }))
}

function* handleFollowUser(
Expand Down Expand Up @@ -571,16 +655,16 @@ function* handleTipGatedTracks(
* no longer accessible by the user.
*/
function* handleRevokeAccess(action: ReturnType<typeof revokeAccess>) {
const cachedTracks = yield* select(getTracks, {
ids: action.payload.trackIds
})
const metadatas = Object.keys(cachedTracks).map((trackId) => {
const { revokeAccessMap } = action.payload
const metadatas = Object.keys(revokeAccessMap).map((trackId) => {
const access =
revokeAccessMap[trackId] === 'stream'
? { stream: false, download: false }
: { stream: true, download: false }
const id = parseInt(trackId)
return {
id,
metadata: {
access: { stream: false, download: false }
}
metadata: { access }
}
})
yield* put(cacheActions.update(Kind.TRACKS, metadatas))
Expand Down
8 changes: 4 additions & 4 deletions packages/common/src/store/gated-content/slice.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

import { ID, AccessSignature, GatedTrackStatus } from '~/models'
import { ID, GatedTrackStatus, NFTAccessSignature } from '~/models'
import { Nullable } from '~/utils'

type GatedContentState = {
nftAccessSignatureMap: { [id: ID]: Nullable<AccessSignature> }
nftAccessSignatureMap: { [id: ID]: Nullable<NFTAccessSignature> }
statusMap: { [id: ID]: GatedTrackStatus }
lockedContentId: Nullable<ID>
followeeIds: ID[]
Expand All @@ -20,11 +20,11 @@ const initialState: GatedContentState = {
}

type UpdateNftAccessSignaturesPayload = {
[id: ID]: Nullable<AccessSignature>
[id: ID]: Nullable<NFTAccessSignature>
}

type RevokeAccessPayload = {
trackIds: ID[]
revokeAccessMap: { [id: ID]: 'stream' | 'download' }
sddioulde marked this conversation as resolved.
Show resolved Hide resolved
}

type UpdateGatedTrackStatusPayload = {
Expand Down
Loading