Emoji Reacts (#2320)

Add support for emoji reacts in conversations

Resolves #2375 and #1577
pull/2428/head
Will G 2 years ago committed by GitHub
parent 67153bbb07
commit 267f49ff1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -109,6 +109,7 @@
"moreInformation": "More information", "moreInformation": "More information",
"resend": "Resend", "resend": "Resend",
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?", "deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
"clear": "Clear",
"clearAllData": "Clear All Data", "clearAllData": "Clear All Data",
"deleteAccountWarning": "This will permanently delete your messages, and contacts.", "deleteAccountWarning": "This will permanently delete your messages, and contacts.",
"deleteContactConfirmation": "Are you sure you want to delete this conversation?", "deleteContactConfirmation": "Are you sure you want to delete this conversation?",
@ -451,5 +452,11 @@
"clearAllConfirmationTitle": "Clear All Message Requests", "clearAllConfirmationTitle": "Clear All Message Requests",
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?", "clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
"hideBanner": "Hide", "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"
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

@ -65,12 +65,12 @@
"coverage": "nyc --reporter=html mocha -r jsdom-global/register --recursive --exit --timeout 10000 \"./ts/test/**/*_test.js\"", "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 .", "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}\"", "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", "transpile:watch": "yarn grunt --force; tsc -w",
"integration-test": "npx playwright test", "integration-test": "npx playwright test",
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots", "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;", "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", "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", "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", "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 ../../" "rebuild-curve25519-js": "cd node_modules/curve25519-js && yarn install && yarn build && cd ../../"
}, },
"dependencies": { "dependencies": {
"@emoji-mart/data": "1.0.2",
"@reduxjs/toolkit": "^1.4.0", "@reduxjs/toolkit": "^1.4.0",
"abort-controller": "3.0.0", "abort-controller": "3.0.0",
"auto-bind": "^4.0.0", "auto-bind": "^4.0.0",
@ -99,7 +100,7 @@
"electron-is-dev": "^1.1.0", "electron-is-dev": "^1.1.0",
"electron-localshortcut": "^3.2.1", "electron-localshortcut": "^3.2.1",
"electron-updater": "^4.2.2", "electron-updater": "^4.2.2",
"emoji-mart": "^2.11.2", "emoji-mart": "5.1.0",
"filesize": "3.6.1", "filesize": "3.6.1",
"firstline": "1.2.1", "firstline": "1.2.1",
"fs-extra": "9.0.0", "fs-extra": "9.0.0",
@ -164,7 +165,7 @@
"@types/config": "0.0.34", "@types/config": "0.0.34",
"@types/dompurify": "^2.0.0", "@types/dompurify": "^2.0.0",
"@types/electron-localshortcut": "^3.1.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/filesize": "3.6.0",
"@types/firstline": "^2.0.2", "@types/firstline": "^2.0.2",
"@types/fs-extra": "5.0.5", "@types/fs-extra": "5.0.5",
@ -329,16 +330,6 @@
"sound/*", "sound/*",
"build/assets", "build/assets",
"node_modules/**", "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/spellchecker/vendor/hunspell/**/*",
"!node_modules/@iconify/icons-mdi/*", "!node_modules/@iconify/icons-mdi/*",
"node_modules/@iconify/icons-mdi/play-circle*", "node_modules/@iconify/icons-mdi/play-circle*",

File diff suppressed because one or more lines are too long

@ -76,6 +76,20 @@ message DataMessage {
EXPIRATION_TIMER_UPDATE = 2; 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 Quote {
message QuotedAttachment { message QuotedAttachment {
@ -153,6 +167,7 @@ message DataMessage {
optional uint64 timestamp = 7; optional uint64 timestamp = 7;
optional Quote quote = 8; optional Quote quote = 8;
repeated Preview preview = 10; repeated Preview preview = 10;
optional Reaction reaction = 11;
optional LokiProfile profile = 101; optional LokiProfile profile = 101;
optional OpenGroupInvitation openGroupInvitation = 102; optional OpenGroupInvitation openGroupInvitation = 102;
optional ClosedGroupControlMessage closedGroupControlMessage = 104; optional ClosedGroupControlMessage closedGroupControlMessage = 104;

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

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

@ -427,6 +427,7 @@ label {
background-color: var(--color-modal-background); background-color: var(--color-modal-background);
color: var(--color-text); color: var(--color-text);
border: var(--border-session); border: var(--border-session);
border-radius: 14px;
box-shadow: var(--color-session-shadow); box-shadow: var(--color-session-shadow);
overflow: hidden; overflow: hidden;
@ -603,10 +604,14 @@ label {
z-index: 30; z-index: 30;
min-width: 200px; 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; 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 { .react-contexify__item {
background: var(--color-cell-background); background: var(--color-received-message-background);
} }
.react-contexify__item:not(.react-contexify__item--disabled):hover .react-contexify__item:not(.react-contexify__item--disabled):hover
@ -881,7 +886,7 @@ label {
&__description { &__description {
font-family: $session-font-default; font-family: $session-font-default;
font-size: $session-font-sm; font-size: $session-font-sm;
font-weight: 100; font-weight: 400;
max-width: 700px; max-width: 700px;
@include session-color-subtle(var(--color-text)); @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 { .group-member-list {
&__container { &__container {
padding: 2px 0px; padding: 2px 0px;

@ -14,24 +14,71 @@ $session-font-mono: 'SpaceMono';
// Roboto is an open replacement for $session-font-default // Roboto is an open replacement for $session-font-default
@font-face { @font-face {
font-family: $session-font-default; font-family: $session-font-default;
src: url('../fonts/Roboto-Regular.ttf') format('truetype'); src: url('../fonts/Roboto-Thin.ttf') format('truetype');
font-weight: 100;
}
@font-face {
font-family: $session-font-default;
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-weight: 300;
} }
@font-face { @font-face {
font-family: $session-font-default; font-family: $session-font-default;
src: url('../fonts/Roboto-Italic.ttf') format('truetype'); src: url('../fonts/Roboto-LightItalic.ttf') format('truetype');
font-style: italic; font-style: italic;
font-weight: 300; 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-face {
font-family: $session-font-default; font-family: $session-font-default;
src: url('../fonts/Roboto-Bold.ttf') format('truetype'); src: url('../fonts/Roboto-Bold.ttf') format('truetype');
font-weight: 600; font-weight: 700;
} }
@font-face { @font-face {
font-family: $session-font-default; font-family: $session-font-default;
src: url('../fonts/Roboto-BoldItalic.ttf') format('truetype'); 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; font-style: italic;
} }

@ -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 { .composition-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -184,122 +173,6 @@
width: 30px; 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 { .session-progress {

@ -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 { .inbox {
background: var(--color-inbox-background); background: var(--color-inbox-background);
color: var(--color-text); color: var(--color-text);

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

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

@ -1,32 +1,137 @@
import React from 'react'; import React, { forwardRef, MutableRefObject, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Picker } from 'emoji-mart'; import styled from 'styled-components';
import { Constants } from '../../session'; import data from '@emoji-mart/data';
// @ts-ignore
import { Picker } from '../../../node_modules/emoji-mart/dist/index.cjs';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getTheme } from '../../state/selectors/theme'; 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 = { type Props = {
onEmojiClicked: (emoji: any) => void; onEmojiClicked: (emoji: FixedBaseEmoji) => void;
show: boolean; show: boolean;
isModal?: boolean;
onKeyDown?: (event: any) => void;
}; };
export const SessionEmojiPanel = (props: Props) => { const pickerProps: FixedPickerProps = {
const { onEmojiClicked, show } = props; title: '',
const darkMode = useSelector(getTheme) === 'dark'; 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 ( return (
<div className={classNames('session-emoji-panel', show && 'show')}> <StyledEmojiPanel
<Picker isModal={isModal}
backgroundImageFn={() => './images/emoji/emoji-sheet-twitter-32.png'} theme={theme}
set={'twitter'} className={classNames(show && 'show')}
sheetSize={32} ref={ref}
darkMode={darkMode}
color={Constants.UI.COLORS.GREEN}
showPreview={true}
title={''}
onSelect={onEmojiClicked}
autoFocus={true}
/> />
</div>
); );
}; });

@ -24,6 +24,7 @@ import {
getSortedMessagesOfSelectedConversation, getSortedMessagesOfSelectedConversation,
} from '../../state/selectors/conversations'; } from '../../state/selectors/conversations';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import styled from 'styled-components';
export type SessionMessageListProps = { export type SessionMessageListProps = {
messageContainerRef: React.RefObject<HTMLDivElement>; messageContainerRef: React.RefObject<HTMLDivElement>;
@ -52,6 +53,39 @@ type Props = SessionMessageListProps & {
scrollToNow: () => Promise<void>; 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> { class SessionMessagesListContainerInner extends React.Component<Props> {
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null; private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
@ -101,7 +135,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
} }
return ( return (
<div <StyledMessagesContainer
className="messages-container" className="messages-container"
id={messageContainerDomID} id={messageContainerDomID}
onScroll={this.handleScroll} onScroll={this.handleScroll}
@ -135,7 +169,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
onClickScrollBottom={this.props.scrollToNow} onClickScrollBottom={this.props.scrollToNow}
key="scroll-down-button" key="scroll-down-button"
/> />
</div> </StyledMessagesContainer>
); );
} }

@ -3,7 +3,7 @@ import _, { debounce, isEmpty } from 'lodash';
import * as MIME from '../../../types/MIME'; import * as MIME from '../../../types/MIME';
import { SessionEmojiPanel } from '../SessionEmojiPanel'; import { SessionEmojiPanel, StyledEmojiPanel } from '../SessionEmojiPanel';
import { SessionRecording } from '../SessionRecording'; import { SessionRecording } from '../SessionRecording';
import { import {
@ -55,6 +55,8 @@ import {
} from './UserMentions'; } from './UserMentions';
import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult'; import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult';
import { LinkPreviews } from '../../../util/linkPreviews'; import { LinkPreviews } from '../../../util/linkPreviews';
import styled from 'styled-components';
import { FixedBaseEmoji } from '../../../types/Reaction';
export interface ReplyingToMessageProps { export interface ReplyingToMessageProps {
convoId: string; convoId: string;
@ -203,6 +205,59 @@ const getSelectionBasedOnMentions = (draft: string, index: number) => {
return Number.MAX_SAFE_INTEGER; 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> { class CompositionBoxInner extends React.Component<Props, State> {
private readonly textarea: React.RefObject<any>; private readonly textarea: React.RefObject<any>;
private readonly fileInput: React.RefObject<HTMLInputElement>; private readonly fileInput: React.RefObject<HTMLInputElement>;
@ -369,8 +424,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
{typingEnabled && <StartRecordingButton onClick={this.onLoadVoiceNoteView} />} {typingEnabled && <StartRecordingButton onClick={this.onLoadVoiceNoteView} />}
<div <StyledSendMessageInput
className="send-message-input"
role="main" role="main"
onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container
ref={el => { ref={el => {
@ -379,19 +433,22 @@ class CompositionBoxInner extends React.Component<Props, State> {
data-testid="message-input" data-testid="message-input"
> >
{this.renderTextArea()} {this.renderTextArea()}
</div> </StyledSendMessageInput>
{typingEnabled && ( {typingEnabled && (
<ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} /> <ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} />
)} )}
<SendMessageButton onClick={this.onSendMessage} /> <SendMessageButton onClick={this.onSendMessage} />
{typingEnabled && ( {typingEnabled && showEmojiPanel && (
<div ref={this.emojiPanel} onKeyDown={this.onKeyDown} role="button"> <StyledEmojiPanelContainer role="button">
{showEmojiPanel && ( <SessionEmojiPanel
<SessionEmojiPanel onEmojiClicked={this.onEmojiClick} show={showEmojiPanel} /> ref={this.emojiPanel}
)} show={showEmojiPanel}
</div> onEmojiClicked={this.onEmojiClick}
onKeyDown={this.onKeyDown}
/>
</StyledEmojiPanelContainer>
)} )}
</> </>
); );
@ -978,7 +1035,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft }); updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft });
} }
private onEmojiClick({ native }: any) { private onEmojiClick(emoji: FixedBaseEmoji) {
if (!this.props.selectedConversationKey) { if (!this.props.selectedConversationKey) {
throw new Error('selectedConversationKey is needed'); throw new Error('selectedConversationKey is needed');
} }
@ -996,7 +1053,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
const before = draft.slice(0, realSelectionStart); const before = draft.slice(0, realSelectionStart);
const end = draft.slice(realSelectionStart); const end = draft.slice(realSelectionStart);
const newMessage = `${before}${native}${end}`; const newMessage = `${before}${emoji.native}${end}`;
this.setState({ draft: newMessage }); this.setState({ draft: newMessage });
updateDraftForConversation({ updateDraftForConversation({
conversationKey: this.props.selectedConversationKey, conversationKey: this.props.selectedConversationKey,

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

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

@ -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 { useDispatch, useSelector } from 'react-redux';
import { useClickAway, useMouse } from 'react-use';
import styled from 'styled-components';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
import { MessageInteraction } from '../../../../interactions'; import { MessageInteraction } from '../../../../interactions';
import { replyToMessage } from '../../../../interactions/conversationInteractions'; import { replyToMessage } from '../../../../interactions/conversationInteractions';
@ -20,8 +22,12 @@ import {
showMessageDetailsView, showMessageDetailsView,
toggleSelectedMessageId, toggleSelectedMessageId,
} from '../../../../state/ducks/conversations'; } from '../../../../state/ducks/conversations';
import { StateType } from '../../../../state/reducer';
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations'; import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil'; import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
import { sendMessageReaction } from '../../../../util/reactions';
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
import { MessageReactBar } from './MessageReactBar';
export type MessageContextMenuSelectorProps = Pick< export type MessageContextMenuSelectorProps = Pick<
MessageRenderingProps, MessageRenderingProps,
@ -42,16 +48,43 @@ export type MessageContextMenuSelectorProps = Pick<
| 'isDeletableForEveryone' | '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 // tslint:disable: max-func-body-length cyclomatic-complexity
export const MessageContextMenu = (props: Props) => { export const MessageContextMenu = (props: Props) => {
const selected = useSelector(state => getMessageContextMenuProps(state as any, props.messageId)); const { messageId, contextMenuId, enableReactions } = props;
const dispatch = useDispatch(); const dispatch = useDispatch();
const { hideAll } = useContextMenu();
const selected = useSelector((state: StateType) => getMessageContextMenuProps(state, messageId));
if (!selected) { if (!selected) {
return null; return null;
} }
const { const {
attachments, attachments,
sender, sender,
@ -68,14 +101,28 @@ export const MessageContextMenu = (props: Props) => {
timestamp, timestamp,
isBlocked, isBlocked,
} = selected; } = selected;
const { messageId, contextMenuId } = props;
const isOutgoing = direction === 'outgoing'; const isOutgoing = direction === 'outgoing';
const showRetry = status === 'error' && isOutgoing; const showRetry = status === 'error' && isOutgoing;
const isSent = status === 'sent' || status === 'read'; // a read message should be replyable 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; window.contextMenuShown = true;
}, []); };
const onContextMenuHidden = useCallback(() => { const onContextMenuHidden = useCallback(() => {
// This function will called before the click event // This function will called before the click event
@ -174,13 +221,86 @@ export const MessageContextMenu = (props: Props) => {
void deleteMessagesByIdForEveryone([messageId], convoId); void deleteMessagesByIdForEveryone([messageId], convoId);
}, [convoId, messageId]); }, [convoId, messageId]);
const onShowEmoji = () => {
hideAll();
setMouseX(docX);
setMouseY(docY);
setShowEmojiPanel(true);
};
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 ( 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>
)}
<Menu <Menu
id={contextMenuId} id={contextMenuId}
onShown={onContextMenuShown} onShown={onContextMenuShown}
onHidden={onContextMenuHidden} onHidden={onContextMenuHidden}
animation={animation.fade} animation={animation.fade}
> >
{enableReactions && (
<MessageReactBar action={onEmojiClick} additionalAction={onShowEmoji} />
)}
{attachments?.length ? ( {attachments?.length ? (
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item> <Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
) : null} ) : null}
@ -215,5 +335,6 @@ export const MessageContextMenu = (props: Props) => {
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item> <Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
) : null} ) : null}
</Menu> </Menu>
</StyledMessageContextMenu>
); );
}; };

@ -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>
);
};

@ -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>
);
};

@ -19,6 +19,7 @@ import { ExpireTimer } from '../../ExpireTimer';
import { MessageAvatar } from '../message-content/MessageAvatar'; import { MessageAvatar } from '../message-content/MessageAvatar';
import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus';
import { ReadableMessage } from './ReadableMessage'; import { ReadableMessage } from './ReadableMessage';
import styled, { keyframes } from 'styled-components';
export type GenericReadableMessageSelectorProps = Pick< export type GenericReadableMessageSelectorProps = Pick<
MessageRenderingProps, MessageRenderingProps,
@ -99,7 +100,50 @@ type Props = {
}; };
// tslint:disable: use-simple-attributes // 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) => { export const GenericReadableMessage = (props: Props) => {
const { ctxMenuID, messageId, isDetailView } = props;
const [enableReactions, setEnableReactions] = useState(true);
const msgProps = useSelector(state => const msgProps = useSelector(state =>
getGenericReadableMessageSelectorProps(state as any, props.messageId) getGenericReadableMessageSelectorProps(state as any, props.messageId)
); );
@ -118,6 +162,13 @@ export const GenericReadableMessage = (props: Props) => {
); );
const multiSelectMode = useSelector(isMessageSelectionMode); const multiSelectMode = useSelector(isMessageSelectionMode);
const [isRightClicked, setIsRightClicked] = useState(false);
const onMessageLoseFocus = useCallback(() => {
if (isRightClicked) {
setIsRightClicked(false);
}
}, [isRightClicked]);
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLElement>) => { (e: React.MouseEvent<HTMLElement>) => {
const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup; const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup;
@ -125,15 +176,31 @@ export const GenericReadableMessage = (props: Props) => {
if (enableContextMenu) { if (enableContextMenu) {
contextMenu.hideAll(); contextMenu.hideAll();
contextMenu.show({ contextMenu.show({
id: props.ctxMenuID, id: ctxMenuID,
event: e, 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) { if (!msgProps) {
return null; return null;
@ -156,10 +223,11 @@ export const GenericReadableMessage = (props: Props) => {
const isIncoming = direction === 'incoming'; const isIncoming = direction === 'incoming';
return ( return (
<ReadableMessage <StyledReadableMessage
messageId={messageId} messageId={messageId}
selected={selected}
isRightClicked={isRightClicked}
className={classNames( className={classNames(
'session-message-wrapper',
selected && 'message-selected', selected && 'message-selected',
isGroup && 'public-chat-message-wrapper', isGroup && 'public-chat-message-wrapper',
isIncoming ? 'session-message-wrapper-incoming' : 'session-message-wrapper-outgoing' isIncoming ? 'session-message-wrapper-incoming' : 'session-message-wrapper-outgoing'
@ -178,10 +246,11 @@ export const GenericReadableMessage = (props: Props) => {
/> />
)} )}
<MessageContentWithStatuses <MessageContentWithStatuses
ctxMenuID={props.ctxMenuID} ctxMenuID={ctxMenuID}
messageId={messageId} messageId={messageId}
isDetailView={isDetailView} isDetailView={isDetailView}
dataTestId={`message-content-${messageId}`} dataTestId={`message-content-${messageId}`}
enableReactions={enableReactions}
/> />
{expirationLength && expirationTimestamp && ( {expirationLength && expirationTimestamp && (
<ExpireTimer <ExpireTimer
@ -190,6 +259,6 @@ export const GenericReadableMessage = (props: Props) => {
expirationTimestamp={expirationTimestamp} expirationTimestamp={expirationTimestamp}
/> />
)} )}
</ReadableMessage> </StyledReadableMessage>
); );
}; };

@ -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>
);
};

@ -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>
);
};

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

@ -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>
);
};

@ -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>
);
};

@ -143,6 +143,7 @@ export const Data = {
getMessageIdsFromServerIds, getMessageIdsFromServerIds,
getMessageById, getMessageById,
getMessageBySenderAndSentAt, getMessageBySenderAndSentAt,
getMessageByServerId,
filterAlreadyFetchedOpengroupMessage, filterAlreadyFetchedOpengroupMessage,
getMessageBySenderAndTimestamp, getMessageBySenderAndTimestamp,
getUnreadByConversation, getUnreadByConversation,
@ -433,6 +434,21 @@ async function getMessageBySenderAndSentAt({
return new MessageModel(messages[0]); 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( async function filterAlreadyFetchedOpengroupMessage(
msgDetails: MsgDuplicateSearchOpenGroup msgDetails: MsgDuplicateSearchOpenGroup
): Promise<MsgDuplicateSearchOpenGroup> { ): Promise<MsgDuplicateSearchOpenGroup> {

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

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

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

@ -78,18 +78,23 @@ import {
ConversationTypeEnum, ConversationTypeEnum,
fillConvoAttributesWithDefaults, fillConvoAttributesWithDefaults,
} from './conversationAttributes'; } from './conversationAttributes';
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding'; import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
import { from_hex } from 'libsodium-wrappers-sumo'; import { from_hex } from 'libsodium-wrappers-sumo';
import { OpenGroupData } from '../data/opengroups'; 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 { addMessagePadding } from '../session/crypto/BufferPadding';
import { getSodiumRenderer } from '../session/crypto'; import { getSodiumRenderer } from '../session/crypto';
import { import {
findCachedOurBlindedPubkeyOrLookItUp, findCachedOurBlindedPubkeyOrLookItUp,
getUsBlindedInThatServer,
isUsAnySogsFromCache, isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile'; 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> { export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any; public updateLastMessage: () => any;
@ -635,6 +640,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await this.sendBlindedMessageRequest(chatMessageParams); await this.sendBlindedMessageRequest(chatMessageParams);
return; return;
} }
if (shouldApprove) { if (shouldApprove) {
await this.setIsApproved(true); await this.setIsApproved(true);
if (hasIncomingMessages) { if (hasIncomingMessages) {
@ -667,6 +673,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
} }
const destinationPubkey = new PubKey(destination); const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) { if (this.isPrivate()) {
if (this.isMe()) { if (this.isMe()) {
chatMessageParams.syncTarget = this.id; chatMessageParams.syncTarget = this.id;
@ -675,7 +682,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await getMessageQueue().sendSyncMessage(chatMessageMe); await getMessageQueue().sendSyncMessage(chatMessageMe);
return; return;
} }
// Handle Group Invitation Message
if (message.get('groupInvitation')) { if (message.get('groupInvitation')) {
const groupInvitation = message.get('groupInvitation'); const groupInvitation = message.get('groupInvitation');
const groupInvitMessage = new GroupInvitationMessage({ 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 * 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() { public async bouncyUpdateLastMessage() {
if (!this.id || !this.get('active_at')) { if (!this.id || !this.get('active_at')) {
return; return;
@ -1309,27 +1442,27 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public setSessionDisplayNameNoCommit(newDisplayName?: string | null) { public setSessionDisplayNameNoCommit(newDisplayName?: string | null) {
const existingSessionName = this.getRealSessionUsername(); const existingSessionName = this.getRealSessionUsername();
if (newDisplayName && newDisplayName !== existingSessionName) { if (newDisplayName !== existingSessionName && newDisplayName) {
this.set({ displayNameInProfile: 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 { public getRealSessionUsername(): string | undefined {
return this.get('displayNameInProfile'); 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 { public getNickname(): string | undefined {
return this.isPrivate() ? this.get('nickname') : 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 { public getNicknameOrRealUsername(): string | undefined {
return this.getNickname() || this.getRealSessionUsername(); return this.getNickname() || this.getRealSessionUsername();
@ -1537,6 +1670,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public hasMember(pubkey: string) { public hasMember(pubkey: string) {
return includes(this.get('members'), pubkey); 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 // returns true if this is a closed/medium or open group
public isGroup() { public isGroup() {
return this.get('type') === ConversationTypeEnum.GROUP; return this.get('type') === ConversationTypeEnum.GROUP;

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

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

@ -997,6 +997,20 @@ function getMessageBySenderAndSentAt({ source, sentAt }: { source: string; sentA
return map(rows, row => jsonToObject(row.json)); 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 }) { function getMessagesCountBySender({ source }: { source: string }) {
if (!source) { if (!source) {
throw new Error('source must be set'); throw new Error('source must be set');
@ -2373,6 +2387,7 @@ export const sqlNode = {
getMessageIdsFromServerIds, getMessageIdsFromServerIds,
getMessageById, getMessageById,
getMessagesBySentAt, getMessagesBySentAt,
getMessageByServerId,
getSeenMessagesByHashList, getSeenMessagesByHashList,
getLastHashBySnode, getLastHashBySnode,
getExpiredMessages, getExpiredMessages,

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

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

@ -16,6 +16,8 @@ import { GoogleChrome } from '../util';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates'; import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { ConversationTypeEnum } from '../models/conversationAttributes'; import { ConversationTypeEnum } from '../models/conversationAttributes';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; 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 { function contentTypeSupported(type: string): boolean {
const Chrome = GoogleChrome; const Chrome = GoogleChrome;
@ -179,6 +181,7 @@ export type RegularMessageType = Pick<
| 'openGroupInvitation' | 'openGroupInvitation'
| 'quote' | 'quote'
| 'preview' | 'preview'
| 'reaction'
| 'profile' | 'profile'
| 'profileKey' | 'profileKey'
| 'expireTimer' | 'expireTimer'
@ -192,6 +195,7 @@ export function toRegularMessage(rawDataMessage: SignalService.DataMessage): Reg
..._.pick(rawDataMessage, [ ..._.pick(rawDataMessage, [
'attachments', 'attachments',
'preview', 'preview',
'reaction',
'body', 'body',
'flags', 'flags',
'profileKey', 'profileKey',
@ -336,6 +340,20 @@ export async function handleMessageJob(
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}` ) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
); );
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
if (
regularDataMessage.reaction.action === Action.REACT &&
conversation.isPrivate() &&
messageModel.get('unread')
) {
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
conversation.throttledNotify(messageModel);
}
confirm?.();
} else {
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait( const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source, source,
ConversationTypeEnum.PRIVATE ConversationTypeEnum.PRIVATE
@ -430,10 +448,10 @@ export async function handleMessageJob(
if (messageModel.get('unread')) { if (messageModel.get('unread')) {
conversation.throttledNotify(messageModel); conversation.throttledNotify(messageModel);
} }
confirm?.(); confirm?.();
} catch (error) { } catch (error) {
const errorForLog = error && error.stack ? error.stack : error; const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog); window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
} }
} }
}

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

@ -52,12 +52,14 @@ export const filterDuplicatesFromDbAndIncomingV4 = async (
a.posted === b.posted a.posted === b.posted
); );
// make sure a sender is set, as we cast it just below // 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 // now, check database to make sure those messages are not already fetched
const filteredInDb = await Data.filterAlreadyFetchedOpengroupMessage( const filteredInDb = await Data.filterAlreadyFetchedOpengroupMessage(
filtered.map(m => { 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! };
}) })
); );

@ -34,6 +34,7 @@ import { handleOutboxMessageModel } from '../../../../receiver/dataMessage';
import { ConversationTypeEnum } from '../../../../models/conversationAttributes'; import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory'; import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
import { Data } from '../../../../data/data'; 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. * 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, serverUrl: string,
roomId: 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));
const deletions = messages.filter(m => Boolean(m.deleted) || m.data === null); const exceptDeletion = messages.filter(m => !m.deleted);
const exceptDeletion = messages.filter(m => !(Boolean(m.deleted) || m.data === null));
if (!deletions.length) { if (!deletions.length) {
return messages; return messages;
} }
@ -156,6 +156,7 @@ const handleSogsV3DeletedMessages = async (
const messageIds = await Data.getMessageIdsFromServerIds(allIdsRemoved, convo.id); 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 // 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( await Promise.all(
(messageIds || []).map(async id => { (messageIds || []).map(async id => {
if (convo) { if (convo) {
@ -205,12 +206,27 @@ const handleMessagesResponseV4 = async (
return; 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. // 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. // Session works with timestamp in ms, for a lot of things, so first, lets fix this.
const messagesWithMsTimestamp = messagesWithoutReactionOnlyUpdates
const messagesWithMsTimestamp = messages.map(m => ({ .sort((a, b) => (a.seqno < b.seqno ? -1 : a.seqno > b.seqno ? 1 : 0))
.map(m => ({
...m, ...m,
posted: Math.floor(m.posted * 1000), posted: m.posted ? Math.floor(m.posted * 1000) : undefined,
})); }));
const messagesWithoutDeleted = await handleSogsV3DeletedMessages( const messagesWithoutDeleted = await handleSogsV3DeletedMessages(
@ -235,6 +251,7 @@ const handleMessagesResponseV4 = async (
const messagesWithResolvedBlindedIdsIfFound = []; const messagesWithResolvedBlindedIdsIfFound = [];
for (let index = 0; index < messagesFilteredBlindedIds.length; index++) { for (let index = 0; index < messagesFilteredBlindedIds.length; index++) {
const newMessage = messagesFilteredBlindedIds[index]; const newMessage = messagesFilteredBlindedIds[index];
if (newMessage.session_id) {
const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id); const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id);
// override the sender in the message itself if we are the sender // override the sender in the message itself if we are the sender
@ -242,9 +259,13 @@ const handleMessagesResponseV4 = async (
newMessage.session_id = unblindedIdFound; newMessage.session_id = unblindedIdFound;
} }
messagesWithResolvedBlindedIdsIfFound.push(newMessage); messagesWithResolvedBlindedIdsIfFound.push(newMessage);
} else {
throw Error('session_id is missing so we cannot resolve the blinded id');
}
} }
// we use the unverified newMessages seqno and id as last polled because we actually did poll up to those ids. // 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 incomingMessageSeqNo = compact(messages.map(n => n.seqno));
const maxNewMessageSeqNo = Math.max(...incomingMessageSeqNo); const maxNewMessageSeqNo = Math.max(...incomingMessageSeqNo);
for (let index = 0; index < messagesWithResolvedBlindedIdsIfFound.length; index++) { for (let index = 0; index < messagesWithResolvedBlindedIdsIfFound.length; index++) {
@ -270,6 +291,19 @@ const handleMessagesResponseV4 = async (
} }
roomInfosRefreshed.lastFetchTimestamp = Date.now(); roomInfosRefreshed.lastFetchTimestamp = Date.now();
await OpenGroupData.saveV2OpenGroupRoom(roomInfosRefreshed); 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) { } catch (e) {
window?.log?.warn('handleNewMessages failed:', e); window?.log?.warn('handleNewMessages failed:', e);
} }

@ -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 { concatUInt8Array, getSodiumRenderer, LibSodiumWrappers } from '../../../crypto';
import { crypto_hash_sha512, from_hex, to_hex } from 'libsodium-wrappers-sumo'; import { crypto_hash_sha512, from_hex, to_hex } from 'libsodium-wrappers-sumo';
import { ByteKeyPair } from '../../../utils/User'; import { ByteKeyPair } from '../../../utils/User';
@ -12,6 +12,7 @@ import {
toX25519, toX25519,
} from '../../../utils/SodiumUtils'; } from '../../../utils/SodiumUtils';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { OnionSending } from '../../../onions/onionSend';
async function getSogsSignature({ async function getSogsSignature({
blinded, blinded,
@ -67,14 +68,18 @@ async function getOpenGroupHeaders(data: {
pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`; 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 // SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HASHED_BODY
let toSign = concatUInt8Array( let toSign = concatUInt8Array(
serverPK, serverPK,
nonce, nonce,
stringToUint8Array(timestamp.toString()), stringToUint8Array(timestamp.toString()),
stringToUint8Array(method), stringToUint8Array(method),
stringToUint8Array(path) encodedPath
); );
if (body) { if (body) {
const bodyHashed = sodium.crypto_generichash(64, body); const bodyHashed = sodium.crypto_generichash(64, body);

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

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

@ -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;
};

@ -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;
};

@ -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 // 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 QUOTED_TEXT_MAX_LENGTH = 150;
export const DEFAULT_RECENT_REACTS = ['😂', '🥰', '😢', '😡', '😮', '😈'];

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

@ -30,14 +30,30 @@ export type OnionFetchOptions = {
useV4: boolean; 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 = ( const buildSendViaOnionPayload = (
url: URL, url: URL,
fetchOptions: OnionFetchOptions fetchOptions: OnionFetchOptions
): FinalDestNonSnodeOptions => { ): FinalDestNonSnodeOptions => {
const endpoint = OnionSending.endpointRequiresDecoding(
url.search ? `${url.pathname}${url.search}` : url.pathname
);
const payloadObj: FinalDestNonSnodeOptions = { const payloadObj: FinalDestNonSnodeOptions = {
method: fetchOptions.method || 'GET', method: fetchOptions.method || 'GET',
body: fetchOptions.body, body: fetchOptions.body,
endpoint: url.search ? `${url.pathname}${url.search}` : url.pathname, endpoint,
headers: fetchOptions.headers || {}, headers: fetchOptions.headers || {},
}; };
@ -86,6 +102,7 @@ export type OnionV4BinarySnodeResponse = {
* Build & send an onion v4 request to a non snode, and handle retries. * 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. * 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 ( const sendViaOnionV4ToNonSnodeWithRetries = async (
destinationX25519Key: string, destinationX25519Key: string,
url: URL, url: URL,
@ -152,6 +169,7 @@ const sendViaOnionV4ToNonSnodeWithRetries = async (
useV4: true, useV4: true,
throwErrors, throwErrors,
}); });
if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) { if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) {
window.log.info( window.log.info(
'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ', 'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ',
@ -285,6 +303,7 @@ async function sendJsonViaOnionV4ToSogs(sendOptions: {
return null; return null;
} }
headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded }; headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded };
const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries( const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
serverPubkey, serverPubkey,
builtUrl, builtUrl,
@ -500,7 +519,9 @@ async function sendJsonViaOnionV4ToFileServer(sendOptions: {
return res as OnionV4JSONSnodeResponse; return res as OnionV4JSONSnodeResponse;
} }
// we export these methods for stubbing during testing
export const OnionSending = { export const OnionSending = {
endpointRequiresDecoding,
sendViaOnionV4ToNonSnodeWithRetries, sendViaOnionV4ToNonSnodeWithRetries,
getOnionPathForSending, getOnionPathForSending,
sendJsonViaOnionV4ToSogs, sendJsonViaOnionV4ToSogs,

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

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

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

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

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

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

@ -12,6 +12,7 @@ import {
InviteContactModalState, InviteContactModalState,
ModalState, ModalState,
OnionPathModalState, OnionPathModalState,
ReactModalsState,
RecoveryPhraseModalState, RecoveryPhraseModalState,
RemoveModeratorsModalState, RemoveModeratorsModalState,
SessionPasswordModalState, SessionPasswordModalState,
@ -98,3 +99,13 @@ export const getDeleteAccountModalState = createSelector(
getModal, getModal,
(state: ModalState): DeleteAccountModalState => state.deleteAccountModal (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

@ -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();
});
});

@ -200,12 +200,20 @@ describe('MessageSender', () => {
stubUtilWorker('arrayBufferToStringBase64', 'ba64'); stubUtilWorker('arrayBufferToStringBase64', 'ba64');
Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any); Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any);
Sinon.stub(OnionSending, 'endpointRequiresDecoding').returnsArg(0);
stubData('getGuardNodes').resolves([]); stubData('getGuardNodes').resolves([]);
Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([ Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([
{ roomId: 'room', serverPublicKey: 'whatever', serverUrl: 'serverUrl' }, { 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(); stubCreateObjectUrl();
Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves(); Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves();

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

@ -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;
}

@ -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;
}

@ -1,3 +1,5 @@
import { FixedBaseEmoji, NativeEmojiData } from '../types/Reaction';
export type SizeClassType = 'default' | 'small' | 'medium' | 'large' | 'jumbo'; export type SizeClassType = 'default' | 'small' | 'medium' | 'large' | 'jumbo';
function getRegexUnicodeEmojis() { function getRegexUnicodeEmojis() {
@ -36,3 +38,133 @@ export function getEmojiSizeClass(str: string): SizeClassType {
return 'jumbo'; 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;
}

@ -40,3 +40,26 @@ export const setupi18n = (locale: string, messages: LocaleMessagesType) => {
return getMessage; 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;
}
}
}
};

@ -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);
};

@ -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;
}
};

@ -1,5 +1,6 @@
import { Data } from '../data/data'; import { Data } from '../data/data';
import { SessionKeyPair } from '../receiver/keypairs'; import { SessionKeyPair } from '../receiver/keypairs';
import { DEFAULT_RECENT_REACTS } from '../session/constants';
let ready = false; let ready = false;
@ -136,4 +137,17 @@ export async function saveRecoveryPhrase(mnemonic: string) {
return Storage.put('mnemonic', mnemonic); 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 }; export const Storage = { fetch, put, get, remove, onready, reset };

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

Loading…
Cancel
Save