From 5f7b87cf47c86a8df22834eca191bbd8043eff3a Mon Sep 17 00:00:00 2001 From: dominic Date: Mon, 4 Nov 2024 09:52:55 +0000 Subject: [PATCH 1/3] feat(matrix-client): implement send emoji message reaction and get message emoji reactions --- src/lib/chat/index.ts | 9 ++ src/lib/chat/matrix-client.test.ts | 148 +++++++++++++++++++++++++++++ src/lib/chat/matrix-client.ts | 62 ++++++++++-- src/store/chat/bus.ts | 4 + src/store/messages/index.ts | 6 +- 5 files changed, 222 insertions(+), 7 deletions(-) diff --git a/src/lib/chat/index.ts b/src/lib/chat/index.ts index bc87daa3c..ca227b12a 100644 --- a/src/lib/chat/index.ts +++ b/src/lib/chat/index.ts @@ -23,6 +23,7 @@ export interface RealtimeChatEvents { readReceiptReceived: (messageId: string, userId: string, roomId: string) => void; roomLabelChange: (roomId: string, labels: string[]) => void; postMessageReactionChange: (roomId: string, reaction: any) => void; + messageEmojiReactionChange: (roomId: string, reaction: any) => void; } export interface MatrixKeyBackupInfo { @@ -382,10 +383,18 @@ export async function sendMeowReactionEvent( return chat.get().matrix.sendMeowReactionEvent(roomId, postMessageId, postOwnerId, meowAmount); } +export async function sendEmojiReactionEvent(roomId: string, messageId: string, key: string) { + return chat.get().matrix.sendEmojiReactionEvent(roomId, messageId, key); +} + export async function getPostMessageReactions(roomId: string) { return chat.get().matrix.getPostMessageReactions(roomId); } +export async function getMessageEmojiReactions(roomId: string) { + return chat.get().matrix.getMessageEmojiReactions(roomId); +} + export async function uploadFile(file: File): Promise { return chat.get().matrix.uploadFile(file); } diff --git a/src/lib/chat/matrix-client.test.ts b/src/lib/chat/matrix-client.test.ts index 83cdc9193..f62679ceb 100644 --- a/src/lib/chat/matrix-client.test.ts +++ b/src/lib/chat/matrix-client.test.ts @@ -675,6 +675,29 @@ describe('matrix client', () => { }); }); + describe('sendEmojiReactionEvent', () => { + it('sends a emoji reaction event successfully', async () => { + const fixedTimestamp = 1727441803628; + jest.spyOn(Date, 'now').mockReturnValue(fixedTimestamp); + + const sendEvent = jest.fn().mockResolvedValue({}); + const client = subject({ createClient: jest.fn(() => getSdkClient({ sendEvent })) }); + + await client.connect(null, 'token'); + await client.sendEmojiReactionEvent('channel-id', 'message-id', '😂'); + + expect(sendEvent).toHaveBeenCalledWith('channel-id', MatrixConstants.REACTION, { + 'm.relates_to': { + rel_type: MatrixConstants.ANNOTATION, + event_id: 'message-id', + key: '😂', + }, + }); + + jest.restoreAllMocks(); + }); + }); + describe('deleteMessageByRoomId', () => { it('deletes a message by room ID and message ID', async () => { const messageId = '123456'; @@ -1715,4 +1738,129 @@ describe('matrix client', () => { // Restore console.warn after the test consoleWarnSpy.mockRestore(); }); + + describe('getMessageEmojiReactions', () => { + it('returns the correct reactions for a room', async () => { + const mockGetRoom = jest.fn().mockReturnValue({ + getLiveTimeline: jest.fn().mockReturnValue({ + getEvents: jest.fn().mockReturnValue([ + { + getType: () => MatrixConstants.REACTION, + getContent: () => ({ + [MatrixConstants.RELATES_TO]: { + event_id: 'message-1', + key: '😂', + }, + }), + }, + + { + getType: () => 'm.room.message', + getContent: () => ({ + body: 'This is a regular message', + }), + }, + ]), + }), + }); + + const client = subject({ createClient: jest.fn(() => getSdkClient({ getRoom: mockGetRoom })) }); + + await client.connect(null, 'token'); + const reactions = await client.getMessageEmojiReactions('room-id'); + + expect(mockGetRoom).toHaveBeenCalledWith('room-id'); + expect(reactions).toEqual([ + { + eventId: 'message-1', + key: '😂', + }, + ]); + }); + + it('returns an empty array if the room is not found', async () => { + const mockGetRoom = jest.fn().mockReturnValue(null); + + const client = subject({ createClient: jest.fn(() => getSdkClient({ getRoom: mockGetRoom })) }); + + await client.connect(null, 'token'); + const reactions = await client.getMessageEmojiReactions('room-id'); + + expect(mockGetRoom).toHaveBeenCalledWith('room-id'); + expect(reactions).toEqual([]); + }); + + it('returns an empty array if there are no reaction events', async () => { + const mockGetRoom = jest.fn().mockReturnValue({ + getLiveTimeline: jest.fn().mockReturnValue({ + getEvents: jest.fn().mockReturnValue([ + { + getType: () => 'm.room.message', // No reaction events + getContent: () => ({ + body: 'This is a regular message', + }), + }, + ]), + }), + }); + + const client = subject({ createClient: jest.fn(() => getSdkClient({ getRoom: mockGetRoom })) }); + + await client.connect(null, 'token'); + const reactions = await client.getMessageEmojiReactions('room-id'); + + expect(mockGetRoom).toHaveBeenCalledWith('room-id'); + expect(reactions).toEqual([]); + }); + }); + + it('filters out invalid reaction events', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const mockGetRoom = jest.fn().mockReturnValue({ + getLiveTimeline: jest.fn().mockReturnValue({ + getEvents: jest.fn().mockReturnValue([ + { + getType: () => MatrixConstants.REACTION, + getContent: () => ({ + // Missing the required RELATES_TO properties + }), + }, + { + getType: () => MatrixConstants.REACTION, + getContent: () => ({ + [MatrixConstants.RELATES_TO]: { + key: '😂', // Missing event_id + }, + }), + }, + { + getType: () => MatrixConstants.REACTION, + getContent: () => ({ + [MatrixConstants.RELATES_TO]: { + event_id: 'message-2', + key: '😂', + }, + }), + }, + ]), + }), + }); + + const client = subject({ createClient: jest.fn(() => getSdkClient({ getRoom: mockGetRoom })) }); + + await client.connect(null, 'token'); + const reactions = await client.getMessageEmojiReactions('room-id'); + + expect(mockGetRoom).toHaveBeenCalledWith('room-id'); + expect(reactions).toEqual([ + { + eventId: 'message-2', + key: '😂', + }, + ]); + + // Restore console.warn after the test + consoleWarnSpy.mockRestore(); + }); }); diff --git a/src/lib/chat/matrix-client.ts b/src/lib/chat/matrix-client.ts index e4e4e1ac8..488c371d0 100644 --- a/src/lib/chat/matrix-client.ts +++ b/src/lib/chat/matrix-client.ts @@ -530,6 +530,48 @@ export class MatrixClient implements IChatClient { await this.matrix.sendEvent(roomId, MatrixConstants.REACTION as any, content); } + async sendEmojiReactionEvent(roomId: string, messageId: string, key: string): Promise { + await this.waitForConnection(); + + const content = { + 'm.relates_to': { + rel_type: MatrixConstants.ANNOTATION, + event_id: messageId, + key, + }, + }; + + await this.matrix.sendEvent(roomId, MatrixConstants.REACTION as any, content); + } + + async getMessageEmojiReactions(roomId: string): Promise<{ eventId: string; key: string }[]> { + const room = this.matrix.getRoom(roomId); + if (!room) return []; + + const events = room.getLiveTimeline().getEvents(); + + const result = events + .filter((event) => event.getType() === MatrixConstants.REACTION) + .map((event) => { + const content = event.getContent(); + const relatesTo = content[MatrixConstants.RELATES_TO]; + + if (relatesTo && relatesTo.event_id && relatesTo.key) { + return { + eventId: relatesTo.event_id, + key: relatesTo.key, + }; + } + + // If the structure is not as we expect, return null to filter it out + console.warn('Invalid reaction event structure:', event); + return null; + }) + .filter((reaction) => reaction !== null); + + return result; + } + async getPostMessageReactions(roomId: string): Promise<{ eventId: string; key: string; amount: number }[]> { const room = this.matrix.getRoom(roomId); if (!room) return []; @@ -1437,12 +1479,20 @@ export class MatrixClient implements IChatClient { private publishReactionChange(event) { const content = event.content; - this.events.postMessageReactionChange(event.room_id, { - eventId: content[MatrixConstants.RELATES_TO].event_id, - key: content[MatrixConstants.RELATES_TO].key, - amount: parseFloat(content.amount), - postOwnerId: content.postOwnerId, - }); + + if (content.postOwnerId) { + this.events.postMessageReactionChange(event.room_id, { + eventId: content[MatrixConstants.RELATES_TO].event_id, + key: content[MatrixConstants.RELATES_TO].key, + amount: parseFloat(content.amount), + postOwnerId: content.postOwnerId, + }); + } else { + this.events.messageEmojiReactionChange(event.room_id, { + eventId: content[MatrixConstants.RELATES_TO].event_id, + key: content[MatrixConstants.RELATES_TO].key, + }); + } } private publishRoomMemberPowerLevelsChanged = (event: MatrixEvent, member: RoomMember) => { diff --git a/src/store/chat/bus.ts b/src/store/chat/bus.ts index 25599ecd3..c2318526c 100644 --- a/src/store/chat/bus.ts +++ b/src/store/chat/bus.ts @@ -23,6 +23,7 @@ export enum Events { ReadReceiptReceived = 'chat/message/readReceiptReceived', RoomLabelChange = 'chat/channel/roomLabelChange', PostMessageReactionChange = 'chat/message/postMessageReactionChange', + MessageEmojiReactionChange = 'chat/message/messageEmojiReactionChange', } let theBus; @@ -96,6 +97,8 @@ export function createChatConnection(userId, chatAccessToken, chatClient: Chat) const roomLabelChange = (roomId, labels) => emit({ type: Events.RoomLabelChange, payload: { roomId, labels } }); const postMessageReactionChange = (roomId, reaction) => emit({ type: Events.PostMessageReactionChange, payload: { roomId, reaction } }); + const messageEmojiReactionChange = (roomId, reaction) => + emit({ type: Events.MessageEmojiReactionChange, payload: { roomId, reaction } }); chatClient.initChat({ receiveNewMessage, @@ -115,6 +118,7 @@ export function createChatConnection(userId, chatAccessToken, chatClient: Chat) readReceiptReceived, roomLabelChange, postMessageReactionChange, + messageEmojiReactionChange, }); connectionPromise = chatClient.connect(userId, chatAccessToken); diff --git a/src/store/messages/index.ts b/src/store/messages/index.ts index 837548bb9..bf349b44b 100644 --- a/src/store/messages/index.ts +++ b/src/store/messages/index.ts @@ -112,6 +112,7 @@ export enum SagaActionTypes { DeleteMessage = 'messages/saga/deleteMessage', EditMessage = 'messages/saga/editMessage', LoadAttachmentDetails = 'messages/saga/loadAttachmentDetails', + SendEmojiReaction = 'messages/saga/sendEmojiReaction', } const fetch = createAction(SagaActionTypes.Fetch); @@ -119,6 +120,9 @@ const send = createAction(SagaActionTypes.Send); const deleteMessage = createAction(SagaActionTypes.DeleteMessage); const editMessage = createAction(SagaActionTypes.EditMessage); const loadAttachmentDetails = createAction<{ media: Media }>(SagaActionTypes.LoadAttachmentDetails); +const sendEmojiReaction = createAction<{ roomId: string; messageId: string; key: string }>( + SagaActionTypes.SendEmojiReaction +); const slice = createNormalizedSlice({ name: 'messages', @@ -126,4 +130,4 @@ const slice = createNormalizedSlice({ export const { receiveNormalized, receive } = slice.actions; export const { normalize, denormalize, schema } = slice; -export { fetch, send, deleteMessage, editMessage, removeAll, loadAttachmentDetails }; +export { fetch, send, deleteMessage, editMessage, removeAll, loadAttachmentDetails, sendEmojiReaction }; From fc01c18affc823c69d9a9781db7f7b89482acaec Mon Sep 17 00:00:00 2001 From: dominic Date: Mon, 4 Nov 2024 10:16:28 +0000 Subject: [PATCH 2/3] feat(messages-saga): send, update and apply message emoji reactions - redux/saga state --- src/store/messages/saga.test.ts | 196 +++++++++++++++++++++++++++++++- src/store/messages/saga.ts | 58 +++++++++- 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/src/store/messages/saga.test.ts b/src/store/messages/saga.test.ts index ddd7c3318..0401a9ea0 100644 --- a/src/store/messages/saga.test.ts +++ b/src/store/messages/saga.test.ts @@ -9,6 +9,10 @@ import { sendBrowserNotification, receiveUpdateMessage, replaceOptimisticMessage, + applyEmojiReactions, + onMessageEmojiReactionChange, + updateMessageEmojiReaction, + sendEmojiReaction, } from './saga'; import { rootReducer } from '../reducer'; @@ -16,7 +20,7 @@ import { mapMessage, send as sendBrowserMessage } from '../../lib/browser'; import { call } from 'redux-saga/effects'; import { StoreBuilder } from '../test/store'; import { MessageSendStatus } from '.'; -import { chat } from '../../lib/chat'; +import { chat, getMessageEmojiReactions, sendEmojiReactionEvent } from '../../lib/chat'; import { NotifiableEventType } from '../../lib/chat/matrix/types'; import { DefaultRoomLabels } from '../channels'; @@ -387,3 +391,193 @@ describe(replaceOptimisticMessage, () => { expect(returnValue[0].preview).toEqual({ url: 'example.com/old-preview' }); }); }); + +describe('applyEmojiReactions', () => { + it('applies emoji reactions to messages correctly', async () => { + const roomId = 'room-id'; + const messages = [ + { id: 'message-1', reactions: {} }, + { id: 'message-2', reactions: {} }, + ] as any; + + const reactions = [ + { eventId: 'message-1', key: '😲' }, + { eventId: 'message-1', key: '❤️' }, + { eventId: 'message-2', key: '😂' }, + { eventId: 'message-2', key: '❤️' }, + ]; + + await expectSaga(applyEmojiReactions, roomId, messages) + .provide([[call(getMessageEmojiReactions, roomId), reactions]]) + .run(); + + expect(messages).toEqual([ + { id: 'message-1', reactions: { '😲': 1, '❤️': 1 } }, + { id: 'message-2', reactions: { '😂': 1, '❤️': 1 } }, + ]); + }); + + it('does not modify messages without reactions', async () => { + const roomId = 'room-id'; + const messages = [ + { id: 'message-1', reactions: {} }, + { id: 'message-2', reactions: {} }, + ] as any; + + const reactions = []; + + await expectSaga(applyEmojiReactions, roomId, messages) + .provide([[call(getMessageEmojiReactions, roomId), reactions]]) + .run(); + + expect(messages).toEqual([ + { id: 'message-1', reactions: {} }, + { id: 'message-2', reactions: {} }, + ]); + }); + + it('accumulates reactions for the same key', async () => { + const roomId = 'room-id'; + const messages = [ + { id: 'message-1', reactions: {} }, + ] as any; + + const reactions = [ + { eventId: 'message-1', key: '❤️' }, + { eventId: 'message-1', key: '❤️' }, + { eventId: 'message-1', key: '😂' }, + ]; + + await expectSaga(applyEmojiReactions, roomId, messages) + .provide([[call(getMessageEmojiReactions, roomId), reactions]]) + .run(); + + expect(messages).toEqual([ + { id: 'message-1', reactions: { '❤️': 2, '😂': 1 } }, + ]); + }); + + it('handles reactions when there are no matching messages', async () => { + const roomId = 'room-id'; + const messages = [ + { id: 'message-1', reactions: {} }, + ] as any; + + const reactions = [ + { eventId: 'message-2', key: '❤️' }, + ]; + + await expectSaga(applyEmojiReactions, roomId, messages) + .provide([[call(getMessageEmojiReactions, roomId), reactions]]) + .run(); + + expect(messages).toEqual([ + { id: 'message-1', reactions: {} }, + ]); + }); +}); + +describe('onMessageEmojiReactionChange', () => { + it('calls updateMessageEmojiReaction with the correct arguments', async () => { + const roomId = 'room-id'; + const reaction = { eventId: 'message-1', key: '❤️' }; + + await expectSaga(onMessageEmojiReactionChange, { payload: { roomId, reaction } }) + .provide([ + [matchers.call.fn(updateMessageEmojiReaction), undefined], + ]) + .call(updateMessageEmojiReaction, roomId, reaction) + .run(); + }); +}); + +describe('updateMessageEmojiReaction', () => { + it('updates the message with the new reaction', async () => { + const roomId = 'room-id'; + const reaction = { eventId: 'message-1', key: '❤️' }; + const messages = [ + { id: 'message-1', reactions: { '👍': 1 } }, + { id: 'message-2', reactions: {} }, + ]; + + const initialState = { + normalized: { + messages: { + 'message-1': messages[0], + 'message-2': messages[1], + }, + channels: { + [roomId]: { + id: roomId, + messages: ['message-1', 'message-2'], + }, + }, + }, + }; + + const updatedMessages = [ + { id: 'message-1', reactions: { '👍': 1, '❤️': 1 } }, + { id: 'message-2', reactions: {} }, + ]; + + const { + storeState: { normalized }, + } = await expectSaga(updateMessageEmojiReaction, roomId, reaction) + .withReducer(rootReducer) + .withState(initialState) + .run(); + + expect(normalized.messages['message-1']).toEqual(updatedMessages[0]); + }); + + it('does not update reactions if the message does not exist', async () => { + const roomId = 'room-id'; + const reaction = { eventId: 'message-3', key: '❤️' }; + const messages = [ + { id: 'message-1', reactions: { '👍': 1 } }, + { id: 'message-2', reactions: {} }, + ]; + + const initialState = { + normalized: { + messages: { + 'message-1': messages[0], + 'message-2': messages[1], + }, + channels: { + [roomId]: { + id: roomId, + messages: ['message-1', 'message-2'], + }, + }, + }, + }; + + const { + storeState: { normalized }, + } = await expectSaga(updateMessageEmojiReaction, roomId, reaction) + .withReducer(rootReducer) + .withState(initialState) + .run(); + + expect(normalized.messages).toEqual({ + 'message-1': messages[0], + 'message-2': messages[1], + }); + }); +}); + +describe('sendEmojiReaction', () => { + it('calls sendEmojiReactionEvent with the correct arguments', async () => { + const roomId = 'room-id'; + const messageId = 'message-1'; + const key = '❤️'; + + await expectSaga(sendEmojiReaction, { payload: { roomId, messageId, key } }) + .provide([ + [matchers.call.fn(sendEmojiReactionEvent), undefined], + ]) + .call(sendEmojiReactionEvent, roomId, messageId, key) + .run(); + }); +}); diff --git a/src/store/messages/saga.ts b/src/store/messages/saga.ts index dc1a33d84..536067655 100644 --- a/src/store/messages/saga.ts +++ b/src/store/messages/saga.ts @@ -10,6 +10,7 @@ import { MediaType, MessageSendStatus, MediaDownloadStatus, + Message, } from '.'; import { receive as receiveMessage } from './'; import { ConversationStatus, MessagesFetchState, DefaultRoomLabels } from '../channels'; @@ -23,7 +24,7 @@ import { send as sendBrowserMessage, mapMessage } from '../../lib/browser'; import { takeEveryFromBus } from '../../lib/saga'; import { Events as ChatEvents, getChatBus } from '../chat/bus'; import { Uploadable, createUploadableFile } from './uploadable'; -import { chat, getMessageReadReceipts } from '../../lib/chat'; +import { chat, getMessageEmojiReactions, getMessageReadReceipts, sendEmojiReactionEvent } from '../../lib/chat'; import { User } from '../channels'; import { mapMessageSenders } from './utils.matrix'; import { uniqNormalizedList } from '../utils'; @@ -165,6 +166,10 @@ export function* fetch(action) { messages = [...messagesResponse.messages, ...existingMessages]; messages = uniqBy(messages, (m) => m.id ?? m); + if (yield select(_isActive(channelId))) { + yield call(applyEmojiReactions, channelId, messages); + } + yield call(receiveChannel, { id: channelId, messages, @@ -586,6 +591,7 @@ export function* saga() { yield takeLatest(SagaActionTypes.DeleteMessage, deleteMessage); yield takeLatest(SagaActionTypes.EditMessage, editMessage); yield takeEvery(SagaActionTypes.LoadAttachmentDetails, loadAttachmentDetails); + yield takeEvery(SagaActionTypes.SendEmojiReaction, sendEmojiReaction); const chatBus = yield call(getChatBus); yield takeEveryFromBus(chatBus, ChatEvents.MessageReceived, receiveNewMessage); @@ -593,6 +599,7 @@ export function* saga() { yield takeEveryFromBus(chatBus, ChatEvents.MessageDeleted, receiveDelete); yield takeEveryFromBus(chatBus, ChatEvents.LiveRoomEventReceived, receiveLiveRoomEventAction); yield takeEveryFromBus(chatBus, ChatEvents.ReadReceiptReceived, readReceiptReceived); + yield takeEveryFromBus(chatBus, ChatEvents.MessageEmojiReactionChange, onMessageEmojiReactionChange); } function* receiveLiveRoomEventAction({ payload }) { @@ -665,3 +672,52 @@ function updateMediaStatus(messageId, media, downloadStatus, url = null) { image: url ? { ...media, url } : undefined, }); } + +export function* sendEmojiReaction(action) { + const { roomId, messageId, key } = action.payload; + try { + yield call(sendEmojiReactionEvent, roomId, messageId, key); + } catch (error) { + console.error('Error sending emoji reaction:', error); + } +} + +export function* onMessageEmojiReactionChange(action) { + const { roomId, reaction } = action.payload; + yield call(updateMessageEmojiReaction, roomId, reaction); +} + +export function* updateMessageEmojiReaction(roomId, { eventId, key }) { + const message = yield select(messageSelector(eventId)); + const existingMessages = yield select(rawMessagesSelector(roomId)); + + if (message) { + const newReactions = { ...message.reactions }; + newReactions[key] = (newReactions[key] || 0) + 1; + + const updatedMessage = { ...message, reactions: newReactions }; + const updatedMessages = existingMessages.map((message) => (message === eventId ? updatedMessage : message)); + + yield call(receiveChannel, { id: roomId, messages: updatedMessages }); + } +} + +export function* applyEmojiReactions(roomId: string, messages: Message[]): Generator { + const reactions = yield call(getMessageEmojiReactions, roomId); + + messages.forEach((message) => { + const relatedReactions = reactions.filter((reaction) => { + const messageId = message?.id?.toString(); + const eventId = reaction.eventId.toString(); + return eventId === messageId; + }); + + if (relatedReactions.length > 0) { + message.reactions = relatedReactions.reduce((acc, reaction) => { + const key = reaction.key; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, message.reactions || {}); + } + }); +} From fc6eacf00bbda87aab3bfd3878f2b3aedc5a9161 Mon Sep 17 00:00:00 2001 From: dominic Date: Mon, 4 Nov 2024 10:31:10 +0000 Subject: [PATCH 3/3] feat(reactions-menu): add reactions menu component containing reaction tray and emoji picker --- src/components/reaction-menu/index.tsx | 184 +++++++++++++++++++++++ src/components/reaction-menu/styles.scss | 66 ++++++++ 2 files changed, 250 insertions(+) create mode 100644 src/components/reaction-menu/index.tsx create mode 100644 src/components/reaction-menu/styles.scss diff --git a/src/components/reaction-menu/index.tsx b/src/components/reaction-menu/index.tsx new file mode 100644 index 000000000..852d372da --- /dev/null +++ b/src/components/reaction-menu/index.tsx @@ -0,0 +1,184 @@ +import React, { createRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Emoji, Picker } from 'emoji-mart'; + +import { IconButton } from '@zero-tech/zui/components'; +import { IconDotsHorizontal, IconHeart } from '@zero-tech/zui/icons'; +import { ViewModes } from '../../shared-components/theme-engine'; + +import './styles.scss'; + +export interface Properties { + onOpenChange?: (isOpen: boolean) => void; + onSelectReaction: (emoji) => void; +} + +export interface State { + isReactionTrayOpen: boolean; + isEmojiPickerOpen: boolean; +} + +const commonEmojiMapping = { + thumbsup: '👍', + heart: '❤️', + joy: '😂', + cry: '😢', + astonished: '😲', +}; + +export class ReactionMenu extends React.Component { + ref = createRef(); + triggerRef = createRef(); + emojiPickerRef = createRef(); + + state = { + isReactionTrayOpen: false, + isEmojiPickerOpen: false, + }; + + componentDidMount() { + document.addEventListener('mousedown', this.onClickOutside); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.onClickOutside); + } + + onClickOutside = (event) => { + if ( + !( + (this.ref.current && this.ref.current.contains(event.target)) || + (this.triggerRef.current && this.triggerRef.current.contains(event.target)) || + (this.emojiPickerRef.current && this.emojiPickerRef.current.contains(event.target)) + ) + ) { + this.setState({ isReactionTrayOpen: false, isEmojiPickerOpen: false }); + this.props.onOpenChange?.(false); + } + }; + + onSelect = (emojiId) => { + const emojiCharacter = commonEmojiMapping[emojiId]; + if (emojiCharacter) { + this.props.onSelectReaction(emojiCharacter); + this.toggleReactionTray(); + } + }; + + toggleReactionTray = () => { + this.setState((prevState) => { + const isReactionTrayOpen = !prevState.isReactionTrayOpen; + this.props.onOpenChange?.(isReactionTrayOpen); + return { isReactionTrayOpen, isEmojiPickerOpen: false }; + }); + }; + + toggleEmojiPicker = () => { + this.setState((prevState) => { + const isEmojiPickerOpen = !prevState.isEmojiPickerOpen; + if (isEmojiPickerOpen) { + this.setState({ isReactionTrayOpen: false }); + } + return { isEmojiPickerOpen }; + }); + }; + + renderCommonReactions() { + const commonReactions = [ + 'thumbsup', + 'heart', + 'joy', + 'cry', + 'astonished', + ]; + + return commonReactions.map((emojiId) => ( + this.onSelect(emojiId)}> + + + )); + } + + renderReactionTray() { + if (!this.triggerRef.current) { + return null; + } + + const triggerRect = this.triggerRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const trayHeight = 56; + const trayWidth = 256; + + const shouldRenderAbove = triggerRect.bottom + trayHeight > viewportHeight; + + const trayStyles = { + top: shouldRenderAbove + ? `${triggerRect.top + window.scrollY - trayHeight - 8}px` + : `${triggerRect.bottom + window.scrollY + 8}px`, + left: `${triggerRect.left + window.scrollX + triggerRect.width / 2 - trayWidth / 2}px`, + transformOrigin: shouldRenderAbove ? 'bottom center' : 'top center', + transform: 'translateY(0)', + }; + + return createPortal( + <> +
+ +
+ {this.renderCommonReactions()} + +
+ +
+
+ , + document.body + ); + } + + renderEmojiPicker() { + return createPortal( +
+ this.insertEmoji(emoji)} + /> +
, + document.body + ); + } + + insertEmoji = (emoji: any) => { + if (emoji && emoji.native) { + this.props.onSelectReaction(emoji.native); + this.toggleEmojiPicker(); + } + }; + + render() { + const { isReactionTrayOpen, isEmojiPickerOpen } = this.state; + + return ( +
+
+ +
+ + {isReactionTrayOpen && this.renderReactionTray()} + {isEmojiPickerOpen && this.renderEmojiPicker()} +
+ ); + } +} diff --git a/src/components/reaction-menu/styles.scss b/src/components/reaction-menu/styles.scss new file mode 100644 index 000000000..8d9120f79 --- /dev/null +++ b/src/components/reaction-menu/styles.scss @@ -0,0 +1,66 @@ +@use '~@zero-tech/zui/styles/theme' as theme; +@import '../../glass'; + +.reaction-tray { + @include flat-thick; + @include glass-shadow-and-blur; + + display: flex; + flex-direction: row; + align-items: center; + padding: 4px; + border-radius: 8px; + min-width: 128px; + max-width: 256px; + position: fixed; + z-index: 1002; +} + +.reaction-tray-item { + @include glass-text-primary-color; + + display: flex; + align-items: center; + outline: none; + border-radius: 8px; + user-select: none; + cursor: pointer; + padding: 8px; + + &:hover { + @include glass-state-hover-color; + } + + &:active { + background: rgba(163, 162, 163, 0.1); + } +} + +.reaction-menu__underlay { + z-index: 1000; + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + pointer-events: auto; + background: transparent; +} + +.reaction-menu-trigger { + z-index: 1003; + outline: none; +} + +.emoji-picker { + position: absolute; + right: 65px; + bottom: 75px; + z-index: 1010; + color: var(--color-greyscale-11); +} + +.emoji-picker-trigger-icon { + background: theme.$color-greyscale-transparency-3; +}