Переглянути джерело

feat(chat) add message reactions

factor2
Patrick He 1 рік тому
джерело
коміт
7bb2f1eaad
Аккаунт користувача з таким Email не знайдено

+ 1
- 0
lang/main.json Переглянути файл

@@ -1260,6 +1260,7 @@
1260 1260
             "privateMessage": "Send private message",
1261 1261
             "profile": "Edit your profile",
1262 1262
             "raiseHand": "Raise your hand",
1263
+            "react": "Message reactions",
1263 1264
             "reactions": "Reactions",
1264 1265
             "reactionsMenu": "Reactions menu",
1265 1266
             "recording": "Toggle recording",

+ 1
- 0
react/features/base/conference/reducer.ts Переглянути файл

@@ -131,6 +131,7 @@ export interface IJitsiConference {
131 131
     sendLobbyMessage: Function;
132 132
     sendMessage: Function;
133 133
     sendPrivateTextMessage: Function;
134
+    sendReaction: Function;
134 135
     sendTextMessage: Function;
135 136
     sendTones: Function;
136 137
     sessionId: string;

+ 24
- 0
react/features/chat/actionTypes.ts Переглянути файл

@@ -13,6 +13,18 @@
13 13
  */
14 14
 export const ADD_MESSAGE = 'ADD_MESSAGE';
15 15
 
16
+/**
17
+ * The type of the action that adds a reaction to a chat message.
18
+ *
19
+ * {
20
+ *     type: ADD_MESSAGE_REACTION,
21
+ *     reaction: string,
22
+ *     messageID: string,
23
+ *     receiverID: string,
24
+ * }
25
+ */
26
+export const ADD_MESSAGE_REACTION = 'ADD_MESSAGE_REACTION';
27
+
16 28
 /**
17 29
  * The type of the action which signals to clear messages in Redux.
18 30
  *
@@ -62,6 +74,18 @@ export const OPEN_CHAT = 'OPEN_CHAT';
62 74
  */
63 75
 export const SEND_MESSAGE = 'SEND_MESSAGE';
64 76
 
77
+/**
78
+ * The type of the action which signals a reaction to a message.
79
+ *
80
+ * {
81
+ *     type: SEND_REACTION,
82
+ *     reaction: string,
83
+ *     messageID: string,
84
+ *     receiverID: string
85
+ * }
86
+ */
87
+export const SEND_REACTION = 'SEND_REACTION';
88
+
65 89
 /**
66 90
  * The type of action which signals the initiation of sending of as private message to the
67 91
  * supplied recipient.

+ 41
- 0
react/features/chat/actions.any.ts Переглянути файл

@@ -6,11 +6,13 @@ import { LOBBY_CHAT_INITIALIZED } from '../lobby/constants';
6 6
 
7 7
 import {
8 8
     ADD_MESSAGE,
9
+    ADD_MESSAGE_REACTION,
9 10
     CLEAR_MESSAGES,
10 11
     CLOSE_CHAT,
11 12
     EDIT_MESSAGE,
12 13
     REMOVE_LOBBY_CHAT_PARTICIPANT,
13 14
     SEND_MESSAGE,
15
+    SEND_REACTION,
14 16
     SET_IS_POLL_TAB_FOCUSED,
15 17
     SET_LOBBY_CHAT_ACTIVE_STATE,
16 18
     SET_LOBBY_CHAT_RECIPIENT,
@@ -49,6 +51,27 @@ export function addMessage(messageDetails: Object) {
49 51
     };
50 52
 }
51 53
 
54
+/**
55
+ * Adds a reaction to a chat message.
56
+ *
57
+ * @param {Object} reactionDetails - The reaction to add.
58
+ * @param {string} reactionDetails.participantId - The ID of the message to react to.
59
+ * @param {string} reactionDetails.reactionList - The reaction to add.
60
+ * @param {string} reactionDetails.messageId - The receiver ID of the reaction.
61
+ * @returns {{
62
+ *     type: ADD_MESSAGE_REACTION,
63
+ *     participantId: string,
64
+ *     reactionList: string[],
65
+ *     messageId: string
66
+ * }}
67
+ */
68
+export function addMessageReaction(reactionDetails: Object) {
69
+    return {
70
+        type: ADD_MESSAGE_REACTION,
71
+        ...reactionDetails
72
+    };
73
+}
74
+
52 75
 /**
53 76
  * Edits an existing chat message.
54 77
  *
@@ -111,6 +134,24 @@ export function sendMessage(message: string, ignorePrivacy = false) {
111 134
     };
112 135
 }
113 136
 
137
+/**
138
+ * Sends a reaction to a message.
139
+ *
140
+ * @param {string} reaction - The reaction to send.
141
+ * @param {string} messageId - The message ID to react to.
142
+ * @param {string} receiverId - The receiver ID of the reaction.
143
+ * @returns {Function}
144
+ */
145
+export function sendReaction(reaction: string, messageId: string, receiverId?: string) {
146
+
147
+    return {
148
+        type: SEND_REACTION,
149
+        reaction,
150
+        messageId,
151
+        receiverId
152
+    };
153
+}
154
+
114 155
 /**
115 156
  * Initiates the sending of a private message to the supplied participant.
116 157
  *

+ 257
- 61
react/features/chat/components/web/ChatMessage.tsx Переглянути файл

@@ -1,28 +1,43 @@
1 1
 import { Theme } from '@mui/material';
2
-import React from 'react';
2
+import React, { useCallback, useState } from 'react';
3 3
 import { connect } from 'react-redux';
4 4
 import { makeStyles } from 'tss-react/mui';
5 5
 
6 6
 import { IReduxState } from '../../../app/types';
7 7
 import { translate } from '../../../base/i18n/functions';
8
+import { getParticipantDisplayName } from '../../../base/participants/functions';
9
+import Popover from '../../../base/popover/components/Popover.web';
8 10
 import Message from '../../../base/react/components/web/Message';
9 11
 import { withPixelLineHeight } from '../../../base/styles/functions.web';
10
-import { getCanReplyToMessage, getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
12
+import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
11 13
 import { IChatMessageProps } from '../../types';
12 14
 
13
-import PrivateMessageButton from './PrivateMessageButton';
15
+import MessageMenu from './MessageMenu';
16
+import ReactButton from './ReactButton';
14 17
 
15 18
 interface IProps extends IChatMessageProps {
16
-
19
+    shouldDisplayChatMessageMenu: boolean;
20
+    state?: IReduxState;
17 21
     type: string;
18 22
 }
19 23
 
20 24
 const useStyles = makeStyles()((theme: Theme) => {
21 25
     return {
26
+        chatMessageFooter: {
27
+            display: 'flex',
28
+            flexDirection: 'row',
29
+            justifyContent: 'space-between',
30
+            alignItems: 'center',
31
+            marginTop: theme.spacing(1)
32
+        },
33
+        chatMessageFooterLeft: {
34
+            display: 'flex',
35
+            flexGrow: 1,
36
+            overflow: 'hidden'
37
+        },
22 38
         chatMessageWrapper: {
23 39
             maxWidth: '100%'
24 40
         },
25
-
26 41
         chatMessage: {
27 42
             display: 'inline-flex',
28 43
             padding: '12px',
@@ -35,96 +50,173 @@ const useStyles = makeStyles()((theme: Theme) => {
35 50
             '&.privatemessage': {
36 51
                 backgroundColor: theme.palette.support05
37 52
             },
38
-
39 53
             '&.local': {
40 54
                 backgroundColor: theme.palette.ui04,
41 55
                 borderRadius: '12px 4px 12px 12px',
42 56
 
43 57
                 '&.privatemessage': {
44 58
                     backgroundColor: theme.palette.support05
59
+                },
60
+                '&.local': {
61
+                    backgroundColor: theme.palette.ui04,
62
+                    borderRadius: '12px 4px 12px 12px',
63
+
64
+                    '&.privatemessage': {
65
+                        backgroundColor: theme.palette.support05
66
+                    }
67
+                },
68
+
69
+                '&.error': {
70
+                    backgroundColor: theme.palette.actionDanger,
71
+                    borderRadius: 0,
72
+                    fontWeight: 100
73
+                },
74
+
75
+                '&.lobbymessage': {
76
+                    backgroundColor: theme.palette.support05
45 77
                 }
46 78
             },
47
-
48 79
             '&.error': {
49 80
                 backgroundColor: theme.palette.actionDanger,
50 81
                 borderRadius: 0,
51 82
                 fontWeight: 100
52 83
             },
53
-
54 84
             '&.lobbymessage': {
55 85
                 backgroundColor: theme.palette.support05
56 86
             }
57 87
         },
58
-
88
+        sideBySideContainer: {
89
+            display: 'flex',
90
+            flexDirection: 'row',
91
+            justifyContent: 'left',
92
+            alignItems: 'center',
93
+            marginLeft: theme.spacing(1)
94
+        },
95
+        reactionBox: {
96
+            display: 'flex',
97
+            alignItems: 'center',
98
+            gap: theme.spacing(1),
99
+            backgroundColor: theme.palette.grey[800],
100
+            borderRadius: theme.shape.borderRadius,
101
+            padding: theme.spacing(0, 1),
102
+            cursor: 'pointer'
103
+        },
104
+        reactionCount: {
105
+            fontSize: '0.8rem',
106
+            color: theme.palette.grey[400]
107
+        },
108
+        replyButton: {
109
+            padding: '2px'
110
+        },
59 111
         replyWrapper: {
60 112
             display: 'flex',
61 113
             flexDirection: 'row' as const,
62 114
             alignItems: 'center',
63 115
             maxWidth: '100%'
64 116
         },
65
-
66 117
         messageContent: {
67 118
             maxWidth: '100%',
68 119
             overflow: 'hidden',
69 120
             flex: 1
70 121
         },
71
-
72
-        replyButtonContainer: {
122
+        optionsButtonContainer: {
73 123
             display: 'flex',
74
-            alignItems: 'flex-start',
75
-            height: '100%'
76
-        },
77
-
78
-        replyButton: {
79
-            padding: '2px'
124
+            flexDirection: 'column',
125
+            alignItems: 'center',
126
+            gap: theme.spacing(1),
127
+            minWidth: '32px',
128
+            minHeight: '32px'
80 129
         },
81
-
82 130
         displayName: {
83 131
             ...withPixelLineHeight(theme.typography.labelBold),
84 132
             color: theme.palette.text02,
85 133
             whiteSpace: 'nowrap',
86 134
             textOverflow: 'ellipsis',
87 135
             overflow: 'hidden',
88
-            marginBottom: theme.spacing(1)
136
+            marginBottom: theme.spacing(1),
137
+            maxWidth: '130px'
89 138
         },
90
-
91 139
         userMessage: {
92 140
             ...withPixelLineHeight(theme.typography.bodyShortRegular),
93 141
             color: theme.palette.text01,
94 142
             whiteSpace: 'pre-wrap',
95 143
             wordBreak: 'break-word'
96 144
         },
97
-
98 145
         privateMessageNotice: {
99 146
             ...withPixelLineHeight(theme.typography.labelRegular),
100 147
             color: theme.palette.text02,
101 148
             marginTop: theme.spacing(1)
102 149
         },
103
-
104 150
         timestamp: {
105 151
             ...withPixelLineHeight(theme.typography.labelRegular),
106 152
             color: theme.palette.text03,
107
-            marginTop: theme.spacing(1)
153
+            marginTop: theme.spacing(1),
154
+            marginLeft: theme.spacing(1),
155
+            whiteSpace: 'nowrap',
156
+            flexShrink: 0
157
+        },
158
+        reactionsPopover: {
159
+            padding: theme.spacing(2),
160
+            backgroundColor: theme.palette.ui03,
161
+            borderRadius: theme.shape.borderRadius,
162
+            maxWidth: '150px',
163
+            maxHeight: '400px',
164
+            overflowY: 'auto',
165
+            color: theme.palette.text01
166
+        },
167
+        reactionItem: {
168
+            display: 'flex',
169
+            alignItems: 'center',
170
+            marginBottom: theme.spacing(1),
171
+            gap: theme.spacing(1),
172
+            borderBottom: `1px solid ${theme.palette.common.white}`,
173
+            paddingBottom: theme.spacing(1),
174
+            '&:last-child': {
175
+                borderBottom: 'none',
176
+                paddingBottom: 0
177
+            }
178
+        },
179
+        participantList: {
180
+            marginLeft: theme.spacing(1),
181
+            fontSize: '0.8rem',
182
+            maxWidth: '120px'
183
+        },
184
+        participant: {
185
+            overflow: 'hidden',
186
+            textOverflow: 'ellipsis',
187
+            whiteSpace: 'nowrap'
108 188
         }
109 189
     };
110 190
 });
111 191
 
112
-/**
113
- * Renders a single chat message.
114
- *
115
- * @param {IProps} props - Component's props.
116
- * @returns {JSX}
117
- */
118 192
 const ChatMessage = ({
119
-    canReply,
120
-    knocking,
121 193
     message,
194
+    state,
122 195
     showDisplayName,
123
-    showTimestamp,
124 196
     type,
197
+    shouldDisplayChatMessageMenu,
198
+    knocking,
125 199
     t
126 200
 }: IProps) => {
127 201
     const { classes, cx } = useStyles();
202
+    const [ isHovered, setIsHovered ] = useState(false);
203
+    const [ isReactionsOpen, setIsReactionsOpen ] = useState(false);
204
+
205
+    const handleMouseEnter = useCallback(() => {
206
+        setIsHovered(true);
207
+    }, []);
208
+
209
+    const handleMouseLeave = useCallback(() => {
210
+        setIsHovered(false);
211
+    }, []);
212
+
213
+    const handleReactionsOpen = useCallback(() => {
214
+        setIsReactionsOpen(true);
215
+    }, []);
216
+
217
+    const handleReactionsClose = useCallback(() => {
218
+        setIsReactionsOpen(false);
219
+    }, []);
128 220
 
129 221
     /**
130 222
      * Renders the display name of the sender.
@@ -167,42 +259,144 @@ const ChatMessage = ({
167 259
         );
168 260
     }
169 261
 
262
+    /**
263
+     * Renders the reactions for the message.
264
+     *
265
+     * @returns {React$Element<*>}
266
+     */
267
+    const renderReactions = () => {
268
+        if (!message.reactions || message.reactions.size === 0) {
269
+            return null;
270
+        }
271
+
272
+        const reactionsArray = Array.from(message.reactions.entries())
273
+            .map(([ reaction, participants ]) => {
274
+                return { reaction,
275
+                    participants };
276
+            })
277
+            .sort((a, b) => b.participants.size - a.participants.size);
278
+
279
+        const totalReactions = reactionsArray.reduce((sum, { participants }) => sum + participants.size, 0);
280
+        const numReactionsDisplayed = 3;
281
+
282
+        const reactionsContent = (
283
+            <div className = { classes.reactionsPopover }>
284
+                {reactionsArray.map(({ reaction, participants }) => (
285
+                    <div
286
+                        className = { classes.reactionItem }
287
+                        key = { reaction }>
288
+                        <span>{reaction}</span>
289
+                        <span>{participants.size}</span>
290
+                        <div className = { classes.participantList }>
291
+                            {Array.from(participants).map(participantId => (
292
+                                <div
293
+                                    className = { classes.participant }
294
+                                    key = { participantId }>
295
+                                    {state && getParticipantDisplayName(state, participantId)}
296
+                                </div>
297
+                            ))}
298
+                        </div>
299
+                    </div>
300
+                ))}
301
+            </div>
302
+        );
303
+
304
+        return (
305
+            <Popover
306
+                content = { reactionsContent }
307
+                onPopoverClose = { handleReactionsClose }
308
+                onPopoverOpen = { handleReactionsOpen }
309
+                position = 'top'
310
+                trigger = 'hover'
311
+                visible = { isReactionsOpen }>
312
+                <div className = { classes.reactionBox }>
313
+                    {reactionsArray.slice(0, numReactionsDisplayed).map(({ reaction }, index) =>
314
+                        <span key = { index }>{reaction}</span>
315
+                    )}
316
+                    {reactionsArray.length > numReactionsDisplayed && (
317
+                        <span className = { classes.reactionCount }>
318
+                            +{totalReactions - numReactionsDisplayed}
319
+                        </span>
320
+                    )}
321
+                </div>
322
+            </Popover>
323
+        );
324
+    };
325
+
170 326
     return (
171 327
         <div
172 328
             className = { cx(classes.chatMessageWrapper, type) }
173 329
             id = { message.messageId }
330
+            onMouseEnter = { handleMouseEnter }
331
+            onMouseLeave = { handleMouseLeave }
174 332
             tabIndex = { -1 }>
175
-            <div
176
-                className = { cx('chatmessage', classes.chatMessage, type,
177
-                    message.privateMessage && 'privatemessage',
178
-                    message.lobbyChat && !knocking && 'lobbymessage') }>
179
-                <div className = { classes.replyWrapper }>
180
-                    <div className = { cx('messagecontent', classes.messageContent) }>
181
-                        {showDisplayName && _renderDisplayName()}
182
-                        <div className = { cx('usermessage', classes.userMessage) }>
183
-                            <span className = 'sr-only'>
184
-                                {message.displayName === message.recipient
185
-                                    ? t('chat.messageAccessibleTitleMe')
186
-                                    : t('chat.messageAccessibleTitle',
187
-                                        { user: message.displayName })}
188
-                            </span>
189
-                            <Message text = { getMessageText(message) } />
333
+            <div className = { classes.sideBySideContainer }>
334
+                {!shouldDisplayChatMessageMenu && (
335
+                    <div className = { classes.optionsButtonContainer }>
336
+                        {isHovered && <MessageMenu
337
+                            isLobbyMessage = { message.lobbyChat }
338
+                            message = { message.message }
339
+                            participantId = { message.participantId }
340
+                            shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
341
+                    </div>
342
+                )}
343
+                <div
344
+                    className = { cx(
345
+                        'chatmessage',
346
+                        classes.chatMessage,
347
+                        type,
348
+                        message.privateMessage && 'privatemessage',
349
+                        message.lobbyChat && !knocking && 'lobbymessage'
350
+                    ) }>
351
+                    <div className = { classes.replyWrapper }>
352
+                        <div className = { cx('messagecontent', classes.messageContent) }>
353
+                            {showDisplayName && _renderDisplayName()}
354
+                            <div className = { cx('usermessage', classes.userMessage) }>
355
+                                <span className = 'sr-only'>
356
+                                    {message.displayName === message.recipient
357
+                                        ? t('chat.messageAccessibleTitleMe')
358
+                                        : t('chat.messageAccessibleTitle', {
359
+                                            user: message.displayName
360
+                                        })}
361
+                                </span>
362
+                                <Message text = { getMessageText(message) } />
363
+                                {(message.privateMessage || (message.lobbyChat && !knocking))
364
+                                    && _renderPrivateNotice()}
365
+                                <div className = { classes.chatMessageFooter }>
366
+                                    <div className = { classes.chatMessageFooterLeft }>
367
+                                        {message.reactions && message.reactions.size > 0 && (
368
+                                            <>
369
+                                                {renderReactions()}
370
+                                            </>
371
+                                        )}
372
+                                    </div>
373
+                                    {_renderTimestamp()}
374
+                                </div>
375
+                            </div>
190 376
                         </div>
191
-                        {(message.privateMessage || (message.lobbyChat && !knocking))
192
-                            && _renderPrivateNotice()}
193 377
                     </div>
194
-                    {canReply
195
-                        && (
196
-                            <div
197
-                                className = { classes.replyButtonContainer }>
198
-                                <PrivateMessageButton
378
+                </div>
379
+                {shouldDisplayChatMessageMenu && (
380
+                    <div className = { classes.sideBySideContainer }>
381
+                        {!message.privateMessage && <div>
382
+                            <div className = { classes.optionsButtonContainer }>
383
+                                {isHovered && <ReactButton
384
+                                    messageId = { message.messageId }
385
+                                    receiverId = { '' } />}
386
+                            </div>
387
+                        </div>}
388
+                        <div>
389
+                            <div className = { classes.optionsButtonContainer }>
390
+                                {isHovered && <MessageMenu
199 391
                                     isLobbyMessage = { message.lobbyChat }
200
-                                    participantID = { message.participantId } />
392
+                                    message = { message.message }
393
+                                    participantId = { message.participantId }
394
+                                    shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
201 395
                             </div>
202
-                        )}
203
-                </div>
396
+                        </div>
397
+                    </div>
398
+                )}
204 399
             </div>
205
-            {showTimestamp && _renderTimestamp()}
206 400
         </div>
207 401
     );
208 402
 };
@@ -215,10 +409,12 @@ const ChatMessage = ({
215 409
  */
216 410
 function _mapStateToProps(state: IReduxState, { message }: IProps) {
217 411
     const { knocking } = state['features/lobby'];
412
+    const localParticipantId = state['features/base/participants'].local?.id;
218 413
 
219 414
     return {
220
-        canReply: getCanReplyToMessage(state, message),
221
-        knocking
415
+        shouldDisplayChatMessageMenu: message.participantId !== localParticipantId,
416
+        knocking,
417
+        state
222 418
     };
223 419
 }
224 420
 

+ 1
- 0
react/features/chat/components/web/ChatMessageGroup.tsx Переглянути файл

@@ -73,6 +73,7 @@ const ChatMessageGroup = ({ className = '', messages }: IProps) => {
73 73
                     <ChatMessage
74 74
                         key = { i }
75 75
                         message = { message }
76
+                        shouldDisplayChatMessageMenu = { false }
76 77
                         showDisplayName = { i === 0 }
77 78
                         showTimestamp = { i === messages.length - 1 }
78 79
                         type = { className } />

+ 60
- 0
react/features/chat/components/web/EmojiSelector.tsx Переглянути файл

@@ -0,0 +1,60 @@
1
+import { Theme } from '@mui/material';
2
+import React, { useCallback } from 'react';
3
+import { makeStyles } from 'tss-react/mui';
4
+
5
+interface IProps {
6
+    onSelect: (emoji: string) => void;
7
+}
8
+
9
+const useStyles = makeStyles()((theme: Theme) => {
10
+    return {
11
+        emojiGrid: {
12
+            display: 'flex',
13
+            flexDirection: 'row',
14
+            borderRadius: '4px',
15
+            backgroundColor: theme.palette.ui03
16
+        },
17
+
18
+        emojiButton: {
19
+            cursor: 'pointer',
20
+            padding: '5px',
21
+            fontSize: '1.5em'
22
+        }
23
+    };
24
+});
25
+
26
+const EmojiSelector: React.FC<IProps> = ({ onSelect }) => {
27
+    const { classes } = useStyles();
28
+
29
+    const emojiMap: Record<string, string> = {
30
+        thumbsUp: '👍',
31
+        redHeart: '❤️',
32
+        faceWithTearsOfJoy: '😂',
33
+        faceWithOpenMouth: '😮',
34
+        fire: '🔥'
35
+    };
36
+    const emojiNames = Object.keys(emojiMap);
37
+
38
+    const handleSelect = useCallback(
39
+        (emoji: string) => (event: React.MouseEvent<HTMLSpanElement>) => {
40
+            event.preventDefault();
41
+            onSelect(emoji);
42
+        },
43
+        [ onSelect ]
44
+    );
45
+
46
+    return (
47
+        <div className = { classes.emojiGrid }>
48
+            {emojiNames.map(name => (
49
+                <span
50
+                    className = { classes.emojiButton }
51
+                    key = { name }
52
+                    onClick = { handleSelect(emojiMap[name]) }>
53
+                    {emojiMap[name]}
54
+                </span>
55
+            ))}
56
+        </div>
57
+    );
58
+};
59
+
60
+export default EmojiSelector;

+ 164
- 0
react/features/chat/components/web/MessageMenu.tsx Переглянути файл

@@ -0,0 +1,164 @@
1
+import React, { useCallback, useRef, useState } from 'react';
2
+import ReactDOM from 'react-dom';
3
+import { useTranslation } from 'react-i18next';
4
+import { useDispatch, useSelector } from 'react-redux';
5
+import { makeStyles } from 'tss-react/mui';
6
+
7
+import { IReduxState } from '../../../app/types';
8
+import { IconDotsHorizontal } from '../../../base/icons/svg';
9
+import { getParticipantById } from '../../../base/participants/functions';
10
+import Popover from '../../../base/popover/components/Popover.web';
11
+import Button from '../../../base/ui/components/web/Button';
12
+import { BUTTON_TYPES } from '../../../base/ui/constants.any';
13
+import { copyText } from '../../../base/util/copyText.web';
14
+import { handleLobbyChatInitialized, openChat } from '../../actions.web';
15
+
16
+export interface IProps {
17
+    className?: string;
18
+    isLobbyMessage: boolean;
19
+    message: string;
20
+    participantId: string;
21
+    shouldDisplayChatMessageMenu: boolean;
22
+}
23
+
24
+const useStyles = makeStyles()(theme => {
25
+    return {
26
+        messageMenuButton: {
27
+            padding: '2px'
28
+        },
29
+        menuItem: {
30
+            padding: '8px 16px',
31
+            cursor: 'pointer',
32
+            color: 'white',
33
+            '&:hover': {
34
+                backgroundColor: theme.palette.action03
35
+            }
36
+        },
37
+        menuPanel: {
38
+            backgroundColor: theme.palette.ui03,
39
+            borderRadius: theme.shape.borderRadius,
40
+            boxShadow: theme.shadows[3],
41
+            overflow: 'hidden'
42
+        },
43
+        copiedMessage: {
44
+            position: 'fixed',
45
+            backgroundColor: theme.palette.ui03,
46
+            color: 'white',
47
+            padding: '4px 8px',
48
+            borderRadius: '4px',
49
+            fontSize: '12px',
50
+            zIndex: 1000,
51
+            opacity: 0,
52
+            transition: 'opacity 0.3s ease-in-out',
53
+            pointerEvents: 'none'
54
+        },
55
+        showCopiedMessage: {
56
+            opacity: 1
57
+        }
58
+    };
59
+});
60
+
61
+const MessageMenu = ({ message, participantId, isLobbyMessage, shouldDisplayChatMessageMenu }: IProps) => {
62
+    const dispatch = useDispatch();
63
+    const { classes, cx } = useStyles();
64
+    const { t } = useTranslation();
65
+    const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
66
+    const [ showCopiedMessage, setShowCopiedMessage ] = useState(false);
67
+    const [ popupPosition, setPopupPosition ] = useState({ top: 0,
68
+        left: 0 });
69
+    const buttonRef = useRef<HTMLDivElement>(null);
70
+
71
+    const participant = useSelector((state: IReduxState) => getParticipantById(state, participantId));
72
+
73
+    const handleMenuClick = useCallback(() => {
74
+        setIsPopoverOpen(true);
75
+    }, []);
76
+
77
+    const handleClose = useCallback(() => {
78
+        setIsPopoverOpen(false);
79
+    }, []);
80
+
81
+    const handlePrivateClick = useCallback(() => {
82
+        if (isLobbyMessage) {
83
+            dispatch(handleLobbyChatInitialized(participantId));
84
+        } else {
85
+            dispatch(openChat(participant));
86
+        }
87
+        handleClose();
88
+    }, [ dispatch, isLobbyMessage, participant, participantId ]);
89
+
90
+    const handleCopyClick = useCallback(() => {
91
+        copyText(message)
92
+            .then(success => {
93
+                if (success) {
94
+                    if (buttonRef.current) {
95
+                        const rect = buttonRef.current.getBoundingClientRect();
96
+
97
+                        setPopupPosition({
98
+                            top: rect.top - 30,
99
+                            left: rect.left
100
+                        });
101
+                    }
102
+                    setShowCopiedMessage(true);
103
+                    setTimeout(() => {
104
+                        setShowCopiedMessage(false);
105
+                    }, 2000);
106
+                } else {
107
+                    console.error('Failed to copy text');
108
+                }
109
+            })
110
+            .catch(error => {
111
+                console.error('Error copying text:', error);
112
+            });
113
+        handleClose();
114
+    }, [ message ]);
115
+
116
+    const popoverContent = (
117
+        <div className = { classes.menuPanel }>
118
+            {shouldDisplayChatMessageMenu && (
119
+                <div
120
+                    className = { classes.menuItem }
121
+                    onClick = { handlePrivateClick }>
122
+                    {t('Private Message')}
123
+                </div>
124
+            )}
125
+            <div
126
+                className = { classes.menuItem }
127
+                onClick = { handleCopyClick }>
128
+                {t('Copy')}
129
+            </div>
130
+        </div>
131
+    );
132
+
133
+    return (
134
+        <div>
135
+            <div ref = { buttonRef }>
136
+                <Popover
137
+                    content = { popoverContent }
138
+                    onPopoverClose = { handleClose }
139
+                    position = 'top'
140
+                    trigger = 'click'
141
+                    visible = { isPopoverOpen }>
142
+                    <Button
143
+                        accessibilityLabel = { t('toolbar.accessibilityLabel.moreOptions') }
144
+                        className = { classes.messageMenuButton }
145
+                        icon = { IconDotsHorizontal }
146
+                        onClick = { handleMenuClick }
147
+                        type = { BUTTON_TYPES.TERTIARY } />
148
+                </Popover>
149
+            </div>
150
+
151
+            {showCopiedMessage && ReactDOM.createPortal(
152
+                <div
153
+                    className = { cx(classes.copiedMessage, { [classes.showCopiedMessage]: showCopiedMessage }) }
154
+                    style = {{ top: `${popupPosition.top}px`,
155
+                        left: `${popupPosition.left}px` }}>
156
+                    {t('Message Copied')}
157
+                </div>,
158
+                document.body
159
+            )}
160
+        </div>
161
+    );
162
+};
163
+
164
+export default MessageMenu;

+ 87
- 0
react/features/chat/components/web/ReactButton.tsx Переглянути файл

@@ -0,0 +1,87 @@
1
+import { Theme } from '@mui/material';
2
+import React, { useCallback, useState } from 'react';
3
+import { useTranslation } from 'react-i18next';
4
+import { useDispatch } from 'react-redux';
5
+import { makeStyles } from 'tss-react/mui';
6
+
7
+import { IconFaceSmile } from '../../../base/icons/svg';
8
+import Popover from '../../../base/popover/components/Popover.web';
9
+import Button from '../../../base/ui/components/web/Button';
10
+import { BUTTON_TYPES } from '../../../base/ui/constants.any';
11
+import { sendReaction } from '../../actions.any';
12
+
13
+import EmojiSelector from './EmojiSelector';
14
+
15
+interface IProps {
16
+    messageId: string;
17
+    receiverId: string;
18
+}
19
+
20
+const useStyles = makeStyles()((theme: Theme) => {
21
+    return {
22
+        reactButton: {
23
+            padding: '2px'
24
+        },
25
+        reactionPanelContainer: {
26
+            position: 'relative',
27
+            display: 'inline-block'
28
+        },
29
+        popoverContent: {
30
+            backgroundColor: theme.palette.background.paper,
31
+            borderRadius: theme.shape.borderRadius,
32
+            boxShadow: theme.shadows[3],
33
+            overflow: 'hidden'
34
+        }
35
+    };
36
+});
37
+
38
+const ReactButton = ({ messageId, receiverId }: IProps) => {
39
+    const { classes } = useStyles();
40
+    const dispatch = useDispatch();
41
+    const { t } = useTranslation();
42
+
43
+    const onSendReaction = useCallback(emoji => {
44
+        dispatch(sendReaction(emoji, messageId, receiverId));
45
+    }, [ dispatch, messageId, receiverId ]);
46
+
47
+    const [ isPopoverOpen, setIsPopoverOpen ] = useState(false);
48
+
49
+    const handleReactClick = useCallback(() => {
50
+        setIsPopoverOpen(true);
51
+    }, []);
52
+
53
+    const handleClose = useCallback(() => {
54
+        setIsPopoverOpen(false);
55
+    }, []);
56
+
57
+    const handleEmojiSelect = useCallback((emoji: string) => {
58
+        onSendReaction(emoji);
59
+        handleClose();
60
+    }, [ onSendReaction, handleClose ]);
61
+
62
+    const popoverContent = (
63
+        <div className = { classes.popoverContent }>
64
+            <EmojiSelector onSelect = { handleEmojiSelect } />
65
+        </div>
66
+    );
67
+
68
+    return (
69
+        <Popover
70
+            content = { popoverContent }
71
+            onPopoverClose = { handleClose }
72
+            position = 'top'
73
+            trigger = 'click'
74
+            visible = { isPopoverOpen }>
75
+            <div className = { classes.reactionPanelContainer }>
76
+                <Button
77
+                    accessibilityLabel = { t('toolbar.accessibilityLabel.react') }
78
+                    className = { classes.reactButton }
79
+                    icon = { IconFaceSmile }
80
+                    onClick = { handleReactClick }
81
+                    type = { BUTTON_TYPES.TERTIARY } />
82
+            </div>
83
+        </Popover>
84
+    );
85
+};
86
+
87
+export default ReactButton;

+ 53
- 3
react/features/chat/middleware.ts Переглянути файл

@@ -34,9 +34,15 @@ import { ENDPOINT_REACTION_NAME } from '../reactions/constants';
34 34
 import { getReactionMessageFromBuffer, isReactionsEnabled } from '../reactions/functions.any';
35 35
 import { showToolbox } from '../toolbox/actions';
36 36
 
37
-
38
-import { ADD_MESSAGE, CLOSE_CHAT, OPEN_CHAT, SEND_MESSAGE, SET_IS_POLL_TAB_FOCUSED } from './actionTypes';
39
-import { addMessage, clearMessages, closeChat } from './actions.any';
37
+import {
38
+    ADD_MESSAGE,
39
+    CLOSE_CHAT,
40
+    OPEN_CHAT,
41
+    SEND_MESSAGE,
42
+    SEND_REACTION,
43
+    SET_IS_POLL_TAB_FOCUSED
44
+} from './actionTypes';
45
+import { addMessage, addMessageReaction, clearMessages, closeChat } from './actions.any';
40 46
 import { ChatPrivacyDialog } from './components';
41 47
 import {
42 48
     INCOMING_MSG_SOUND_ID,
@@ -209,6 +215,18 @@ MiddlewareRegistry.register(store => next => action => {
209 215
         break;
210 216
     }
211 217
 
218
+    case SEND_REACTION: {
219
+        const state = store.getState();
220
+        const conference = getCurrentConference(state);
221
+
222
+        if (conference) {
223
+            const { reaction, messageId, receiverId } = action;
224
+
225
+            conference.sendReaction(reaction, messageId, receiverId);
226
+        }
227
+        break;
228
+    }
229
+
212 230
     case ADD_REACTION_MESSAGE: {
213 231
         if (localParticipant?.id) {
214 232
             _handleReceivedMessage(store, {
@@ -289,6 +307,17 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
289 307
         }
290 308
     );
291 309
 
310
+    conference.on(
311
+        JitsiConferenceEvents.REACTION_RECEIVED,
312
+        (participantId: string, reactionList: string[], messageId: string) => {
313
+            _onReactionReceived(store, {
314
+                participantId,
315
+                reactionList,
316
+                messageId
317
+            });
318
+        }
319
+    );
320
+
292 321
     conference.on(
293 322
         JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
294 323
         (participantId: string, message: string, timestamp: number, messageId: string) => {
@@ -341,6 +370,27 @@ function _onConferenceMessageReceived(store: IStore,
341 370
     }, true, isGif);
342 371
 }
343 372
 
373
+/**
374
+ * Handles a received reaction.
375
+ *
376
+ * @param {Object} store - Redux store.
377
+ * @param {string} participantId - Id of the participant that sent the message.
378
+ * @param {string} reactionList - The list of received reactions.
379
+ * @param {string} messageId - The id of the message that the reaction is for.
380
+ * @returns {void}
381
+ */
382
+function _onReactionReceived(store: IStore, { participantId, reactionList, messageId }: {
383
+    messageId: string; participantId: string; reactionList: string[]; }) {
384
+
385
+    const reactionPayload = {
386
+        participantId,
387
+        reactionList,
388
+        messageId
389
+    };
390
+
391
+    store.dispatch(addMessageReaction(reactionPayload));
392
+}
393
+
344 394
 /**
345 395
  * Handles a received gif message.
346 396
  *

+ 36
- 0
react/features/chat/reducer.ts Переглянути файл

@@ -3,6 +3,7 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
3 3
 
4 4
 import {
5 5
     ADD_MESSAGE,
6
+    ADD_MESSAGE_REACTION,
6 7
     CLEAR_MESSAGES,
7 8
     CLOSE_CHAT,
8 9
     EDIT_MESSAGE,
@@ -20,6 +21,7 @@ const DEFAULT_STATE = {
20 21
     isPollsTabFocused: false,
21 22
     lastReadMessage: undefined,
22 23
     messages: [],
24
+    reactions: {},
23 25
     nbUnreadMessages: 0,
24 26
     privateMessageRecipient: undefined,
25 27
     lobbyMessageRecipient: undefined,
@@ -51,6 +53,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
51 53
             messageId: action.messageId,
52 54
             messageType: action.messageType,
53 55
             message: action.message,
56
+            reactions: action.reactions,
54 57
             privateMessage: action.privateMessage,
55 58
             lobbyChat: action.lobbyChat,
56 59
             recipient: action.recipient,
@@ -77,6 +80,39 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
77 80
         };
78 81
     }
79 82
 
83
+    case ADD_MESSAGE_REACTION: {
84
+        const { participantId, reactionList, messageId } = action;
85
+
86
+        const messages = state.messages.map(message => {
87
+            if (messageId === message.messageId) {
88
+                const newReactions = new Map(message.reactions);
89
+
90
+                reactionList.forEach((reaction: string) => {
91
+                    let participants = newReactions.get(reaction);
92
+
93
+                    if (!participants) {
94
+                        participants = new Set();
95
+                        newReactions.set(reaction, participants);
96
+                    }
97
+
98
+                    participants.add(participantId);
99
+                });
100
+
101
+                return {
102
+                    ...message,
103
+                    reactions: newReactions
104
+                };
105
+            }
106
+
107
+            return message;
108
+        });
109
+
110
+        return {
111
+            ...state,
112
+            messages
113
+        };
114
+    }
115
+
80 116
     case CLEAR_MESSAGES:
81 117
         return {
82 118
             ...state,

+ 6
- 0
react/features/chat/types.ts Переглянути файл

@@ -12,6 +12,7 @@ export interface IMessage {
12 12
     messageType: string;
13 13
     participantId: string;
14 14
     privateMessage: boolean;
15
+    reactions: Map<string, Set<string>>;
15 16
     recipient: string;
16 17
     timestamp: number;
17 18
 }
@@ -59,6 +60,11 @@ export interface IChatMessageProps extends WithTranslation {
59 60
      */
60 61
     message: IMessage;
61 62
 
63
+    /**
64
+     * Whether the chat message menu is visible or not.
65
+     */
66
+    shouldDisplayChatMessageMenu?: boolean;
67
+
62 68
     /**
63 69
      * Whether or not the avatar image of the participant which sent the message
64 70
      * should be displayed.

Завантаження…
Відмінити
Зберегти