Emoji Reacts (#2320)

Add support for emoji reacts in conversations

Resolves #2375 and #1577
This commit is contained in:
Will G 2022-08-22 16:40:14 +10:00 committed by GitHub
parent 67153bbb07
commit 267f49ff1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 6227 additions and 693 deletions

View File

@ -1,7 +1,6 @@
{
"includePaths": [
"node_modules/sanitize.css",
"node_modules/emoji-mart/css",
"node_modules/react-h5-audio-player/lib",
"node_modules/react-contexify/dist",
"node_modules/react-toastify/dist"

View File

@ -109,6 +109,7 @@
"moreInformation": "More information",
"resend": "Resend",
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
"clear": "Clear",
"clearAllData": "Clear All Data",
"deleteAccountWarning": "This will permanently delete your messages, and contacts.",
"deleteContactConfirmation": "Are you sure you want to delete this conversation?",
@ -451,5 +452,11 @@
"clearAllConfirmationTitle": "Clear All Message Requests",
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
"hideBanner": "Hide",
"openMessageRequestInboxDescription": "View your Message Request inbox"
"openMessageRequestInboxDescription": "View your Message Request inbox",
"clearAllReactions": "Are you sure you want to clear all $emoji$ ?",
"reactionTooltip": "reacted with",
"expandedReactionsText": "Show Less",
"reactionNotification": "Reacts to a message with $emoji$",
"readableListCounterSingular": "other",
"readableListCounterPlural": "others"
}

BIN
fonts/Roboto-Black.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Roboto-Light.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Roboto-Medium.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Roboto-Thin.ttf Normal file

Binary file not shown.

BIN
fonts/Roboto-ThinItalic.ttf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

View File

@ -65,12 +65,12 @@
"coverage": "nyc --reporter=html mocha -r jsdom-global/register --recursive --exit --timeout 10000 \"./ts/test/**/*_test.js\"",
"lint-full": "yarn format-full && eslint . && tslint --format stylish --project .",
"format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"",
"transpile": "yarn tsc && yarn parcel-util-worker",
"transpile": "yarn tsc && yarn parcel-util-worker && yarn sass",
"transpile:watch": "yarn grunt --force; tsc -w",
"integration-test": "npx playwright test",
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots",
"clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
"ready": "yarn sass && yarn grunt && yarn lint-full && yarn test",
"ready": "yarn grunt && yarn lint-full && yarn test",
"sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json",
"sass": "rimraf 'stylesheets/dist/' && parcel build --target sass --no-autoinstall --no-cache",
@ -79,6 +79,7 @@
"rebuild-curve25519-js": "cd node_modules/curve25519-js && yarn install && yarn build && cd ../../"
},
"dependencies": {
"@emoji-mart/data": "1.0.2",
"@reduxjs/toolkit": "^1.4.0",
"abort-controller": "3.0.0",
"auto-bind": "^4.0.0",
@ -99,7 +100,7 @@
"electron-is-dev": "^1.1.0",
"electron-localshortcut": "^3.2.1",
"electron-updater": "^4.2.2",
"emoji-mart": "^2.11.2",
"emoji-mart": "5.1.0",
"filesize": "3.6.1",
"firstline": "1.2.1",
"fs-extra": "9.0.0",
@ -164,7 +165,7 @@
"@types/config": "0.0.34",
"@types/dompurify": "^2.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/emoji-mart": "^2.11.3",
"@types/emoji-mart": "3.0.9",
"@types/filesize": "3.6.0",
"@types/firstline": "^2.0.2",
"@types/fs-extra": "5.0.5",
@ -329,16 +330,6 @@
"sound/*",
"build/assets",
"node_modules/**",
"!node_modules/emoji-panel/dist/*",
"!node_modules/emoji-panel/lib/emoji-panel-emojione-*.css",
"!node_modules/emoji-panel/lib/emoji-panel-google-*.css",
"!node_modules/emoji-panel/lib/emoji-panel-twitter-*.css",
"!node_modules/emoji-panel/lib/emoji-panel-apple-{16,20,64}.css",
"!node_modules/emoji-datasource/emoji_pretty.json",
"!node_modules/emoji-datasource/*.png",
"!node_modules/emoji-datasource-apple/emoji_pretty.json",
"!node_modules/emoji-datasource-apple/img/apple/{sheets-128,sheets-256}/*.png",
"!node_modules/emoji-datasource-apple/img/apple/sheets/{16,20,32}.png",
"!node_modules/spellchecker/vendor/hunspell/**/*",
"!node_modules/@iconify/icons-mdi/*",
"node_modules/@iconify/icons-mdi/play-circle*",

File diff suppressed because one or more lines are too long

View File

@ -76,6 +76,20 @@ message DataMessage {
EXPIRATION_TIMER_UPDATE = 2;
}
message Reaction {
enum Action {
REACT = 0;
REMOVE = 1;
}
// @required
required uint64 id = 1; // Message timestamp
// @required
required string author = 2;
optional string emoji = 3;
// @required
required Action action = 4;
}
message Quote {
message QuotedAttachment {
@ -153,6 +167,7 @@ message DataMessage {
optional uint64 timestamp = 7;
optional Quote quote = 8;
repeated Preview preview = 10;
optional Reaction reaction = 11;
optional LokiProfile profile = 101;
optional OpenGroupInvitation openGroupInvitation = 102;
optional ClosedGroupControlMessage closedGroupControlMessage = 104;

View File

@ -28,9 +28,3 @@
@include color-svg($svg, black);
}
}
@keyframes highlightedMessageAnimation {
1% {
background-color: #00f782;
}
}

View File

@ -104,27 +104,28 @@
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.3);
}
button {
float: right;
margin-inline-start: 10px;
background-color: $color-loki-green;
border-radius: 100px;
padding: 5px 15px;
border: 1px solid $color-loki-green;
color: white;
outline: none;
user-select: none;
// TODO is this being used anywhere? Seems not
// button {
// float: right;
// margin-inline-start: 10px;
// background-color: $color-loki-green;
// border-radius: 100px;
// padding: 5px 15px;
// border: 1px solid $color-loki-green;
// color: white;
// outline: none;
// user-select: none;
&:hover,
&:disabled {
background-color: $color-loki-green-dark;
border-color: $color-loki-green-dark;
}
// &:hover,
// &:disabled {
// background-color: $color-loki-green-dark;
// border-color: $color-loki-green-dark;
// }
&:disabled {
cursor: not-allowed;
}
}
// &:disabled {
// cursor: not-allowed;
// }
// }
input {
width: 100%;
@ -258,7 +259,7 @@
font-size: $session-font-md;
padding: 0px $session-margin-lg;
font-family: $session-font-default;
font-weight: 100;
font-weight: 400;
color: var(--color-text);
font-size: $session-font-md;
@ -362,3 +363,15 @@
}
}
}
.reaction-list-modal {
.session-confirm-wrapper {
.session-modal__body {
width: 376px;
padding: 0;
.session-modal__centered {
margin: 0;
}
}
}
}

View File

@ -427,6 +427,7 @@ label {
background-color: var(--color-modal-background);
color: var(--color-text);
border: var(--border-session);
border-radius: 14px;
box-shadow: var(--color-session-shadow);
overflow: hidden;
@ -603,10 +604,14 @@ label {
z-index: 30;
min-width: 200px;
box-shadow: 0 10px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19) !important;
background: var(--color-cell-background);
background-color: var(--color-received-message-background);
&.react-contexify__theme--dark {
background-color: var(--color-received-message-background);
}
.react-contexify__item {
background: var(--color-cell-background);
background: var(--color-received-message-background);
}
.react-contexify__item:not(.react-contexify__item--disabled):hover
@ -881,7 +886,7 @@ label {
&__description {
font-family: $session-font-default;
font-size: $session-font-sm;
font-weight: 100;
font-weight: 400;
max-width: 700px;
@include session-color-subtle(var(--color-text));
}
@ -1142,30 +1147,6 @@ input {
}
}
.messages-container {
.session-icon-button {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
border-radius: 50%;
opacity: 1;
background-color: var(--color-cell-background);
box-shadow: var(--color-session-shadow);
svg path {
transition: $session-transition-duration;
opacity: 0.6;
fill: var(--color-text);
}
&:hover svg path {
opacity: 1;
}
}
}
.group-member-list {
&__container {
padding: 2px 0px;

View File

@ -14,24 +14,71 @@ $session-font-mono: 'SpaceMono';
// Roboto is an open replacement for $session-font-default
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
font-weight: 300;
src: url('../fonts/Roboto-Thin.ttf') format('truetype');
font-weight: 100;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
src: url('../fonts/Roboto-ThinItalic.ttf') format('truetype');
font-style: italic;
font-weight: 100;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Light.ttf') format('truetype');
font-weight: 300;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-LightItalic.ttf') format('truetype');
font-style: italic;
font-weight: 300;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
font-weight: 400;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
font-style: italic;
font-weight: 400;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Medium.ttf') format('truetype');
font-weight: 500;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-MediumItalic.ttf') format('truetype');
font-style: italic;
font-weight: 500;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
font-weight: 600;
font-weight: 700;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-BoldItalic.ttf') format('truetype');
font-weight: 600;
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-Black.ttf') format('truetype');
font-weight: 900;
}
@font-face {
font-family: $session-font-default;
src: url('../fonts/Roboto-BlackItalic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}

View File

@ -142,17 +142,6 @@
}
}
.messages-container {
display: flex;
flex-grow: 1;
flex-direction: column-reverse;
position: relative;
overflow-x: hidden;
min-width: 370px;
scrollbar-width: 4px;
padding: $session-margin-sm $session-margin-lg $session-margin-lg;
}
.composition-container {
display: flex;
justify-content: center;
@ -184,122 +173,6 @@
width: 30px;
}
}
.send-message-input {
cursor: text;
display: flex;
align-items: center;
flex-grow: 1;
min-height: $composition-container-height;
padding: $session-margin-xs 0;
z-index: 1;
background-color: inherit;
ul {
max-height: 70vh;
overflow: auto;
}
textarea {
font-family: $session-font-default;
min-height: calc($composition-container-height / 3);
max-height: 3 * $composition-container-height;
margin-right: $session-margin-md;
color: var(--color-text);
background: transparent;
resize: none;
display: flex;
flex-grow: 1;
outline: none;
border: none;
font-size: 14px;
line-height: $session-font-h2;
letter-spacing: 0.5px;
}
&__emoji-overlay {
// Should have identical properties to the textarea above to line up perfectly.
position: absolute;
font-size: 14px;
font-family: $session-font-default;
margin-left: 2px;
line-height: $session-font-h2;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0);
}
}
}
.session-emoji-panel {
position: absolute;
bottom: 68px;
right: 0px;
padding: $session-margin-lg;
z-index: 5;
opacity: 0;
visibility: hidden;
transition: $session-transition-duration;
button:focus {
outline: none;
}
&.show {
opacity: 1;
visibility: visible;
}
& > section.emoji-mart {
font-family: $session-font-default;
font-size: $session-font-sm;
background-color: var(--color-cell-background);
border: 1px solid var(--color-session-border);
border-radius: 8px;
padding-bottom: $session-margin-sm;
.emoji-mart-category-label {
top: -2px;
span {
font-family: $session-font-default;
padding-top: $session-margin-sm;
background-color: var(--color-cell-background);
}
}
.emoji-mart-scroll {
height: 340px;
}
.emoji-mart-category .emoji-mart-emoji span {
cursor: pointer;
}
.emoji-mart-bar:last-child {
border: none;
.emoji-mart-preview {
display: none;
}
}
&:after {
content: '';
position: absolute;
top: calc(100% - 40px);
left: calc(100% - 79px);
width: 22px;
height: 22px;
background-color: var(--color-cell-background);
transform: rotate(45deg);
border-radius: 3px;
transform: scaleY(1.4) rotate(45deg);
border: 0.7px solid var(--color-session-border);
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
}
}
}
.session-progress {

View File

@ -103,24 +103,6 @@
}
}
.session-message-wrapper {
letter-spacing: 0.03em;
margin-top: 3px;
display: flex;
align-items: center;
&.message-highlighted {
animation: highlightedMessageAnimation 1s ease-in-out;
}
&.message-selected {
.module-message {
&__container {
box-shadow: var(--color-session-shadow);
}
}
}
}
.inbox {
background: var(--color-inbox-background);
color: var(--color-text);

View File

@ -1,5 +1,4 @@
// Modules
@import '../node_modules/emoji-mart/css/emoji-mart.css';
@import '../node_modules/react-h5-audio-player/lib/styles.css';
@import '../node_modules/react-contexify/dist/ReactContexify.min.css';
@import '../node_modules/react-toastify/dist/ReactToastify.css';

View File

@ -15,6 +15,7 @@ export enum SessionButtonColor {
Green = 'green',
White = 'white',
Primary = 'primary',
Secondary = 'secondary',
Success = 'success',
Danger = 'danger',
Warning = 'warning',

View File

@ -1,32 +1,137 @@
import React from 'react';
import React, { forwardRef, MutableRefObject, useEffect } from 'react';
import classNames from 'classnames';
import { Picker } from 'emoji-mart';
import { Constants } from '../../session';
import styled from 'styled-components';
import data from '@emoji-mart/data';
// @ts-ignore
import { Picker } from '../../../node_modules/emoji-mart/dist/index.cjs';
import { useSelector } from 'react-redux';
import { getTheme } from '../../state/selectors/theme';
import { noop } from 'lodash';
import { loadEmojiPanelI18n } from '../../util/i18n';
import { FixedBaseEmoji, FixedPickerProps } from '../../types/Reaction';
export const StyledEmojiPanel = styled.div<{ isModal: boolean; theme: 'light' | 'dark' }>`
padding: var(--margins-lg);
z-index: 5;
opacity: 0;
visibility: hidden;
transition: var(--default-duration);
button:focus {
outline: none;
}
&.show {
opacity: 1;
visibility: visible;
}
em-emoji-picker {
background-color: var(--color-cell-background);
border: 1px solid var(--color-session-border);
padding-bottom: var(--margins-sm);
--shadow: none;
--border-radius: 8px;
--color-border: var(--color-session-border);
--font-family: var(--font-default);
--font-size: var(--font-size-sm);
--rgb-accent: 0, 247, 130; // Constants.UI.COLORS.GREEN
${props => {
switch (props.theme) {
case 'dark':
return `
--background-rgb: 27, 27, 27; // var(--color-cell-background)
--rgb-background: 27, 27, 27;
--rgb-color: 255, 255, 255; // var(--color-text)
--rgb-input: 27, 27, 27;
`;
case 'light':
default:
return `
--background-rgb: 249, 249, 249; // var(--color-cell-background)
--rgb-background: 249, 249, 249;
--rgb-color: 0, 0, 0; // var(--color-text)
--rgb-input: 249, 249, 249;
`;
}
}}
${props =>
!props.isModal &&
`
&:after {
content: '';
position: absolute;
top: calc(100% - 40px);
left: calc(100% - 79px);
width: 22px;
height: 22px;
background-color: var(--color-cell-background);
transform: rotate(45deg);
border-radius: 3px;
transform: scaleY(1.4) rotate(45deg);
border: 0.7px solid var(--color-session-border);
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
}
`}
}
`;
type Props = {
onEmojiClicked: (emoji: any) => void;
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
show: boolean;
isModal?: boolean;
onKeyDown?: (event: any) => void;
};
export const SessionEmojiPanel = (props: Props) => {
const { onEmojiClicked, show } = props;
const darkMode = useSelector(getTheme) === 'dark';
const pickerProps: FixedPickerProps = {
title: '',
showPreview: true,
autoFocus: true,
skinTonePosition: 'preview',
};
export const SessionEmojiPanel = forwardRef<HTMLDivElement, Props>((props: Props, ref) => {
const { onEmojiClicked, show, isModal = false, onKeyDown } = props;
const theme = useSelector(getTheme);
const pickerRef = ref as MutableRefObject<HTMLDivElement>;
useEffect(() => {
let isCancelled = false;
if (pickerRef.current !== null) {
if (pickerRef.current.children.length === 0) {
loadEmojiPanelI18n()
.then(async i18n => {
if (isCancelled) {
return;
}
// tslint:disable-next-line: no-unused-expression
new Picker({
data,
ref,
i18n,
theme,
onEmojiSelect: onEmojiClicked,
onKeyDown,
...pickerProps,
});
})
.catch(noop);
}
}
return () => {
isCancelled = true;
};
}, [data, pickerProps]);
return (
<div className={classNames('session-emoji-panel', show && 'show')}>
<Picker
backgroundImageFn={() => './images/emoji/emoji-sheet-twitter-32.png'}
set={'twitter'}
sheetSize={32}
darkMode={darkMode}
color={Constants.UI.COLORS.GREEN}
showPreview={true}
title={''}
onSelect={onEmojiClicked}
autoFocus={true}
/>
</div>
<StyledEmojiPanel
isModal={isModal}
theme={theme}
className={classNames(show && 'show')}
ref={ref}
/>
);
};
});

View File

@ -24,6 +24,7 @@ import {
getSortedMessagesOfSelectedConversation,
} from '../../state/selectors/conversations';
import { TypingBubble } from './TypingBubble';
import styled from 'styled-components';
export type SessionMessageListProps = {
messageContainerRef: React.RefObject<HTMLDivElement>;
@ -52,6 +53,39 @@ type Props = SessionMessageListProps & {
scrollToNow: () => Promise<void>;
};
const StyledMessagesContainer = styled.div<{}>`
display: flex;
flex-grow: 1;
flex-direction: column-reverse;
position: relative;
overflow-x: hidden;
min-width: 370px;
scrollbar-width: 4px;
padding: var(--margins-sm) 0 var(--margins-lg);
.session-icon-button {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 40px;
border-radius: 50%;
opacity: 1;
background-color: var(--color-cell-background);
box-shadow: var(--color-session-shadow);
svg path {
transition: var(--default-duration);
opacity: 0.6;
fill: var(--color-text);
}
&:hover svg path {
opacity: 1;
}
}
`;
class SessionMessagesListContainerInner extends React.Component<Props> {
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
@ -101,7 +135,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
}
return (
<div
<StyledMessagesContainer
className="messages-container"
id={messageContainerDomID}
onScroll={this.handleScroll}
@ -135,7 +169,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
onClickScrollBottom={this.props.scrollToNow}
key="scroll-down-button"
/>
</div>
</StyledMessagesContainer>
);
}

View File

@ -3,7 +3,7 @@ import _, { debounce, isEmpty } from 'lodash';
import * as MIME from '../../../types/MIME';
import { SessionEmojiPanel } from '../SessionEmojiPanel';
import { SessionEmojiPanel, StyledEmojiPanel } from '../SessionEmojiPanel';
import { SessionRecording } from '../SessionRecording';
import {
@ -55,6 +55,8 @@ import {
} from './UserMentions';
import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult';
import { LinkPreviews } from '../../../util/linkPreviews';
import styled from 'styled-components';
import { FixedBaseEmoji } from '../../../types/Reaction';
export interface ReplyingToMessageProps {
convoId: string;
@ -203,6 +205,59 @@ const getSelectionBasedOnMentions = (draft: string, index: number) => {
return Number.MAX_SAFE_INTEGER;
};
const StyledEmojiPanelContainer = styled.div`
${StyledEmojiPanel} {
position: absolute;
bottom: 68px;
right: 0px;
}
`;
const StyledSendMessageInput = styled.div`
cursor: text;
display: flex;
align-items: center;
flex-grow: 1;
min-height: var(--compositionContainerHeight);
padding: var(--margins-xs) 0;
z-index: 1;
background-color: inherit;
ul {
max-height: 70vh;
overflow: auto;
}
textarea {
font-family: var(--font-default);
min-height: calc(var(--compositionContainerHeight) / 3);
max-height: 3 * var(--compositionContainerHeight);
margin-right: var(--margins-md);
color: var(--color-text);
background: transparent;
resize: none;
display: flex;
flex-grow: 1;
outline: none;
border: none;
font-size: 14px;
line-height: var(--font-size-h2);
letter-spacing: 0.5px;
}
&__emoji-overlay {
// Should have identical properties to the textarea above to line up perfectly.
position: absolute;
font-size: 14px;
font-family: var(--font-default);
margin-left: 2px;
line-height: var(--font-size-h2);
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0);
}
`;
class CompositionBoxInner extends React.Component<Props, State> {
private readonly textarea: React.RefObject<any>;
private readonly fileInput: React.RefObject<HTMLInputElement>;
@ -369,8 +424,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
{typingEnabled && <StartRecordingButton onClick={this.onLoadVoiceNoteView} />}
<div
className="send-message-input"
<StyledSendMessageInput
role="main"
onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container
ref={el => {
@ -379,19 +433,22 @@ class CompositionBoxInner extends React.Component<Props, State> {
data-testid="message-input"
>
{this.renderTextArea()}
</div>
</StyledSendMessageInput>
{typingEnabled && (
<ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} />
)}
<SendMessageButton onClick={this.onSendMessage} />
{typingEnabled && (
<div ref={this.emojiPanel} onKeyDown={this.onKeyDown} role="button">
{showEmojiPanel && (
<SessionEmojiPanel onEmojiClicked={this.onEmojiClick} show={showEmojiPanel} />
)}
</div>
{typingEnabled && showEmojiPanel && (
<StyledEmojiPanelContainer role="button">
<SessionEmojiPanel
ref={this.emojiPanel}
show={showEmojiPanel}
onEmojiClicked={this.onEmojiClick}
onKeyDown={this.onKeyDown}
/>
</StyledEmojiPanelContainer>
)}
</>
);
@ -978,7 +1035,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft });
}
private onEmojiClick({ native }: any) {
private onEmojiClick(emoji: FixedBaseEmoji) {
if (!this.props.selectedConversationKey) {
throw new Error('selectedConversationKey is needed');
}
@ -996,7 +1053,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
const before = draft.slice(0, realSelectionStart);
const end = draft.slice(realSelectionStart);
const newMessage = `${before}${native}${end}`;
const newMessage = `${before}${emoji.native}${end}`;
this.setState({ draft: newMessage });
updateDraftForConversation({
conversationKey: this.props.selectedConversationKey,

View File

@ -1,7 +1,9 @@
import React from 'react';
import { SuggestionDataItem } from 'react-mentions';
import styled from 'styled-components';
import { BaseEmoji, emojiIndex } from 'emoji-mart';
// @ts-ignore
import { SearchIndex } from '../../../../node_modules/emoji-mart/dist/index.cjs';
import { searchSync } from '../../../util/emoji.js';
const EmojiQuickResult = styled.span`
width: 100%;
@ -25,22 +27,24 @@ export const renderEmojiQuickResultRow = (suggestion: SuggestionDataItem) => {
};
export const searchEmojiForQuery = (query: string): Array<SuggestionDataItem> => {
if (query.length === 0 || !emojiIndex) {
if (query.length === 0 || !SearchIndex) {
return [];
}
const results1 = emojiIndex.search(`:${query}`) || [];
const results2 = emojiIndex.search(query) || [];
const results1 = searchSync(`:${query}`);
const results2 = searchSync(query);
const results = [...new Set(results1.concat(results2))];
if (!results || !results.length) {
return [];
}
return results
.map(o => {
const onlyBaseEmoji = o as BaseEmoji;
const cleanResults = results
.map(emoji => {
return {
id: onlyBaseEmoji.native,
display: onlyBaseEmoji.colons,
id: emoji.skins[0].native,
display: `:${emoji.id}:`,
};
})
.slice(0, 8);
return cleanResults;
};

View File

@ -1,17 +1,21 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { replyToMessage } from '../../../../interactions/conversationInteractions';
import { MessageRenderingProps } from '../../../../models/messageType';
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
import { updateReactListModal } from '../../../../state/ducks/modalDialog';
import {
getMessageContentWithStatusesSelectorProps,
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { sendMessageReaction } from '../../../../util/reactions';
import { MessageAuthorText } from './MessageAuthorText';
import { MessageContent } from './MessageContent';
import { MessageContextMenu } from './MessageContextMenu';
import { MessageReactions, StyledMessageReactions } from './MessageReactions';
import { MessageStatus } from './MessageStatus';
export type MessageContentWithStatusSelectorProps = Pick<
@ -24,8 +28,21 @@ type Props = {
ctxMenuID: string;
isDetailView?: boolean;
dataTestId?: string;
enableReactions: boolean;
};
const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }>`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: ${props => (props.direction === 'left' ? 'flex-start' : 'flex-end')};
width: 100%;
${StyledMessageReactions} {
margin-right: var(--margins-sm);
}
`;
export const MessageContentWithStatuses = (props: Props) => {
const contentProps = useSelector(state =>
getMessageContentWithStatusesSelectorProps(state as any, props.messageId)
@ -63,38 +80,72 @@ export const MessageContentWithStatuses = (props: Props) => {
}
};
const { messageId, ctxMenuID, isDetailView, dataTestId } = props;
const { messageId, ctxMenuID, isDetailView, dataTestId, enableReactions } = props;
if (!contentProps) {
return null;
}
const { direction, isDeleted, hasAttachments, isTrustedForAttachmentDownload } = contentProps;
const isIncoming = direction === 'incoming';
return (
<div
className={classNames('module-message', `module-message--${direction}`)}
role="button"
onClick={onClickOnMessageOuterContainer}
onDoubleClickCapture={onDoubleClickReplyToMessage}
style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }}
data-testid={dataTestId}
>
<MessageStatus
dataTestId="msg-status-incoming"
messageId={messageId}
isCorrectSide={isIncoming}
/>
<div>
<MessageAuthorText messageId={messageId} />
const [popupReaction, setPopupReaction] = useState('');
<MessageContent messageId={messageId} isDetailView={isDetailView} />
const handleMessageReaction = async (emoji: string) => {
await sendMessageReaction(messageId, emoji);
};
const handlePopupClick = () => {
dispatch(updateReactListModal({ reaction: popupReaction, messageId }));
};
return (
<StyledMessageContentContainer
direction={isIncoming ? 'left' : 'right'}
onMouseLeave={() => {
setPopupReaction('');
}}
>
<div
className={classNames('module-message', `module-message--${direction}`)}
role="button"
onClick={onClickOnMessageOuterContainer}
onDoubleClickCapture={onDoubleClickReplyToMessage}
style={{
width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto',
}}
data-testid={dataTestId}
>
<MessageStatus
dataTestId="msg-status-incoming"
messageId={messageId}
isCorrectSide={isIncoming}
/>
<div>
<MessageAuthorText messageId={messageId} />
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</div>
<MessageStatus
dataTestId="msg-status-outgoing"
messageId={messageId}
isCorrectSide={!isIncoming}
/>
{!isDeleted && (
<MessageContextMenu
messageId={messageId}
contextMenuId={ctxMenuID}
enableReactions={enableReactions}
/>
)}
</div>
<MessageStatus
dataTestId="msg-status-outgoing"
messageId={messageId}
isCorrectSide={!isIncoming}
/>
{!isDeleted && <MessageContextMenu messageId={messageId} contextMenuId={ctxMenuID} />}
</div>
{enableReactions && (
<MessageReactions
messageId={messageId}
onClick={handleMessageReaction}
popupReaction={popupReaction}
setPopupReaction={setPopupReaction}
onPopupClick={handlePopupClick}
/>
)}
</StyledMessageContentContainer>
);
};

View File

@ -1,8 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { animation, Item, Menu } from 'react-contexify';
import { animation, Item, Menu, useContextMenu } from 'react-contexify';
import { useDispatch, useSelector } from 'react-redux';
import { useClickAway, useMouse } from 'react-use';
import styled from 'styled-components';
import { Data } from '../../../../data/data';
import { MessageInteraction } from '../../../../interactions';
import { replyToMessage } from '../../../../interactions/conversationInteractions';
@ -20,8 +22,12 @@ import {
showMessageDetailsView,
toggleSelectedMessageId,
} from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
import { sendMessageReaction } from '../../../../util/reactions';
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
import { MessageReactBar } from './MessageReactBar';
export type MessageContextMenuSelectorProps = Pick<
MessageRenderingProps,
@ -42,16 +48,43 @@ export type MessageContextMenuSelectorProps = Pick<
| 'isDeletableForEveryone'
>;
type Props = { messageId: string; contextMenuId: string };
type Props = { messageId: string; contextMenuId: string; enableReactions: boolean };
const StyledMessageContextMenu = styled.div`
position: relative;
.react-contexify {
margin-left: -104px;
}
`;
const StyledEmojiPanelContainer = styled.div<{ x: number; y: number }>`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 101;
${StyledEmojiPanel} {
position: absolute;
left: ${props => `${props.x}px`};
top: ${props => `${props.y}px`};
}
`;
// tslint:disable: max-func-body-length cyclomatic-complexity
export const MessageContextMenu = (props: Props) => {
const selected = useSelector(state => getMessageContextMenuProps(state as any, props.messageId));
const { messageId, contextMenuId, enableReactions } = props;
const dispatch = useDispatch();
const { hideAll } = useContextMenu();
const selected = useSelector((state: StateType) => getMessageContextMenuProps(state, messageId));
if (!selected) {
return null;
}
const {
attachments,
sender,
@ -68,14 +101,28 @@ export const MessageContextMenu = (props: Props) => {
timestamp,
isBlocked,
} = selected;
const { messageId, contextMenuId } = props;
const isOutgoing = direction === 'outgoing';
const showRetry = status === 'error' && isOutgoing;
const isSent = status === 'sent' || status === 'read'; // a read message should be replyable
const onContextMenuShown = useCallback(() => {
const emojiPanelRef = useRef<HTMLDivElement>(null);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
// emoji-mart v5.1 default dimensions
const emojiPanelWidth = 354;
const emojiPanelHeight = 435;
const contextMenuRef = useRef(null);
const { docX, docY } = useMouse(contextMenuRef);
const [mouseX, setMouseX] = useState(0);
const [mouseY, setMouseY] = useState(0);
const onContextMenuShown = () => {
if (showEmojiPanel) {
setShowEmojiPanel(false);
}
window.contextMenuShown = true;
}, []);
};
const onContextMenuHidden = useCallback(() => {
// This function will called before the click event
@ -174,46 +221,120 @@ export const MessageContextMenu = (props: Props) => {
void deleteMessagesByIdForEveryone([messageId], convoId);
}, [convoId, messageId]);
return (
<Menu
id={contextMenuId}
onShown={onContextMenuShown}
onHidden={onContextMenuHidden}
animation={animation.fade}
>
{attachments?.length ? (
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
) : null}
const onShowEmoji = () => {
hideAll();
setMouseX(docX);
setMouseY(docY);
setShowEmojiPanel(true);
};
<Item onClick={copyText}>{window.i18n('copyMessage')}</Item>
{(isSent || !isOutgoing) && <Item onClick={onReply}>{window.i18n('replyToMessage')}</Item>}
{(!isPublic || isOutgoing) && (
<Item onClick={onShowDetail}>{window.i18n('moreInformation')}</Item>
const onCloseEmoji = () => {
setShowEmojiPanel(false);
hideAll();
};
const onEmojiLoseFocus = () => {
window.log.info('closed due to lost focus');
onCloseEmoji();
};
const onEmojiClick = async (args: any) => {
const emoji = args.native ?? args;
onCloseEmoji();
await sendMessageReaction(messageId, emoji);
};
const onEmojiKeyDown = (event: any) => {
if (event.key === 'Escape' && showEmojiPanel) {
onCloseEmoji();
}
};
useClickAway(emojiPanelRef, () => {
onEmojiLoseFocus();
});
useEffect(() => {
if (emojiPanelRef.current && emojiPanelRef.current) {
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
if (mouseX + emojiPanelWidth > windowWidth) {
let x = mouseX;
x = (mouseX + emojiPanelWidth - windowWidth) * 2;
if (x === mouseX) {
return;
}
setMouseX(mouseX - x);
}
if (mouseY + emojiPanelHeight > windowHeight) {
const y = mouseY + emojiPanelHeight * 1.25 - windowHeight;
if (y === mouseY) {
return;
}
setMouseY(mouseY - y);
}
}
}, [emojiPanelRef.current, emojiPanelWidth, emojiPanelHeight, mouseX, mouseY]);
return (
<StyledMessageContextMenu ref={contextMenuRef}>
{enableReactions && showEmojiPanel && (
<StyledEmojiPanelContainer role="button" x={mouseX} y={mouseY}>
<SessionEmojiPanel
ref={emojiPanelRef}
onEmojiClicked={onEmojiClick}
show={showEmojiPanel}
isModal={true}
onKeyDown={onEmojiKeyDown}
/>
</StyledEmojiPanelContainer>
)}
{showRetry ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null}
{isDeletable ? (
<>
<Item onClick={onSelect}>{selectMessageText}</Item>
</>
) : null}
{isDeletable && !isPublic ? (
<>
<Item onClick={onDelete}>{deleteMessageJustForMeText}</Item>
</>
) : null}
{isDeletableForEveryone ? (
<>
<Item onClick={onDeleteForEveryone}>{unsendMessageText}</Item>
</>
) : null}
{weAreAdmin && isPublic ? <Item onClick={onBan}>{window.i18n('banUser')}</Item> : null}
{weAreAdmin && isPublic ? <Item onClick={onUnban}>{window.i18n('unbanUser')}</Item> : null}
{weAreAdmin && isPublic && !isSenderAdmin ? (
<Item onClick={addModerator}>{window.i18n('addAsModerator')}</Item>
) : null}
{weAreAdmin && isPublic && isSenderAdmin ? (
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
) : null}
</Menu>
<Menu
id={contextMenuId}
onShown={onContextMenuShown}
onHidden={onContextMenuHidden}
animation={animation.fade}
>
{enableReactions && (
<MessageReactBar action={onEmojiClick} additionalAction={onShowEmoji} />
)}
{attachments?.length ? (
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
) : null}
<Item onClick={copyText}>{window.i18n('copyMessage')}</Item>
{(isSent || !isOutgoing) && <Item onClick={onReply}>{window.i18n('replyToMessage')}</Item>}
{(!isPublic || isOutgoing) && (
<Item onClick={onShowDetail}>{window.i18n('moreInformation')}</Item>
)}
{showRetry ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null}
{isDeletable ? (
<>
<Item onClick={onSelect}>{selectMessageText}</Item>
</>
) : null}
{isDeletable && !isPublic ? (
<>
<Item onClick={onDelete}>{deleteMessageJustForMeText}</Item>
</>
) : null}
{isDeletableForEveryone ? (
<>
<Item onClick={onDeleteForEveryone}>{unsendMessageText}</Item>
</>
) : null}
{weAreAdmin && isPublic ? <Item onClick={onBan}>{window.i18n('banUser')}</Item> : null}
{weAreAdmin && isPublic ? <Item onClick={onUnban}>{window.i18n('unbanUser')}</Item> : null}
{weAreAdmin && isPublic && !isSenderAdmin ? (
<Item onClick={addModerator}>{window.i18n('addAsModerator')}</Item>
) : null}
{weAreAdmin && isPublic && isSenderAdmin ? (
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
) : null}
</Menu>
</StyledMessageContextMenu>
);
};

View File

@ -0,0 +1,92 @@
import React, { ReactElement, useEffect, useState } from 'react';
import styled from 'styled-components';
import { getRecentReactions } from '../../../../util/storage';
import { SessionIconButton } from '../../../icon';
import { nativeEmojiData } from '../../../../util/emoji';
import { isEqual } from 'lodash';
import { RecentReactions } from '../../../../types/Reaction';
type Props = {
action: (...args: Array<any>) => void;
additionalAction: (...args: Array<any>) => void;
};
const StyledMessageReactBar = styled.div`
background-color: var(--color-received-message-background);
border-radius: 25px;
box-shadow: 0 2px 16px 0 rgba(0, 0, 0, 0.2), 0 0px 20px 0 rgba(0, 0, 0, 0.19);
position: absolute;
top: -56px;
padding: 4px 8px;
white-space: nowrap;
width: 302px;
display: flex;
align-items: center;
.session-icon-button {
border-color: transparent !important;
box-shadow: none !important;
margin: 0 4px;
}
`;
const ReactButton = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
border-radius: 300px;
cursor: pointer;
font-size: 24px;
:hover {
background-color: var(--color-compose-view-button-background);
}
`;
export const MessageReactBar = (props: Props): ReactElement => {
const { action, additionalAction } = props;
const [recentReactions, setRecentReactions] = useState<RecentReactions>();
useEffect(() => {
const reactions = new RecentReactions(getRecentReactions());
if (reactions && !isEqual(reactions, recentReactions)) {
setRecentReactions(reactions);
}
}, []);
if (!recentReactions) {
return <></>;
}
return (
<StyledMessageReactBar>
{recentReactions &&
recentReactions.items.map(emoji => (
<ReactButton
key={emoji}
role={'img'}
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
onClick={() => {
action(emoji);
}}
>
{emoji}
</ReactButton>
))}
<SessionIconButton
iconColor={'var(--color-text)'}
iconPadding={'12px'}
iconSize={'huge2'}
iconType="plusThin"
backgroundColor={'var(--color-compose-view-button-background)'}
borderRadius="300px"
onClick={additionalAction}
/>
</StyledMessageReactBar>
);
};

View File

@ -0,0 +1,218 @@
import React, { ReactElement, useEffect, useState } from 'react';
import styled from 'styled-components';
import { MessageRenderingProps } from '../../../../models/messageType';
import { isEmpty, isEqual } from 'lodash';
import { SortedReactionList } from '../../../../types/Reaction';
import { StyledPopupContainer } from '../reactions/ReactionPopup';
import { Flex } from '../../../basic/Flex';
import { nativeEmojiData } from '../../../../util/emoji';
import { Reaction, ReactionProps } from '../reactions/Reaction';
import { SessionIcon } from '../../../icon';
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
export const popupXDefault = -101;
export const popupYDefault = -90;
const StyledMessageReactionsContainer = styled(Flex)<{ x: number; y: number }>`
${StyledPopupContainer} {
position: absolute;
top: ${props => `${props.y}px;`};
left: ${props => `${props.x}px;`};
}
`;
export const StyledMessageReactions = styled(Flex)<{ inModal: boolean }>`
${props => (props.inModal ? '' : 'max-width: 320px;')}
`;
const StyledReactionOverflow = styled.button`
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
align-items: center;
border: none;
margin-right: 4px;
margin-bottom: var(--margins-sm);
span {
background-color: var(--color-received-message-background);
border: 1px solid var(--color-inbox-background);
border-radius: 50%;
overflow: hidden;
margin-right: -9px;
padding: 1px 4.5px;
}
`;
const StyledReadLess = styled.span`
font-size: var(--font-size-xs);
margin-top: 8px;
cursor: pointer;
svg {
margin-right: 5px;
}
`;
type ReactionsProps = Omit<ReactionProps, 'emoji'>;
const Reactions = (props: ReactionsProps): ReactElement => {
const { messageId, reactions, inModal } = props;
return (
<StyledMessageReactions
container={true}
flexWrap={inModal ? 'nowrap' : 'wrap'}
alignItems={'center'}
inModal={inModal}
>
{reactions.map(([emoji, _]) => (
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
))}
</StyledMessageReactions>
);
};
interface ExpandReactionsProps extends ReactionsProps {
handleExpand: () => void;
}
const CompressedReactions = (props: ExpandReactionsProps): ReactElement => {
const { messageId, reactions, inModal, handleExpand } = props;
return (
<StyledMessageReactions
container={true}
flexWrap={inModal ? 'nowrap' : 'wrap'}
alignItems={'center'}
inModal={inModal}
>
{reactions.slice(0, 4).map(([emoji, _]) => (
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
))}
<StyledReactionOverflow onClick={handleExpand}>
{reactions
.slice(4, 7)
.reverse()
.map(([emoji, _]) => {
return (
<span
key={`${messageId}-${emoji}`}
role={'img'}
aria-label={
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined
}
>
{emoji}
</span>
);
})}
</StyledReactionOverflow>
</StyledMessageReactions>
);
};
const ExpandedReactions = (props: ExpandReactionsProps): ReactElement => {
const { handleExpand } = props;
return (
<>
<Reactions {...props} />
<StyledReadLess onClick={handleExpand}>
<SessionIcon iconType="chevron" iconSize="medium" iconRotation={180} />
{window.i18n('expandedReactionsText')}
</StyledReadLess>
</>
);
};
export type MessageReactsSelectorProps = Pick<
MessageRenderingProps,
'convoId' | 'conversationType' | 'isPublic' | 'serverId' | 'reacts' | 'sortedReacts'
>;
type Props = {
messageId: string;
hasReactLimit?: boolean;
onClick: (emoji: string) => void;
popupReaction?: string;
setPopupReaction?: (emoji: string) => void;
onPopupClick?: () => void;
inModal?: boolean;
onSelected?: (emoji: string) => boolean;
};
export const MessageReactions = (props: Props): ReactElement => {
const {
messageId,
hasReactLimit = true,
onClick,
popupReaction,
setPopupReaction,
onPopupClick,
inModal = false,
onSelected,
} = props;
const [reactions, setReactions] = useState<SortedReactionList>([]);
const [isExpanded, setIsExpanded] = useState(false);
const handleExpand = () => {
setIsExpanded(!isExpanded);
};
const [popupX, setPopupX] = useState(popupXDefault);
const [popupY, setPopupY] = useState(popupYDefault);
const msgProps = useMessageReactsPropsById(messageId);
useEffect(() => {
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
setReactions(msgProps?.sortedReacts);
}
if (!isEmpty(reactions) && isEmpty(msgProps?.sortedReacts)) {
setReactions([]);
}
}, [msgProps?.sortedReacts, reactions]);
if (!msgProps) {
return <></>;
}
const { conversationType, sortedReacts } = msgProps;
const inGroup = conversationType === 'group';
const reactLimit = 6;
const reactionsProps = {
messageId,
reactions,
inModal,
inGroup,
handlePopupX: setPopupX,
handlePopupY: setPopupY,
onClick,
popupReaction,
onSelected,
handlePopupReaction: setPopupReaction,
handlePopupClick: onPopupClick,
};
const ExtendedReactions = isExpanded ? ExpandedReactions : CompressedReactions;
return (
<StyledMessageReactionsContainer
container={true}
flexDirection={'column'}
justifyContent={'center'}
alignItems={inModal ? 'flex-start' : 'center'}
x={popupX}
y={popupY}
>
{sortedReacts &&
sortedReacts !== [] &&
(!hasReactLimit || sortedReacts.length <= reactLimit ? (
<Reactions {...reactionsProps} />
) : (
<ExtendedReactions handleExpand={handleExpand} {...reactionsProps} />
))}
</StyledMessageReactionsContainer>
);
};

View File

@ -19,6 +19,7 @@ import { ExpireTimer } from '../../ExpireTimer';
import { MessageAvatar } from '../message-content/MessageAvatar';
import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus';
import { ReadableMessage } from './ReadableMessage';
import styled, { keyframes } from 'styled-components';
export type GenericReadableMessageSelectorProps = Pick<
MessageRenderingProps,
@ -99,7 +100,50 @@ type Props = {
};
// tslint:disable: use-simple-attributes
const highlightedMessageAnimation = keyframes`
1% {
background-color: #00f782;
}
`;
const StyledReadableMessage = styled(ReadableMessage)<{
selected: boolean;
isRightClicked: boolean;
}>`
display: flex;
align-items: center;
width: 100%;
letter-spacing: 0.03em;
padding: 5px var(--margins-lg) 0;
&.message-highlighted {
animation: ${highlightedMessageAnimation} 1s ease-in-out;
}
${props =>
props.isRightClicked &&
`
background-color: var(--color-compose-view-button-background);
`}
${props =>
props.selected &&
`
&.message-selected {
.module-message {
&__container {
box-shadow: var(--color-session-shadow);
}
}
}
`}
`;
export const GenericReadableMessage = (props: Props) => {
const { ctxMenuID, messageId, isDetailView } = props;
const [enableReactions, setEnableReactions] = useState(true);
const msgProps = useSelector(state =>
getGenericReadableMessageSelectorProps(state as any, props.messageId)
);
@ -118,6 +162,13 @@ export const GenericReadableMessage = (props: Props) => {
);
const multiSelectMode = useSelector(isMessageSelectionMode);
const [isRightClicked, setIsRightClicked] = useState(false);
const onMessageLoseFocus = useCallback(() => {
if (isRightClicked) {
setIsRightClicked(false);
}
}, [isRightClicked]);
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup;
@ -125,15 +176,31 @@ export const GenericReadableMessage = (props: Props) => {
if (enableContextMenu) {
contextMenu.hideAll();
contextMenu.show({
id: props.ctxMenuID,
id: ctxMenuID,
event: e,
});
}
setIsRightClicked(enableContextMenu);
},
[props.ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup]
[ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup]
);
const { messageId, isDetailView } = props;
useEffect(() => {
if (msgProps?.convoId) {
const conversationModel = getConversationController().get(msgProps?.convoId);
if (conversationModel) {
setEnableReactions(conversationModel.hasReactions());
}
}
}, [msgProps?.convoId]);
useEffect(() => {
document.addEventListener('click', onMessageLoseFocus);
return () => {
document.removeEventListener('click', onMessageLoseFocus);
};
}, [onMessageLoseFocus]);
if (!msgProps) {
return null;
@ -156,10 +223,11 @@ export const GenericReadableMessage = (props: Props) => {
const isIncoming = direction === 'incoming';
return (
<ReadableMessage
<StyledReadableMessage
messageId={messageId}
selected={selected}
isRightClicked={isRightClicked}
className={classNames(
'session-message-wrapper',
selected && 'message-selected',
isGroup && 'public-chat-message-wrapper',
isIncoming ? 'session-message-wrapper-incoming' : 'session-message-wrapper-outgoing'
@ -178,10 +246,11 @@ export const GenericReadableMessage = (props: Props) => {
/>
)}
<MessageContentWithStatuses
ctxMenuID={props.ctxMenuID}
ctxMenuID={ctxMenuID}
messageId={messageId}
isDetailView={isDetailView}
dataTestId={`message-content-${messageId}`}
enableReactions={enableReactions}
/>
{expirationLength && expirationTimestamp && (
<ExpireTimer
@ -190,6 +259,6 @@ export const GenericReadableMessage = (props: Props) => {
expirationTimestamp={expirationTimestamp}
/>
)}
</ReadableMessage>
</StyledReadableMessage>
);
};

View File

@ -0,0 +1,158 @@
import React, { ReactElement, useRef, useState } from 'react';
import { SortedReactionList } from '../../../../types/Reaction';
import { UserUtils } from '../../../../session/utils';
import { abbreviateNumber } from '../../../../util/abbreviateNumber';
import { nativeEmojiData } from '../../../../util/emoji';
import styled from 'styled-components';
import { useMouse } from 'react-use';
import { ReactionPopup, TipPosition } from './ReactionPopup';
import { popupXDefault, popupYDefault } from '../message-content/MessageReactions';
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; showCount: boolean }>`
display: flex;
justify-content: ${props => (props.showCount ? 'flex-start' : 'center')};
align-items: center;
background-color: var(--color-received-message-background);
border-width: 1px;
border-style: solid;
border-color: ${props => (props.selected ? 'var(--color-accent)' : 'transparent')};
border-radius: 11px;
box-sizing: border-box;
padding: 0 7px;
margin: 0 4px var(--margins-sm);
height: 24px;
min-width: ${props => (props.showCount ? '48px' : '24px')};
${props => props.inModal && 'width: 100%;'}
span {
width: 100%;
}
`;
const StyledReactionContainer = styled.div<{
inModal: boolean;
}>`
position: relative;
${props => props.inModal && 'margin-right: 8px;'}
`;
export type ReactionProps = {
emoji: string;
messageId: string;
reactions: SortedReactionList;
inModal: boolean;
inGroup: boolean;
handlePopupX: (x: number) => void;
handlePopupY: (y: number) => void;
onClick: (emoji: string) => void;
popupReaction?: string;
onSelected?: (emoji: string) => boolean;
handlePopupReaction?: (emoji: string) => void;
handlePopupClick?: () => void;
};
export const Reaction = (props: ReactionProps): ReactElement => {
const {
emoji,
messageId,
reactions,
inModal,
inGroup,
handlePopupX,
handlePopupY,
onClick,
popupReaction,
onSelected,
handlePopupReaction,
handlePopupClick,
} = props;
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const senders = reactionsMap[emoji].senders ? Object.keys(reactionsMap[emoji].senders) : [];
const count = reactionsMap[emoji].count;
const showCount = count !== undefined && (count > 1 || inGroup);
const reactionRef = useRef<HTMLDivElement>(null);
const { docX, elW } = useMouse(reactionRef);
const gutterWidth = 380;
const tooltipMidPoint = 108; // width is 216px;
const [tooltipPosition, setTooltipPosition] = useState<TipPosition>('center');
const me = UserUtils.getOurPubKeyStrFromCache();
const isBlindedMe =
senders && senders.length > 0 && senders.filter(isUsAnySogsFromCache).length > 0;
const selected = () => {
if (onSelected) {
return onSelected(emoji);
}
return senders && senders.length > 0 && (senders.includes(me) || isBlindedMe);
};
const handleReactionClick = () => {
onClick(emoji);
};
return (
<StyledReactionContainer ref={reactionRef} inModal={inModal}>
<StyledReaction
showCount={showCount}
selected={selected()}
inModal={inModal}
onClick={handleReactionClick}
onMouseEnter={() => {
if (inGroup) {
const { innerWidth: windowWidth } = window;
if (handlePopupReaction) {
// overflow on far right means we shift left
if (docX + tooltipMidPoint > windowWidth) {
handlePopupX(Math.abs(popupXDefault) * 1.5 * -1);
setTooltipPosition('right');
// overflow onto conversations means we lock to the right
} else if (docX - elW <= gutterWidth + tooltipMidPoint) {
const offset = -12.5;
handlePopupX(offset);
setTooltipPosition('left');
} else {
handlePopupX(popupXDefault);
setTooltipPosition('center');
}
handlePopupReaction(emoji);
}
}
}}
>
<span
role={'img'}
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
>
{emoji}
{showCount && `\u00A0\u00A0${abbreviateNumber(count)}`}
</span>
</StyledReaction>
{inGroup && popupReaction && popupReaction === emoji && (
<ReactionPopup
messageId={messageId}
emoji={popupReaction}
senders={Object.keys(reactionsMap[popupReaction].senders)}
tooltipPosition={tooltipPosition}
onClick={() => {
if (handlePopupReaction) {
handlePopupReaction('');
}
handlePopupX(popupXDefault);
handlePopupY(popupYDefault);
setTooltipPosition('center');
if (handlePopupClick) {
handlePopupClick();
}
}}
/>
)}
</StyledReactionContainer>
);
};

View File

@ -0,0 +1,146 @@
import React, { ReactElement, useEffect, useState } from 'react';
import styled from 'styled-components';
import { Data } from '../../../../data/data';
import { PubKey } from '../../../../session/types/PubKey';
import { nativeEmojiData } from '../../../../util/emoji';
import { readableList } from '../../../../util/readableList';
export type TipPosition = 'center' | 'left' | 'right';
export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>`
display: flex;
justify-content: space-between;
align-items: center;
width: 216px;
height: 72px;
z-index: 5;
background-color: var(--color-received-message-background);
color: var(--color-pill-divider-text);
box-shadow: 0px 0px 13px rgba(0, 0, 0, 0.51);
font-size: 12px;
font-weight: 600;
overflow-wrap: break-word;
padding: 16px;
border-radius: 12px;
cursor: pointer;
&:after {
content: '';
position: absolute;
top: calc(100% - 19px);
left: ${props => {
switch (props.tooltipPosition) {
case 'left':
return '24px';
case 'right':
return 'calc(100% - 48px)';
case 'center':
default:
return 'calc(100% - 100px)';
}
}};
width: 22px;
height: 22px;
background-color: var(--color-received-message-background);
transform: rotate(45deg);
border-radius: 3px;
transform: scaleY(1.4) rotate(45deg);
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
}
`;
const StyledEmoji = styled.span`
font-size: 36px;
margin-left: 8px;
`;
const StyledOthers = styled.span`
color: var(--color-accent);
`;
const generateContacts = async (messageId: string, senders: Array<string>) => {
let results = null;
const message = await Data.getMessageById(messageId);
if (message) {
let meIndex = -1;
results = senders.map((sender, index) => {
const contact = message.findAndFormatContact(sender);
if (contact.isMe) {
meIndex = index;
}
return contact?.profileName || contact?.name || PubKey.shorten(sender);
});
if (meIndex >= 0) {
results.splice(meIndex, 1);
results = [window.i18n('you'), ...results];
}
}
return results;
};
const Contacts = (contacts: string) => {
if (!contacts) {
return;
}
if (contacts.includes('&') && contacts.includes('other')) {
const [names, others] = contacts.split('&');
return (
<span>
{names} & <StyledOthers>{others}</StyledOthers> {window.i18n('reactionTooltip')}
</span>
);
}
return (
<span>
{contacts} {window.i18n('reactionTooltip')}
</span>
);
};
type Props = {
messageId: string;
emoji: string;
senders: Array<string>;
tooltipPosition?: TipPosition;
onClick: (...args: Array<any>) => void;
};
export const ReactionPopup = (props: Props): ReactElement => {
const { messageId, emoji, senders, tooltipPosition = 'center', onClick } = props;
const [contacts, setContacts] = useState('');
useEffect(() => {
let isCancelled = false;
generateContacts(messageId, senders)
.then(async results => {
if (isCancelled) {
return;
}
if (results && results.length > 0) {
setContacts(readableList(results));
}
})
.catch(() => {
if (isCancelled) {
return;
}
});
return () => {
isCancelled = true;
};
}, [generateContacts]);
return (
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
{Contacts(contacts)}
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
{emoji}
</StyledEmoji>
</StyledPopupContainer>
);
};

View File

@ -10,6 +10,8 @@ import {
getEditProfileDialog,
getInviteContactModal,
getOnionPathDialog,
getReactClearAllDialog,
getReactListDialog,
getRecoveryPhraseDialog,
getRemoveModeratorsModal,
getSessionPasswordDialog,
@ -32,6 +34,8 @@ import { UpdateGroupMembersDialog } from './UpdateGroupMembersDialog';
import { UpdateGroupNameDialog } from './UpdateGroupNameDialog';
import { SessionNicknameDialog } from './SessionNicknameDialog';
import { BanOrUnBanUserDialog } from './BanOrUnbanUserDialog';
import { ReactListModal } from './ReactListModal';
import { ReactClearAllModal } from './ReactClearAllModal';
export const ModalContainer = () => {
const confirmModalState = useSelector(getConfirmModal);
@ -49,6 +53,8 @@ export const ModalContainer = () => {
const sessionPasswordModalState = useSelector(getSessionPasswordDialog);
const deleteAccountModalState = useSelector(getDeleteAccountModalState);
const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState);
const reactListModalState = useSelector(getReactListDialog);
const reactClearAllModalState = useSelector(getReactClearAllDialog);
return (
<>
@ -71,6 +77,8 @@ export const ModalContainer = () => {
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
{confirmModalState && <SessionConfirm {...confirmModalState} />}
{reactListModalState && <ReactListModal {...reactListModalState} />}
{reactClearAllModalState && <ReactClearAllModal {...reactClearAllModalState} />}
</>
);
};

View File

@ -0,0 +1,119 @@
import React, { ReactElement, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
import { clearSogsReactionByServerId } from '../../session/apis/open_group_api/sogsv3/sogsV3ClearReaction';
import { getConversationController } from '../../session/conversations';
import { updateReactClearAllModal } from '../../state/ducks/modalDialog';
import { getTheme } from '../../state/selectors/theme';
import { Flex } from '../basic/Flex';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionSpinner } from '../basic/SessionSpinner';
import { SessionWrapperModal } from '../SessionWrapperModal';
type Props = {
reaction: string;
messageId: string;
};
const StyledButtonContainer = styled.div`
div:first-child {
margin-right: 0px;
}
div:not(:first-child) {
margin-left: 20px;
}
`;
const StyledReactClearAllContainer = styled(Flex)<{ darkMode: boolean }>`
margin: var(--margins-lg);
p {
font-size: 18px;
font-weight: 500;
padding-bottom: var(--margins-lg);
margin: var(--margins-md) auto;
border-bottom: 1.5px solid ${props => (props.darkMode ? '#2D2D2D' : '#EEEEEE')};
span {
margin-left: 4px;
}
}
.session-button {
font-size: 16px;
height: 36px;
padding-top: 3px;
}
`;
// tslint:disable-next-line: max-func-body-length
export const ReactClearAllModal = (props: Props): ReactElement => {
const { reaction, messageId } = props;
const [clearingInProgress, setClearingInProgress] = useState(false);
const dispatch = useDispatch();
const darkMode = useSelector(getTheme) === 'dark';
const msgProps = useMessageReactsPropsById(messageId);
if (!msgProps) {
return <></>;
}
const { convoId, serverId } = msgProps;
const roomInfos = getConversationController()
.get(convoId)
.toOpenGroupV2();
const confirmButtonColor = darkMode ? SessionButtonColor.Green : SessionButtonColor.Secondary;
const handleClose = () => {
dispatch(updateReactClearAllModal(null));
};
const handleClearAll = async () => {
if (roomInfos && serverId) {
setClearingInProgress(true);
await clearSogsReactionByServerId(reaction, serverId, roomInfos);
setClearingInProgress(false);
handleClose();
} else {
window.log.warn('Error for batch removal of', reaction, 'on message', messageId);
}
};
return (
<SessionWrapperModal
additionalClassName={'reaction-list-modal'}
showHeader={false}
onClose={handleClose}
>
<StyledReactClearAllContainer
container={true}
flexDirection={'column'}
alignItems="center"
darkMode={darkMode}
>
<p>{window.i18n('clearAllReactions', [reaction])}</p>
<StyledButtonContainer className="session-modal__button-group">
<SessionButton
text={window.i18n('clear')}
buttonColor={confirmButtonColor}
buttonType={SessionButtonType.BrandOutline}
onClick={handleClearAll}
disabled={clearingInProgress}
/>
<SessionButton
text={window.i18n('cancel')}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.BrandOutline}
onClick={handleClose}
disabled={clearingInProgress}
/>
</StyledButtonContainer>
<SessionSpinner loading={clearingInProgress} />
</StyledReactClearAllContainer>
</SessionWrapperModal>
);
};

View File

@ -0,0 +1,324 @@
import { isEmpty, isEqual } from 'lodash';
import React, { ReactElement, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { Data } from '../../data/data';
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { getConversationController } from '../../session/conversations';
import { UserUtils } from '../../session/utils';
import {
updateReactClearAllModal,
updateReactListModal,
updateUserDetailsModal,
} from '../../state/ducks/modalDialog';
import { SortedReactionList } from '../../types/Reaction';
import { nativeEmojiData } from '../../util/emoji';
import { sendMessageReaction } from '../../util/reactions';
import { Avatar, AvatarSize } from '../avatar/Avatar';
import { Flex } from '../basic/Flex';
import { ContactName } from '../conversation/ContactName';
import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
import { SessionIconButton } from '../icon';
import { SessionWrapperModal } from '../SessionWrapperModal';
const StyledReactListContainer = styled(Flex)`
width: 376px;
`;
const StyledReactionsContainer = styled.div`
background-color: var(--color-cell-background);
border-bottom: 1px solid var(--color-session-border);
width: 100%;
overflow-x: auto;
padding: 12px 8px 0;
`;
const StyledSendersContainer = styled(Flex)`
width: 100%;
min-height: 350px;
height: 100%;
max-height: 496px;
overflow-x: hidden;
overflow-y: auto;
padding: 0 16px 32px;
`;
const StyledReactionBar = styled(Flex)`
width: 100%;
margin: 12px 0 20px 4px;
p {
color: var(--color-text-subtle);
margin: 0;
span:nth-child(1) {
margin: 0 8px;
color: var(--color-text);
}
span:nth-child(2) {
margin-right: 8px;
}
}
`;
const StyledReactionSender = styled(Flex)`
width: 100%;
margin-bottom: 12px;
.module-avatar {
margin-right: 12px;
}
.module-conversation__user__profile-name {
color: var(--color-text);
font-weight: normal;
}
`;
const StyledClearButton = styled.button`
font-size: var(--font-size-sm);
color: var(--color-destructive);
border: none;
`;
type ReactionSendersProps = {
messageId: string;
currentReact: string;
senders: Array<string>;
me: string;
handleClose: () => void;
};
const ReactionSenders = (props: ReactionSendersProps) => {
const { messageId, currentReact, senders, me, handleClose } = props;
const dispatch = useDispatch();
const handleAvatarClick = async (sender: string) => {
const message = await Data.getMessageById(messageId);
if (message) {
handleClose();
const contact = message.findAndFormatContact(sender);
dispatch(
updateUserDetailsModal({
conversationId: sender,
userName: contact.name || contact.profileName || sender,
authorAvatarPath: contact.avatarPath,
})
);
}
};
const handleRemoveReaction = async () => {
await sendMessageReaction(messageId, currentReact);
};
return (
<>
{senders.map((sender: string) => (
<StyledReactionSender
key={`${messageId}-${sender}`}
container={true}
justifyContent={'space-between'}
alignItems={'center'}
>
<Flex container={true} alignItems={'center'}>
<Avatar
size={AvatarSize.XS}
pubkey={sender}
onAvatarClick={async () => {
await handleAvatarClick(sender);
}}
/>
{sender === me ? (
window.i18n('you')
) : (
<ContactName
pubkey={sender}
module="module-conversation__user"
shouldShowPubkey={false}
/>
)}
</Flex>
{sender === me && (
<SessionIconButton
iconType="exit"
iconSize="small"
onClick={async () => {
await handleRemoveReaction();
}}
/>
)}
</StyledReactionSender>
))}
</>
);
};
type Props = {
reaction: string;
messageId: string;
};
const handleSenders = (senders: Array<string>, me: string) => {
let updatedSenders = senders;
const blindedMe = updatedSenders.filter(isUsAnySogsFromCache);
let meIndex = -1;
if (blindedMe && blindedMe[0]) {
meIndex = updatedSenders.indexOf(blindedMe[0]);
} else {
meIndex = updatedSenders.indexOf(me);
}
if (meIndex >= 0) {
updatedSenders.splice(meIndex, 1);
updatedSenders = [me, ...updatedSenders];
}
return updatedSenders;
};
export const ReactListModal = (props: Props): ReactElement => {
const { reaction, messageId } = props;
const [reactions, setReactions] = useState<SortedReactionList>([]);
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const [currentReact, setCurrentReact] = useState('');
const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>();
const [senders, setSenders] = useState<Array<string>>([]);
const me = UserUtils.getOurPubKeyStrFromCache();
const msgProps = useMessageReactsPropsById(messageId);
// tslint:disable: cyclomatic-complexity
useEffect(() => {
if (currentReact === '' && currentReact !== reaction) {
setReactAriaLabel(
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[reaction] : undefined
);
setCurrentReact(reaction);
}
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
setReactions(msgProps?.sortedReacts);
}
if (
reactions &&
reactions.length > 0 &&
(msgProps?.sortedReacts === [] || msgProps?.sortedReacts === undefined)
) {
setReactions([]);
}
let _senders =
reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders
? Object.keys(reactionsMap[currentReact].senders)
: null;
if (_senders && !isEqual(senders, _senders)) {
if (_senders.length > 0) {
_senders = handleSenders(_senders, me);
}
setSenders(_senders);
}
if (senders.length > 0 && (!reactionsMap[currentReact]?.senders || isEmpty(_senders))) {
setSenders([]);
}
}, [currentReact, me, reaction, msgProps?.sortedReacts, reactionsMap, senders]);
if (!msgProps) {
return <></>;
}
const dispatch = useDispatch();
const { convoId, isPublic } = msgProps;
const convo = getConversationController().get(convoId);
const weAreModerator = convo.getConversationModelProps().weAreModerator;
const handleSelectedReaction = (emoji: string): boolean => {
return currentReact === emoji;
};
const handleReactionClick = (emoji: string) => {
setReactAriaLabel(nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined);
setCurrentReact(emoji);
};
const handleClose = () => {
dispatch(updateReactListModal(null));
};
const handleClearReactions = (event: any) => {
event.preventDefault();
handleClose();
dispatch(
updateReactClearAllModal({
reaction: currentReact,
messageId,
})
);
};
return (
<SessionWrapperModal
additionalClassName={'reaction-list-modal'}
showHeader={false}
onClose={handleClose}
>
<StyledReactListContainer container={true} flexDirection={'column'} alignItems={'flex-start'}>
<StyledReactionsContainer>
<MessageReactions
messageId={messageId}
hasReactLimit={false}
inModal={true}
onSelected={handleSelectedReaction}
onClick={handleReactionClick}
/>
</StyledReactionsContainer>
{reactionsMap && currentReact && (
<StyledSendersContainer
container={true}
flexDirection={'column'}
alignItems={'flex-start'}
>
<StyledReactionBar
container={true}
justifyContent={'space-between'}
alignItems={'center'}
>
<p>
<span role={'img'} aria-label={reactAriaLabel}>
{currentReact}
</span>
{reactionsMap[currentReact].count && (
<>
<span>&#8226;</span>
<span>{reactionsMap[currentReact].count}</span>
</>
)}
</p>
{isPublic && weAreModerator && (
<StyledClearButton onClick={handleClearReactions}>
{window.i18n('clearAll')}
</StyledClearButton>
)}
</StyledReactionBar>
{senders && senders.length > 0 && (
<ReactionSenders
messageId={messageId}
currentReact={currentReact}
senders={senders}
me={me}
handleClose={handleClose}
/>
)}
</StyledSendersContainer>
)}
</StyledReactListContainer>
</SessionWrapperModal>
);
};

View File

@ -143,6 +143,7 @@ export const Data = {
getMessageIdsFromServerIds,
getMessageById,
getMessageBySenderAndSentAt,
getMessageByServerId,
filterAlreadyFetchedOpengroupMessage,
getMessageBySenderAndTimestamp,
getUnreadByConversation,
@ -433,6 +434,21 @@ async function getMessageBySenderAndSentAt({
return new MessageModel(messages[0]);
}
async function getMessageByServerId(
serverId: number,
skipTimerInit: boolean = false
): Promise<MessageModel | null> {
const message = await channels.getMessageByServerId(serverId);
if (!message) {
return null;
}
if (skipTimerInit) {
message.skipTimerInit = skipTimerInit;
}
return new MessageModel(message);
}
async function filterAlreadyFetchedOpengroupMessage(
msgDetails: MsgDuplicateSearchOpenGroup
): Promise<MsgDuplicateSearchOpenGroup> {

View File

@ -50,6 +50,7 @@ const channelsToMake = new Set([
'getMessageIdsFromServerIds',
'getMessageById',
'getMessagesBySentAt',
'getMessageByServerId',
'getExpiredMessages',
'getOutgoingWithoutExpiresAt',
'getNextExpiringMessage',

View File

@ -4,6 +4,7 @@ import { ConversationModel } from '../models/conversation';
import { PubKey } from '../session/types';
import { UserUtils } from '../session/utils';
import { StateType } from '../state/reducer';
import { getMessageReactsProps } from '../state/selectors/conversations';
export function useAvatarPath(convoId: string | undefined) {
const convoProps = useConversationPropsById(convoId);
@ -169,3 +170,16 @@ export function useConversationPropsById(convoId?: string) {
return convo;
});
}
export function useMessageReactsPropsById(messageId?: string) {
return useSelector((state: StateType) => {
if (!messageId) {
return null;
}
const messageReactsProps = getMessageReactsProps(state, messageId);
if (!messageReactsProps) {
return null;
}
return messageReactsProps;
});
}

View File

@ -19,6 +19,9 @@ import ReactDOM from 'react-dom';
import React from 'react';
import { OpenGroupData } from '../data/opengroups';
import { loadKnownBlindedKeys } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import nativeEmojiData from '@emoji-mart/data';
import { initialiseEmojiData } from '../util/emoji';
import { loadEmojiPanelI18n } from '../util/i18n';
// tslint:disable: max-classes-per-file
// Globally disable drag and drop
@ -169,6 +172,7 @@ Storage.onready(async () => {
window.Events.setThemeSetting(newThemeSetting);
try {
initialiseEmojiData(nativeEmojiData);
await AttachmentDownloads.initAttachmentPaths();
await Promise.all([
@ -176,6 +180,7 @@ Storage.onready(async () => {
BlockedNumberController.load(),
OpenGroupData.opengroupRoomsLoad(),
loadKnownBlindedKeys(),
loadEmojiPanelI18n(),
]);
} catch (error) {
window.log.error(

View File

@ -78,18 +78,23 @@ import {
ConversationTypeEnum,
fillConvoAttributesWithDefaults,
} from './conversationAttributes';
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
import { from_hex } from 'libsodium-wrappers-sumo';
import { OpenGroupData } from '../data/opengroups';
import { roomHasBlindEnabled } from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
import {
roomHasBlindEnabled,
roomHasReactionsEnabled,
} from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
import { addMessagePadding } from '../session/crypto/BufferPadding';
import { getSodiumRenderer } from '../session/crypto';
import {
findCachedOurBlindedPubkeyOrLookItUp,
getUsBlindedInThatServer,
isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
import { Reaction } from '../types/Reaction';
import { handleMessageReaction } from '../util/reactions';
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any;
@ -635,6 +640,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await this.sendBlindedMessageRequest(chatMessageParams);
return;
}
if (shouldApprove) {
await this.setIsApproved(true);
if (hasIncomingMessages) {
@ -667,6 +673,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) {
if (this.isMe()) {
chatMessageParams.syncTarget = this.id;
@ -675,7 +682,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await getMessageQueue().sendSyncMessage(chatMessageMe);
return;
}
// Handle Group Invitation Message
if (message.get('groupInvitation')) {
const groupInvitation = message.get('groupInvitation');
const groupInvitMessage = new GroupInvitationMessage({
@ -718,6 +725,120 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
}
public async sendReactionJob(sourceMessage: MessageModel, reaction: Reaction) {
try {
const destination = this.id;
const sentAt = sourceMessage.get('sent_at');
if (!sentAt) {
throw new Error('sendReactMessageJob() sent_at must be set.');
}
if (this.isPublic() && !this.isOpenGroupV2()) {
throw new Error('Only opengroupv2 are supported now');
}
let sender = UserUtils.getOurPubKeyStrFromCache();
// an OpenGroupV2 message is just a visible message
const chatMessageParams: VisibleMessageParams = {
body: '',
timestamp: sentAt,
reaction,
lokiProfile: UserUtils.getOurProfile(),
};
const shouldApprove = !this.isApproved() && this.isPrivate();
const incomingMessageCount = await Data.getMessageCountByType(
this.id,
MessageDirection.incoming
);
const hasIncomingMessages = incomingMessageCount > 0;
if (this.id.startsWith('15')) {
window.log.info('Sending a blinded message to this user: ', this.id);
// TODO confirm this works with Reacts
await this.sendBlindedMessageRequest(chatMessageParams);
return;
}
if (shouldApprove) {
await this.setIsApproved(true);
if (hasIncomingMessages) {
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
await this.addOutgoingApprovalMessage(Date.now());
if (!this.didApproveMe()) {
await this.setDidApproveMe(true);
}
// should only send once
await this.sendMessageRequestResponse();
void forceSyncConfigurationNowIfNeeded();
}
}
if (this.isOpenGroupV2()) {
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
const roomInfos = this.toOpenGroupV2();
if (!roomInfos) {
throw new Error('Could not find this room in db');
}
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
const blinded = Boolean(roomHasBlindEnabled(openGroup));
if (blinded) {
const blindedSender = getUsBlindedInThatServer(this);
if (blindedSender) {
sender = blindedSender;
}
}
await handleMessageReaction(reaction, sender, true);
// send with blinding if we need to
await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos, blinded, []);
return;
} else {
await handleMessageReaction(reaction, sender, false);
}
const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) {
// TODO is this still fine without isMe?
const chatMessageMe = new VisibleMessage({
...chatMessageParams,
syncTarget: this.id,
});
await getMessageQueue().sendSyncMessage(chatMessageMe);
const chatMessagePrivate = new VisibleMessage(chatMessageParams);
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
return;
}
if (this.isMediumGroup()) {
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
chatMessage: chatMessageMediumGroup,
groupId: destination,
});
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
return;
}
if (this.isClosedGroup()) {
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
}
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
} catch (e) {
window.log.error(`Reaction job failed id:${reaction.id} error:`, e);
return null;
}
}
/**
* Does this conversation contain the properties to be considered a message request
*/
@ -908,6 +1029,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
});
}
public async sendReaction(sourceId: string, reaction: Reaction) {
const sourceMessage = await Data.getMessageById(sourceId);
if (!sourceMessage) {
return;
}
void this.queueJob(async () => {
await this.sendReactionJob(sourceMessage, reaction);
});
}
public async bouncyUpdateLastMessage() {
if (!this.id || !this.get('active_at')) {
return;
@ -1309,27 +1442,27 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public setSessionDisplayNameNoCommit(newDisplayName?: string | null) {
const existingSessionName = this.getRealSessionUsername();
if (newDisplayName && newDisplayName !== existingSessionName) {
if (newDisplayName !== existingSessionName && newDisplayName) {
this.set({ displayNameInProfile: newDisplayName });
}
}
/**
* @returns `displayNameInProfile` - the real username as defined by that user/group
* @returns `displayNameInProfile` so the real username as defined by that user/group
*/
public getRealSessionUsername(): string | undefined {
return this.get('displayNameInProfile');
}
/**
* @returns `nickname` - the nickname we forced for that user. For a group, this returns undefined
* @returns `nickname` so the nickname we forced for that user. For a group, this returns `undefined`
*/
public getNickname(): string | undefined {
return this.isPrivate() ? this.get('nickname') : undefined;
}
/**
* @returns `getNickname` - the nickname if a private convo and a nickname is set, or `getRealSessionUsername`
* @returns `getNickname` if a private convo and a nickname is set, or `getRealSessionUsername`
*/
public getNicknameOrRealUsername(): string | undefined {
return this.getNickname() || this.getRealSessionUsername();
@ -1537,6 +1670,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public hasMember(pubkey: string) {
return includes(this.get('members'), pubkey);
}
public hasReactions() {
// message requests should not have reactions
if (this.isPrivate() && !this.isApproved()) {
return false;
}
// older open group conversations won't have reaction support
if (this.isOpenGroupV2()) {
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
return roomHasReactionsEnabled(openGroup);
} else {
return true;
}
}
// returns true if this is a closed/medium or open group
public isGroup() {
return this.get('type') === ConversationTypeEnum.GROUP;

View File

@ -59,7 +59,20 @@ import { OpenGroupData } from '../data/opengroups';
import { isUsFromCache } from '../session/utils/User';
import { perfEnd, perfStart } from '../session/utils/Performance';
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
import _, { isEmpty, uniq } from 'lodash';
import _, {
cloneDeep,
debounce,
groupBy,
isEmpty,
map,
partition,
pick,
reduce,
reject,
size as lodashSize,
sortBy,
uniq,
} from 'lodash';
import { SettingsKey } from '../data/settings-key';
import {
deleteExternalMessageFiles,
@ -80,6 +93,7 @@ import {
isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { QUOTED_TEXT_MAX_LENGTH } from '../session/constants';
import { ReactionList } from '../types/Reaction';
// tslint:disable: cyclomatic-complexity
/**
@ -361,7 +375,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
const groupUpdate = this.getGroupUpdateAsArray();
if (!groupUpdate || _.isEmpty(groupUpdate)) {
if (!groupUpdate || isEmpty(groupUpdate)) {
return null;
}
@ -495,6 +509,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
if (previews && previews.length) {
props.previews = previews;
}
const reacts = this.getPropsForReacts();
if (reacts && Object.keys(reacts).length) {
props.reacts = reacts;
}
const quote = this.getPropsForQuote(options);
if (quote) {
props.quote = quote;
@ -516,8 +534,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const nbsp = '\xa0';
const regex = /(\S)( +)(\S+\s*)$/;
return text.replace(regex, (_match, start, spaces, end) => {
const newSpaces =
end.length < 12 ? _.reduce(spaces, accumulator => accumulator + nbsp, '') : spaces;
const newSpaces: any =
end.length < 12 ? reduce(spaces, accumulator => accumulator + nbsp, '') : spaces;
return `${start}${newSpaces}${end}`;
});
}
@ -567,6 +585,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
});
}
public getPropsForReacts(): ReactionList | null {
return this.get('reacts') || null;
}
public getPropsForQuote(_options: any = {}) {
const quote = this.get('quote');
@ -702,8 +724,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = _.reject(allErrors, error => Boolean(error.number));
const errorsGroupedById = _.groupBy(allErrors, 'number');
const errors = reject(allErrors, error => Boolean(error.number));
const errorsGroupedById = groupBy(allErrors, 'number');
const finalContacts = await Promise.all(
(phoneNumbers || []).map(async id => {
const errorsForContact = errorsGroupedById[id];
@ -725,7 +747,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
// The prefix created here ensures that contacts with errors are listed
// first; otherwise it's alphabetical
const sortedContacts = _.sortBy(
const sortedContacts = sortBy(
finalContacts,
contact => `${contact.isPrimaryDevice ? '0' : '1'}${contact.pubkey}`
);
@ -827,6 +849,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
hasVisualMediaAttachments: 0,
attachments: undefined,
preview: undefined,
reacts: undefined,
reactsIndex: undefined,
});
await this.markRead(Date.now());
await this.commit();
@ -884,6 +908,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
expireTimer: this.get('expireTimer'),
attachments,
preview: preview ? [preview] : [],
reacts: this.get('reacts'),
quote,
lokiProfile: UserUtils.getOurProfile(),
};
@ -925,7 +950,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
public removeOutgoingErrors(number: string) {
const errors = _.partition(
const errors = partition(
this.get('errors'),
e => e.number === number && e.name === 'SendMessageNetworkError'
);
@ -966,7 +991,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
public hasErrors() {
return _.size(this.get('errors')) > 0;
return lodashSize(this.get('errors')) > 0;
}
public getStatus(pubkey: string) {
@ -1048,7 +1073,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
e.constructor === TypeError ||
e.constructor === ReferenceError
) {
return _.pick(e, 'name', 'message', 'code', 'number', 'reason');
return pick(e, 'name', 'message', 'code', 'number', 'reason');
}
return e;
});
@ -1065,7 +1090,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
perfStart(`messageCommit-${this.attributes.id}`);
// because the saving to db calls _cleanData which mutates the field for cleaning, we need to save a copy
const id = await Data.saveMessage(_.cloneDeep(this.attributes));
const id = await Data.saveMessage(cloneDeep(this.attributes));
if (triggerUIUpdate) {
this.dispatchMessageUpdate();
}
@ -1182,12 +1207,15 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
}
private findAndFormatContact(pubkey: string): FindAndFormatContactType {
public findAndFormatContact(pubkey: string): FindAndFormatContactType {
const contactModel = getConversationController().get(pubkey);
let profileName: string | null = null;
let isMe = false;
if (pubkey === UserUtils.getOurPubKeyStrFromCache()) {
if (
pubkey === UserUtils.getOurPubKeyStrFromCache() ||
(pubkey && pubkey.startsWith('15') && isUsAnySogsFromCache(pubkey))
) {
profileName = window.i18n('you');
isMe = true;
} else {
@ -1215,7 +1243,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
*/
private getGroupUpdateAsArray() {
const groupUpdate = this.get('group_update');
if (!groupUpdate || _.isEmpty(groupUpdate)) {
if (!groupUpdate || isEmpty(groupUpdate)) {
return undefined;
}
const left: Array<string> | undefined = Array.isArray(groupUpdate.left)
@ -1288,7 +1316,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
}
if (groupUpdate.kicked && groupUpdate.kicked.length) {
const names = _.map(
const names = map(
groupUpdate.kicked,
getConversationController().getContactProfileNameOrShortenedPubKey
);
@ -1337,6 +1365,12 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return window.i18n('answeredACall', [displayName]);
}
}
if (this.get('reaction')) {
const reaction = this.get('reaction');
if (reaction && reaction.emoji && reaction.emoji !== '') {
return window.i18n('reactionNotification', [reaction.emoji]);
}
}
return this.get('body');
}
}
@ -1349,7 +1383,7 @@ export function sliceQuoteText(quotedText: string | undefined | null) {
return quotedText.slice(0, QUOTED_TEXT_MAX_LENGTH);
}
const throttledAllMessagesDispatch = _.debounce(
const throttledAllMessagesDispatch = debounce(
() => {
if (updatesToDispatch.size === 0) {
return;

View File

@ -2,6 +2,7 @@ import { defaultsDeep } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { CallNotificationType, PropsForMessageWithConvoProps } from '../state/ducks/conversations';
import { AttachmentTypeWithPath } from '../types/Attachment';
import { Reaction, ReactionList, SortedReactionList } from '../types/Reaction';
export type MessageModelType = 'incoming' | 'outgoing';
export type MessageDeliveryStatus = 'sending' | 'sent' | 'read' | 'error';
@ -16,6 +17,9 @@ export interface MessageAttributes {
received_at?: number;
sent_at?: number;
preview?: any;
reaction?: Reaction;
reacts?: ReactionList;
reactsIndex?: number;
body?: string;
expirationStartTimestamp: number;
read_by: Array<string>; // we actually only care about the length of this. values are not used for anything
@ -157,6 +161,9 @@ export interface MessageAttributesOptionals {
received_at?: number;
sent_at?: number;
preview?: any;
reaction?: Reaction;
reacts?: ReactionList;
reactsIndex?: number;
body?: string;
expirationStartTimestamp?: number;
read_by?: Array<string>; // we actually only care about the length of this. values are not used for anything
@ -241,4 +248,6 @@ export type MessageRenderingProps = PropsForMessageWithConvoProps & {
multiSelectMode: boolean;
firstMessageOfSeries: boolean;
lastMessageOfSeries: boolean;
sortedReacts?: SortedReactionList;
};

View File

@ -997,6 +997,20 @@ function getMessageBySenderAndSentAt({ source, sentAt }: { source: string; sentA
return map(rows, row => jsonToObject(row.json));
}
function getMessageByServerId(serverId: number) {
const row = assertGlobalInstance()
.prepare(`SELECT * FROM ${MESSAGES_TABLE} WHERE serverId = $serverId;`)
.get({
serverId,
});
if (!row) {
return null;
}
return jsonToObject(row.json);
}
function getMessagesCountBySender({ source }: { source: string }) {
if (!source) {
throw new Error('source must be set');
@ -2373,6 +2387,7 @@ export const sqlNode = {
getMessageIdsFromServerIds,
getMessageById,
getMessagesBySentAt,
getMessageByServerId,
getSeenMessagesByHashList,
getLastHashBySnode,
getExpiredMessages,

View File

@ -21,6 +21,7 @@ import { isUsFromCache } from '../session/utils/User';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { toLogFormat } from '../types/attachments/Errors';
import { ConversationTypeEnum } from '../models/conversationAttributes';
import { handleMessageReaction } from '../util/reactions';
function cleanAttachment(attachment: any) {
return {
@ -79,7 +80,16 @@ function cleanAttachments(decrypted: SignalService.DataMessage) {
}
export function isMessageEmpty(message: SignalService.DataMessage) {
const { flags, body, attachments, group, quote, preview, openGroupInvitation } = message;
const {
flags,
body,
attachments,
group,
quote,
preview,
openGroupInvitation,
reaction,
} = message;
return (
!flags &&
@ -89,7 +99,8 @@ export function isMessageEmpty(message: SignalService.DataMessage) {
isEmpty(group) &&
isEmpty(quote) &&
isEmpty(preview) &&
isEmpty(openGroupInvitation)
isEmpty(openGroupInvitation) &&
isEmpty(reaction)
);
}
@ -218,6 +229,7 @@ export async function handleSwarmDataMessage(
cleanDataMessage.profileKey
);
}
if (isMessageEmpty(cleanDataMessage)) {
window?.log?.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
return removeFromCache(envelope);
@ -306,15 +318,28 @@ async function handleSwarmMessage(
void convoToAddMessageTo.queueJob(async () => {
// this call has to be made inside the queueJob!
if (!msgModel.get('isPublic') && rawDataMessage.reaction && rawDataMessage.syncTarget) {
await handleMessageReaction(
rawDataMessage.reaction,
msgModel.get('source'),
false,
messageHash
);
confirm();
return;
}
const isDuplicate = await isSwarmMessageDuplicate({
source: msgModel.get('source'),
sentAt,
});
if (isDuplicate) {
window?.log?.info('Received duplicate message. Dropping it.');
confirm();
return;
}
await handleMessageJob(
msgModel,
convoToAddMessageTo,

View File

@ -20,8 +20,11 @@ export const handleOpenGroupV4Message = async (
roomInfos: OpenGroupRequestCommonType
) => {
const { data, id, posted, session_id } = message;
await handleOpenGroupMessage(roomInfos, data, posted, session_id, id);
if (data && posted && session_id) {
await handleOpenGroupMessage(roomInfos, data, posted, session_id, id);
} else {
throw Error('Missing data passed to handleOpenGroupV4Message.');
}
};
/**

View File

@ -16,6 +16,8 @@ import { GoogleChrome } from '../util';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { ConversationTypeEnum } from '../models/conversationAttributes';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { handleMessageReaction } from '../util/reactions';
import { Action, Reaction } from '../types/Reaction';
function contentTypeSupported(type: string): boolean {
const Chrome = GoogleChrome;
@ -179,6 +181,7 @@ export type RegularMessageType = Pick<
| 'openGroupInvitation'
| 'quote'
| 'preview'
| 'reaction'
| 'profile'
| 'profileKey'
| 'expireTimer'
@ -192,6 +195,7 @@ export function toRegularMessage(rawDataMessage: SignalService.DataMessage): Reg
..._.pick(rawDataMessage, [
'attachments',
'preview',
'reaction',
'body',
'flags',
'profileKey',
@ -336,104 +340,118 @@ export async function handleMessageJob(
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
);
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source,
ConversationTypeEnum.PRIVATE
);
try {
messageModel.set({ flags: regularDataMessage.flags });
if (messageModel.isExpirationTimerUpdate()) {
const { expireTimer } = regularDataMessage;
const oldValue = conversation.get('expireTimer');
if (expireTimer === oldValue) {
confirm?.();
window?.log?.info(
'Dropping ExpireTimerUpdate message as we already have the same one set.'
);
return;
}
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
} else {
// this does not commit to db nor UI unless we need to approve a convo
await handleRegularMessage(
conversation,
sendingDeviceConversation,
messageModel,
regularDataMessage,
source,
messageHash
);
}
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
// save the message model to the db and it save the messageId generated to our in-memory copy
const id = await messageModel.commit();
messageModel.set({ id });
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
const unreadCount = await conversation.getUnreadCount();
conversation.set({ unreadCount });
conversation.set({
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
});
// this is a throttled call and will only run once every 1 sec at most
conversation.updateLastMessage();
await conversation.commit();
if (conversation.id !== sendingDeviceConversation.id) {
await sendingDeviceConversation.commit();
}
void queueAttachmentDownloads(messageModel, conversation);
// Check if we need to update any profile names
// the only profile we don't update with what is coming here is ours,
// as our profile is shared accross our devices with a ConfigurationMessage
if (messageModel.isIncoming() && regularDataMessage.profile) {
void appendFetchAvatarAndProfileJob(
sendingDeviceConversation,
regularDataMessage.profile,
regularDataMessage.profileKey
);
}
// even with all the warnings, I am very sus about if this is usefull or not
// try {
// // We go to the database here because, between the message save above and
// // the previous line's trigger() call, we might have marked all messages
// // unread in the database. This message might already be read!
// const fetched = await getMessageById(messageModel.get('id'));
// const previousUnread = messageModel.get('unread');
// // Important to update message with latest read state from database
// messageModel.merge(fetched);
// if (previousUnread !== messageModel.get('unread')) {
// window?.log?.warn(
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
// );
// // We call markRead() even though the message is already
// // marked read because we need to start expiration
// // timers, etc.
// await messageModel.markRead(Date.now());
// }
// } catch (error) {
// window?.log?.warn(
// 'handleMessageJob: Message',
// messageModel.idForLogging(),
// 'was deleted'
// );
// }
if (messageModel.get('unread')) {
if (
regularDataMessage.reaction.action === Action.REACT &&
conversation.isPrivate() &&
messageModel.get('unread')
) {
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
conversation.throttledNotify(messageModel);
}
confirm?.();
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
} else {
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source,
ConversationTypeEnum.PRIVATE
);
try {
messageModel.set({ flags: regularDataMessage.flags });
if (messageModel.isExpirationTimerUpdate()) {
const { expireTimer } = regularDataMessage;
const oldValue = conversation.get('expireTimer');
if (expireTimer === oldValue) {
confirm?.();
window?.log?.info(
'Dropping ExpireTimerUpdate message as we already have the same one set.'
);
return;
}
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
} else {
// this does not commit to db nor UI unless we need to approve a convo
await handleRegularMessage(
conversation,
sendingDeviceConversation,
messageModel,
regularDataMessage,
source,
messageHash
);
}
// save the message model to the db and it save the messageId generated to our in-memory copy
const id = await messageModel.commit();
messageModel.set({ id });
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
const unreadCount = await conversation.getUnreadCount();
conversation.set({ unreadCount });
conversation.set({
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
});
// this is a throttled call and will only run once every 1 sec at most
conversation.updateLastMessage();
await conversation.commit();
if (conversation.id !== sendingDeviceConversation.id) {
await sendingDeviceConversation.commit();
}
void queueAttachmentDownloads(messageModel, conversation);
// Check if we need to update any profile names
// the only profile we don't update with what is coming here is ours,
// as our profile is shared accross our devices with a ConfigurationMessage
if (messageModel.isIncoming() && regularDataMessage.profile) {
void appendFetchAvatarAndProfileJob(
sendingDeviceConversation,
regularDataMessage.profile,
regularDataMessage.profileKey
);
}
// even with all the warnings, I am very sus about if this is usefull or not
// try {
// // We go to the database here because, between the message save above and
// // the previous line's trigger() call, we might have marked all messages
// // unread in the database. This message might already be read!
// const fetched = await getMessageById(messageModel.get('id'));
// const previousUnread = messageModel.get('unread');
// // Important to update message with latest read state from database
// messageModel.merge(fetched);
// if (previousUnread !== messageModel.get('unread')) {
// window?.log?.warn(
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
// );
// // We call markRead() even though the message is already
// // marked read because we need to start expiration
// // timers, etc.
// await messageModel.markRead(Date.now());
// }
// } catch (error) {
// window?.log?.warn(
// 'handleMessageJob: Message',
// messageModel.idForLogging(),
// 'was deleted'
// );
// }
if (messageModel.get('unread')) {
conversation.throttledNotify(messageModel);
}
confirm?.();
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
}
}
}

View File

@ -19,18 +19,20 @@ import {
fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl,
roomHasBlindEnabled,
} from '../sogsv3/sogsV3Capabilities';
import { OpenGroupReaction } from '../../../../types/Reaction';
export type OpenGroupMessageV4 = {
/** AFAIK: indicates the number of the message in the group. e.g. 2nd message will be 1 or 2 */
seqno: number;
session_id: string;
session_id?: string;
/** base64 */
signature: string;
signature?: string;
/** timestamp number with decimal */
posted: number;
posted?: number;
id: number;
data: string;
deleted: boolean;
data?: string;
deleted?: boolean;
reactions: Record<string, OpenGroupReaction>;
};
const pollForEverythingInterval = DURATION.SECONDS * 10;

View File

@ -52,12 +52,14 @@ export const filterDuplicatesFromDbAndIncomingV4 = async (
a.posted === b.posted
);
// make sure a sender is set, as we cast it just below
}).filter(m => Boolean(m.session_id));
}).filter(m => Boolean(m.session_id && m.posted));
// now, check database to make sure those messages are not already fetched
const filteredInDb = await Data.filterAlreadyFetchedOpengroupMessage(
filtered.map(m => {
return { sender: m.session_id as string, serverTimestamp: m.posted };
// We have confirmed these exist by filtering above.
// tslint:disable-next-line no-non-null-assertion
return { sender: m.session_id! as string, serverTimestamp: m.posted! };
})
);

View File

@ -34,6 +34,7 @@ import { handleOutboxMessageModel } from '../../../../receiver/dataMessage';
import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
import { Data } from '../../../../data/data';
import { handleOpenGroupMessageReactions } from '../../../../util/reactions';
/**
* Get the convo matching those criteria and make sure it is an opengroup convo, or return null.
@ -143,9 +144,8 @@ const handleSogsV3DeletedMessages = async (
serverUrl: string,
roomId: string
) => {
// FIXME those 2 `m.data === null` test should be removed when we add support for emoji-reacts
const deletions = messages.filter(m => Boolean(m.deleted) || m.data === null);
const exceptDeletion = messages.filter(m => !(Boolean(m.deleted) || m.data === null));
const deletions = messages.filter(m => Boolean(m.deleted));
const exceptDeletion = messages.filter(m => !m.deleted);
if (!deletions.length) {
return messages;
}
@ -156,6 +156,7 @@ const handleSogsV3DeletedMessages = async (
const messageIds = await Data.getMessageIdsFromServerIds(allIdsRemoved, convo.id);
// we shouldn't get too many messages to delete at a time, so no need to add a function to remove multiple messages for now
await Promise.all(
(messageIds || []).map(async id => {
if (convo) {
@ -205,13 +206,28 @@ const handleMessagesResponseV4 = async (
return;
}
const messagesWithoutReactionOnlyUpdates = messages.filter(m => {
const keys = Object.keys(m);
if (
keys.length === 3 &&
keys.includes('id') &&
keys.includes('seqno') &&
keys.includes('reactions')
) {
return false;
}
return true;
});
// Incoming messages from sogvs v3 are returned in descending order from the latest seqno, we need to sort it chronologically
// Incoming messages for sogs v3 have a timestamp in seconds and not ms.
// Session works with timestamp in ms, for a lot of things, so first, lets fix this.
const messagesWithMsTimestamp = messages.map(m => ({
...m,
posted: Math.floor(m.posted * 1000),
}));
const messagesWithMsTimestamp = messagesWithoutReactionOnlyUpdates
.sort((a, b) => (a.seqno < b.seqno ? -1 : a.seqno > b.seqno ? 1 : 0))
.map(m => ({
...m,
posted: m.posted ? Math.floor(m.posted * 1000) : undefined,
}));
const messagesWithoutDeleted = await handleSogsV3DeletedMessages(
messagesWithMsTimestamp,
@ -235,16 +251,21 @@ const handleMessagesResponseV4 = async (
const messagesWithResolvedBlindedIdsIfFound = [];
for (let index = 0; index < messagesFilteredBlindedIds.length; index++) {
const newMessage = messagesFilteredBlindedIds[index];
const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id);
if (newMessage.session_id) {
const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id);
// override the sender in the message itself if we are the sender
if (unblindedIdFound && UserUtils.isUsFromCache(unblindedIdFound)) {
newMessage.session_id = unblindedIdFound;
// override the sender in the message itself if we are the sender
if (unblindedIdFound && UserUtils.isUsFromCache(unblindedIdFound)) {
newMessage.session_id = unblindedIdFound;
}
messagesWithResolvedBlindedIdsIfFound.push(newMessage);
} else {
throw Error('session_id is missing so we cannot resolve the blinded id');
}
messagesWithResolvedBlindedIdsIfFound.push(newMessage);
}
// we use the unverified newMessages seqno and id as last polled because we actually did poll up to those ids.
const incomingMessageSeqNo = compact(messages.map(n => n.seqno));
const maxNewMessageSeqNo = Math.max(...incomingMessageSeqNo);
for (let index = 0; index < messagesWithResolvedBlindedIdsIfFound.length; index++) {
@ -270,6 +291,19 @@ const handleMessagesResponseV4 = async (
}
roomInfosRefreshed.lastFetchTimestamp = Date.now();
await OpenGroupData.saveV2OpenGroupRoom(roomInfosRefreshed);
const messagesWithReactions = messages.filter(m => m.reactions !== undefined);
if (messagesWithReactions.length > 0) {
const conversationId = getOpenGroupV2ConversationId(serverUrl, roomId);
const groupConvo = getConversationController().get(conversationId);
if (groupConvo && groupConvo.isOpenGroupV2()) {
for (const message of messagesWithReactions) {
void groupConvo.queueJob(async () => {
await handleOpenGroupMessageReactions(message.reactions, message.id);
});
}
}
}
} catch (e) {
window?.log?.warn('handleNewMessages failed:', e);
}

View File

@ -1,4 +1,4 @@
import { fromUInt8ArrayToBase64, stringToUint8Array, toHex } from '../../../utils/String';
import { encode, fromUInt8ArrayToBase64, stringToUint8Array, toHex } from '../../../utils/String';
import { concatUInt8Array, getSodiumRenderer, LibSodiumWrappers } from '../../../crypto';
import { crypto_hash_sha512, from_hex, to_hex } from 'libsodium-wrappers-sumo';
import { ByteKeyPair } from '../../../utils/User';
@ -12,6 +12,7 @@ import {
toX25519,
} from '../../../utils/SodiumUtils';
import { isEqual } from 'lodash';
import { OnionSending } from '../../../onions/onionSend';
async function getSogsSignature({
blinded,
@ -67,14 +68,18 @@ async function getOpenGroupHeaders(data: {
pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`;
}
const rawPath = OnionSending.endpointRequiresDecoding(path); // this gets a string of the path wioth potentially emojis in it
const encodedPath = new Uint8Array(encode(rawPath, 'utf8')); // this gets the binary content of that utf8 string
// SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HASHED_BODY
let toSign = concatUInt8Array(
serverPK,
nonce,
stringToUint8Array(timestamp.toString()),
stringToUint8Array(method),
stringToUint8Array(path)
encodedPath
);
if (body) {
const bodyHashed = sodium.crypto_generichash(64, body);

View File

@ -198,6 +198,15 @@ export type SubRequestUpdateRoomType = {
};
};
export type SubRequestDeleteReactionType = {
type: 'deleteReaction';
deleteReaction: {
reaction: string;
messageId: number;
roomId: string;
};
};
export type OpenGroupBatchRow =
| SubRequestCapabilitiesType
| SubRequestMessagesType
@ -208,7 +217,8 @@ export type OpenGroupBatchRow =
| SubRequestAddRemoveModeratorType
| SubRequestBanUnbanUserType
| SubRequestDeleteAllUserPostsType
| SubRequestUpdateRoomType;
| SubRequestUpdateRoomType
| SubRequestDeleteReactionType;
/**
*
@ -228,8 +238,9 @@ const makeBatchRequestPayload = (
if (options.messages) {
return {
method: 'GET',
// TODO Consistency across platforms with fetching reactors
path: isNumber(options.messages.sinceSeqNo)
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}`
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r`
: `/room/${options.messages.roomId}/messages/recent`,
};
}
@ -303,6 +314,11 @@ const makeBatchRequestPayload = (
path: `/room/${options.updateRoom.roomId}`,
json: { image: options.updateRoom.imageId },
};
case 'deleteReaction':
return {
method: 'DELETE',
path: `/room/${options.deleteReaction.roomId}/reactions/${options.deleteReaction.messageId}/${options.deleteReaction.reaction}`,
};
default:
throw new Error('Invalid batch request row');
}
@ -394,7 +410,7 @@ const sendSogsBatchRequestOnionV4 = async (
if (isObject(batchResponse.body)) {
return batchResponse as BatchSogsReponse;
}
window?.log?.warn('sogsbatch: batch response decoded body is not object. Returning null');
window?.log?.warn('sogsbatch: batch response decoded body is not object. Returning null');
return null;
};

View File

@ -76,6 +76,10 @@ export function capabilitiesListHasBlindEnabled(caps?: Array<string> | null) {
return Boolean(caps?.includes('blind'));
}
export function roomHasReactionsEnabled(openGroup?: OpenGroupV2Room) {
return Boolean(openGroup?.capabilities?.includes('reactions'));
}
export async function fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl(serverUrl: string) {
let relatedRooms = OpenGroupData.getV2OpenGroupRoomsByServerUrl(serverUrl);
if (!relatedRooms || relatedRooms.length === 0) {

View File

@ -0,0 +1,46 @@
import AbortController from 'abort-controller';
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
import {
batchFirstSubIsSuccess,
batchGlobalIsSuccess,
OpenGroupBatchRow,
sogsBatchSend,
} from './sogsV3BatchPoll';
import { hasReactionSupport } from './sogsV3SendReaction';
/**
* Clears a reaction on open group server using onion v4 logic and batch send
* User must have moderator permissions
* Clearing implies removing all reactors for a specific emoji
*/
export const clearSogsReactionByServerId = async (
reaction: string,
serverId: number,
roomInfos: OpenGroupRequestCommonType
): Promise<boolean> => {
const canReact = await hasReactionSupport(serverId);
if (!canReact) {
return false;
}
const options: Array<OpenGroupBatchRow> = [
{
type: 'deleteReaction',
deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId },
},
];
const result = await sogsBatchSend(
roomInfos.serverUrl,
new Set([roomInfos.roomId]),
new AbortController().signal,
options,
'batch'
);
try {
return batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result);
} catch (e) {
window?.log?.error("clearSogsReactionByServerId Can't decode JSON body");
}
return false;
};

View File

@ -0,0 +1,91 @@
import { AbortSignal } from 'abort-controller';
import { Data } from '../../../../data/data';
import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction';
import { getEmojiDataFromNative } from '../../../../util/emoji';
import { OnionSending } from '../../../onions/onionSend';
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
export const hasReactionSupport = async (serverId: number): Promise<boolean> => {
const found = await Data.getMessageByServerId(serverId);
if (!found) {
window.log.warn(`Open Group Message ${serverId} not found in db`);
return false;
}
const conversationModel = found?.getConversation();
if (!conversationModel) {
window.log.warn(`Conversation for ${serverId} not found in db`);
return false;
}
if (!conversationModel.hasReactions()) {
window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId);
return false;
}
return true;
};
export const sendSogsReactionOnionV4 = async (
serverUrl: string,
room: string,
abortSignal: AbortSignal,
reaction: Reaction,
blinded: boolean
): Promise<boolean> => {
const allValidRoomInfos = OpenGroupPollingUtils.getAllValidRoomInfos(serverUrl, new Set([room]));
if (!allValidRoomInfos?.length) {
window?.log?.info('getSendReactionRequest: no valid roominfos got.');
throw new Error(`Could not find sogs pubkey of url:${serverUrl}`);
}
const canReact = await hasReactionSupport(reaction.id);
if (!canReact) {
return false;
}
// for an invalid reaction we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji
const emoji = getEmojiDataFromNative(reaction.emoji) ? reaction.emoji : '🖾';
const endpoint = `/room/${room}/reaction/${reaction.id}/${emoji}`;
const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE';
const serverPubkey = allValidRoomInfos[0].serverPublicKey;
// reaction endpoint requires an empty dict {}
const stringifiedBody = null;
const result = await OnionSending.sendJsonViaOnionV4ToSogs({
serverUrl,
endpoint,
serverPubkey,
method,
abortSignal,
blinded,
stringifiedBody,
headers: null,
throwErrors: true,
});
if (!batchGlobalIsSuccess(result)) {
window?.log?.warn('sendSogsReactionWithOnionV4 Got unknown status code; res:', result);
throw new Error(
`sendSogsReactionOnionV4: invalid status code: ${parseBatchGlobalStatusCode(result)}`
);
}
if (!result) {
throw new Error('Could not putReaction, res is invalid');
}
const rawMessage = result.body as OpenGroupReactionResponse;
if (!rawMessage) {
throw new Error('putReaction parsing failed');
}
window.log.info(
`You ${reaction.action === Action.REACT ? 'added' : 'removed'} a`,
reaction.emoji,
`reaction on ${serverUrl}/${room}`
);
const success = Boolean(reaction.action === Action.REACT ? rawMessage.added : rawMessage.removed);
return success;
};

View File

@ -58,3 +58,5 @@ export const UI = {
// we keep 150 chars, because quoting someone with 66 hex chars need to be kept in full so we can render it in the quote with its name
export const QUOTED_TEXT_MAX_LENGTH = 150;
export const DEFAULT_RECENT_REACTS = ['😂', '🥰', '😢', '😡', '😮', '😈'];

View File

@ -2,6 +2,7 @@ import ByteBuffer from 'bytebuffer';
import { DataMessage } from '..';
import { SignalService } from '../../../../protobuf';
import { LokiProfile } from '../../../../types/Message';
import { Reaction } from '../../../../types/Reaction';
import { MessageParams } from '../Message';
interface AttachmentPointerCommon {
@ -67,11 +68,13 @@ export interface VisibleMessageParams extends MessageParams {
expireTimer?: number;
lokiProfile?: LokiProfile;
preview?: Array<PreviewWithAttachmentUrl>;
reaction?: Reaction;
syncTarget?: string; // undefined means it is not a synced message
}
export class VisibleMessage extends DataMessage {
public readonly expireTimer?: number;
public readonly reaction?: Reaction;
private readonly attachments?: Array<AttachmentPointerWithUrl>;
private readonly body?: string;
@ -107,6 +110,7 @@ export class VisibleMessage extends DataMessage {
this.displayName = params.lokiProfile && params.lokiProfile.displayName;
this.avatarPointer = params.lokiProfile && params.lokiProfile.avatarPointer;
this.preview = params.preview;
this.reaction = params.reaction;
this.syncTarget = params.syncTarget;
}
@ -126,6 +130,9 @@ export class VisibleMessage extends DataMessage {
if (this.preview) {
dataMessage.preview = this.preview;
}
if (this.reaction) {
dataMessage.reaction = this.reaction;
}
if (this.syncTarget) {
dataMessage.syncTarget = this.syncTarget;
}

View File

@ -30,14 +30,30 @@ export type OnionFetchOptions = {
useV4: boolean;
};
// NOTE some endpoints require decoded strings
const endpointExceptions = ['/reaction'];
const endpointRequiresDecoding = (url: string): string => {
// tslint:disable-next-line: prefer-for-of
for (let i = 0; i < endpointExceptions.length; i++) {
if (url.includes(endpointExceptions[i])) {
return decodeURIComponent(url);
}
}
return url;
};
const buildSendViaOnionPayload = (
url: URL,
fetchOptions: OnionFetchOptions
): FinalDestNonSnodeOptions => {
const endpoint = OnionSending.endpointRequiresDecoding(
url.search ? `${url.pathname}${url.search}` : url.pathname
);
const payloadObj: FinalDestNonSnodeOptions = {
method: fetchOptions.method || 'GET',
body: fetchOptions.body,
endpoint: url.search ? `${url.pathname}${url.search}` : url.pathname,
endpoint,
headers: fetchOptions.headers || {},
};
@ -86,6 +102,7 @@ export type OnionV4BinarySnodeResponse = {
* Build & send an onion v4 request to a non snode, and handle retries.
* We actually can only send v4 request to non snode, as the snodes themselves do not support v4 request as destination.
*/
// tslint:disable-next-line: max-func-body-length
const sendViaOnionV4ToNonSnodeWithRetries = async (
destinationX25519Key: string,
url: URL,
@ -152,6 +169,7 @@ const sendViaOnionV4ToNonSnodeWithRetries = async (
useV4: true,
throwErrors,
});
if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) {
window.log.info(
'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ',
@ -285,6 +303,7 @@ async function sendJsonViaOnionV4ToSogs(sendOptions: {
return null;
}
headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded };
const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
serverPubkey,
builtUrl,
@ -500,7 +519,9 @@ async function sendJsonViaOnionV4ToFileServer(sendOptions: {
return res as OnionV4JSONSnodeResponse;
}
// we export these methods for stubbing during testing
export const OnionSending = {
endpointRequiresDecoding,
sendViaOnionV4ToNonSnodeWithRetries,
getOnionPathForSending,
sendJsonViaOnionV4ToSogs,

View File

@ -22,6 +22,7 @@ import { OpenGroupRequestCommonType } from '../apis/open_group_api/opengroupV2/A
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage';
import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2';
type ClosedGroupMessageType =
| ClosedGroupVisibleMessage
@ -74,15 +75,23 @@ export class MessageQueue {
// Skipping the queue for Open Groups v2; the message is sent directly
try {
const { sentTimestamp, serverId } = await MessageSender.sendToOpenGroupV2(
const result = await MessageSender.sendToOpenGroupV2(
message,
roomInfos,
blinded,
filesToLink
);
// NOTE Reactions are handled in the MessageSender
if (message.reaction) {
return;
}
const { sentTimestamp, serverId } = result as OpenGroupMessageV2;
if (!serverId || serverId === -1) {
throw new Error(`Invalid serverId returned by server: ${serverId}`);
}
await MessageSentHandler.handlePublicMessageSentSuccess(message.identifier, {
serverId: serverId,
serverTimestamp: sentTimestamp,

View File

@ -26,6 +26,7 @@ import {
sendSogsMessageOnionV4,
} from '../apis/open_group_api/sogsv3/sogsV3SendMessage';
import { AbortController } from 'abort-controller';
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
const DEFAULT_CONNECTIONS = 1;
@ -278,7 +279,7 @@ export async function sendToOpenGroupV2(
roomInfos: OpenGroupRequestCommonType,
blinded: boolean,
filesToLink: Array<number>
): Promise<OpenGroupMessageV2> {
): Promise<OpenGroupMessageV2 | boolean> {
// we agreed to pad message for opengroupv2
const paddedBody = addMessagePadding(rawMessage.plainTextBuffer());
const v2Message = new OpenGroupMessageV2({
@ -287,14 +288,25 @@ export async function sendToOpenGroupV2(
filesToLink,
});
const msg = await sendSogsMessageOnionV4(
roomInfos.serverUrl,
roomInfos.roomId,
new AbortController().signal,
v2Message,
blinded
);
return msg;
if (rawMessage.reaction) {
const msg = await sendSogsReactionOnionV4(
roomInfos.serverUrl,
roomInfos.roomId,
new AbortController().signal,
rawMessage.reaction,
blinded
);
return msg;
} else {
const msg = await sendSogsMessageOnionV4(
roomInfos.serverUrl,
roomInfos.roomId,
new AbortController().signal,
v2Message,
blinded
);
return msg;
}
}
/**

View File

@ -323,6 +323,10 @@ export const SessionGlobalStyles = createGlobalStyle`
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-md: 15px;
--font-size-h1: 30px;
--font-size-h2: 24px;
--font-size-h3: 20px;
--font-size-h4: 16px;
/* MARGINS */
--margins-xs: 5px;
@ -338,6 +342,9 @@ export const SessionGlobalStyles = createGlobalStyle`
--border-unread: ${lightUnreadBorder};
--border-session: ${lightColorSessionBorder};
/* CONSTANTS */
--compositionContainerHeight: 60px;
/* COLORS NOT CHANGING BETWEEN THEMES */
--color-warning: ${warning};
--color-destructive: ${destructive};

View File

@ -15,6 +15,7 @@ import {
ConversationNotificationSettingType,
ConversationTypeEnum,
} from '../../models/conversationAttributes';
import { ReactionList } from '../../types/Reaction';
export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call';
export type PropsForCallNotification = {
@ -175,6 +176,8 @@ export type PropsForMessageWithoutConvoProps = {
serverId?: number;
status?: LastMessageStatusType;
attachments?: Array<PropsForAttachment>;
reacts?: ReactionList;
reactsIndex?: number;
previews?: Array<any>;
quote?: {
text?: string;

View File

@ -29,6 +29,11 @@ export type UserDetailsModalState = {
userName: string;
} | null;
export type ReactModalsState = {
reaction: string;
messageId: string;
} | null;
export type ModalState = {
confirmModal: ConfirmModalState;
inviteContactModal: InviteContactModalState;
@ -45,6 +50,8 @@ export type ModalState = {
adminLeaveClosedGroup: AdminLeaveClosedGroupModalState;
sessionPasswordModal: SessionPasswordModalState;
deleteAccountModal: DeleteAccountModalState;
reactListModalState: ReactModalsState;
reactClearAllModalState: ReactModalsState;
};
export const initialModalState: ModalState = {
@ -63,6 +70,8 @@ export const initialModalState: ModalState = {
adminLeaveClosedGroup: null,
sessionPasswordModal: null,
deleteAccountModal: null,
reactListModalState: null,
reactClearAllModalState: null,
};
const ModalSlice = createSlice({
@ -114,6 +123,12 @@ const ModalSlice = createSlice({
updateDeleteAccountModal(state, action: PayloadAction<DeleteAccountModalState>) {
return { ...state, deleteAccountModal: action.payload };
},
updateReactListModal(state, action: PayloadAction<ReactModalsState>) {
return { ...state, reactListModalState: action.payload };
},
updateReactClearAllModal(state, action: PayloadAction<ReactModalsState>) {
return { ...state, reactClearAllModalState: action.payload };
},
},
});
@ -134,5 +149,7 @@ export const {
sessionPassword,
updateDeleteAccountModal,
updateBanOrUnbanUserModal,
updateReactListModal,
updateReactClearAllModal,
} = actions;
export const modalReducer = reducer;

View File

@ -17,7 +17,6 @@ import { BlockedNumberController } from '../../util';
import { ConversationModel } from '../../models/conversation';
import { LocalizerType } from '../../types/Util';
import { ConversationHeaderTitleProps } from '../../components/conversation/ConversationHeader';
import _ from 'lodash';
import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox';
import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment';
import { MessageAuthorSelectorProps } from '../../components/conversation/message/message-content/MessageAuthorText';
@ -35,6 +34,8 @@ import { getConversationController } from '../../session/conversations';
import { UserUtils } from '../../session/utils';
import { Storage } from '../../util/storage';
import { ConversationTypeEnum } from '../../models/conversationAttributes';
import { MessageReactsSelectorProps } from '../../components/conversation/message/message-content/MessageReactions';
import { filter, isEmpty, pick } from 'lodash';
export const getConversations = (state: StateType): ConversationsStateType => state.conversations;
@ -456,7 +457,7 @@ export const getSortedConversations = createSelector(
const _getConversationRequests = (
sortedConversations: Array<ReduxConversationType>
): Array<ReduxConversationType> => {
return _.filter(sortedConversations, conversation => {
return filter(sortedConversations, conversation => {
const { isApproved, isBlocked, isPrivate, isMe, activeAt } = conversation;
const isRequest = ConversationModel.hasValidIncomingRequestValues({
isApproved,
@ -477,7 +478,7 @@ export const getConversationRequests = createSelector(
const _getUnreadConversationRequests = (
sortedConversationRequests: Array<ReduxConversationType>
): Array<ReduxConversationType> => {
return _.filter(sortedConversationRequests, conversation => {
return filter(sortedConversationRequests, conversation => {
return conversation && conversation.unreadCount && conversation.unreadCount > 0;
});
};
@ -490,7 +491,7 @@ export const getUnreadConversationRequests = createSelector(
const _getPrivateContactsPubkeys = (
sortedConversations: Array<ReduxConversationType>
): Array<string> => {
return _.filter(sortedConversations, conversation => {
return filter(sortedConversations, conversation => {
return (
conversation.isPrivate &&
!conversation.isBlocked &&
@ -880,51 +881,63 @@ export const getMessagePropsByMessageId = createSelector(
export const getMessageAvatarProps = createSelector(getMessagePropsByMessageId, (props):
| MessageAvatarSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
authorAvatarPath,
authorName,
sender,
authorProfileName,
conversationType,
direction,
isPublic,
isSenderAdmin,
} = props.propsForMessage;
const { lastMessageOfSeries } = props;
const messageAvatarProps: MessageAvatarSelectorProps = {
authorAvatarPath,
authorName,
sender,
authorProfileName,
conversationType,
direction,
isPublic,
isSenderAdmin,
lastMessageOfSeries,
lastMessageOfSeries: props.lastMessageOfSeries,
...pick(props.propsForMessage, [
'authorAvatarPath',
'authorName',
'sender',
'authorProfileName',
'conversationType',
'direction',
'isPublic',
'isSenderAdmin',
]),
};
return messageAvatarProps;
});
export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, (props):
| MessagePreviewSelectorProps
export const getMessageReactsProps = createSelector(getMessagePropsByMessageId, (props):
| MessageReactsSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const { attachments, previews } = props.propsForMessage;
const msgProps: MessageReactsSelectorProps = pick(props.propsForMessage, [
'convoId',
'conversationType',
'isPublic',
'reacts',
'serverId',
]);
const msgProps: MessagePreviewSelectorProps = {
attachments,
previews,
};
if (msgProps.reacts) {
const sortedReacts = Object.entries(msgProps.reacts).sort((a, b) => {
return a[1].index < b[1].index ? -1 : a[1].index > b[1].index ? 1 : 0;
});
msgProps.sortedReacts = sortedReacts;
}
return msgProps;
});
export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, (props):
| MessagePreviewSelectorProps
| undefined => {
if (!props || isEmpty(props)) {
return undefined;
}
const msgProps: MessagePreviewSelectorProps = pick(props.propsForMessage, [
'attachments',
'previews',
]);
return msgProps;
});
@ -932,16 +945,11 @@ export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId,
export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, (props):
| MessageQuoteSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const { direction, quote } = props.propsForMessage;
const msgProps: MessageQuoteSelectorProps = {
direction,
quote,
};
const msgProps: MessageQuoteSelectorProps = pick(props.propsForMessage, ['direction', 'quote']);
return msgProps;
});
@ -949,16 +957,11 @@ export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, (
export const getMessageStatusProps = createSelector(getMessagePropsByMessageId, (props):
| MessageStatusSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const { direction, status } = props.propsForMessage;
const msgProps: MessageStatusSelectorProps = {
direction,
status,
};
const msgProps: MessageStatusSelectorProps = pick(props.propsForMessage, ['direction', 'status']);
return msgProps;
});
@ -966,19 +969,17 @@ export const getMessageStatusProps = createSelector(getMessagePropsByMessageId,
export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (props):
| MessageTextSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const { direction, status, text, isDeleted, conversationType } = props.propsForMessage;
const msgProps: MessageTextSelectorProps = {
direction,
status,
text,
isDeleted,
conversationType,
};
const msgProps: MessageTextSelectorProps = pick(props.propsForMessage, [
'direction',
'status',
'text',
'isDeleted',
'conversationType',
]);
return msgProps;
});
@ -986,45 +987,27 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p
export const getMessageContextMenuProps = createSelector(getMessagePropsByMessageId, (props):
| MessageContextMenuSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
attachments,
sender,
convoId,
direction,
status,
isDeletable,
isPublic,
isOpenGroupV2,
weAreAdmin,
isSenderAdmin,
text,
serverTimestamp,
timestamp,
isBlocked,
isDeletableForEveryone,
} = props.propsForMessage;
const msgProps: MessageContextMenuSelectorProps = {
attachments,
sender,
convoId,
direction,
status,
isDeletable,
isPublic,
isOpenGroupV2,
weAreAdmin,
isSenderAdmin,
text,
serverTimestamp,
timestamp,
isBlocked,
isDeletableForEveryone,
};
const msgProps: MessageContextMenuSelectorProps = pick(props.propsForMessage, [
'attachments',
'sender',
'convoId',
'direction',
'status',
'isDeletable',
'isPublic',
'isOpenGroupV2',
'weAreAdmin',
'isSenderAdmin',
'text',
'serverTimestamp',
'timestamp',
'isBlocked',
'isDeletableForEveryone',
]);
return msgProps;
});
@ -1032,19 +1015,13 @@ export const getMessageContextMenuProps = createSelector(getMessagePropsByMessag
export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId, (props):
| MessageAuthorSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const { authorName, sender, authorProfileName, direction } = props.propsForMessage;
const { firstMessageOfSeries } = props;
const msgProps: MessageAuthorSelectorProps = {
authorName,
sender,
authorProfileName,
direction,
firstMessageOfSeries,
firstMessageOfSeries: props.firstMessageOfSeries,
...pick(props.propsForMessage, ['authorName', 'sender', 'authorProfileName', 'direction']),
};
return msgProps;
@ -1053,7 +1030,7 @@ export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId,
export const getMessageIsDeletable = createSelector(
getMessagePropsByMessageId,
(props): boolean => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return false;
}
@ -1064,27 +1041,20 @@ export const getMessageIsDeletable = createSelector(
export const getMessageAttachmentProps = createSelector(getMessagePropsByMessageId, (props):
| MessageAttachmentSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
attachments,
direction,
isTrustedForAttachmentDownload,
timestamp,
serverTimestamp,
sender,
convoId,
} = props.propsForMessage;
const msgProps: MessageAttachmentSelectorProps = {
attachments: attachments || [],
direction,
isTrustedForAttachmentDownload,
timestamp,
serverTimestamp,
sender,
convoId,
attachments: props.propsForMessage.attachments || [],
...pick(props.propsForMessage, [
'direction',
'isTrustedForAttachmentDownload',
'timestamp',
'serverTimestamp',
'sender',
'convoId',
]),
};
return msgProps;
@ -1094,7 +1064,7 @@ export const getIsMessageSelected = createSelector(
getMessagePropsByMessageId,
getSelectedMessageIds,
(props, selectedIds): boolean => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return false;
}
@ -1107,31 +1077,22 @@ export const getIsMessageSelected = createSelector(
export const getMessageContentSelectorProps = createSelector(getMessagePropsByMessageId, (props):
| MessageContentSelectorProps
| undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
text,
direction,
timestamp,
serverTimestamp,
previews,
attachments,
quote,
} = props.propsForMessage;
const { firstMessageOfSeries, lastMessageOfSeries } = props;
const msgProps: MessageContentSelectorProps = {
direction,
firstMessageOfSeries,
lastMessageOfSeries,
serverTimestamp,
text,
timestamp,
previews,
quote,
attachments,
firstMessageOfSeries: props.firstMessageOfSeries,
lastMessageOfSeries: props.lastMessageOfSeries,
...pick(props.propsForMessage, [
'direction',
'serverTimestamp',
'text',
'timestamp',
'previews',
'quote',
'attachments',
]),
};
return msgProps;
@ -1140,22 +1101,13 @@ export const getMessageContentSelectorProps = createSelector(getMessagePropsByMe
export const getMessageContentWithStatusesSelectorProps = createSelector(
getMessagePropsByMessageId,
(props): MessageContentWithStatusSelectorProps | undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
direction,
isDeleted,
attachments,
isTrustedForAttachmentDownload,
} = props.propsForMessage;
const msgProps: MessageContentWithStatusSelectorProps = {
direction,
isDeleted,
hasAttachments: Boolean(attachments?.length) || false,
isTrustedForAttachmentDownload,
hasAttachments: Boolean(props.propsForMessage.attachments?.length) || false,
...pick(props.propsForMessage, ['direction', 'isDeleted', 'isTrustedForAttachmentDownload']),
};
return msgProps;
@ -1165,34 +1117,22 @@ export const getMessageContentWithStatusesSelectorProps = createSelector(
export const getGenericReadableMessageSelectorProps = createSelector(
getMessagePropsByMessageId,
(props): GenericReadableMessageSelectorProps | undefined => {
if (!props || _.isEmpty(props)) {
if (!props || isEmpty(props)) {
return undefined;
}
const {
direction,
conversationType,
expirationLength,
expirationTimestamp,
isExpired,
isUnread,
receivedAt,
isKickedFromGroup,
isDeleted,
} = props.propsForMessage;
const msgProps: GenericReadableMessageSelectorProps = {
direction,
conversationType,
expirationLength,
expirationTimestamp,
isUnread,
isExpired,
convoId: props.propsForMessage.convoId,
receivedAt,
isKickedFromGroup,
isDeleted,
};
const msgProps: GenericReadableMessageSelectorProps = pick(props.propsForMessage, [
'convoId',
'direction',
'conversationType',
'expirationLength',
'expirationTimestamp',
'isExpired',
'isUnread',
'receivedAt',
'isKickedFromGroup',
'isDeleted',
]);
return msgProps;
}

View File

@ -12,6 +12,7 @@ import {
InviteContactModalState,
ModalState,
OnionPathModalState,
ReactModalsState,
RecoveryPhraseModalState,
RemoveModeratorsModalState,
SessionPasswordModalState,
@ -98,3 +99,13 @@ export const getDeleteAccountModalState = createSelector(
getModal,
(state: ModalState): DeleteAccountModalState => state.deleteAccountModal
);
export const getReactListDialog = createSelector(
getModal,
(state: ModalState): ReactModalsState => state.reactListModalState
);
export const getReactClearAllDialog = createSelector(
getModal,
(state: ModalState): ReactModalsState => state.reactClearAllModalState
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,132 @@
import chai, { expect } from 'chai';
import Sinon, { useFakeTimers } from 'sinon';
import { handleMessageReaction, sendMessageReaction } from '../../../../util/reactions';
import { Data } from '../../../../data/data';
import * as Storage from '../../../../util/storage';
import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils';
import { DEFAULT_RECENT_REACTS } from '../../../../session/constants';
import { noop } from 'lodash';
import { UserUtils } from '../../../../session/utils';
import { SignalService } from '../../../../protobuf';
import { MessageCollection } from '../../../../models/message';
import chaiAsPromised from 'chai-as-promised';
chai.use(chaiAsPromised as any);
describe('ReactionMessage', () => {
stubWindowLog();
let clock: Sinon.SinonFakeTimers;
const ourNumber = '0123456789abcdef';
const originalMessage = generateFakeIncomingPrivateMessage();
originalMessage.set('sent_at', Date.now());
beforeEach(() => {
Sinon.stub(originalMessage, 'getConversation').returns({
hasReactions: () => true,
sendReaction: noop,
} as any);
// sendMessageReaction stubs
Sinon.stub(Data, 'getMessageById').resolves(originalMessage);
Sinon.stub(Storage, 'getRecentReactions').returns(DEFAULT_RECENT_REACTS);
Sinon.stub(Storage, 'saveRecentReations').resolves();
Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(ourNumber);
// handleMessageReaction stubs
Sinon.stub(Data, 'getMessagesBySentAt').resolves(new MessageCollection([originalMessage]));
Sinon.stub(originalMessage, 'commit').resolves();
});
it('can react to a message', async () => {
// Send reaction
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at'))
);
expect(reaction?.author, 'author should match the original message author').to.be.equal(
originalMessage.get('source')
);
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
expect(reaction?.action, 'action should be 0').to.be.equal(0);
// Handling reaction
const updatedMessage = await handleMessageReaction(
reaction as SignalService.DataMessage.IReaction,
ourNumber,
false,
originalMessage.get('id')
);
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
.undefined;
// tslint:disable: no-non-null-assertion
expect(updatedMessage?.get('reacts')!['😄'], 'reacts should have 😄 key').to.not.be.undefined;
// tslint:disable: no-non-null-assertion
expect(
Object.keys(updatedMessage!.get('reacts')!['😄'].senders)[0],
'sender pubkey should match'
).to.be.equal(ourNumber);
expect(updatedMessage!.get('reacts')!['😄'].count, 'count should be 1').to.be.equal(1);
});
it('can remove a reaction from a message', async () => {
// Send reaction
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at'))
);
expect(reaction?.author, 'author should match the original message author').to.be.equal(
originalMessage.get('source')
);
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
expect(reaction?.action, 'action should be 1').to.be.equal(1);
// Handling reaction
const updatedMessage = await handleMessageReaction(
reaction as SignalService.DataMessage.IReaction,
ourNumber,
false,
originalMessage.get('id')
);
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be
.undefined;
});
it('reactions are rate limited to 20 reactions per minute', async () => {
// we have already sent 2 messages when this test runs
for (let i = 0; i < 18; i++) {
// Send reaction
await sendMessageReaction(originalMessage.get('id'), '👍');
}
let reaction = await sendMessageReaction(originalMessage.get('id'), '👎');
expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be
.undefined;
clock = useFakeTimers(Date.now());
// Wait a miniute for the rate limit to clear
clock.tick(1 * 60 * 1000);
reaction = await sendMessageReaction(originalMessage.get('id'), '👋');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at'))
);
expect(reaction?.author, 'author should match the original message author').to.be.equal(
originalMessage.get('source')
);
expect(reaction?.emoji, 'emoji should be 👋').to.be.equal('👋');
expect(reaction?.action, 'action should be 0').to.be.equal(0);
clock.restore();
});
afterEach(() => {
Sinon.restore();
});
});

View File

@ -200,12 +200,20 @@ describe('MessageSender', () => {
stubUtilWorker('arrayBufferToStringBase64', 'ba64');
Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any);
Sinon.stub(OnionSending, 'endpointRequiresDecoding').returnsArg(0);
stubData('getGuardNodes').resolves([]);
Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([
{ roomId: 'room', serverPublicKey: 'whatever', serverUrl: 'serverUrl' },
]);
Sinon.stub(OpenGroupPollingUtils, 'getOurOpenGroupHeaders').resolves({
'X-SOGS-Pubkey': '00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc',
'X-SOGS-Timestamp': '1642472103',
'X-SOGS-Nonce': 'CdB5nyKVmQGCw6s0Bvv8Ww==',
'X-SOGS-Signature':
'gYqpWZX6fnF4Gb2xQM3xaXs0WIYEI49+B8q4mUUEg8Rw0ObaHUWfoWjMHMArAtP9QlORfiydsKWz1o6zdPVeCQ==',
});
stubCreateObjectUrl();
Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves();

View File

@ -76,10 +76,12 @@ export type LocalizerKeys =
| 'mustBeApproved'
| 'appMenuHideOthers'
| 'sendFailed'
| 'expandedReactionsText'
| 'openMessageRequestInbox'
| 'enterPassword'
| 'enterSessionIDOfRecipient'
| 'dialogClearAllDataDeletionFailedMultiple'
| 'clearAllReactions'
| 'pinConversationLimitToastDescription'
| 'appMenuQuit'
| 'windowMenuZoom'
@ -127,6 +129,7 @@ export type LocalizerKeys =
| 'blocked'
| 'hideRequestBannerDescription'
| 'noBlockedContacts'
| 'reactionNotification'
| 'leaveGroupConfirmation'
| 'banUserAndDeleteAll'
| 'joinOpenGroupAfterInvitationConfirmationDesc'
@ -137,6 +140,7 @@ export type LocalizerKeys =
| 'banUser'
| 'answeredACall'
| 'sendMessage'
| 'readableListCounterSingular'
| 'recoveryPhraseRevealMessage'
| 'showRecoveryPhrase'
| 'autoUpdateSettingDescription'
@ -182,6 +186,7 @@ export type LocalizerKeys =
| 'nameAndMessage'
| 'autoUpdateDownloadedMessage'
| 'onionPathIndicatorTitle'
| 'readableListCounterPlural'
| 'unknown'
| 'mediaMessage'
| 'addAsModerator'
@ -229,6 +234,7 @@ export type LocalizerKeys =
| 'messageDeletedPlaceholder'
| 'notificationFrom'
| 'displayName'
| 'clear'
| 'invalidSessionId'
| 'audioPermissionNeeded'
| 'add'
@ -319,6 +325,7 @@ export type LocalizerKeys =
| 'media'
| 'noMembersInThisGroup'
| 'saveLogToDesktop'
| 'reactionTooltip'
| 'copyErrorAndQuit'
| 'onlyAdminCanRemoveMembers'
| 'passwordTypeError'

148
ts/types/Reaction.ts Normal file
View File

@ -0,0 +1,148 @@
import { EmojiSet } from 'emoji-mart';
export const reactionLimit: number = 6;
export class RecentReactions {
public items: Array<string> = [];
constructor(items: Array<string>) {
this.items = items;
}
public size(): number {
return this.items.length;
}
public push(item: string): void {
if (this.size() === reactionLimit) {
this.items.pop();
}
this.items.unshift(item);
}
public pop(): string | undefined {
return this.items.pop();
}
public swap(index: number): void {
const temp = this.items.splice(index, 1);
this.push(temp[0]);
}
}
type BaseEmojiSkin = { unified: string; native: string };
export interface FixedBaseEmoji {
id: string;
name: string;
keywords: Array<string>;
skins: Array<BaseEmojiSkin>;
version: number;
search?: string;
// props from emoji panel click event
native?: string;
aliases?: Array<string>;
shortcodes?: string;
unified?: string;
}
export interface NativeEmojiData {
categories: Array<{ id: string; emojis: Array<string> }>;
emojis: Record<string, FixedBaseEmoji>;
aliases: Record<string, string>;
sheet: { cols: number; rows: number };
ariaLabels?: Record<string, string>;
}
// Types for EmojiMart 5 are currently broken these are a temporary fixes
export interface FixedPickerProps {
autoFocus?: boolean | undefined;
title?: string | undefined;
theme?: 'auto' | 'light' | 'dark' | undefined;
perLine?: number | undefined;
stickySearch?: boolean | undefined;
searchPosition?: 'sticky' | 'static' | 'none' | undefined;
emojiButtonSize?: number | undefined;
emojiButtonRadius?: number | undefined;
emojiButtonColors?: string | undefined;
maxFrequentRows?: number | undefined;
icons?: 'auto' | 'outline' | 'solid';
set?: EmojiSet | undefined;
emoji?: string | undefined;
navPosition?: 'bottom' | 'top' | 'none' | undefined;
showPreview?: boolean | undefined;
previewEmoji?: boolean | undefined;
noResultsEmoji?: string | undefined;
previewPosition?: 'bottom' | 'top' | 'none' | undefined;
skinTonePosition?: 'preview' | 'search' | 'none';
onEmojiSelect?: (emoji: FixedBaseEmoji) => void;
onClickOutside?: () => void;
onKeyDown?: (event: any) => void;
onAddCustomEmoji?: () => void;
getImageURL?: () => void;
getSpritesheetURL?: () => void;
// Below here I'm currently unsure of usage
// i18n?: PartialI18n | undefined;
// style?: React.CSSProperties | undefined;
// color?: string | undefined;
// skin?: EmojiSkin | undefined;
// defaultSkin?: EmojiSkin | undefined;
// backgroundImageFn?: BackgroundImageFn | undefined;
// sheetSize?: EmojiSheetSize | undefined;
// emojisToShowFilter?(emoji: EmojiData): boolean;
// showSkinTones?: boolean | undefined;
// emojiTooltip?: boolean | undefined;
// include?: CategoryName[] | undefined;
// exclude?: CategoryName[] | undefined;
// recent?: string[] | undefined;
// /** NOTE: custom emoji are copied into a singleton object on every new mount */
// custom?: CustomEmoji[] | undefined;
// skinEmoji?: string | undefined;
// notFound?(): React.Component;
// notFoundEmoji?: string | undefined;
// enableFrequentEmojiSort?: boolean | undefined;
// useButton?: boolean | undefined;
}
export enum Action {
REACT = 0,
REMOVE = 1,
}
export interface Reaction {
// this is in fact a uint64 so we will have an issue
id: number; // original message timestamp
author: string;
emoji: string;
action: Action;
}
// used for logic operations with reactions i.e reponses, db, etc.
export type ReactionList = Record<
string,
{
count: number;
index: number; // relies on reactsIndex in the message model
senders: Record<string, string>; // <sender pubkey, messageHash or serverId>
}
>;
// used when rendering reactions to guarantee sorted order using the index
export type SortedReactionList = Array<
[string, { count: number; index: number; senders: Record<string, string> }]
>;
export interface OpenGroupReaction {
index: number;
count: number;
first: number;
reactors: Array<string>;
you: boolean;
}
export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
export interface OpenGroupReactionResponse {
added?: boolean;
removed?: boolean;
}

View File

@ -0,0 +1,33 @@
// Refactored from
// https://stackoverflow.com/questions/2685911/is-there-a-way-to-round-numbers-into-a-reader-friendly-format-e-g-1-1k
const abbreviations = ['k', 'm', 'b', 't'];
export function abbreviateNumber(number: number, decimals: number = 2): string {
let result = String(number);
const d = Math.pow(10, decimals);
// Go through the array backwards, so we do the largest first
for (let i = abbreviations.length - 1; i >= 0; i--) {
// Convert array index to "1000", "1000000", etc
const size = Math.pow(10, (i + 1) * 3);
// If the number is bigger or equal do the abbreviation
if (size <= number) {
// Here, we multiply by decimals, round, and then divide by decimals.
// This gives us nice rounding to a particular decimal place.
let n = Math.round((number * d) / size) / d;
// Handle special case where we round up to the next abbreviation
if (n === 1000 && i < abbreviations.length - 1) {
n = 1;
i++;
}
result = String(n) + abbreviations[i];
break;
}
}
return result;
}

View File

@ -1,3 +1,5 @@
import { FixedBaseEmoji, NativeEmojiData } from '../types/Reaction';
export type SizeClassType = 'default' | 'small' | 'medium' | 'large' | 'jumbo';
function getRegexUnicodeEmojis() {
@ -36,3 +38,133 @@ export function getEmojiSizeClass(str: string): SizeClassType {
return 'jumbo';
}
}
export let nativeEmojiData: NativeEmojiData | null = null;
export function initialiseEmojiData(data: any) {
const ariaLabels: Record<string, string> = {};
Object.entries(data.emojis).forEach(([key, value]: [string, any]) => {
value.search = `,${[
[value.id, false],
[value.name, true],
[value.keywords, false],
[value.emoticons, false],
]
.map(([strings, split]) => {
if (!strings) {
return null;
}
return (Array.isArray(strings) ? strings : [strings])
.map(string =>
(split ? string.split(/[-|_|\s]+/) : [string]).map((s: string) => s.toLowerCase())
)
.flat();
})
.flat()
.filter(a => a && a.trim())
.join(',')})}`;
(value as FixedBaseEmoji).skins.forEach(skin => {
ariaLabels[skin.native] = value.name;
});
data.emojis[key] = value;
});
data.ariaLabels = ariaLabels;
nativeEmojiData = data;
}
// Synchronous version of Emoji Mart's SearchIndex.search()
// If you upgrade the package things will probably break
export function searchSync(query: string, args?: any): Array<any> {
if (!nativeEmojiData) {
window.log.error('No native emoji data found');
return [];
}
if (!query || !query.trim().length) {
return [];
}
const maxResults = args && args.maxResults ? args.maxResults : 90;
const values = query
.toLowerCase()
.replace(/(\w)-/, '$1 ')
.split(/[\s|,]+/)
.filter((word: string, i: number, words: Array<string>) => {
return word.trim() && words.indexOf(word) === i;
});
if (!values.length) {
return [];
}
let pool: any = Object.values(nativeEmojiData.emojis);
let results: Array<FixedBaseEmoji> = [];
let scores: Record<string, number> = {};
for (const value of values) {
if (!pool.length) {
break;
}
results = [];
scores = {};
for (const emoji of pool) {
if (!emoji.search) {
continue;
}
const score: number = emoji.search.indexOf(`,${value}`);
if (score === -1) {
continue;
}
results.push(emoji);
scores[emoji.id] = scores[emoji.id] ? scores[emoji.id] : 0;
scores[emoji.id] += emoji.id === value ? 0 : score + 1;
}
pool = results;
}
if (results.length < 2) {
return results;
}
results.sort((a: FixedBaseEmoji, b: FixedBaseEmoji) => {
const aScore = scores[a.id];
const bScore = scores[b.id];
if (aScore === bScore) {
return a.id.localeCompare(b.id);
}
return aScore - bScore;
});
if (results.length > maxResults) {
results = results.slice(0, maxResults);
}
return results;
}
// No longer exists on emoji-mart v5.1
export function getEmojiDataFromNative(nativeString: string): FixedBaseEmoji | null {
if (!nativeEmojiData) {
return null;
}
const matches = Object.values(nativeEmojiData.emojis).filter((emoji: any) => {
const skinMatches = (emoji as FixedBaseEmoji).skins.filter((skin: any) => {
return skin.native === nativeString;
});
return skinMatches.length > 0;
});
if (matches.length === 0) {
return null;
}
return matches[0] as FixedBaseEmoji;
}

View File

@ -40,3 +40,26 @@ export const setupi18n = (locale: string, messages: LocaleMessagesType) => {
return getMessage;
};
export let langNotSupportedMessageShown = false;
export const loadEmojiPanelI18n = async () => {
if (!window) {
return undefined;
}
const lang = (window.i18n as any).getLocale();
if (lang !== 'en') {
try {
const langData = await import(`@emoji-mart/data/i18n/${lang}.json`);
return langData;
} catch (err) {
if (!langNotSupportedMessageShown) {
window?.log?.warn(
'Language is not supported by emoji-mart package. See https://github.com/missive/emoji-mart/tree/main/packages/emoji-mart-data/i18n'
);
langNotSupportedMessageShown = true;
}
}
}
};

251
ts/util/reactions.ts Normal file
View File

@ -0,0 +1,251 @@
import { isEmpty } from 'lodash';
import { Data } from '../data/data';
import { MessageModel } from '../models/message';
import { SignalService } from '../protobuf';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { UserUtils } from '../session/utils';
import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction';
import { getRecentReactions, saveRecentReations } from '../util/storage';
const rateCountLimit = 20;
const rateTimeLimit = 60 * 1000;
const latestReactionTimestamps: Array<number> = [];
/**
* Retrieves the original message of a reaction
*/
const getMessageByReaction = async (
reaction: SignalService.DataMessage.IReaction,
isOpenGroup: boolean
): Promise<MessageModel | null> => {
let originalMessage = null;
const originalMessageId = Number(reaction.id);
const originalMessageAuthor = reaction.author;
if (isOpenGroup) {
originalMessage = await Data.getMessageByServerId(originalMessageId);
} else {
const collection = await Data.getMessagesBySentAt(originalMessageId);
originalMessage = collection.find((item: MessageModel) => {
const messageTimestamp = item.get('sent_at');
const author = item.get('source');
return Boolean(
messageTimestamp &&
messageTimestamp === originalMessageId &&
author &&
author === originalMessageAuthor
);
});
}
if (!originalMessage) {
window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`);
return null;
}
return originalMessage;
};
/**
* Sends a Reaction Data Message, don't use for OpenGroups
*/
export const sendMessageReaction = async (messageId: string, emoji: string) => {
const found = await Data.getMessageById(messageId);
if (found) {
const conversationModel = found?.getConversation();
if (!conversationModel) {
window.log.warn(`Conversation for ${messageId} not found in db`);
return;
}
if (!conversationModel.hasReactions()) {
window.log.warn("This conversation doesn't have reaction support");
return;
}
const timestamp = Date.now();
latestReactionTimestamps.push(timestamp);
if (latestReactionTimestamps.length > rateCountLimit) {
const firstTimestamp = latestReactionTimestamps[0];
if (timestamp - firstTimestamp < rateTimeLimit) {
latestReactionTimestamps.pop();
return;
} else {
latestReactionTimestamps.shift();
}
}
const isOpenGroup = Boolean(found?.get('isPublic'));
const id = (isOpenGroup && found.get('serverId')) || Number(found.get('sent_at'));
const me =
(isOpenGroup && getUsBlindedInThatServer(conversationModel)) ||
UserUtils.getOurPubKeyStrFromCache();
const author = found.get('source');
let action: Action = Action.REACT;
const reacts = found.get('reacts');
if (
reacts &&
Object.keys(reacts).includes(emoji) &&
Object.keys(reacts[emoji].senders).includes(me)
) {
window.log.info('found matching reaction removing it');
action = Action.REMOVE;
} else {
const reactions = getRecentReactions();
if (reactions) {
await updateRecentReactions(reactions, emoji);
}
}
const reaction = {
id,
author,
emoji,
action,
};
await conversationModel.sendReaction(messageId, reaction);
window.log.info(
`You ${action === Action.REACT ? 'added' : 'removed'} a`,
emoji,
'reaction for message',
id
);
return reaction;
} else {
window.log.warn(`Message ${messageId} not found in db`);
return;
}
};
/**
* Handle reactions on the client by updating the state of the source message
*/
export const handleMessageReaction = async (
reaction: SignalService.DataMessage.IReaction,
sender: string,
isOpenGroup: boolean,
messageId?: string
) => {
if (!reaction.emoji) {
window?.log?.warn(`There is no emoji for the reaction ${messageId}.`);
return;
}
const originalMessage = await getMessageByReaction(reaction, isOpenGroup);
if (!originalMessage) {
return;
}
const reacts: ReactionList = originalMessage.get('reacts') ?? {};
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: {} };
const details = reacts[reaction.emoji] ?? {};
const senders = Object.keys(details.senders);
window.log.info(
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
reaction.emoji
} reaction`
);
switch (reaction.action) {
case SignalService.DataMessage.Reaction.Action.REACT:
if (senders.includes(sender) && details.senders[sender] !== '') {
window?.log?.info(
'Received duplicate message reaction. Dropping it. id:',
details.senders[sender]
);
return;
}
details.senders[sender] = messageId ?? '';
break;
case SignalService.DataMessage.Reaction.Action.REMOVE:
default:
if (senders.length > 0) {
if (senders.indexOf(sender) >= 0) {
// tslint:disable-next-line: no-dynamic-delete
delete details.senders[sender];
}
}
}
const count = Object.keys(details.senders).length;
if (count > 0) {
reacts[reaction.emoji].count = count;
reacts[reaction.emoji].senders = details.senders;
// sorting for open groups convos is handled by SOGS
if (!isOpenGroup && details && details.index === undefined) {
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
}
} else {
// tslint:disable-next-line: no-dynamic-delete
delete reacts[reaction.emoji];
}
originalMessage.set({
reacts: !isEmpty(reacts) ? reacts : undefined,
});
await originalMessage.commit();
return originalMessage;
};
/**
* Handle all updates to messages reactions from the SOGS API
*/
export const handleOpenGroupMessageReactions = async (
reactions: OpenGroupReactionList,
serverId: number
) => {
const originalMessage = await Data.getMessageByServerId(serverId);
if (!originalMessage) {
window?.log?.warn(`Cannot find the original reacted message ${serverId}.`);
return;
}
if (isEmpty(reactions)) {
if (originalMessage.get('reacts')) {
originalMessage.set({
reacts: undefined,
});
}
} else {
const reacts: ReactionList = {};
Object.keys(reactions).forEach(key => {
const emoji = decodeURI(key);
const senders: Record<string, string> = {};
reactions[key].reactors.forEach(reactor => {
senders[reactor] = String(serverId);
});
reacts[emoji] = { count: reactions[key].count, index: reactions[key].index, senders };
});
originalMessage.set({
reacts,
});
}
await originalMessage.commit();
return originalMessage;
};
export const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
window?.log?.info('updating recent reactions with', newReaction);
const recentReactions = new RecentReactions(reactions);
const foundIndex = recentReactions.items.indexOf(newReaction);
if (foundIndex === 0) {
return;
}
if (foundIndex > 0) {
recentReactions.swap(foundIndex);
} else {
recentReactions.push(newReaction);
}
await saveRecentReations(recentReactions.items);
};

41
ts/util/readableList.ts Normal file
View File

@ -0,0 +1,41 @@
export const readableList = (
arr: Array<string>,
conjunction: string = '&',
limit: number = 3
): string => {
if (arr.length === 0) {
return '';
}
const count = arr.length;
switch (count) {
case 1:
return arr[0];
default:
let result = '';
let others = 0;
for (let i = 0; i < count; i++) {
if (others === 0 && i === count - 1 && i < limit) {
result += ` ${conjunction} `;
} else if (i !== 0 && i < limit) {
result += ', ';
} else if (i >= limit) {
others++;
}
if (others === 0) {
result += arr[i];
}
}
if (others > 0) {
result += ` ${conjunction} ${others} ${
others > 1
? window.i18n('readableListCounterPlural')
: window.i18n('readableListCounterSingular')
}`;
}
return result;
}
};

View File

@ -1,5 +1,6 @@
import { Data } from '../data/data';
import { SessionKeyPair } from '../receiver/keypairs';
import { DEFAULT_RECENT_REACTS } from '../session/constants';
let ready = false;
@ -136,4 +137,17 @@ export async function saveRecoveryPhrase(mnemonic: string) {
return Storage.put('mnemonic', mnemonic);
}
export function getRecentReactions(): Array<string> {
const reactions = Storage.get('recent_reactions') as string;
if (reactions) {
return reactions.split(' ');
} else {
return DEFAULT_RECENT_REACTS;
}
}
export async function saveRecentReations(reactions: Array<string>) {
return Storage.put('recent_reactions', reactions.join(' '));
}
export const Storage = { fetch, put, get, remove, onready, reset };

View File

@ -645,6 +645,11 @@
global-agent "^3.0.0"
global-tunnel-ng "^2.7.1"
"@emoji-mart/data@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.0.2.tgz#2b94c5b5f2c79611c12238438dad9516576a09ab"
integrity sha512-+ZdzBM4llDJJvjuCEsdOYVoSlNA16MMmxKG3oF5LARkwhx6N5clr6phzneWV1qIwJsywqwG7NaBjH8DV6yzjcA==
"@emotion/is-prop-valid@^0.8.8":
version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
@ -1764,10 +1769,10 @@
dependencies:
electron "*"
"@types/emoji-mart@^2.11.3":
version "2.11.3"
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-2.11.3.tgz#9949f6a8a231aea47aac1b2d4212597b41140b07"
integrity sha512-pRlU6+CFIB+9+FwjGGCVtDQq78u7N0iUijrO0Qh1j9RJ6T23DSNNfe0X6kf81N4ubVhF9jVckCI1M3kHpkwjqA==
"@types/emoji-mart@3.0.9":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.9.tgz#2f7ef5d9ec194f28029c46c81a5fc1e5b0efa73c"
integrity sha512-qdBo/2Y8MXaJ/2spKjDZocuq79GpnOhkwMHnK2GnVFa8WYFgfA+ei6sil3aeWQPCreOKIx9ogPpR5+7MaOqYAA==
dependencies:
"@types/react" "*"
@ -3818,12 +3823,10 @@ elliptic@^6.5.3:
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
emoji-mart@^2.11.2:
version "2.11.2"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-2.11.2.tgz#ed331867f7f55bb33c8421c9a493090fa4a378c7"
integrity sha512-IdHZR5hc3mipTY/r0ergtqBgQ96XxmRdQDSg7fsL+GiJQQ4akMws6+cjLSyIhGQxtvNuPVNaEQiAlU00NsyZUg==
dependencies:
prop-types "^15.6.0"
emoji-mart@5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.1.0.tgz#8a36a872e1297747342d1385bd7b7141ac2f4365"
integrity sha512-ytXgeemyw4FormPQqWd35Vh06ZSnQFhVUqW51kASZzzjhQOPSGtiN3VCC7vDq94Pkxmsbet+Gps/qj5N90mEnw==
emoji-regex@^8.0.0:
version "8.0.0"
@ -7340,7 +7343,7 @@ progress@^2.0.3:
promise-inflight@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==
promise-retry@^2.0.1:
version "2.0.1"
@ -7350,7 +7353,7 @@ promise-retry@^2.0.1:
err-code "^2.0.2"
retry "^0.12.0"
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==