mirror of
https://github.com/oxen-io/session-desktop.git
synced 2023-12-14 02:12:57 +01:00
Merge remote-tracking branch 'upstream/clearnet' into menu-redesign
This commit is contained in:
commit
ef1f634e6b
77 changed files with 6273 additions and 678 deletions
|
@ -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?",
|
||||
|
@ -452,5 +453,11 @@
|
|||
"clearAllConfirmationTitle": "Clear All Message Requests",
|
||||
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
|
||||
"hideBanner": "Hide",
|
||||
"openMessageRequestInboxDescription": "View your Message Request inbox"
|
||||
"openMessageRequestInboxDescription": "View your Message Request inbox",
|
||||
"clearAllReactions": "Are you sure you want to clear all $emoji$ ?",
|
||||
"reactionTooltip": "reacted with",
|
||||
"expandedReactionsText": "Show Less",
|
||||
"reactionNotification": "Reacts to a message with $emoji$",
|
||||
"readableListCounterSingular": "other",
|
||||
"readableListCounterPlural": "others"
|
||||
}
|
||||
|
|
BIN
fonts/Roboto-Black.ttf
Normal file
BIN
fonts/Roboto-Black.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-BlackItalic.ttf
Normal file
BIN
fonts/Roboto-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-Light.ttf
Normal file
BIN
fonts/Roboto-Light.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-LightItalic.ttf
Normal file
BIN
fonts/Roboto-LightItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-Medium.ttf
Normal file
BIN
fonts/Roboto-Medium.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-MediumItalic.ttf
Normal file
BIN
fonts/Roboto-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-Thin.ttf
Normal file
BIN
fonts/Roboto-Thin.ttf
Normal file
Binary file not shown.
BIN
fonts/Roboto-ThinItalic.ttf
Normal file
BIN
fonts/Roboto-ThinItalic.ttf
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 839 KiB |
19
package.json
19
package.json
|
@ -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*",
|
||||
|
|
2817
patches/emoji-mart+5.1.0.patch
Normal file
2817
patches/emoji-mart+5.1.0.patch
Normal file
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;
|
||||
// TODO is this being used anywhere? Seems not
|
||||
// button {
|
||||
// float: right;
|
||||
// margin-inline-start: 10px;
|
||||
// background-color: $color-loki-green;
|
||||
// border-radius: 100px;
|
||||
// padding: 5px 15px;
|
||||
// border: 1px solid $color-loki-green;
|
||||
// color: white;
|
||||
// outline: none;
|
||||
// user-select: none;
|
||||
|
||||
&:hover,
|
||||
&:disabled {
|
||||
background-color: $color-loki-green-dark;
|
||||
border-color: $color-loki-green-dark;
|
||||
}
|
||||
// &:hover,
|
||||
// &:disabled {
|
||||
// background-color: $color-loki-green-dark;
|
||||
// border-color: $color-loki-green-dark;
|
||||
// }
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
// &:disabled {
|
||||
// cursor: not-allowed;
|
||||
// }
|
||||
// }
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
|
@ -207,7 +208,6 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.session-button {
|
||||
width: 148px;
|
||||
}
|
||||
|
@ -305,3 +305,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-list-modal {
|
||||
.session-confirm-wrapper {
|
||||
.session-modal__body {
|
||||
width: 376px;
|
||||
padding: 0;
|
||||
.session-modal__centered {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -426,6 +426,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;
|
||||
|
@ -602,10 +603,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
|
||||
|
@ -880,7 +885,7 @@ label {
|
|||
&__description {
|
||||
font-family: $session-font-default;
|
||||
font-size: $session-font-sm;
|
||||
font-weight: 100;
|
||||
font-weight: 400;
|
||||
max-width: 700px;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
|
|
@ -14,24 +14,71 @@ $session-font-mono: 'SpaceMono';
|
|||
// Roboto is an open replacement for $session-font-default
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
src: url('../fonts/Roboto-Thin.ttf') format('truetype');
|
||||
font-weight: 100;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
|
||||
src: url('../fonts/Roboto-ThinItalic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-LightItalic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-MediumItalic.ttf') format('truetype');
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-BoldItalic.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: $session-font-default;
|
||||
src: url('../fonts/Roboto-BlackItalic.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('module-message', `module-message--${direction}`)}
|
||||
role="button"
|
||||
onClick={onClickOnMessageOuterContainer}
|
||||
onDoubleClickCapture={onDoubleClickReplyToMessage}
|
||||
style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<MessageStatus
|
||||
dataTestId="msg-status-incoming"
|
||||
messageId={messageId}
|
||||
isCorrectSide={isIncoming}
|
||||
/>
|
||||
<div>
|
||||
<MessageAuthorText messageId={messageId} />
|
||||
const [popupReaction, setPopupReaction] = useState('');
|
||||
|
||||
<MessageContent messageId={messageId} isDetailView={isDetailView} />
|
||||
const handleMessageReaction = async (emoji: string) => {
|
||||
await sendMessageReaction(messageId, emoji);
|
||||
};
|
||||
|
||||
const handlePopupClick = () => {
|
||||
dispatch(updateReactListModal({ reaction: popupReaction, messageId }));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledMessageContentContainer
|
||||
direction={isIncoming ? 'left' : 'right'}
|
||||
onMouseLeave={() => {
|
||||
setPopupReaction('');
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames('module-message', `module-message--${direction}`)}
|
||||
role="button"
|
||||
onClick={onClickOnMessageOuterContainer}
|
||||
onDoubleClickCapture={onDoubleClickReplyToMessage}
|
||||
style={{
|
||||
width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto',
|
||||
}}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<MessageStatus
|
||||
dataTestId="msg-status-incoming"
|
||||
messageId={messageId}
|
||||
isCorrectSide={isIncoming}
|
||||
/>
|
||||
<div>
|
||||
<MessageAuthorText messageId={messageId} />
|
||||
|
||||
<MessageContent messageId={messageId} isDetailView={isDetailView} />
|
||||
</div>
|
||||
<MessageStatus
|
||||
dataTestId="msg-status-outgoing"
|
||||
messageId={messageId}
|
||||
isCorrectSide={!isIncoming}
|
||||
/>
|
||||
{!isDeleted && (
|
||||
<MessageContextMenu
|
||||
messageId={messageId}
|
||||
contextMenuId={ctxMenuID}
|
||||
enableReactions={enableReactions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MessageStatus
|
||||
dataTestId="msg-status-outgoing"
|
||||
messageId={messageId}
|
||||
isCorrectSide={!isIncoming}
|
||||
/>
|
||||
{!isDeleted && <MessageContextMenu messageId={messageId} contextMenuId={ctxMenuID} />}
|
||||
</div>
|
||||
{enableReactions && (
|
||||
<MessageReactions
|
||||
messageId={messageId}
|
||||
onClick={handleMessageReaction}
|
||||
popupReaction={popupReaction}
|
||||
setPopupReaction={setPopupReaction}
|
||||
onPopupClick={handlePopupClick}
|
||||
/>
|
||||
)}
|
||||
</StyledMessageContentContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { animation, Item, Menu } from 'react-contexify';
|
||||
import { animation, Item, Menu, useContextMenu } from 'react-contexify';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useClickAway, useMouse } from 'react-use';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { MessageInteraction } from '../../../../interactions';
|
||||
import { replyToMessage } from '../../../../interactions/conversationInteractions';
|
||||
|
@ -20,8 +22,12 @@ import {
|
|||
showMessageDetailsView,
|
||||
toggleSelectedMessageId,
|
||||
} from '../../../../state/ducks/conversations';
|
||||
import { StateType } from '../../../../state/reducer';
|
||||
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
|
||||
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
|
||||
import { sendMessageReaction } from '../../../../util/reactions';
|
||||
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
|
||||
import { MessageReactBar } from './MessageReactBar';
|
||||
|
||||
export type MessageContextMenuSelectorProps = Pick<
|
||||
MessageRenderingProps,
|
||||
|
@ -42,16 +48,43 @@ export type MessageContextMenuSelectorProps = Pick<
|
|||
| 'isDeletableForEveryone'
|
||||
>;
|
||||
|
||||
type Props = { messageId: string; contextMenuId: string };
|
||||
type Props = { messageId: string; contextMenuId: string; enableReactions: boolean };
|
||||
|
||||
const StyledMessageContextMenu = styled.div`
|
||||
position: relative;
|
||||
|
||||
.react-contexify {
|
||||
margin-left: -104px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEmojiPanelContainer = styled.div<{ x: number; y: number }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 101;
|
||||
|
||||
${StyledEmojiPanel} {
|
||||
position: absolute;
|
||||
left: ${props => `${props.x}px`};
|
||||
top: ${props => `${props.y}px`};
|
||||
}
|
||||
`;
|
||||
|
||||
// tslint:disable: max-func-body-length cyclomatic-complexity
|
||||
export const MessageContextMenu = (props: Props) => {
|
||||
const selected = useSelector(state => getMessageContextMenuProps(state as any, props.messageId));
|
||||
const { messageId, contextMenuId, enableReactions } = props;
|
||||
const dispatch = useDispatch();
|
||||
const { hideAll } = useContextMenu();
|
||||
|
||||
const selected = useSelector((state: StateType) => getMessageContextMenuProps(state, messageId));
|
||||
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
attachments,
|
||||
sender,
|
||||
|
@ -68,14 +101,28 @@ export const MessageContextMenu = (props: Props) => {
|
|||
timestamp,
|
||||
isBlocked,
|
||||
} = selected;
|
||||
const { messageId, contextMenuId } = props;
|
||||
|
||||
const isOutgoing = direction === 'outgoing';
|
||||
const showRetry = status === 'error' && isOutgoing;
|
||||
const isSent = status === 'sent' || status === 'read'; // a read message should be replyable
|
||||
|
||||
const onContextMenuShown = useCallback(() => {
|
||||
const emojiPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||
// emoji-mart v5.1 default dimensions
|
||||
const emojiPanelWidth = 354;
|
||||
const emojiPanelHeight = 435;
|
||||
|
||||
const contextMenuRef = useRef(null);
|
||||
const { docX, docY } = useMouse(contextMenuRef);
|
||||
const [mouseX, setMouseX] = useState(0);
|
||||
const [mouseY, setMouseY] = useState(0);
|
||||
|
||||
const onContextMenuShown = () => {
|
||||
if (showEmojiPanel) {
|
||||
setShowEmojiPanel(false);
|
||||
}
|
||||
window.contextMenuShown = true;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const onContextMenuHidden = useCallback(() => {
|
||||
// This function will called before the click event
|
||||
|
@ -174,46 +221,120 @@ export const MessageContextMenu = (props: Props) => {
|
|||
void deleteMessagesByIdForEveryone([messageId], convoId);
|
||||
}, [convoId, messageId]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
id={contextMenuId}
|
||||
onShown={onContextMenuShown}
|
||||
onHidden={onContextMenuHidden}
|
||||
animation={animation.fade}
|
||||
>
|
||||
{attachments?.length ? (
|
||||
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
|
||||
) : null}
|
||||
const onShowEmoji = () => {
|
||||
hideAll();
|
||||
setMouseX(docX);
|
||||
setMouseY(docY);
|
||||
setShowEmojiPanel(true);
|
||||
};
|
||||
|
||||
<Item onClick={copyText}>{window.i18n('copyMessage')}</Item>
|
||||
{(isSent || !isOutgoing) && <Item onClick={onReply}>{window.i18n('replyToMessage')}</Item>}
|
||||
{(!isPublic || isOutgoing) && (
|
||||
<Item onClick={onShowDetail}>{window.i18n('moreInformation')}</Item>
|
||||
const onCloseEmoji = () => {
|
||||
setShowEmojiPanel(false);
|
||||
hideAll();
|
||||
};
|
||||
|
||||
const onEmojiLoseFocus = () => {
|
||||
window.log.info('closed due to lost focus');
|
||||
onCloseEmoji();
|
||||
};
|
||||
|
||||
const onEmojiClick = async (args: any) => {
|
||||
const emoji = args.native ?? args;
|
||||
onCloseEmoji();
|
||||
await sendMessageReaction(messageId, emoji);
|
||||
};
|
||||
|
||||
const onEmojiKeyDown = (event: any) => {
|
||||
if (event.key === 'Escape' && showEmojiPanel) {
|
||||
onCloseEmoji();
|
||||
}
|
||||
};
|
||||
|
||||
useClickAway(emojiPanelRef, () => {
|
||||
onEmojiLoseFocus();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (emojiPanelRef.current && emojiPanelRef.current) {
|
||||
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
|
||||
|
||||
if (mouseX + emojiPanelWidth > windowWidth) {
|
||||
let x = mouseX;
|
||||
x = (mouseX + emojiPanelWidth - windowWidth) * 2;
|
||||
|
||||
if (x === mouseX) {
|
||||
return;
|
||||
}
|
||||
setMouseX(mouseX - x);
|
||||
}
|
||||
|
||||
if (mouseY + emojiPanelHeight > windowHeight) {
|
||||
const y = mouseY + emojiPanelHeight * 1.25 - windowHeight;
|
||||
|
||||
if (y === mouseY) {
|
||||
return;
|
||||
}
|
||||
setMouseY(mouseY - y);
|
||||
}
|
||||
}
|
||||
}, [emojiPanelRef.current, emojiPanelWidth, emojiPanelHeight, mouseX, mouseY]);
|
||||
|
||||
return (
|
||||
<StyledMessageContextMenu ref={contextMenuRef}>
|
||||
{enableReactions && showEmojiPanel && (
|
||||
<StyledEmojiPanelContainer role="button" x={mouseX} y={mouseY}>
|
||||
<SessionEmojiPanel
|
||||
ref={emojiPanelRef}
|
||||
onEmojiClicked={onEmojiClick}
|
||||
show={showEmojiPanel}
|
||||
isModal={true}
|
||||
onKeyDown={onEmojiKeyDown}
|
||||
/>
|
||||
</StyledEmojiPanelContainer>
|
||||
)}
|
||||
{showRetry ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null}
|
||||
{isDeletable ? (
|
||||
<>
|
||||
<Item onClick={onSelect}>{selectMessageText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{isDeletable && !isPublic ? (
|
||||
<>
|
||||
<Item onClick={onDelete}>{deleteMessageJustForMeText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{isDeletableForEveryone ? (
|
||||
<>
|
||||
<Item onClick={onDeleteForEveryone}>{unsendMessageText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{weAreAdmin && isPublic ? <Item onClick={onBan}>{window.i18n('banUser')}</Item> : null}
|
||||
{weAreAdmin && isPublic ? <Item onClick={onUnban}>{window.i18n('unbanUser')}</Item> : null}
|
||||
{weAreAdmin && isPublic && !isSenderAdmin ? (
|
||||
<Item onClick={addModerator}>{window.i18n('addAsModerator')}</Item>
|
||||
) : null}
|
||||
{weAreAdmin && isPublic && isSenderAdmin ? (
|
||||
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
|
||||
) : null}
|
||||
</Menu>
|
||||
<Menu
|
||||
id={contextMenuId}
|
||||
onShown={onContextMenuShown}
|
||||
onHidden={onContextMenuHidden}
|
||||
animation={animation.fade}
|
||||
>
|
||||
{enableReactions && (
|
||||
<MessageReactBar action={onEmojiClick} additionalAction={onShowEmoji} />
|
||||
)}
|
||||
{attachments?.length ? (
|
||||
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
|
||||
) : null}
|
||||
|
||||
<Item onClick={copyText}>{window.i18n('copyMessage')}</Item>
|
||||
{(isSent || !isOutgoing) && <Item onClick={onReply}>{window.i18n('replyToMessage')}</Item>}
|
||||
{(!isPublic || isOutgoing) && (
|
||||
<Item onClick={onShowDetail}>{window.i18n('moreInformation')}</Item>
|
||||
)}
|
||||
{showRetry ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null}
|
||||
{isDeletable ? (
|
||||
<>
|
||||
<Item onClick={onSelect}>{selectMessageText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{isDeletable && !isPublic ? (
|
||||
<>
|
||||
<Item onClick={onDelete}>{deleteMessageJustForMeText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{isDeletableForEveryone ? (
|
||||
<>
|
||||
<Item onClick={onDeleteForEveryone}>{unsendMessageText}</Item>
|
||||
</>
|
||||
) : null}
|
||||
{weAreAdmin && isPublic ? <Item onClick={onBan}>{window.i18n('banUser')}</Item> : null}
|
||||
{weAreAdmin && isPublic ? <Item onClick={onUnban}>{window.i18n('unbanUser')}</Item> : null}
|
||||
{weAreAdmin && isPublic && !isSenderAdmin ? (
|
||||
<Item onClick={addModerator}>{window.i18n('addAsModerator')}</Item>
|
||||
) : null}
|
||||
{weAreAdmin && isPublic && isSenderAdmin ? (
|
||||
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
|
||||
) : null}
|
||||
</Menu>
|
||||
</StyledMessageContextMenu>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
158
ts/components/conversation/message/reactions/Reaction.tsx
Normal file
158
ts/components/conversation/message/reactions/Reaction.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import React, { ReactElement, useRef, useState } from 'react';
|
||||
import { SortedReactionList } from '../../../../types/Reaction';
|
||||
import { UserUtils } from '../../../../session/utils';
|
||||
import { abbreviateNumber } from '../../../../util/abbreviateNumber';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import styled from 'styled-components';
|
||||
import { useMouse } from 'react-use';
|
||||
import { ReactionPopup, TipPosition } from './ReactionPopup';
|
||||
import { popupXDefault, popupYDefault } from '../message-content/MessageReactions';
|
||||
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
|
||||
const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; showCount: boolean }>`
|
||||
display: flex;
|
||||
justify-content: ${props => (props.showCount ? 'flex-start' : 'center')};
|
||||
align-items: center;
|
||||
|
||||
background-color: var(--color-received-message-background);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${props => (props.selected ? 'var(--color-accent)' : 'transparent')};
|
||||
border-radius: 11px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 7px;
|
||||
margin: 0 4px var(--margins-sm);
|
||||
height: 24px;
|
||||
min-width: ${props => (props.showCount ? '48px' : '24px')};
|
||||
${props => props.inModal && 'width: 100%;'}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionContainer = styled.div<{
|
||||
inModal: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
${props => props.inModal && 'margin-right: 8px;'}
|
||||
`;
|
||||
|
||||
export type ReactionProps = {
|
||||
emoji: string;
|
||||
messageId: string;
|
||||
reactions: SortedReactionList;
|
||||
inModal: boolean;
|
||||
inGroup: boolean;
|
||||
handlePopupX: (x: number) => void;
|
||||
handlePopupY: (y: number) => void;
|
||||
onClick: (emoji: string) => void;
|
||||
popupReaction?: string;
|
||||
onSelected?: (emoji: string) => boolean;
|
||||
handlePopupReaction?: (emoji: string) => void;
|
||||
handlePopupClick?: () => void;
|
||||
};
|
||||
|
||||
export const Reaction = (props: ReactionProps): ReactElement => {
|
||||
const {
|
||||
emoji,
|
||||
messageId,
|
||||
reactions,
|
||||
inModal,
|
||||
inGroup,
|
||||
handlePopupX,
|
||||
handlePopupY,
|
||||
onClick,
|
||||
popupReaction,
|
||||
onSelected,
|
||||
handlePopupReaction,
|
||||
handlePopupClick,
|
||||
} = props;
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const senders = reactionsMap[emoji].senders ? Object.keys(reactionsMap[emoji].senders) : [];
|
||||
const count = reactionsMap[emoji].count;
|
||||
const showCount = count !== undefined && (count > 1 || inGroup);
|
||||
|
||||
const reactionRef = useRef<HTMLDivElement>(null);
|
||||
const { docX, elW } = useMouse(reactionRef);
|
||||
|
||||
const gutterWidth = 380;
|
||||
const tooltipMidPoint = 108; // width is 216px;
|
||||
const [tooltipPosition, setTooltipPosition] = useState<TipPosition>('center');
|
||||
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
const isBlindedMe =
|
||||
senders && senders.length > 0 && senders.filter(isUsAnySogsFromCache).length > 0;
|
||||
|
||||
const selected = () => {
|
||||
if (onSelected) {
|
||||
return onSelected(emoji);
|
||||
}
|
||||
|
||||
return senders && senders.length > 0 && (senders.includes(me) || isBlindedMe);
|
||||
};
|
||||
|
||||
const handleReactionClick = () => {
|
||||
onClick(emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledReactionContainer ref={reactionRef} inModal={inModal}>
|
||||
<StyledReaction
|
||||
showCount={showCount}
|
||||
selected={selected()}
|
||||
inModal={inModal}
|
||||
onClick={handleReactionClick}
|
||||
onMouseEnter={() => {
|
||||
if (inGroup) {
|
||||
const { innerWidth: windowWidth } = window;
|
||||
if (handlePopupReaction) {
|
||||
// overflow on far right means we shift left
|
||||
if (docX + tooltipMidPoint > windowWidth) {
|
||||
handlePopupX(Math.abs(popupXDefault) * 1.5 * -1);
|
||||
setTooltipPosition('right');
|
||||
// overflow onto conversations means we lock to the right
|
||||
} else if (docX - elW <= gutterWidth + tooltipMidPoint) {
|
||||
const offset = -12.5;
|
||||
handlePopupX(offset);
|
||||
setTooltipPosition('left');
|
||||
} else {
|
||||
handlePopupX(popupXDefault);
|
||||
setTooltipPosition('center');
|
||||
}
|
||||
|
||||
handlePopupReaction(emoji);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
role={'img'}
|
||||
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
|
||||
>
|
||||
{emoji}
|
||||
{showCount && `\u00A0\u00A0${abbreviateNumber(count)}`}
|
||||
</span>
|
||||
</StyledReaction>
|
||||
{inGroup && popupReaction && popupReaction === emoji && (
|
||||
<ReactionPopup
|
||||
messageId={messageId}
|
||||
emoji={popupReaction}
|
||||
senders={Object.keys(reactionsMap[popupReaction].senders)}
|
||||
tooltipPosition={tooltipPosition}
|
||||
onClick={() => {
|
||||
if (handlePopupReaction) {
|
||||
handlePopupReaction('');
|
||||
}
|
||||
handlePopupX(popupXDefault);
|
||||
handlePopupY(popupYDefault);
|
||||
setTooltipPosition('center');
|
||||
if (handlePopupClick) {
|
||||
handlePopupClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledReactionContainer>
|
||||
);
|
||||
};
|
146
ts/components/conversation/message/reactions/ReactionPopup.tsx
Normal file
146
ts/components/conversation/message/reactions/ReactionPopup.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { PubKey } from '../../../../session/types/PubKey';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import { readableList } from '../../../../util/readableList';
|
||||
|
||||
export type TipPosition = 'center' | 'left' | 'right';
|
||||
|
||||
export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 216px;
|
||||
height: 72px;
|
||||
z-index: 5;
|
||||
|
||||
background-color: var(--color-received-message-background);
|
||||
color: var(--color-pill-divider-text);
|
||||
box-shadow: 0px 0px 13px rgba(0, 0, 0, 0.51);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: break-word;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 19px);
|
||||
left: ${props => {
|
||||
switch (props.tooltipPosition) {
|
||||
case 'left':
|
||||
return '24px';
|
||||
case 'right':
|
||||
return 'calc(100% - 48px)';
|
||||
case 'center':
|
||||
default:
|
||||
return 'calc(100% - 100px)';
|
||||
}
|
||||
}};
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-color: var(--color-received-message-background);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 3px;
|
||||
transform: scaleY(1.4) rotate(45deg);
|
||||
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEmoji = styled.span`
|
||||
font-size: 36px;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const StyledOthers = styled.span`
|
||||
color: var(--color-accent);
|
||||
`;
|
||||
|
||||
const generateContacts = async (messageId: string, senders: Array<string>) => {
|
||||
let results = null;
|
||||
const message = await Data.getMessageById(messageId);
|
||||
if (message) {
|
||||
let meIndex = -1;
|
||||
results = senders.map((sender, index) => {
|
||||
const contact = message.findAndFormatContact(sender);
|
||||
if (contact.isMe) {
|
||||
meIndex = index;
|
||||
}
|
||||
return contact?.profileName || contact?.name || PubKey.shorten(sender);
|
||||
});
|
||||
if (meIndex >= 0) {
|
||||
results.splice(meIndex, 1);
|
||||
results = [window.i18n('you'), ...results];
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const Contacts = (contacts: string) => {
|
||||
if (!contacts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.includes('&') && contacts.includes('other')) {
|
||||
const [names, others] = contacts.split('&');
|
||||
return (
|
||||
<span>
|
||||
{names} & <StyledOthers>{others}</StyledOthers> {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{contacts} {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
senders: Array<string>;
|
||||
tooltipPosition?: TipPosition;
|
||||
onClick: (...args: Array<any>) => void;
|
||||
};
|
||||
|
||||
export const ReactionPopup = (props: Props): ReactElement => {
|
||||
const { messageId, emoji, senders, tooltipPosition = 'center', onClick } = props;
|
||||
|
||||
const [contacts, setContacts] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
generateContacts(messageId, senders)
|
||||
.then(async results => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (results && results.length > 0) {
|
||||
setContacts(readableList(results));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [generateContacts]);
|
||||
|
||||
return (
|
||||
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
|
||||
{Contacts(contacts)}
|
||||
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
|
||||
{emoji}
|
||||
</StyledEmoji>
|
||||
</StyledPopupContainer>
|
||||
);
|
||||
};
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
119
ts/components/dialog/ReactClearAllModal.tsx
Normal file
119
ts/components/dialog/ReactClearAllModal.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import React, { ReactElement, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
|
||||
import { clearSogsReactionByServerId } from '../../session/apis/open_group_api/sogsv3/sogsV3ClearReaction';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { updateReactClearAllModal } from '../../state/ducks/modalDialog';
|
||||
import { getTheme } from '../../state/selectors/theme';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
|
||||
import { SessionSpinner } from '../basic/SessionSpinner';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
|
||||
type Props = {
|
||||
reaction: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
div:first-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
div:not(:first-child) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactClearAllContainer = styled(Flex)<{ darkMode: boolean }>`
|
||||
margin: var(--margins-lg);
|
||||
|
||||
p {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: var(--margins-lg);
|
||||
margin: var(--margins-md) auto;
|
||||
border-bottom: 1.5px solid ${props => (props.darkMode ? '#2D2D2D' : '#EEEEEE')};
|
||||
|
||||
span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-button {
|
||||
font-size: 16px;
|
||||
height: 36px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
`;
|
||||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
export const ReactClearAllModal = (props: Props): ReactElement => {
|
||||
const { reaction, messageId } = props;
|
||||
|
||||
const [clearingInProgress, setClearingInProgress] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const darkMode = useSelector(getTheme) === 'dark';
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
|
||||
if (!msgProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { convoId, serverId } = msgProps;
|
||||
const roomInfos = getConversationController()
|
||||
.get(convoId)
|
||||
.toOpenGroupV2();
|
||||
|
||||
const confirmButtonColor = darkMode ? SessionButtonColor.Green : SessionButtonColor.Secondary;
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(updateReactClearAllModal(null));
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (roomInfos && serverId) {
|
||||
setClearingInProgress(true);
|
||||
await clearSogsReactionByServerId(reaction, serverId, roomInfos);
|
||||
setClearingInProgress(false);
|
||||
handleClose();
|
||||
} else {
|
||||
window.log.warn('Error for batch removal of', reaction, 'on message', messageId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionWrapperModal
|
||||
additionalClassName={'reaction-list-modal'}
|
||||
showHeader={false}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<StyledReactClearAllContainer
|
||||
container={true}
|
||||
flexDirection={'column'}
|
||||
alignItems="center"
|
||||
darkMode={darkMode}
|
||||
>
|
||||
<p>{window.i18n('clearAllReactions', [reaction])}</p>
|
||||
<StyledButtonContainer className="session-modal__button-group">
|
||||
<SessionButton
|
||||
text={window.i18n('clear')}
|
||||
buttonColor={confirmButtonColor}
|
||||
buttonType={SessionButtonType.BrandOutline}
|
||||
onClick={handleClearAll}
|
||||
disabled={clearingInProgress}
|
||||
/>
|
||||
<SessionButton
|
||||
text={window.i18n('cancel')}
|
||||
buttonColor={SessionButtonColor.Danger}
|
||||
buttonType={SessionButtonType.BrandOutline}
|
||||
onClick={handleClose}
|
||||
disabled={clearingInProgress}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
<SessionSpinner loading={clearingInProgress} />
|
||||
</StyledReactClearAllContainer>
|
||||
</SessionWrapperModal>
|
||||
);
|
||||
};
|
324
ts/components/dialog/ReactListModal.tsx
Normal file
324
ts/components/dialog/ReactListModal.tsx
Normal file
|
@ -0,0 +1,324 @@
|
|||
import { isEmpty, isEqual } from 'lodash';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../data/data';
|
||||
import { useMessageReactsPropsById } from '../../hooks/useParamSelector';
|
||||
import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { UserUtils } from '../../session/utils';
|
||||
import {
|
||||
updateReactClearAllModal,
|
||||
updateReactListModal,
|
||||
updateUserDetailsModal,
|
||||
} from '../../state/ducks/modalDialog';
|
||||
import { SortedReactionList } from '../../types/Reaction';
|
||||
import { nativeEmojiData } from '../../util/emoji';
|
||||
import { sendMessageReaction } from '../../util/reactions';
|
||||
import { Avatar, AvatarSize } from '../avatar/Avatar';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
|
||||
import { SessionIconButton } from '../icon';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
|
||||
const StyledReactListContainer = styled(Flex)`
|
||||
width: 376px;
|
||||
`;
|
||||
|
||||
const StyledReactionsContainer = styled.div`
|
||||
background-color: var(--color-cell-background);
|
||||
border-bottom: 1px solid var(--color-session-border);
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 12px 8px 0;
|
||||
`;
|
||||
|
||||
const StyledSendersContainer = styled(Flex)`
|
||||
width: 100%;
|
||||
min-height: 350px;
|
||||
height: 100%;
|
||||
max-height: 496px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 32px;
|
||||
`;
|
||||
|
||||
const StyledReactionBar = styled(Flex)`
|
||||
width: 100%;
|
||||
margin: 12px 0 20px 4px;
|
||||
|
||||
p {
|
||||
color: var(--color-text-subtle);
|
||||
margin: 0;
|
||||
|
||||
span:nth-child(1) {
|
||||
margin: 0 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
span:nth-child(2) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionSender = styled(Flex)`
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
.module-avatar {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.module-conversation__user__profile-name {
|
||||
color: var(--color-text);
|
||||
font-weight: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledClearButton = styled.button`
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-destructive);
|
||||
border: none;
|
||||
`;
|
||||
|
||||
type ReactionSendersProps = {
|
||||
messageId: string;
|
||||
currentReact: string;
|
||||
senders: Array<string>;
|
||||
me: string;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
const ReactionSenders = (props: ReactionSendersProps) => {
|
||||
const { messageId, currentReact, senders, me, handleClose } = props;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleAvatarClick = async (sender: string) => {
|
||||
const message = await Data.getMessageById(messageId);
|
||||
if (message) {
|
||||
handleClose();
|
||||
const contact = message.findAndFormatContact(sender);
|
||||
dispatch(
|
||||
updateUserDetailsModal({
|
||||
conversationId: sender,
|
||||
userName: contact.name || contact.profileName || sender,
|
||||
authorAvatarPath: contact.avatarPath,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveReaction = async () => {
|
||||
await sendMessageReaction(messageId, currentReact);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{senders.map((sender: string) => (
|
||||
<StyledReactionSender
|
||||
key={`${messageId}-${sender}`}
|
||||
container={true}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
>
|
||||
<Flex container={true} alignItems={'center'}>
|
||||
<Avatar
|
||||
size={AvatarSize.XS}
|
||||
pubkey={sender}
|
||||
onAvatarClick={async () => {
|
||||
await handleAvatarClick(sender);
|
||||
}}
|
||||
/>
|
||||
{sender === me ? (
|
||||
window.i18n('you')
|
||||
) : (
|
||||
<ContactName
|
||||
pubkey={sender}
|
||||
module="module-conversation__user"
|
||||
shouldShowPubkey={false}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{sender === me && (
|
||||
<SessionIconButton
|
||||
iconType="exit"
|
||||
iconSize="small"
|
||||
onClick={async () => {
|
||||
await handleRemoveReaction();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledReactionSender>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reaction: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const handleSenders = (senders: Array<string>, me: string) => {
|
||||
let updatedSenders = senders;
|
||||
const blindedMe = updatedSenders.filter(isUsAnySogsFromCache);
|
||||
|
||||
let meIndex = -1;
|
||||
if (blindedMe && blindedMe[0]) {
|
||||
meIndex = updatedSenders.indexOf(blindedMe[0]);
|
||||
} else {
|
||||
meIndex = updatedSenders.indexOf(me);
|
||||
}
|
||||
if (meIndex >= 0) {
|
||||
updatedSenders.splice(meIndex, 1);
|
||||
updatedSenders = [me, ...updatedSenders];
|
||||
}
|
||||
|
||||
return updatedSenders;
|
||||
};
|
||||
|
||||
export const ReactListModal = (props: Props): ReactElement => {
|
||||
const { reaction, messageId } = props;
|
||||
|
||||
const [reactions, setReactions] = useState<SortedReactionList>([]);
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const [currentReact, setCurrentReact] = useState('');
|
||||
const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>();
|
||||
const [senders, setSenders] = useState<Array<string>>([]);
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
|
||||
// tslint:disable: cyclomatic-complexity
|
||||
useEffect(() => {
|
||||
if (currentReact === '' && currentReact !== reaction) {
|
||||
setReactAriaLabel(
|
||||
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[reaction] : undefined
|
||||
);
|
||||
setCurrentReact(reaction);
|
||||
}
|
||||
|
||||
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
|
||||
setReactions(msgProps?.sortedReacts);
|
||||
}
|
||||
|
||||
if (
|
||||
reactions &&
|
||||
reactions.length > 0 &&
|
||||
(msgProps?.sortedReacts === [] || msgProps?.sortedReacts === undefined)
|
||||
) {
|
||||
setReactions([]);
|
||||
}
|
||||
|
||||
let _senders =
|
||||
reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders
|
||||
? Object.keys(reactionsMap[currentReact].senders)
|
||||
: null;
|
||||
|
||||
if (_senders && !isEqual(senders, _senders)) {
|
||||
if (_senders.length > 0) {
|
||||
_senders = handleSenders(_senders, me);
|
||||
}
|
||||
setSenders(_senders);
|
||||
}
|
||||
|
||||
if (senders.length > 0 && (!reactionsMap[currentReact]?.senders || isEmpty(_senders))) {
|
||||
setSenders([]);
|
||||
}
|
||||
}, [currentReact, me, reaction, msgProps?.sortedReacts, reactionsMap, senders]);
|
||||
|
||||
if (!msgProps) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { convoId, isPublic } = msgProps;
|
||||
|
||||
const convo = getConversationController().get(convoId);
|
||||
const weAreModerator = convo.getConversationModelProps().weAreModerator;
|
||||
|
||||
const handleSelectedReaction = (emoji: string): boolean => {
|
||||
return currentReact === emoji;
|
||||
};
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
setReactAriaLabel(nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined);
|
||||
setCurrentReact(emoji);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(updateReactListModal(null));
|
||||
};
|
||||
|
||||
const handleClearReactions = (event: any) => {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
dispatch(
|
||||
updateReactClearAllModal({
|
||||
reaction: currentReact,
|
||||
messageId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionWrapperModal
|
||||
additionalClassName={'reaction-list-modal'}
|
||||
showHeader={false}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<StyledReactListContainer container={true} flexDirection={'column'} alignItems={'flex-start'}>
|
||||
<StyledReactionsContainer>
|
||||
<MessageReactions
|
||||
messageId={messageId}
|
||||
hasReactLimit={false}
|
||||
inModal={true}
|
||||
onSelected={handleSelectedReaction}
|
||||
onClick={handleReactionClick}
|
||||
/>
|
||||
</StyledReactionsContainer>
|
||||
{reactionsMap && currentReact && (
|
||||
<StyledSendersContainer
|
||||
container={true}
|
||||
flexDirection={'column'}
|
||||
alignItems={'flex-start'}
|
||||
>
|
||||
<StyledReactionBar
|
||||
container={true}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
>
|
||||
<p>
|
||||
<span role={'img'} aria-label={reactAriaLabel}>
|
||||
{currentReact}
|
||||
</span>
|
||||
{reactionsMap[currentReact].count && (
|
||||
<>
|
||||
<span>•</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();
|
||||
|
@ -1446,6 +1579,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
admins?: Array<string>;
|
||||
image_id?: number;
|
||||
moderators?: Array<string>;
|
||||
hidden_admins?: Array<string>;
|
||||
hidden_moderators?: Array<string>;
|
||||
};
|
||||
}) {
|
||||
if (!infos || isEmpty(infos)) {
|
||||
|
@ -1477,26 +1612,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
this.set('uploadCapability', Boolean(upload));
|
||||
}
|
||||
|
||||
if (details.admins && isArray(details.admins)) {
|
||||
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(details.admins);
|
||||
const adminChanged = await this.updateGroupAdmins(replacedWithOurRealSessionId, false);
|
||||
if (adminChanged) {
|
||||
hasChange = adminChanged;
|
||||
}
|
||||
}
|
||||
const adminChanged = await this.handleModsOrAdminsChanges({
|
||||
modsOrAdmins: details.admins,
|
||||
hiddenModsOrAdmins: details.hidden_admins,
|
||||
type: 'admins',
|
||||
});
|
||||
|
||||
if (details.moderators && isArray(details.moderators)) {
|
||||
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
|
||||
details.moderators
|
||||
);
|
||||
const moderatorsChanged = await this.updateGroupModerators(
|
||||
replacedWithOurRealSessionId,
|
||||
false
|
||||
);
|
||||
if (moderatorsChanged) {
|
||||
hasChange = moderatorsChanged;
|
||||
}
|
||||
}
|
||||
hasChange = hasChange || adminChanged;
|
||||
|
||||
const modsChanged = await this.handleModsOrAdminsChanges({
|
||||
modsOrAdmins: details.moderators,
|
||||
hiddenModsOrAdmins: details.hidden_moderators,
|
||||
type: 'mods',
|
||||
});
|
||||
|
||||
hasChange = hasChange || modsChanged;
|
||||
|
||||
if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) {
|
||||
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
|
||||
|
@ -1537,6 +1667,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;
|
||||
|
@ -1926,6 +2071,34 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
);
|
||||
return replacedWithOurRealSessionId;
|
||||
}
|
||||
|
||||
private async handleModsOrAdminsChanges({
|
||||
modsOrAdmins,
|
||||
hiddenModsOrAdmins,
|
||||
type,
|
||||
}: {
|
||||
modsOrAdmins?: Array<string>;
|
||||
hiddenModsOrAdmins?: Array<string>;
|
||||
type: 'mods' | 'admins';
|
||||
}) {
|
||||
if (modsOrAdmins && isArray(modsOrAdmins)) {
|
||||
const localModsOrAdmins = [...modsOrAdmins];
|
||||
if (hiddenModsOrAdmins && isArray(hiddenModsOrAdmins)) {
|
||||
localModsOrAdmins.push(...hiddenModsOrAdmins);
|
||||
}
|
||||
|
||||
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
|
||||
uniq(localModsOrAdmins)
|
||||
);
|
||||
|
||||
const moderatorsOrAdminsChanged =
|
||||
type === 'admins'
|
||||
? await this.updateGroupAdmins(replacedWithOurRealSessionId, false)
|
||||
: await this.updateGroupModerators(replacedWithOurRealSessionId, false);
|
||||
return moderatorsOrAdminsChanged;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const throttledAllConversationsDispatch = debounce(
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
|
||||
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
|
||||
|
||||
// save the message model to the db and it save the messageId generated to our in-memory copy
|
||||
const id = await messageModel.commit();
|
||||
messageModel.set({ id });
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
|
||||
const unreadCount = await conversation.getUnreadCount();
|
||||
conversation.set({ unreadCount });
|
||||
conversation.set({
|
||||
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
|
||||
});
|
||||
// this is a throttled call and will only run once every 1 sec at most
|
||||
conversation.updateLastMessage();
|
||||
await conversation.commit();
|
||||
|
||||
if (conversation.id !== sendingDeviceConversation.id) {
|
||||
await sendingDeviceConversation.commit();
|
||||
}
|
||||
|
||||
void queueAttachmentDownloads(messageModel, conversation);
|
||||
// Check if we need to update any profile names
|
||||
// the only profile we don't update with what is coming here is ours,
|
||||
// as our profile is shared accross our devices with a ConfigurationMessage
|
||||
if (messageModel.isIncoming() && regularDataMessage.profile) {
|
||||
void appendFetchAvatarAndProfileJob(
|
||||
sendingDeviceConversation,
|
||||
regularDataMessage.profile,
|
||||
regularDataMessage.profileKey
|
||||
);
|
||||
}
|
||||
|
||||
// even with all the warnings, I am very sus about if this is usefull or not
|
||||
// try {
|
||||
// // We go to the database here because, between the message save above and
|
||||
// // the previous line's trigger() call, we might have marked all messages
|
||||
// // unread in the database. This message might already be read!
|
||||
// const fetched = await getMessageById(messageModel.get('id'));
|
||||
|
||||
// const previousUnread = messageModel.get('unread');
|
||||
|
||||
// // Important to update message with latest read state from database
|
||||
// messageModel.merge(fetched);
|
||||
|
||||
// if (previousUnread !== messageModel.get('unread')) {
|
||||
// window?.log?.warn(
|
||||
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
|
||||
// );
|
||||
// // We call markRead() even though the message is already
|
||||
// // marked read because we need to start expiration
|
||||
// // timers, etc.
|
||||
// await messageModel.markRead(Date.now());
|
||||
// }
|
||||
// } catch (error) {
|
||||
// window?.log?.warn(
|
||||
// 'handleMessageJob: Message',
|
||||
// messageModel.idForLogging(),
|
||||
// 'was deleted'
|
||||
// );
|
||||
// }
|
||||
|
||||
if (messageModel.get('unread')) {
|
||||
if (
|
||||
regularDataMessage.reaction.action === Action.REACT &&
|
||||
conversation.isPrivate() &&
|
||||
messageModel.get('unread')
|
||||
) {
|
||||
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
|
||||
conversation.throttledNotify(messageModel);
|
||||
}
|
||||
|
||||
confirm?.();
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
||||
} else {
|
||||
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
||||
source,
|
||||
ConversationTypeEnum.PRIVATE
|
||||
);
|
||||
try {
|
||||
messageModel.set({ flags: regularDataMessage.flags });
|
||||
if (messageModel.isExpirationTimerUpdate()) {
|
||||
const { expireTimer } = regularDataMessage;
|
||||
const oldValue = conversation.get('expireTimer');
|
||||
if (expireTimer === oldValue) {
|
||||
confirm?.();
|
||||
window?.log?.info(
|
||||
'Dropping ExpireTimerUpdate message as we already have the same one set.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
|
||||
} else {
|
||||
// this does not commit to db nor UI unless we need to approve a convo
|
||||
await handleRegularMessage(
|
||||
conversation,
|
||||
sendingDeviceConversation,
|
||||
messageModel,
|
||||
regularDataMessage,
|
||||
source,
|
||||
messageHash
|
||||
);
|
||||
}
|
||||
|
||||
// save the message model to the db and it save the messageId generated to our in-memory copy
|
||||
const id = await messageModel.commit();
|
||||
messageModel.set({ id });
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
|
||||
const unreadCount = await conversation.getUnreadCount();
|
||||
conversation.set({ unreadCount });
|
||||
conversation.set({
|
||||
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
|
||||
});
|
||||
// this is a throttled call and will only run once every 1 sec at most
|
||||
conversation.updateLastMessage();
|
||||
await conversation.commit();
|
||||
|
||||
if (conversation.id !== sendingDeviceConversation.id) {
|
||||
await sendingDeviceConversation.commit();
|
||||
}
|
||||
|
||||
void queueAttachmentDownloads(messageModel, conversation);
|
||||
// Check if we need to update any profile names
|
||||
// the only profile we don't update with what is coming here is ours,
|
||||
// as our profile is shared accross our devices with a ConfigurationMessage
|
||||
if (messageModel.isIncoming() && regularDataMessage.profile) {
|
||||
void appendFetchAvatarAndProfileJob(
|
||||
sendingDeviceConversation,
|
||||
regularDataMessage.profile,
|
||||
regularDataMessage.profileKey
|
||||
);
|
||||
}
|
||||
|
||||
// even with all the warnings, I am very sus about if this is usefull or not
|
||||
// try {
|
||||
// // We go to the database here because, between the message save above and
|
||||
// // the previous line's trigger() call, we might have marked all messages
|
||||
// // unread in the database. This message might already be read!
|
||||
// const fetched = await getMessageById(messageModel.get('id'));
|
||||
|
||||
// const previousUnread = messageModel.get('unread');
|
||||
|
||||
// // Important to update message with latest read state from database
|
||||
// messageModel.merge(fetched);
|
||||
|
||||
// if (previousUnread !== messageModel.get('unread')) {
|
||||
// window?.log?.warn(
|
||||
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
|
||||
// );
|
||||
// // We call markRead() even though the message is already
|
||||
// // marked read because we need to start expiration
|
||||
// // timers, etc.
|
||||
// await messageModel.markRead(Date.now());
|
||||
// }
|
||||
// } catch (error) {
|
||||
// window?.log?.warn(
|
||||
// 'handleMessageJob: Message',
|
||||
// messageModel.idForLogging(),
|
||||
// 'was deleted'
|
||||
// );
|
||||
// }
|
||||
|
||||
if (messageModel.get('unread')) {
|
||||
conversation.throttledNotify(messageModel);
|
||||
}
|
||||
confirm?.();
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
@ -208,7 +210,6 @@ export class OpenGroupServerPoller {
|
|||
pollInfo: {
|
||||
roomId,
|
||||
infoUpdated: 0,
|
||||
// infoUpdated: -1,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
@ -73,7 +74,13 @@ async function handlePollInfoResponse(
|
|||
token: string;
|
||||
upload: boolean;
|
||||
write: boolean;
|
||||
details: { admins?: Array<string>; image_id: number; moderators?: Array<string> };
|
||||
details: {
|
||||
admins?: Array<string>;
|
||||
image_id: number;
|
||||
moderators?: Array<string>;
|
||||
hidden_admins?: Array<string>;
|
||||
hidden_moderators?: Array<string>;
|
||||
};
|
||||
},
|
||||
serverUrl: string,
|
||||
roomIdsStillPolled: Set<string>
|
||||
|
@ -109,7 +116,14 @@ async function handlePollInfoResponse(
|
|||
write,
|
||||
upload,
|
||||
subscriberCount: active_users,
|
||||
details: pick(details, 'admins', 'image_id', 'moderators'),
|
||||
details: pick(
|
||||
details,
|
||||
'admins',
|
||||
'image_id',
|
||||
'moderators',
|
||||
'hidden_admins',
|
||||
'hidden_moderators'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -143,9 +157,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 +169,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 +219,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 +264,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 +304,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) {
|
||||
|
|
46
ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts
Normal file
46
ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import AbortController from 'abort-controller';
|
||||
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
|
||||
import {
|
||||
batchFirstSubIsSuccess,
|
||||
batchGlobalIsSuccess,
|
||||
OpenGroupBatchRow,
|
||||
sogsBatchSend,
|
||||
} from './sogsV3BatchPoll';
|
||||
import { hasReactionSupport } from './sogsV3SendReaction';
|
||||
|
||||
/**
|
||||
* Clears a reaction on open group server using onion v4 logic and batch send
|
||||
* User must have moderator permissions
|
||||
* Clearing implies removing all reactors for a specific emoji
|
||||
*/
|
||||
export const clearSogsReactionByServerId = async (
|
||||
reaction: string,
|
||||
serverId: number,
|
||||
roomInfos: OpenGroupRequestCommonType
|
||||
): Promise<boolean> => {
|
||||
const canReact = await hasReactionSupport(serverId);
|
||||
if (!canReact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const options: Array<OpenGroupBatchRow> = [
|
||||
{
|
||||
type: 'deleteReaction',
|
||||
deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId },
|
||||
},
|
||||
];
|
||||
const result = await sogsBatchSend(
|
||||
roomInfos.serverUrl,
|
||||
new Set([roomInfos.roomId]),
|
||||
new AbortController().signal,
|
||||
options,
|
||||
'batch'
|
||||
);
|
||||
|
||||
try {
|
||||
return batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result);
|
||||
} catch (e) {
|
||||
window?.log?.error("clearSogsReactionByServerId Can't decode JSON body");
|
||||
}
|
||||
return false;
|
||||
};
|
91
ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts
Normal file
91
ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { AbortSignal } from 'abort-controller';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction';
|
||||
import { getEmojiDataFromNative } from '../../../../util/emoji';
|
||||
import { OnionSending } from '../../../onions/onionSend';
|
||||
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
|
||||
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
|
||||
|
||||
export const hasReactionSupport = async (serverId: number): Promise<boolean> => {
|
||||
const found = await Data.getMessageByServerId(serverId);
|
||||
if (!found) {
|
||||
window.log.warn(`Open Group Message ${serverId} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const conversationModel = found?.getConversation();
|
||||
if (!conversationModel) {
|
||||
window.log.warn(`Conversation for ${serverId} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!conversationModel.hasReactions()) {
|
||||
window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendSogsReactionOnionV4 = async (
|
||||
serverUrl: string,
|
||||
room: string,
|
||||
abortSignal: AbortSignal,
|
||||
reaction: Reaction,
|
||||
blinded: boolean
|
||||
): Promise<boolean> => {
|
||||
const allValidRoomInfos = OpenGroupPollingUtils.getAllValidRoomInfos(serverUrl, new Set([room]));
|
||||
if (!allValidRoomInfos?.length) {
|
||||
window?.log?.info('getSendReactionRequest: no valid roominfos got.');
|
||||
throw new Error(`Could not find sogs pubkey of url:${serverUrl}`);
|
||||
}
|
||||
|
||||
const canReact = await hasReactionSupport(reaction.id);
|
||||
if (!canReact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// for an invalid reaction we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji
|
||||
const emoji = getEmojiDataFromNative(reaction.emoji) ? reaction.emoji : '🖾';
|
||||
const endpoint = `/room/${room}/reaction/${reaction.id}/${emoji}`;
|
||||
const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE';
|
||||
const serverPubkey = allValidRoomInfos[0].serverPublicKey;
|
||||
|
||||
// reaction endpoint requires an empty dict {}
|
||||
const stringifiedBody = null;
|
||||
const result = await OnionSending.sendJsonViaOnionV4ToSogs({
|
||||
serverUrl,
|
||||
endpoint,
|
||||
serverPubkey,
|
||||
method,
|
||||
abortSignal,
|
||||
blinded,
|
||||
stringifiedBody,
|
||||
headers: null,
|
||||
throwErrors: true,
|
||||
});
|
||||
|
||||
if (!batchGlobalIsSuccess(result)) {
|
||||
window?.log?.warn('sendSogsReactionWithOnionV4 Got unknown status code; res:', result);
|
||||
throw new Error(
|
||||
`sendSogsReactionOnionV4: invalid status code: ${parseBatchGlobalStatusCode(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Could not putReaction, res is invalid');
|
||||
}
|
||||
|
||||
const rawMessage = result.body as OpenGroupReactionResponse;
|
||||
if (!rawMessage) {
|
||||
throw new Error('putReaction parsing failed');
|
||||
}
|
||||
|
||||
window.log.info(
|
||||
`You ${reaction.action === Action.REACT ? 'added' : 'removed'} a`,
|
||||
reaction.emoji,
|
||||
`reaction on ${serverUrl}/${room}`
|
||||
);
|
||||
const success = Boolean(reaction.action === Action.REACT ? rawMessage.added : rawMessage.removed);
|
||||
return success;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -320,10 +320,14 @@ export const SessionGlobalStyles = createGlobalStyle`
|
|||
--font-default: 'Roboto';
|
||||
--font-font-accent: 'Loor';
|
||||
--font-font-mono: 'SpaceMono';
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-md: 15px;
|
||||
--font-size-lg: 17px;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-md: 15px;
|
||||
--font-size-lg: 17px;
|
||||
--font-size-h1: 30px;
|
||||
--font-size-h2: 24px;
|
||||
--font-size-h3: 20px;
|
||||
--font-size-h4: 16px;
|
||||
|
||||
/* MARGINS */
|
||||
--margins-xs: 5px;
|
||||
|
@ -339,6 +343,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;
|
||||
|
|
|
@ -34,7 +34,9 @@ import { getConversationController } from '../../session/conversations';
|
|||
import { UserUtils } from '../../session/utils';
|
||||
import { Storage } from '../../util/storage';
|
||||
import { ConversationTypeEnum } from '../../models/conversationAttributes';
|
||||
import { filter, isEmpty, sortBy } from 'lodash';
|
||||
|
||||
import { MessageReactsSelectorProps } from '../../components/conversation/message/message-content/MessageReactions';
|
||||
import { filter, isEmpty, pick, sortBy } from 'lodash';
|
||||
|
||||
export const getConversations = (state: StateType): ConversationsStateType => state.conversations;
|
||||
|
||||
|
@ -912,34 +914,48 @@ export const getMessageAvatarProps = createSelector(getMessagePropsByMessageId,
|
|||
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 => {
|
||||
|
@ -947,12 +963,10 @@ export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId,
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const { attachments, previews } = props.propsForMessage;
|
||||
|
||||
const msgProps: MessagePreviewSelectorProps = {
|
||||
attachments,
|
||||
previews,
|
||||
};
|
||||
const msgProps: MessagePreviewSelectorProps = pick(props.propsForMessage, [
|
||||
'attachments',
|
||||
'previews',
|
||||
]);
|
||||
|
||||
return msgProps;
|
||||
});
|
||||
|
@ -964,12 +978,7 @@ export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, (
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const { direction, quote } = props.propsForMessage;
|
||||
|
||||
const msgProps: MessageQuoteSelectorProps = {
|
||||
direction,
|
||||
quote,
|
||||
};
|
||||
const msgProps: MessageQuoteSelectorProps = pick(props.propsForMessage, ['direction', 'quote']);
|
||||
|
||||
return msgProps;
|
||||
});
|
||||
|
@ -981,12 +990,7 @@ export const getMessageStatusProps = createSelector(getMessagePropsByMessageId,
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const { direction, status } = props.propsForMessage;
|
||||
|
||||
const msgProps: MessageStatusSelectorProps = {
|
||||
direction,
|
||||
status,
|
||||
};
|
||||
const msgProps: MessageStatusSelectorProps = pick(props.propsForMessage, ['direction', 'status']);
|
||||
|
||||
return msgProps;
|
||||
});
|
||||
|
@ -998,15 +1002,13 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p
|
|||
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;
|
||||
});
|
||||
|
@ -1018,41 +1020,23 @@ export const getMessageContextMenuProps = createSelector(getMessagePropsByMessag
|
|||
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;
|
||||
});
|
||||
|
@ -1064,15 +1048,9 @@ export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId,
|
|||
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;
|
||||
|
@ -1096,23 +1074,16 @@ export const getMessageAttachmentProps = createSelector(getMessagePropsByMessage
|
|||
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;
|
||||
|
@ -1139,27 +1110,18 @@ export const getMessageContentSelectorProps = createSelector(getMessagePropsByMe
|
|||
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;
|
||||
|
@ -1172,18 +1134,9 @@ export const getMessageContentWithStatusesSelectorProps = createSelector(
|
|||
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;
|
||||
|
@ -1197,30 +1150,18 @@ export const getGenericReadableMessageSelectorProps = createSelector(
|
|||
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 |
132
ts/test/session/unit/reactions/ReactionMessage_test.ts
Normal file
132
ts/test/session/unit/reactions/ReactionMessage_test.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import chai, { expect } from 'chai';
|
||||
import Sinon, { useFakeTimers } from 'sinon';
|
||||
import { handleMessageReaction, sendMessageReaction } from '../../../../util/reactions';
|
||||
import { Data } from '../../../../data/data';
|
||||
import * as Storage from '../../../../util/storage';
|
||||
import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils';
|
||||
import { DEFAULT_RECENT_REACTS } from '../../../../session/constants';
|
||||
import { noop } from 'lodash';
|
||||
import { UserUtils } from '../../../../session/utils';
|
||||
import { SignalService } from '../../../../protobuf';
|
||||
import { MessageCollection } from '../../../../models/message';
|
||||
import chaiAsPromised from 'chai-as-promised';
|
||||
|
||||
chai.use(chaiAsPromised as any);
|
||||
|
||||
describe('ReactionMessage', () => {
|
||||
stubWindowLog();
|
||||
|
||||
let clock: Sinon.SinonFakeTimers;
|
||||
const ourNumber = '0123456789abcdef';
|
||||
const originalMessage = generateFakeIncomingPrivateMessage();
|
||||
originalMessage.set('sent_at', Date.now());
|
||||
|
||||
beforeEach(() => {
|
||||
Sinon.stub(originalMessage, 'getConversation').returns({
|
||||
hasReactions: () => true,
|
||||
sendReaction: noop,
|
||||
} as any);
|
||||
|
||||
// sendMessageReaction stubs
|
||||
Sinon.stub(Data, 'getMessageById').resolves(originalMessage);
|
||||
Sinon.stub(Storage, 'getRecentReactions').returns(DEFAULT_RECENT_REACTS);
|
||||
Sinon.stub(Storage, 'saveRecentReations').resolves();
|
||||
Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(ourNumber);
|
||||
|
||||
// handleMessageReaction stubs
|
||||
Sinon.stub(Data, 'getMessagesBySentAt').resolves(new MessageCollection([originalMessage]));
|
||||
Sinon.stub(originalMessage, 'commit').resolves();
|
||||
});
|
||||
|
||||
it('can react to a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
|
||||
expect(reaction?.action, 'action should be 0').to.be.equal(0);
|
||||
|
||||
// Handling reaction
|
||||
const updatedMessage = await handleMessageReaction(
|
||||
reaction as SignalService.DataMessage.IReaction,
|
||||
ourNumber,
|
||||
false,
|
||||
originalMessage.get('id')
|
||||
);
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
|
||||
.undefined;
|
||||
// tslint:disable: no-non-null-assertion
|
||||
expect(updatedMessage?.get('reacts')!['😄'], 'reacts should have 😄 key').to.not.be.undefined;
|
||||
// tslint:disable: no-non-null-assertion
|
||||
expect(
|
||||
Object.keys(updatedMessage!.get('reacts')!['😄'].senders)[0],
|
||||
'sender pubkey should match'
|
||||
).to.be.equal(ourNumber);
|
||||
expect(updatedMessage!.get('reacts')!['😄'].count, 'count should be 1').to.be.equal(1);
|
||||
});
|
||||
|
||||
it('can remove a reaction from a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 😄').to.be.equal('😄');
|
||||
expect(reaction?.action, 'action should be 1').to.be.equal(1);
|
||||
|
||||
// Handling reaction
|
||||
const updatedMessage = await handleMessageReaction(
|
||||
reaction as SignalService.DataMessage.IReaction,
|
||||
ourNumber,
|
||||
false,
|
||||
originalMessage.get('id')
|
||||
);
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be
|
||||
.undefined;
|
||||
});
|
||||
|
||||
it('reactions are rate limited to 20 reactions per minute', async () => {
|
||||
// we have already sent 2 messages when this test runs
|
||||
for (let i = 0; i < 18; i++) {
|
||||
// Send reaction
|
||||
await sendMessageReaction(originalMessage.get('id'), '👍');
|
||||
}
|
||||
|
||||
let reaction = await sendMessageReaction(originalMessage.get('id'), '👎');
|
||||
|
||||
expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be
|
||||
.undefined;
|
||||
|
||||
clock = useFakeTimers(Date.now());
|
||||
|
||||
// Wait a miniute for the rate limit to clear
|
||||
clock.tick(1 * 60 * 1000);
|
||||
|
||||
reaction = await sendMessageReaction(originalMessage.get('id'), '👋');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
);
|
||||
expect(reaction?.author, 'author should match the original message author').to.be.equal(
|
||||
originalMessage.get('source')
|
||||
);
|
||||
expect(reaction?.emoji, 'emoji should be 👋').to.be.equal('👋');
|
||||
expect(reaction?.action, 'action should be 0').to.be.equal(0);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -75,10 +75,12 @@ export type LocalizerKeys =
|
|||
| 'mustBeApproved'
|
||||
| 'appMenuHideOthers'
|
||||
| 'sendFailed'
|
||||
| 'expandedReactionsText'
|
||||
| 'openMessageRequestInbox'
|
||||
| 'enterPassword'
|
||||
| 'enterSessionIDOfRecipient'
|
||||
| 'dialogClearAllDataDeletionFailedMultiple'
|
||||
| 'clearAllReactions'
|
||||
| 'pinConversationLimitToastDescription'
|
||||
| 'appMenuQuit'
|
||||
| 'windowMenuZoom'
|
||||
|
@ -126,6 +128,7 @@ export type LocalizerKeys =
|
|||
| 'blocked'
|
||||
| 'hideRequestBannerDescription'
|
||||
| 'noBlockedContacts'
|
||||
| 'reactionNotification'
|
||||
| 'leaveGroupConfirmation'
|
||||
| 'banUserAndDeleteAll'
|
||||
| 'joinOpenGroupAfterInvitationConfirmationDesc'
|
||||
|
@ -135,6 +138,7 @@ export type LocalizerKeys =
|
|||
| 'banUser'
|
||||
| 'answeredACall'
|
||||
| 'sendMessage'
|
||||
| 'readableListCounterSingular'
|
||||
| 'recoveryPhraseRevealMessage'
|
||||
| 'showRecoveryPhrase'
|
||||
| 'autoUpdateSettingDescription'
|
||||
|
@ -180,6 +184,7 @@ export type LocalizerKeys =
|
|||
| 'nameAndMessage'
|
||||
| 'autoUpdateDownloadedMessage'
|
||||
| 'onionPathIndicatorTitle'
|
||||
| 'readableListCounterPlural'
|
||||
| 'unknown'
|
||||
| 'mediaMessage'
|
||||
| 'addAsModerator'
|
||||
|
@ -227,6 +232,7 @@ export type LocalizerKeys =
|
|||
| 'messageDeletedPlaceholder'
|
||||
| 'notificationFrom'
|
||||
| 'displayName'
|
||||
| 'clear'
|
||||
| 'invalidSessionId'
|
||||
| 'audioPermissionNeeded'
|
||||
| 'createGroup'
|
||||
|
@ -319,6 +325,7 @@ export type LocalizerKeys =
|
|||
| 'media'
|
||||
| 'noMembersInThisGroup'
|
||||
| 'saveLogToDesktop'
|
||||
| 'reactionTooltip'
|
||||
| 'copyErrorAndQuit'
|
||||
| 'onlyAdminCanRemoveMembers'
|
||||
| 'passwordTypeError'
|
||||
|
|
148
ts/types/Reaction.ts
Normal file
148
ts/types/Reaction.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { EmojiSet } from 'emoji-mart';
|
||||
|
||||
export const reactionLimit: number = 6;
|
||||
|
||||
export class RecentReactions {
|
||||
public items: Array<string> = [];
|
||||
|
||||
constructor(items: Array<string>) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
public push(item: string): void {
|
||||
if (this.size() === reactionLimit) {
|
||||
this.items.pop();
|
||||
}
|
||||
this.items.unshift(item);
|
||||
}
|
||||
|
||||
public pop(): string | undefined {
|
||||
return this.items.pop();
|
||||
}
|
||||
|
||||
public swap(index: number): void {
|
||||
const temp = this.items.splice(index, 1);
|
||||
this.push(temp[0]);
|
||||
}
|
||||
}
|
||||
|
||||
type BaseEmojiSkin = { unified: string; native: string };
|
||||
|
||||
export interface FixedBaseEmoji {
|
||||
id: string;
|
||||
name: string;
|
||||
keywords: Array<string>;
|
||||
skins: Array<BaseEmojiSkin>;
|
||||
version: number;
|
||||
search?: string;
|
||||
// props from emoji panel click event
|
||||
native?: string;
|
||||
aliases?: Array<string>;
|
||||
shortcodes?: string;
|
||||
unified?: string;
|
||||
}
|
||||
|
||||
export interface NativeEmojiData {
|
||||
categories: Array<{ id: string; emojis: Array<string> }>;
|
||||
emojis: Record<string, FixedBaseEmoji>;
|
||||
aliases: Record<string, string>;
|
||||
sheet: { cols: number; rows: number };
|
||||
ariaLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Types for EmojiMart 5 are currently broken these are a temporary fixes
|
||||
export interface FixedPickerProps {
|
||||
autoFocus?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
theme?: 'auto' | 'light' | 'dark' | undefined;
|
||||
perLine?: number | undefined;
|
||||
stickySearch?: boolean | undefined;
|
||||
searchPosition?: 'sticky' | 'static' | 'none' | undefined;
|
||||
emojiButtonSize?: number | undefined;
|
||||
emojiButtonRadius?: number | undefined;
|
||||
emojiButtonColors?: string | undefined;
|
||||
maxFrequentRows?: number | undefined;
|
||||
icons?: 'auto' | 'outline' | 'solid';
|
||||
set?: EmojiSet | undefined;
|
||||
emoji?: string | undefined;
|
||||
navPosition?: 'bottom' | 'top' | 'none' | undefined;
|
||||
showPreview?: boolean | undefined;
|
||||
previewEmoji?: boolean | undefined;
|
||||
noResultsEmoji?: string | undefined;
|
||||
previewPosition?: 'bottom' | 'top' | 'none' | undefined;
|
||||
skinTonePosition?: 'preview' | 'search' | 'none';
|
||||
onEmojiSelect?: (emoji: FixedBaseEmoji) => void;
|
||||
onClickOutside?: () => void;
|
||||
onKeyDown?: (event: any) => void;
|
||||
onAddCustomEmoji?: () => void;
|
||||
getImageURL?: () => void;
|
||||
getSpritesheetURL?: () => void;
|
||||
// Below here I'm currently unsure of usage
|
||||
// i18n?: PartialI18n | undefined;
|
||||
// style?: React.CSSProperties | undefined;
|
||||
// color?: string | undefined;
|
||||
// skin?: EmojiSkin | undefined;
|
||||
// defaultSkin?: EmojiSkin | undefined;
|
||||
// backgroundImageFn?: BackgroundImageFn | undefined;
|
||||
// sheetSize?: EmojiSheetSize | undefined;
|
||||
// emojisToShowFilter?(emoji: EmojiData): boolean;
|
||||
// showSkinTones?: boolean | undefined;
|
||||
// emojiTooltip?: boolean | undefined;
|
||||
// include?: CategoryName[] | undefined;
|
||||
// exclude?: CategoryName[] | undefined;
|
||||
// recent?: string[] | undefined;
|
||||
// /** NOTE: custom emoji are copied into a singleton object on every new mount */
|
||||
// custom?: CustomEmoji[] | undefined;
|
||||
// skinEmoji?: string | undefined;
|
||||
// notFound?(): React.Component;
|
||||
// notFoundEmoji?: string | undefined;
|
||||
// enableFrequentEmojiSort?: boolean | undefined;
|
||||
// useButton?: boolean | undefined;
|
||||
}
|
||||
|
||||
export enum Action {
|
||||
REACT = 0,
|
||||
REMOVE = 1,
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
// this is in fact a uint64 so we will have an issue
|
||||
id: number; // original message timestamp
|
||||
author: string;
|
||||
emoji: string;
|
||||
action: Action;
|
||||
}
|
||||
|
||||
// used for logic operations with reactions i.e reponses, db, etc.
|
||||
export type ReactionList = Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
index: number; // relies on reactsIndex in the message model
|
||||
senders: Record<string, string>; // <sender pubkey, messageHash or serverId>
|
||||
}
|
||||
>;
|
||||
|
||||
// used when rendering reactions to guarantee sorted order using the index
|
||||
export type SortedReactionList = Array<
|
||||
[string, { count: number; index: number; senders: Record<string, string> }]
|
||||
>;
|
||||
|
||||
export interface OpenGroupReaction {
|
||||
index: number;
|
||||
count: number;
|
||||
first: number;
|
||||
reactors: Array<string>;
|
||||
you: boolean;
|
||||
}
|
||||
|
||||
export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
|
||||
|
||||
export interface OpenGroupReactionResponse {
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}
|
33
ts/util/abbreviateNumber.ts
Normal file
33
ts/util/abbreviateNumber.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Refactored from
|
||||
// https://stackoverflow.com/questions/2685911/is-there-a-way-to-round-numbers-into-a-reader-friendly-format-e-g-1-1k
|
||||
|
||||
const abbreviations = ['k', 'm', 'b', 't'];
|
||||
|
||||
export function abbreviateNumber(number: number, decimals: number = 2): string {
|
||||
let result = String(number);
|
||||
const d = Math.pow(10, decimals);
|
||||
|
||||
// Go through the array backwards, so we do the largest first
|
||||
for (let i = abbreviations.length - 1; i >= 0; i--) {
|
||||
// Convert array index to "1000", "1000000", etc
|
||||
const size = Math.pow(10, (i + 1) * 3);
|
||||
|
||||
// If the number is bigger or equal do the abbreviation
|
||||
if (size <= number) {
|
||||
// Here, we multiply by decimals, round, and then divide by decimals.
|
||||
// This gives us nice rounding to a particular decimal place.
|
||||
let n = Math.round((number * d) / size) / d;
|
||||
|
||||
// Handle special case where we round up to the next abbreviation
|
||||
if (n === 1000 && i < abbreviations.length - 1) {
|
||||
n = 1;
|
||||
i++;
|
||||
}
|
||||
|
||||
result = String(n) + abbreviations[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
132
ts/util/emoji.ts
132
ts/util/emoji.ts
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
251
ts/util/reactions.ts
Normal file
251
ts/util/reactions.ts
Normal file
|
@ -0,0 +1,251 @@
|
|||
import { isEmpty } from 'lodash';
|
||||
import { Data } from '../data/data';
|
||||
import { MessageModel } from '../models/message';
|
||||
import { SignalService } from '../protobuf';
|
||||
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { UserUtils } from '../session/utils';
|
||||
|
||||
import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction';
|
||||
import { getRecentReactions, saveRecentReations } from '../util/storage';
|
||||
|
||||
const rateCountLimit = 20;
|
||||
const rateTimeLimit = 60 * 1000;
|
||||
const latestReactionTimestamps: Array<number> = [];
|
||||
|
||||
/**
|
||||
* Retrieves the original message of a reaction
|
||||
*/
|
||||
const getMessageByReaction = async (
|
||||
reaction: SignalService.DataMessage.IReaction,
|
||||
isOpenGroup: boolean
|
||||
): Promise<MessageModel | null> => {
|
||||
let originalMessage = null;
|
||||
const originalMessageId = Number(reaction.id);
|
||||
const originalMessageAuthor = reaction.author;
|
||||
|
||||
if (isOpenGroup) {
|
||||
originalMessage = await Data.getMessageByServerId(originalMessageId);
|
||||
} else {
|
||||
const collection = await Data.getMessagesBySentAt(originalMessageId);
|
||||
originalMessage = collection.find((item: MessageModel) => {
|
||||
const messageTimestamp = item.get('sent_at');
|
||||
const author = item.get('source');
|
||||
return Boolean(
|
||||
messageTimestamp &&
|
||||
messageTimestamp === originalMessageId &&
|
||||
author &&
|
||||
author === originalMessageAuthor
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!originalMessage) {
|
||||
window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a Reaction Data Message, don't use for OpenGroups
|
||||
*/
|
||||
export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
||||
const found = await Data.getMessageById(messageId);
|
||||
if (found) {
|
||||
const conversationModel = found?.getConversation();
|
||||
if (!conversationModel) {
|
||||
window.log.warn(`Conversation for ${messageId} not found in db`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationModel.hasReactions()) {
|
||||
window.log.warn("This conversation doesn't have reaction support");
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
latestReactionTimestamps.push(timestamp);
|
||||
|
||||
if (latestReactionTimestamps.length > rateCountLimit) {
|
||||
const firstTimestamp = latestReactionTimestamps[0];
|
||||
if (timestamp - firstTimestamp < rateTimeLimit) {
|
||||
latestReactionTimestamps.pop();
|
||||
return;
|
||||
} else {
|
||||
latestReactionTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
const isOpenGroup = Boolean(found?.get('isPublic'));
|
||||
const id = (isOpenGroup && found.get('serverId')) || Number(found.get('sent_at'));
|
||||
const me =
|
||||
(isOpenGroup && getUsBlindedInThatServer(conversationModel)) ||
|
||||
UserUtils.getOurPubKeyStrFromCache();
|
||||
const author = found.get('source');
|
||||
let action: Action = Action.REACT;
|
||||
|
||||
const reacts = found.get('reacts');
|
||||
if (
|
||||
reacts &&
|
||||
Object.keys(reacts).includes(emoji) &&
|
||||
Object.keys(reacts[emoji].senders).includes(me)
|
||||
) {
|
||||
window.log.info('found matching reaction removing it');
|
||||
action = Action.REMOVE;
|
||||
} else {
|
||||
const reactions = getRecentReactions();
|
||||
if (reactions) {
|
||||
await updateRecentReactions(reactions, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
const reaction = {
|
||||
id,
|
||||
author,
|
||||
emoji,
|
||||
action,
|
||||
};
|
||||
|
||||
await conversationModel.sendReaction(messageId, reaction);
|
||||
|
||||
window.log.info(
|
||||
`You ${action === Action.REACT ? 'added' : 'removed'} a`,
|
||||
emoji,
|
||||
'reaction for message',
|
||||
id
|
||||
);
|
||||
return reaction;
|
||||
} else {
|
||||
window.log.warn(`Message ${messageId} not found in db`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle reactions on the client by updating the state of the source message
|
||||
*/
|
||||
export const handleMessageReaction = async (
|
||||
reaction: SignalService.DataMessage.IReaction,
|
||||
sender: string,
|
||||
isOpenGroup: boolean,
|
||||
messageId?: string
|
||||
) => {
|
||||
if (!reaction.emoji) {
|
||||
window?.log?.warn(`There is no emoji for the reaction ${messageId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMessage = await getMessageByReaction(reaction, isOpenGroup);
|
||||
if (!originalMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reacts: ReactionList = originalMessage.get('reacts') ?? {};
|
||||
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: {} };
|
||||
const details = reacts[reaction.emoji] ?? {};
|
||||
const senders = Object.keys(details.senders);
|
||||
|
||||
window.log.info(
|
||||
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
|
||||
reaction.emoji
|
||||
} reaction`
|
||||
);
|
||||
|
||||
switch (reaction.action) {
|
||||
case SignalService.DataMessage.Reaction.Action.REACT:
|
||||
if (senders.includes(sender) && details.senders[sender] !== '') {
|
||||
window?.log?.info(
|
||||
'Received duplicate message reaction. Dropping it. id:',
|
||||
details.senders[sender]
|
||||
);
|
||||
return;
|
||||
}
|
||||
details.senders[sender] = messageId ?? '';
|
||||
break;
|
||||
case SignalService.DataMessage.Reaction.Action.REMOVE:
|
||||
default:
|
||||
if (senders.length > 0) {
|
||||
if (senders.indexOf(sender) >= 0) {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete details.senders[sender];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = Object.keys(details.senders).length;
|
||||
if (count > 0) {
|
||||
reacts[reaction.emoji].count = count;
|
||||
reacts[reaction.emoji].senders = details.senders;
|
||||
|
||||
// sorting for open groups convos is handled by SOGS
|
||||
if (!isOpenGroup && details && details.index === undefined) {
|
||||
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
|
||||
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
|
||||
}
|
||||
} else {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete reacts[reaction.emoji];
|
||||
}
|
||||
|
||||
originalMessage.set({
|
||||
reacts: !isEmpty(reacts) ? reacts : undefined,
|
||||
});
|
||||
|
||||
await originalMessage.commit();
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle all updates to messages reactions from the SOGS API
|
||||
*/
|
||||
export const handleOpenGroupMessageReactions = async (
|
||||
reactions: OpenGroupReactionList,
|
||||
serverId: number
|
||||
) => {
|
||||
const originalMessage = await Data.getMessageByServerId(serverId);
|
||||
if (!originalMessage) {
|
||||
window?.log?.warn(`Cannot find the original reacted message ${serverId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(reactions)) {
|
||||
if (originalMessage.get('reacts')) {
|
||||
originalMessage.set({
|
||||
reacts: undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const reacts: ReactionList = {};
|
||||
Object.keys(reactions).forEach(key => {
|
||||
const emoji = decodeURI(key);
|
||||
const senders: Record<string, string> = {};
|
||||
reactions[key].reactors.forEach(reactor => {
|
||||
senders[reactor] = String(serverId);
|
||||
});
|
||||
reacts[emoji] = { count: reactions[key].count, index: reactions[key].index, senders };
|
||||
});
|
||||
|
||||
originalMessage.set({
|
||||
reacts,
|
||||
});
|
||||
}
|
||||
|
||||
await originalMessage.commit();
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
export const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
|
||||
window?.log?.info('updating recent reactions with', newReaction);
|
||||
const recentReactions = new RecentReactions(reactions);
|
||||
const foundIndex = recentReactions.items.indexOf(newReaction);
|
||||
if (foundIndex === 0) {
|
||||
return;
|
||||
}
|
||||
if (foundIndex > 0) {
|
||||
recentReactions.swap(foundIndex);
|
||||
} else {
|
||||
recentReactions.push(newReaction);
|
||||
}
|
||||
await saveRecentReations(recentReactions.items);
|
||||
};
|
41
ts/util/readableList.ts
Normal file
41
ts/util/readableList.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
export const readableList = (
|
||||
arr: Array<string>,
|
||||
conjunction: string = '&',
|
||||
limit: number = 3
|
||||
): string => {
|
||||
if (arr.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const count = arr.length;
|
||||
switch (count) {
|
||||
case 1:
|
||||
return arr[0];
|
||||
default:
|
||||
let result = '';
|
||||
let others = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (others === 0 && i === count - 1 && i < limit) {
|
||||
result += ` ${conjunction} `;
|
||||
} else if (i !== 0 && i < limit) {
|
||||
result += ', ';
|
||||
} else if (i >= limit) {
|
||||
others++;
|
||||
}
|
||||
|
||||
if (others === 0) {
|
||||
result += arr[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (others > 0) {
|
||||
result += ` ${conjunction} ${others} ${
|
||||
others > 1
|
||||
? window.i18n('readableListCounterPlural')
|
||||
: window.i18n('readableListCounterSingular')
|
||||
}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
|
@ -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 };
|
||||
|
|
27
yarn.lock
27
yarn.lock
|
@ -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…
Reference in a new issue