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": [
"node_modules/sanitize.css",
"node_modules/emoji-mart/css",
"node_modules/react-h5-audio-player/lib",
"node_modules/react-contexify/dist",
"node_modules/react-toastify/dist"

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -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');
Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any);
Sinon.stub(OnionSending, 'endpointRequiresDecoding').returnsArg(0);
stubData('getGuardNodes').resolves([]);
Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([
{ roomId: 'room', serverPublicKey: 'whatever', serverUrl: 'serverUrl' },
]);
Sinon.stub(OpenGroupPollingUtils, 'getOurOpenGroupHeaders').resolves({
'X-SOGS-Pubkey': '00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc',
'X-SOGS-Timestamp': '1642472103',
'X-SOGS-Nonce': 'CdB5nyKVmQGCw6s0Bvv8Ww==',
'X-SOGS-Signature':
'gYqpWZX6fnF4Gb2xQM3xaXs0WIYEI49+B8q4mUUEg8Rw0ObaHUWfoWjMHMArAtP9QlORfiydsKWz1o6zdPVeCQ==',
});
stubCreateObjectUrl();
Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves();

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

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

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

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

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

Loading…
Cancel
Save