diff --git a/packages/commands/src/index.mjs b/packages/commands/src/index.mjs index 06a58e5153b..0818908869c 100755 --- a/packages/commands/src/index.mjs +++ b/packages/commands/src/index.mjs @@ -20,6 +20,7 @@ import './upload-track.mjs' import './edit-track.mjs' import './mint-tokens.mjs' import './tip-audio.mjs' +import './tip-reaction.mjs' import './auth-headers.mjs' import './get-audio-balance.mjs' import './create-user-bank.mjs' diff --git a/packages/commands/src/tip-reaction.mjs b/packages/commands/src/tip-reaction.mjs new file mode 100644 index 00000000000..360537fbbaa --- /dev/null +++ b/packages/commands/src/tip-reaction.mjs @@ -0,0 +1,38 @@ +import BN from "bn.js"; +import chalk from "chalk"; +import { program } from "commander"; + +import { initializeAudiusLibs, initializeAudiusSdk } from "./utils.mjs"; + +program.command("tip-reaction") + .description("Send a tip reaction") + .argument("", "users handle") + .argument("", "signature of the tip to react to") + .action(async (handle, signature) => { + const audiusLibs = await initializeAudiusLibs(handle); + + // extract privkey and pubkey from hedgehog + // only works with accounts created via audius-cmd + const wallet = audiusLibs?.hedgehog?.getWallet() + const privKey = wallet?.getPrivateKeyString() + const pubKey = wallet?.getAddressString() + + // init sdk with priv and pub keys as api keys and secret + // this enables writes via sdk + const audiusSdk = await initializeAudiusSdk({ apiKey: pubKey, apiSecret: privKey }) + + try { + const { data: { id }} = await audiusSdk.users.getUserByHandle({ handle }) + await audiusSdk.users.sendTipReaction({ + userId: id, + metadata: { + reactedTo: signature, + reactionValue: "🔥" + } + }) + + } catch (err) { + program.error(err.message) + } + process.exit(0); + }); diff --git a/packages/commands/src/utils.mjs b/packages/commands/src/utils.mjs index e60f8c53111..ff2dad89309 100644 --- a/packages/commands/src/utils.mjs +++ b/packages/commands/src/utils.mjs @@ -74,7 +74,7 @@ export const initializeAudiusLibs = async (handle) => { }; let audiusSdk; -export const initializeAudiusSdk = async () => { +export const initializeAudiusSdk = async ({ apiKey = undefined, apiSecret = undefined } = {}) => { const discoveryNodeSelector = new DiscoveryNodeSelector({ healthCheckThresholds: { minVersion: developmentConfig.minVersion, @@ -93,6 +93,8 @@ export const initializeAudiusSdk = async () => { if (!audiusSdk) { audiusSdk = AudiusSdk({ appName: "audius-cmd", + apiKey, + apiSecret, services: { discoveryNodeSelector, entityManager diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index d5b6425dfaa..36b6b31b652 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -57,7 +57,8 @@ export enum FeatureFlags { COINFLOW_OFFRAMP_ENABLED = 'coinflow_offramp_enabled', TIKTOK_NATIVE_AUTH = 'tiktok_native_auth', PREMIUM_ALBUMS_ENABLED = 'premium_albums_enabled', - REWARDS_COOLDOWN = 'rewards_cooldown' + REWARDS_COOLDOWN = 'rewards_cooldown', + DISCOVERY_TIP_REACTIONS = 'discovery_tip_reactions' } type FlagDefaults = Record @@ -130,5 +131,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.COINFLOW_OFFRAMP_ENABLED]: false, [FeatureFlags.TIKTOK_NATIVE_AUTH]: true, [FeatureFlags.PREMIUM_ALBUMS_ENABLED]: false, - [FeatureFlags.REWARDS_COOLDOWN]: false + [FeatureFlags.REWARDS_COOLDOWN]: false, + [FeatureFlags.DISCOVERY_TIP_REACTIONS]: false } diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_tip_reactions.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_tip_reactions.py new file mode 100644 index 00000000000..863722932cb --- /dev/null +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_tip_reactions.py @@ -0,0 +1,330 @@ +import json +from typing import List + +from web3 import Web3 +from web3.datastructures import AttributeDict + +from integration_tests.challenges.index_helpers import UpdateTask +from integration_tests.utils import populate_mock_db, populate_mock_db_blocks +from src.models.social.reaction import Reaction +from src.tasks.entity_manager.entity_manager import entity_manager_update +from src.tasks.entity_manager.utils import Action, EntityType +from src.tasks.index_reactions import ReactionResponse, index_identity_reactions +from src.utils.db_session import get_db +from src.utils.redis_connection import get_redis + + +def test_index_tip_reactions(app, mocker): + "Tests indexing of tip reactions" + + with app.app_context(): + db = get_db() + web3 = Web3() + update_task = UpdateTask(web3, None) + + tx_receipts = { + # user 2 reacts to the tip they received from user 1 + "IndexTipReaction1Tx": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 2, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_2", "reaction_value": 1 })}}}', + "_signer": "user2wallet", + } + ) + }, + ], + # user 1 fake reacts to the tip they sent to user 2 + "IndexTipReaction2Tx": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 1, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_2", "reaction_value": 1 })}}}', + "_signer": "user1wallet", + } + ) + }, + ], + # user 3 reacts to tip received from user 1 + "IndexTipReaction3Tx": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_3", "reaction_value": 2 })}}}', + "_signer": "user3wallet", + } + ) + }, + ], + # user 3 sends a new reaction to the previous tip + "IndexTipReaction4Tx": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_3", "reaction_value": 3 })}}}', + "_signer": "user2wallet", + } + ) + }, + ], + # user 3 reacts to tip received from user 1 with incorrect reaction_value + "IndexTipReaction5Tx": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_3", "reaction_value": 10 })}}}', + "_signer": "user3wallet", + } + ) + }, + ], + "MalformedReaction1": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({ "reaction_value": 1 })}}}', + "_signer": "user3wallet", + } + ) + }, + ], + "MalformedReaction2": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_3" })}}}', + "_signer": "user3wallet", + } + ) + }, + ], + "MalformedReaction3": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 3, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({})}}}', + "_signer": "user3wallet", + } + ) + }, + ], + } + + 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, + ) + + entities = { + "users": [ + {"user_id": user_id, "wallet": f"user{user_id}wallet"} + for user_id in range(1, 4) + ], + "user_tips": [ + { + "slot": 0, + "signature": "user_1_tip_2", + "sender_user_id": 1, + "receiver_user_id": 2, + "amount": 100000000, + }, + { + "slot": 1, + "signature": "user_1_tip_3", + "sender_user_id": 1, + "receiver_user_id": 3, + "amount": 100000000, + }, + ], + } + populate_mock_db_blocks(db, 0, 1) + populate_mock_db(db, entities) + + with db.scoped_session() as session: + # index transactions + entity_manager_update( + update_task, + session, + entity_manager_txs, + block_number=0, + block_timestamp=1000000000, + block_hash=hex(0), + ) + + all_tip_reactions: List[Reaction] = session.query(Reaction).all() + + assert 2 == len(all_tip_reactions) + + reaction1 = all_tip_reactions[0] + assert reaction1.id == 1 + assert reaction1.reacted_to == "user_1_tip_2" + assert reaction1.reaction_type == "tip" + assert reaction1.reaction_value == 1 + assert reaction1.sender_wallet == "user1wallet" + assert reaction1.slot == 0 + + reaction2 = all_tip_reactions[1] + assert reaction2.id == 2 + assert reaction2.reacted_to == "user_1_tip_3" + assert reaction2.reaction_type == "tip" + assert reaction2.reaction_value == 3 + assert reaction2.sender_wallet == "user1wallet" + assert reaction2.slot == 1 + + +def test_identity_and_discovery_tip_reactions(app, mocker): + "tests that identity and discovery tip reactions are compatible" + with app.app_context(): + db = get_db() + redis = get_redis() + web3 = Web3() + update_task = UpdateTask(web3, None) + + entities = { + "users": [ + {"user_id": user_id, "wallet": f"user{user_id}wallet"} + for user_id in range(1, 2) + ], + "user_tips": [ + { + "slot": 0, + "signature": "user_1_tip_2_1", + "sender_user_id": 1, + "receiver_user_id": 2, + "amount": 100000000, + }, + { + "slot": 1, + "signature": "user_1_tip_2_2", + "sender_user_id": 1, + "receiver_user_id": 2, + "amount": 100000000, + }, + ], + } + + tx_receipts = { + "TipReactionOne": [ + { + "args": AttributeDict( + { + "_entityId": 1, + "_entityType": EntityType.TIP, + "_userId": 2, + "_action": Action.UPDATE, + "_metadata": f'{{ "cid": "", "data": {json.dumps({"reacted_to": "user_1_tip_2_2", "reaction_value": 1 })}}}', + "_signer": "user2wallet", + } + ) + }, + ], + } + + 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, + ) + + def get_reactions_from_identity_mock(_) -> List[ReactionResponse]: + return [ + ReactionResponse( + id=1, + slot=0, + reactionValue=1, + senderWallet="user1wallet", + reactedTo="user_1_tip_2_1", + reactionType="tip", + createdAt="2024-01-01T00:00:00Z", + updatedAt="2024-01-02T00:00:00Z", + ) + ] + + mocker.patch( + "src.tasks.index_reactions.fetch_reactions_from_identity", + side_effect=get_reactions_from_identity_mock, + ) + + populate_mock_db_blocks(db, 0, 1) + populate_mock_db(db, entities) + + with db.scoped_session() as session: + # index tip one reaction with identity + index_identity_reactions(session, redis) + + # index one tip reaction with discovery + entity_manager_update( + update_task, + session, + entity_manager_txs, + block_number=0, + block_timestamp=1000000000, + block_hash=hex(0), + ) + + all_tip_reactions: List[Reaction] = session.query(Reaction).all() + assert 2 == len(all_tip_reactions) + + identity_reaction = all_tip_reactions[0] + discovery_reaction = all_tip_reactions[1] + + assert 1 == identity_reaction.id + assert 0 == identity_reaction.slot + assert 1 == identity_reaction.reaction_value + assert "user_1_tip_2_1" == identity_reaction.reacted_to + assert "user1wallet" == identity_reaction.sender_wallet + + assert 2 == discovery_reaction.id + assert 1 == discovery_reaction.slot + assert 1 == discovery_reaction.reaction_value + assert "user_1_tip_2_2" == discovery_reaction.reacted_to + assert "user1wallet" == discovery_reaction.sender_wallet diff --git a/packages/discovery-provider/src/queries/get_tips.py b/packages/discovery-provider/src/queries/get_tips.py index 1a7170dbbe2..f0296078ef0 100644 --- a/packages/discovery-provider/src/queries/get_tips.py +++ b/packages/discovery-provider/src/queries/get_tips.py @@ -13,7 +13,7 @@ from src.models.users.user_tip import UserTip from src.queries.get_unpopulated_users import get_unpopulated_users from src.queries.query_helpers import paginate_query, populate_user_metadata -from src.utils.db_session import get_db_read_replica +from src.utils.db_session import get_db logger = logging.getLogger(__name__) @@ -291,8 +291,8 @@ def _get_tips(session: Session, args: GetTipsArgs): return tips_results -def get_tips(args: GetTipsArgs) -> List[PopulatedTipResult]: - db = get_db_read_replica() +def get_tips(args: GetTipsArgs, db=None) -> List[PopulatedTipResult]: + db = get_db() with db.scoped_session() as session: results: Union[List[Tuple[UserTip, List[str]]], List[UserTip]] = _get_tips( session, args diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/tip_reactions.py b/packages/discovery-provider/src/tasks/entity_manager/entities/tip_reactions.py new file mode 100644 index 00000000000..87086f2927b --- /dev/null +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/tip_reactions.py @@ -0,0 +1,104 @@ +import logging +from datetime import datetime + +from src.exceptions import IndexingValidationError +from src.models.social.reaction import Reaction +from src.models.users.user import User +from src.models.users.user_tip import UserTip +from src.tasks.entity_manager.utils import Action, EntityType, ManageEntityParameters + +logger = logging.getLogger(__name__) + + +# validates a valid tip reaction based on the manage entity parameters +# returns a valid reaction model +def validate_tip_reaction(params: ManageEntityParameters): + if params.entity_type != EntityType.TIP: + raise IndexingValidationError( + f"tip_reactions.py | Entity type {params.entity_type} is not a tip" + ) + if params.action != Action.UPDATE: + raise IndexingValidationError("tip_reactions.py | Expected action to be update") + + if not params.metadata: + raise IndexingValidationError( + "tip_reactions.py | Metadata is required for tip reaction" + ) + + metadata = params.metadata + + if metadata.get("reacted_to") is None: + raise IndexingValidationError( + "tip_reactions.py | reactedTo is required in tip reactions metadata" + ) + + reaction_value = metadata.get("reaction_value") + if reaction_value is None: + raise IndexingValidationError( + "tip_reactions.py | reactionValue is required in tip reactions metadata" + ) + + if not 1 <= reaction_value <= 4: + raise IndexingValidationError( + f"rtip_reactions.py | eaction value out of range {metadata}" + ) + + +def tip_reaction(params: ManageEntityParameters): + logger.info("tip_reactions.py | indexing tip reaction") + try: + validate_tip_reaction(params) + + metadata = params.metadata + + # pull relevant fields out of em metadata + reacted_to = metadata.get("reacted_to") + reaction_value = metadata.get("reaction_value") + + reactor_user_id = params.user_id + + session = params.session + tip = ( + session.query(UserTip.slot, UserTip.sender_user_id) + .filter( + UserTip.signature == reacted_to, + UserTip.receiver_user_id == reactor_user_id, + ) + .one_or_none() + ) + + if not tip: + raise IndexingValidationError( + f"tip_reactions.py | reactor {reactor_user_id} reacted to a tip {reacted_to} that doesn't exist" + ) + + slot, sender_user_id = tip + + sender = ( + session.query(User).filter(User.user_id == sender_user_id).one_or_none() + ) + + if not sender: + raise IndexingValidationError( + f"tip_reactions.py | sender on tip {reacted_to} was not found" + ) + + sender_wallet = sender.wallet + if not sender_wallet: + raise IndexingValidationError( + f"tip_reactions.py | sender wallet not available {sender} {metadata}" + ) + reaction_type = "tip" + + reaction = Reaction( + reacted_to=reacted_to, + reaction_value=reaction_value, + slot=slot, + sender_wallet=sender_wallet, + reaction_type=reaction_type, + timestamp=datetime.now(), + ) + + params.add_record(reacted_to, reaction) + except Exception as e: + logger.error(f"tip_reactions.py | error indexing tip reactions {e}") diff --git a/packages/discovery-provider/src/tasks/entity_manager/entity_manager.py b/packages/discovery-provider/src/tasks/entity_manager/entity_manager.py index 22d8d8713a0..d06cfbca7a4 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entity_manager.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entity_manager.py @@ -66,6 +66,7 @@ delete_social_action_types, delete_social_record, ) +from src.tasks.entity_manager.entities.tip_reactions import tip_reaction from src.tasks.entity_manager.entities.track import ( create_track, delete_track, @@ -329,6 +330,11 @@ def entity_manager_update( and params.entity_type == EntityType.DASHBOARD_WALLET_USER ): delete_dashboard_wallet_user(params) + elif ( + params.action == Action.UPDATE + and params.entity_type == EntityType.TIP + ): + tip_reaction(params) logger.info("process transaction") # log event context except IndexingValidationError as e: diff --git a/packages/discovery-provider/src/tasks/entity_manager/utils.py b/packages/discovery-provider/src/tasks/entity_manager/utils.py index 5c030a8cb09..70bbb0c21c4 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/utils.py +++ b/packages/discovery-provider/src/tasks/entity_manager/utils.py @@ -95,6 +95,7 @@ class EntityType(str, Enum): REMIX = "Remix" TRACK_ROUTE = "TrackRoute" PLAYLIST_ROUTE = "PlaylistRoute" + TIP = "Tip" def __str__(self) -> str: return str.__str__(self) diff --git a/packages/libs/src/sdk/api/users/UsersApi.test.ts b/packages/libs/src/sdk/api/users/UsersApi.test.ts index 3137452d702..917fcb5e150 100644 --- a/packages/libs/src/sdk/api/users/UsersApi.test.ts +++ b/packages/libs/src/sdk/api/users/UsersApi.test.ts @@ -26,6 +26,7 @@ import { StorageNodeSelector } from '../../services/StorageNodeSelector' import { Configuration } from '../generated/default' import { UsersApi } from './UsersApi' +import { getReaction } from '../../utils/reactionsMap' const pngFile = fs.readFileSync( path.resolve(__dirname, '../../test/png-file.png') @@ -341,4 +342,48 @@ describe('UsersApi', () => { expect(solanaRelay.relay).toHaveBeenCalledTimes(1) }) }) + + describe('sendTipReaction', () => { + it('converts correct reaction values', () => { + const heartEyes = getReaction(1) + const fire = getReaction(2) + const party = getReaction(3) + const headExploding = getReaction(4) + const invalidEmoji = getReaction(5) + + expect(heartEyes).toEqual('😍') + expect(fire).toEqual('🔥') + expect(party).toEqual('🥳') + expect(headExploding).toEqual('🤯') + expect(invalidEmoji).toBeUndefined() + + const one = getReaction('😍') + const two = getReaction('🔥') + const three = getReaction('🥳') + const four = getReaction('🤯') + //@ts-ignore because type checker only accepts previous four emojis + const invalidNumber = getReaction('🦀') + + expect(one).toEqual(1) + expect(two).toEqual(2) + expect(three).toEqual(3) + expect(four).toEqual(4) + expect(invalidNumber).toBeUndefined() + }) + + it('creates and relays a properly formatted tip reaction', async () => { + const result = await users.sendTipReaction({ + userId: '7eP5n', + metadata: { + reactedTo: 'userTip1', + reactionValue: '🔥' + } + }) + + expect(result).toStrictEqual({ + blockHash: 'a', + blockNumber: 1 + }) + }) + }) }) diff --git a/packages/libs/src/sdk/api/users/UsersApi.ts b/packages/libs/src/sdk/api/users/UsersApi.ts index d8822851d7b..49ac5330400 100644 --- a/packages/libs/src/sdk/api/users/UsersApi.ts +++ b/packages/libs/src/sdk/api/users/UsersApi.ts @@ -37,8 +37,11 @@ import { UnsubscribeFromUserSchema, UpdateProfileSchema, SendTipRequest, - SendTipSchema + SendTipSchema, + SendTipReactionRequest, + SendTipReactionRequestSchema } from './types' +import { getReaction } from '../../utils/reactionsMap' export class UsersApi extends GeneratedUsersApi { constructor( @@ -461,6 +464,34 @@ export class UsersApi extends GeneratedUsersApi { return await this.claimableTokens.sendTransaction(transaction) } + /** + * Submits a reaction to a tip being received. + * @hidden + */ + async sendTipReaction( + params: SendTipReactionRequest, + advancedOptions?: AdvancedOptions + ) { + // Parse inputs + const { userId, metadata } = await parseParams( + 'sendTipReaction', + SendTipReactionRequestSchema + )(params) + + return await this.entityManager.manageEntity({ + userId, + entityType: EntityType.TIP, + entityId: userId, + action: Action.UPDATE, + auth: this.auth, + metadata: JSON.stringify({ + cid: '', + data: snakecaseKeys(metadata) + }), + ...advancedOptions + }) + } + /** * Helper function for sendTip that gets the user wallet and creates * or gets the wAUDIO user bank for given user ID. diff --git a/packages/libs/src/sdk/api/users/types.ts b/packages/libs/src/sdk/api/users/types.ts index 7d850222e79..c4683a4862a 100644 --- a/packages/libs/src/sdk/api/users/types.ts +++ b/packages/libs/src/sdk/api/users/types.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { ImageFile } from '../../types/File' import { HashId } from '../../types/HashId' +import { getReaction, reactionsMap } from '../../utils/reactionsMap' export const UpdateProfileSchema = z .object({ @@ -77,3 +78,39 @@ export const SendTipSchema = z .strict() export type SendTipRequest = z.input + +export type ReactionTypes = keyof typeof reactionsMap + +const ReactionTypeSchema = z + .custom( + (value) => { + const validReactions = Object.keys(reactionsMap) as ReactionTypes[] + return validReactions.includes(value as ReactionTypes) + }, + { + message: 'Invalid reaction type' + } + ) + .transform((data, ctx) => { + const value = getReaction(data) + if (value === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'reactionValue invalid' + }) + return z.NEVER + } + return value + }) + +export const SendTipReactionRequestSchema = z.object({ + userId: HashId, + metadata: z.object({ + reactedTo: z.string().nonempty(), + reactionValue: ReactionTypeSchema + }) +}) + +export type SendTipReactionRequest = z.input< + typeof SendTipReactionRequestSchema +> diff --git a/packages/libs/src/sdk/services/EntityManager/types.ts b/packages/libs/src/sdk/services/EntityManager/types.ts index cf6c7930993..7d7d0ed1a12 100644 --- a/packages/libs/src/sdk/services/EntityManager/types.ts +++ b/packages/libs/src/sdk/services/EntityManager/types.ts @@ -73,7 +73,8 @@ export enum EntityType { NOTIFICATION = 'Notification', DEVELOPER_APP = 'DeveloperApp', GRANT = 'Grant', - DASHBOARD_WALLET_USER = 'DashboardWalletUser' + DASHBOARD_WALLET_USER = 'DashboardWalletUser', + TIP = 'Tip' } export type AdvancedOptions = { diff --git a/packages/libs/src/sdk/utils/reactionsMap.ts b/packages/libs/src/sdk/utils/reactionsMap.ts new file mode 100644 index 00000000000..dca8956bafa --- /dev/null +++ b/packages/libs/src/sdk/utils/reactionsMap.ts @@ -0,0 +1,22 @@ +export const reactionsMap = { + '😍': 1, + '🔥': 2, + '🥳': 3, + '🤯': 4 +} as const + +type ReactionTypes = keyof typeof reactionsMap + +/** use overloads to be able to use same function with the two different types */ +export function getReaction(reaction: number): ReactionTypes | undefined +export function getReaction(reaction: ReactionTypes): number | undefined +export function getReaction( + reaction: number | ReactionTypes +): ReactionTypes | number | undefined { + if (typeof reaction === 'number') { + return Object.keys(reactionsMap).find( + (key) => reactionsMap[key as ReactionTypes] === reaction + ) as ReactionTypes | undefined + } + return reactionsMap[reaction] +} diff --git a/packages/web/src/common/store/ui/reactions/sagas.ts b/packages/web/src/common/store/ui/reactions/sagas.ts index 83e40c9f309..8cb4275a471 100644 --- a/packages/web/src/common/store/ui/reactions/sagas.ts +++ b/packages/web/src/common/store/ui/reactions/sagas.ts @@ -1,12 +1,14 @@ -import { AudiusBackend } from '@audius/common/services' +import { AudiusBackend, FeatureFlags } from '@audius/common/services' import { reactionsUIActions, reactionsUISelectors, reactionsMap, getReactionFromRawValue, - getContext + getContext, + ReactionTypes } from '@audius/common/store' import { getErrorMessage, removeNullable } from '@audius/common/utils' +import { AudiusSdk } from '@audius/sdk' import { call, takeEvery, all, put, select } from 'typed-redux-saga' const { fetchReactionValues, setLocalReactionValues, writeReactionValue } = @@ -15,8 +17,10 @@ const { makeGetReactionForSignature } = reactionsUISelectors type SubmitReactionConfig = { reactedTo: string - reactionValue: number + reactionValue: ReactionTypes | null audiusBackend: AudiusBackend + audiusSdk: AudiusSdk + useDiscoveryReactions: Promise } type SubmitReactionResponse = { success: boolean; error: any } @@ -24,11 +28,31 @@ type SubmitReactionResponse = { success: boolean; error: any } const submitReaction = async ({ reactedTo, reactionValue, - audiusBackend + audiusBackend, + audiusSdk, + useDiscoveryReactions }: SubmitReactionConfig): Promise => { try { - const libs = await audiusBackend.getAudiusLibs() - return libs.Reactions.submitReaction({ reactedTo, reactionValue }) + if (await useDiscoveryReactions) { + const account = await audiusBackend.getAccount() + if (account === null) { + throw new Error('could not submit reaction, user account null') + } + await audiusSdk.users.sendTipReaction({ + userId: account.user_id.toString(), + metadata: { + reactedTo, + reactionValue: reactionValue ? reactionValue : '😍' + } + }) + return { success: true, error: undefined } + } else { + const libs = await audiusBackend.getAudiusLibs() + return libs.Reactions.submitReaction({ + reactedTo, + reactionValue: reactionValue ? reactionsMap[reactionValue] : 0 + }) + } } catch (err) { const errorMessage = getErrorMessage(err) console.error(errorMessage) @@ -81,11 +105,20 @@ function* writeReactionValueAsync({ ) const audiusBackend = yield* getContext('audiusBackendInstance') + const audiusSdk = yield* getContext('audiusSdk') + const sdk = yield* call(audiusSdk) + + const getFeatureEnabled = yield* getContext('getFeatureEnabled') + const useDiscoveryReactions = getFeatureEnabled( + FeatureFlags.DISCOVERY_TIP_REACTIONS + ) yield* call(submitReaction, { reactedTo: entityId, - reactionValue: newReactionValue ? reactionsMap[newReactionValue] : 0, - audiusBackend + reactionValue: newReactionValue, + audiusBackend, + audiusSdk: sdk, + useDiscoveryReactions }) }