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

feat(rewards): add meow transfer functionality with error handling and state management updates #2290

Merged
merged 8 commits into from
Sep 27, 2024
Merged
9 changes: 9 additions & 0 deletions src/lib/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,12 @@ export async function sendPostByChannelId(channelId: string, message: string, op
export async function getPostMessagesByChannelId(channelId: string, lastCreatedAt?: number) {
return chat.get().matrix.getPostMessagesByChannelId(channelId, lastCreatedAt);
}

export async function sendMeowReactionEvent(
roomId: string,
postMessageId: string,
postOwnerId: string,
meowAmount: number
) {
return chat.get().matrix.sendMeowReactionEvent(roomId, postMessageId, postOwnerId, meowAmount);
}
22 changes: 21 additions & 1 deletion src/lib/chat/matrix-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { uploadImage as _uploadImage } from '../../store/channels-list/api';
import { when } from 'jest-when';
import { config } from '../../config';
import { PowerLevels } from './types';
import { MatrixConstants, ReadReceiptPreferenceType } from './matrix/types';
import { MatrixConstants, ReactionKeys, ReadReceiptPreferenceType } from './matrix/types';
import { DefaultRoomLabels } from '../../store/channels';

jest.mock('./matrix/utils', () => ({ setAsDM: jest.fn().mockResolvedValue(undefined) }));
Expand Down Expand Up @@ -652,6 +652,26 @@ describe('matrix client', () => {
});
});

describe('sendMeowReactionEvent', () => {
it('sends a meow reaction event successfully', async () => {
const sendEvent = jest.fn().mockResolvedValue({});
const client = subject({ createClient: jest.fn(() => getSdkClient({ sendEvent })) });

await client.connect(null, 'token');
await client.sendMeowReactionEvent('channel-id', 'post-message-id', 'post-owner-id', 10);

expect(sendEvent).toHaveBeenCalledWith('channel-id', MatrixConstants.REACTION, {
'm.relates_to': {
rel_type: MatrixConstants.ANNOTATION,
event_id: 'post-message-id',
key: ReactionKeys.MEOW,
},
amount: 10,
postOwnerId: 'post-owner-id',
});
});
});

describe('deleteMessageByRoomId', () => {
it('deletes a message by room ID and message ID', async () => {
const messageId = '123456';
Expand Down
22 changes: 22 additions & 0 deletions src/lib/chat/matrix-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
IN_ROOM_MEMBERSHIP_STATES,
MatrixConstants,
MembershipStateType,
ReactionKeys,
ReadReceiptPreferenceType,
} from './matrix/types';
import { constructFallbackForParentMessage, getFilteredMembersForAutoComplete, setAsDM } from './matrix/utils';
Expand Down Expand Up @@ -463,6 +464,27 @@ export class MatrixClient implements IChatClient {
return { postMessages, hasMore };
}

async sendMeowReactionEvent(
roomId: string,
postMessageId: string,
postOwnerId: string,
meowAmount: number
): Promise<void> {
await this.waitForConnection();

const content = {
'm.relates_to': {
rel_type: MatrixConstants.ANNOTATION,
event_id: postMessageId,
key: ReactionKeys.MEOW,
},
amount: meowAmount,
postOwnerId: postOwnerId,
};

await this.matrix.sendEvent(roomId, MatrixConstants.REACTION as any, content);
}

async getMessageByRoomId(channelId: string, messageId: string) {
await this.waitForConnection();
const newMessage = await this.matrix.fetchRoomEvent(channelId, messageId);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/chat/matrix/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export enum MatrixConstants {
REPLACE = 'm.replace',
BAD_ENCRYPTED_MSGTYPE = 'm.bad.encrypted',
READ_RECEIPT_PREFERENCE = 'm.read_receipt_preference',
REACTION = 'm.reaction',
ANNOTATION = 'm.annotation',
}

export enum ReactionKeys {
MEOW = 'MEOW',
}

export enum CustomEventType {
Expand Down
29 changes: 28 additions & 1 deletion src/store/rewards/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { get } from '../../lib/api/rest';
import { get, post } from '../../lib/api/rest';
import BN from 'bn.js';

export interface RewardsResp {
success: boolean;
Expand All @@ -24,3 +25,29 @@ export async function fetchCurrentMeowPriceInUSD() {
response: response.body,
};
}

export async function transferMeow(senderUserId: string, recipientUserId: string, amount: string) {
try {
const amountInWei = new BN(amount).mul(new BN('1000000000000000000')).toString();

const response = await post('/rewards/transfer').send({
senderUserId,
recipientUserId,
amount: amountInWei,
});

return {
success: true,
response: response.body,
};
} catch (error: any) {
if (error?.response?.status === 400) {
return {
success: false,
response: error.response.body.code,
error: error.response.body.message,
};
}
throw error;
}
}
20 changes: 20 additions & 0 deletions src/store/rewards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
export enum SagaActionTypes {
TotalRewardsViewed = 'rewards/totalRewardsViewed',
CloseRewardsTooltip = 'registration/closeRewardsTooltip',
TransferMeow = 'rewards/transferMeow',
}

export type RewardsState = {
Expand All @@ -13,6 +14,8 @@ export type RewardsState = {
showRewardsInTooltip: boolean;
showRewardsInPopup: boolean;
showNewRewardsIndicator: boolean;
transferLoading: boolean;
transferError?: string;
};

export const initialState: RewardsState = {
Expand All @@ -23,10 +26,19 @@ export const initialState: RewardsState = {
showRewardsInTooltip: false,
showRewardsInPopup: false,
showNewRewardsIndicator: false,
transferLoading: false,
transferError: null,
};

export const totalRewardsViewed = createAction(SagaActionTypes.TotalRewardsViewed);
export const closeRewardsTooltip = createAction(SagaActionTypes.CloseRewardsTooltip);
export const transferMeow = createAction<{
meowSenderId: string;
postOwnerId: string;
postMessageId: string;
meowAmount: string;
roomId: string;
}>(SagaActionTypes.TransferMeow);

const slice = createSlice({
name: 'rewards',
Expand Down Expand Up @@ -59,6 +71,12 @@ const slice = createSlice({
setShowNewRewardsIndicator: (state, action: PayloadAction<RewardsState['showNewRewardsIndicator']>) => {
state.showNewRewardsIndicator = action.payload;
},
setTransferError: (state, action: PayloadAction<{ error: string }>) => {
state.transferError = action.payload.error;
},
setTransferLoading: (state, action: PayloadAction<RewardsState['transferLoading']>) => {
state.transferLoading = action.payload;
},
},
});

Expand All @@ -72,5 +90,7 @@ export const {
setShowRewardsInTooltip,
setShowRewardsInPopup,
setShowNewRewardsIndicator,
setTransferError,
setTransferLoading,
} = slice.actions;
export const { reducer } = slice;
109 changes: 107 additions & 2 deletions src/store/rewards/saga.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { expectSaga } from 'redux-saga-test-plan';
import { call } from 'redux-saga/effects';

import { checkNewRewardsLoaded, closeRewardsTooltipAfterDelay, fetch, fetchCurrentMeowPriceInUSD } from './saga';
import { fetchRewards } from './api';
import {
checkNewRewardsLoaded,
closeRewardsTooltipAfterDelay,
fetch,
fetchCurrentMeowPriceInUSD,
transferMeow,
} from './saga';
import { fetchRewards, transferMeow as transferMeowAPI } from './api';
import { RewardsState, initialState as initialRewardsState } from '.';

import { rootReducer } from '../reducer';
Expand Down Expand Up @@ -105,6 +111,105 @@ describe(checkNewRewardsLoaded, () => {
});
});

describe(transferMeow, () => {
it('handles successful MEOW transfer', async () => {
const meowSenderId = 'sender-id';
const postOwnerId = 'post-owner-id';
const meowAmount = '500000000000000000';

const apiResponse = {
success: true,
response: {
senderBalance: '500000000000000000',
recipientBalance: '1000000000000000000',
},
};

const { storeState } = await expectSaga(transferMeow, {
payload: { meowSenderId, postOwnerId, meowAmount },
})
.withReducer(rootReducer, initialState())
.provide([[call(transferMeowAPI, meowSenderId, postOwnerId, meowAmount), apiResponse]])
.run();

expect(storeState.rewards.meow).toEqual('500000000000000000');
});

it('handles meowSenderId is equal to postOwnerId failure', async () => {
const meowSenderId = 'sender-id';
const postOwnerId = 'sender-id';
const meowAmount = '500000000000000000';

const { storeState } = await expectSaga(transferMeow, {
payload: { meowSenderId, postOwnerId, meowAmount },
})
.withReducer(rootReducer, initialState())
.run();

expect(storeState.rewards.transferError).toEqual('Cannot transfer MEOW to yourself.');
});

it('handles API MEOW transfer failure', async () => {
const meowSenderId = 'sender-id';
const postOwnerId = 'post-owner-id';
const meowAmount = '500000000000000000';

const apiResponse = {
success: false,
error: 'Transfer failed.',
};

const { storeState } = await expectSaga(transferMeow, {
payload: { meowSenderId, postOwnerId, meowAmount },
})
.withReducer(rootReducer, initialState())
.provide([[call(transferMeowAPI, meowSenderId, postOwnerId, meowAmount), apiResponse]])
.run();

expect(storeState.rewards.transferError).toEqual('Transfer failed.');
});

it('handles unexpected error during MEOW transfer', async () => {
const meowSenderId = 'sender-id';
const postOwnerId = 'post-owner-id';
const meowAmount = '500000000000000000';

const error = new Error('Network error');

const { storeState } = await expectSaga(transferMeow, {
payload: { meowSenderId, postOwnerId, meowAmount },
})
.withReducer(rootReducer, initialState())
.provide([[call(transferMeowAPI, meowSenderId, postOwnerId, meowAmount), Promise.reject(error)]])
.run();

expect(storeState.rewards.transferError).toEqual('Network error');
});

it('handles transfer loading', async () => {
const meowSenderUserId = 'sender-id';
const postOwnerId = 'post-owner-id';
const meowAmount = '500000000000000000';

const apiResponse = {
success: true,
response: {
senderBalance: '500000000000000000',
recipientBalance: '1000000000000000000',
},
};

const { storeState } = await expectSaga(transferMeow, {
payload: { meowSenderUserId, postOwnerId, meowAmount },
})
.withReducer(rootReducer, initialState())
.provide([[call(transferMeowAPI, meowSenderUserId, postOwnerId, meowAmount), apiResponse]])
.run();

expect(storeState.rewards.transferLoading).toEqual(false);
});
});

function initialState(attrs: Partial<RewardsState> = {}, otherAttrs: any = {}) {
return {
rewards: {
Expand Down
41 changes: 40 additions & 1 deletion src/store/rewards/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ import {
setMeowPreviousDay,
setShowRewardsInPopup,
setShowNewRewardsIndicator,
setTransferError,
setTransferLoading,
} from '.';
import { RewardsResp, fetchCurrentMeowPriceInUSD as fetchCurrentMeowPriceInUSDAPI, fetchRewards } from './api';
import {
RewardsResp,
fetchCurrentMeowPriceInUSD as fetchCurrentMeowPriceInUSDAPI,
fetchRewards,
transferMeow as transferMeowAPI,
} from './api';
import { takeEveryFromBus } from '../../lib/saga';
import { getAuthChannel, Events as AuthEvents } from '../authentication/channels';
import { featureFlags } from '../../lib/feature-flags';
import { sendMeowReactionEvent } from '../../lib/chat';

const FETCH_REWARDS_INTERVAL = 60 * 60 * 1000; // 1 hour
const SYNC_MEOW_TOKEN_PRICE_INTERVAL = 2 * 60 * 1000; // every 2 minutes
Expand Down Expand Up @@ -116,6 +124,35 @@ export function* closeRewardsTooltip() {
}
}

export function* transferMeow(action) {
yield put(setTransferError({ error: null }));

const { meowSenderId, postOwnerId, postMessageId, meowAmount, roomId } = action.payload;

if (meowSenderId === postOwnerId) {
yield put(setTransferError({ error: 'Cannot transfer MEOW to yourself.' }));
return;
}

yield put(setTransferLoading(true));

try {
const result = yield call(transferMeowAPI, meowSenderId, postOwnerId, meowAmount);

if (result.success) {
yield put(setMeow(result.response.senderBalance));
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we set Meow, this should handle live updates of the users total rewards. Then when rewards are fetched again, i.e. on reload, the same amount of meow should be displayed based on the db updates when hitting transferMeowApi


yield call(sendMeowReactionEvent, roomId, postMessageId, postOwnerId, meowAmount);
} else {
yield put(setTransferError({ error: result.error }));
}
} catch (error: any) {
yield put(setTransferError({ error: error.message || 'An unexpected error occurred.' }));
} finally {
yield put(setTransferLoading(false));
}
}

function* clearOnLogout() {
yield put(setLoading(false));
yield put(setMeow('0'));
Expand All @@ -124,11 +161,13 @@ function* clearOnLogout() {
yield put(setShowRewardsInTooltip(false));
yield put(setShowRewardsInPopup(false));
yield put(setShowNewRewardsIndicator(false));
yield put(setTransferError({ error: '' }));
}

export function* saga() {
yield takeEvery(SagaActionTypes.TotalRewardsViewed, totalRewardsViewed);
yield takeEvery(SagaActionTypes.CloseRewardsTooltip, closeRewardsTooltip);
yield takeEvery(SagaActionTypes.TransferMeow, transferMeow);
yield takeEveryFromBus(yield call(getAuthChannel), AuthEvents.UserLogin, syncRewardsAndTokenPrice);
yield takeEveryFromBus(yield call(getAuthChannel), AuthEvents.UserLogout, clearOnLogout);
}