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": [
|
"includePaths": [
|
||||||
"node_modules/sanitize.css",
|
"node_modules/sanitize.css",
|
||||||
"node_modules/emoji-mart/css",
|
|
||||||
"node_modules/react-h5-audio-player/lib",
|
"node_modules/react-h5-audio-player/lib",
|
||||||
"node_modules/react-contexify/dist",
|
"node_modules/react-contexify/dist",
|
||||||
"node_modules/react-toastify/dist"
|
"node_modules/react-toastify/dist"
|
||||||
|
|
|
@ -109,6 +109,7 @@
|
||||||
"moreInformation": "More information",
|
"moreInformation": "More information",
|
||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
|
"deleteConversationConfirmation": "Permanently delete the messages in this conversation?",
|
||||||
|
"clear": "Clear",
|
||||||
"clearAllData": "Clear All Data",
|
"clearAllData": "Clear All Data",
|
||||||
"deleteAccountWarning": "This will permanently delete your messages, and contacts.",
|
"deleteAccountWarning": "This will permanently delete your messages, and contacts.",
|
||||||
"deleteContactConfirmation": "Are you sure you want to delete this conversation?",
|
"deleteContactConfirmation": "Are you sure you want to delete this conversation?",
|
||||||
|
@ -452,5 +453,11 @@
|
||||||
"clearAllConfirmationTitle": "Clear All Message Requests",
|
"clearAllConfirmationTitle": "Clear All Message Requests",
|
||||||
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
|
"clearAllConfirmationBody": "Are you sure you want to clear all message requests?",
|
||||||
"hideBanner": "Hide",
|
"hideBanner": "Hide",
|
||||||
"openMessageRequestInboxDescription": "View your Message Request inbox"
|
"openMessageRequestInboxDescription": "View your Message Request inbox",
|
||||||
|
"clearAllReactions": "Are you sure you want to clear all $emoji$ ?",
|
||||||
|
"reactionTooltip": "reacted with",
|
||||||
|
"expandedReactionsText": "Show Less",
|
||||||
|
"reactionNotification": "Reacts to a message with $emoji$",
|
||||||
|
"readableListCounterSingular": "other",
|
||||||
|
"readableListCounterPlural": "others"
|
||||||
}
|
}
|
||||||
|
|
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\"",
|
"coverage": "nyc --reporter=html mocha -r jsdom-global/register --recursive --exit --timeout 10000 \"./ts/test/**/*_test.js\"",
|
||||||
"lint-full": "yarn format-full && eslint . && tslint --format stylish --project .",
|
"lint-full": "yarn format-full && eslint . && tslint --format stylish --project .",
|
||||||
"format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"",
|
"format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"",
|
||||||
"transpile": "yarn tsc && yarn parcel-util-worker",
|
"transpile": "yarn tsc && yarn parcel-util-worker && yarn sass",
|
||||||
"transpile:watch": "yarn grunt --force; tsc -w",
|
"transpile:watch": "yarn grunt --force; tsc -w",
|
||||||
"integration-test": "npx playwright test",
|
"integration-test": "npx playwright test",
|
||||||
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots",
|
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots",
|
||||||
"clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
|
"clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
|
||||||
"ready": "yarn sass && yarn grunt && yarn lint-full && yarn test",
|
"ready": "yarn grunt && yarn lint-full && yarn test",
|
||||||
"sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
|
"sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
|
||||||
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json",
|
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json",
|
||||||
"sass": "rimraf 'stylesheets/dist/' && parcel build --target sass --no-autoinstall --no-cache",
|
"sass": "rimraf 'stylesheets/dist/' && parcel build --target sass --no-autoinstall --no-cache",
|
||||||
|
@ -79,6 +79,7 @@
|
||||||
"rebuild-curve25519-js": "cd node_modules/curve25519-js && yarn install && yarn build && cd ../../"
|
"rebuild-curve25519-js": "cd node_modules/curve25519-js && yarn install && yarn build && cd ../../"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "1.0.2",
|
||||||
"@reduxjs/toolkit": "^1.4.0",
|
"@reduxjs/toolkit": "^1.4.0",
|
||||||
"abort-controller": "3.0.0",
|
"abort-controller": "3.0.0",
|
||||||
"auto-bind": "^4.0.0",
|
"auto-bind": "^4.0.0",
|
||||||
|
@ -99,7 +100,7 @@
|
||||||
"electron-is-dev": "^1.1.0",
|
"electron-is-dev": "^1.1.0",
|
||||||
"electron-localshortcut": "^3.2.1",
|
"electron-localshortcut": "^3.2.1",
|
||||||
"electron-updater": "^4.2.2",
|
"electron-updater": "^4.2.2",
|
||||||
"emoji-mart": "^2.11.2",
|
"emoji-mart": "5.1.0",
|
||||||
"filesize": "3.6.1",
|
"filesize": "3.6.1",
|
||||||
"firstline": "1.2.1",
|
"firstline": "1.2.1",
|
||||||
"fs-extra": "9.0.0",
|
"fs-extra": "9.0.0",
|
||||||
|
@ -164,7 +165,7 @@
|
||||||
"@types/config": "0.0.34",
|
"@types/config": "0.0.34",
|
||||||
"@types/dompurify": "^2.0.0",
|
"@types/dompurify": "^2.0.0",
|
||||||
"@types/electron-localshortcut": "^3.1.0",
|
"@types/electron-localshortcut": "^3.1.0",
|
||||||
"@types/emoji-mart": "^2.11.3",
|
"@types/emoji-mart": "3.0.9",
|
||||||
"@types/filesize": "3.6.0",
|
"@types/filesize": "3.6.0",
|
||||||
"@types/firstline": "^2.0.2",
|
"@types/firstline": "^2.0.2",
|
||||||
"@types/fs-extra": "5.0.5",
|
"@types/fs-extra": "5.0.5",
|
||||||
|
@ -329,16 +330,6 @@
|
||||||
"sound/*",
|
"sound/*",
|
||||||
"build/assets",
|
"build/assets",
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"!node_modules/emoji-panel/dist/*",
|
|
||||||
"!node_modules/emoji-panel/lib/emoji-panel-emojione-*.css",
|
|
||||||
"!node_modules/emoji-panel/lib/emoji-panel-google-*.css",
|
|
||||||
"!node_modules/emoji-panel/lib/emoji-panel-twitter-*.css",
|
|
||||||
"!node_modules/emoji-panel/lib/emoji-panel-apple-{16,20,64}.css",
|
|
||||||
"!node_modules/emoji-datasource/emoji_pretty.json",
|
|
||||||
"!node_modules/emoji-datasource/*.png",
|
|
||||||
"!node_modules/emoji-datasource-apple/emoji_pretty.json",
|
|
||||||
"!node_modules/emoji-datasource-apple/img/apple/{sheets-128,sheets-256}/*.png",
|
|
||||||
"!node_modules/emoji-datasource-apple/img/apple/sheets/{16,20,32}.png",
|
|
||||||
"!node_modules/spellchecker/vendor/hunspell/**/*",
|
"!node_modules/spellchecker/vendor/hunspell/**/*",
|
||||||
"!node_modules/@iconify/icons-mdi/*",
|
"!node_modules/@iconify/icons-mdi/*",
|
||||||
"node_modules/@iconify/icons-mdi/play-circle*",
|
"node_modules/@iconify/icons-mdi/play-circle*",
|
||||||
|
|
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;
|
EXPIRATION_TIMER_UPDATE = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Reaction {
|
||||||
|
enum Action {
|
||||||
|
REACT = 0;
|
||||||
|
REMOVE = 1;
|
||||||
|
}
|
||||||
|
// @required
|
||||||
|
required uint64 id = 1; // Message timestamp
|
||||||
|
// @required
|
||||||
|
required string author = 2;
|
||||||
|
optional string emoji = 3;
|
||||||
|
// @required
|
||||||
|
required Action action = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message Quote {
|
message Quote {
|
||||||
|
|
||||||
message QuotedAttachment {
|
message QuotedAttachment {
|
||||||
|
@ -153,6 +167,7 @@ message DataMessage {
|
||||||
optional uint64 timestamp = 7;
|
optional uint64 timestamp = 7;
|
||||||
optional Quote quote = 8;
|
optional Quote quote = 8;
|
||||||
repeated Preview preview = 10;
|
repeated Preview preview = 10;
|
||||||
|
optional Reaction reaction = 11;
|
||||||
optional LokiProfile profile = 101;
|
optional LokiProfile profile = 101;
|
||||||
optional OpenGroupInvitation openGroupInvitation = 102;
|
optional OpenGroupInvitation openGroupInvitation = 102;
|
||||||
optional ClosedGroupControlMessage closedGroupControlMessage = 104;
|
optional ClosedGroupControlMessage closedGroupControlMessage = 104;
|
||||||
|
|
|
@ -28,9 +28,3 @@
|
||||||
@include color-svg($svg, black);
|
@include color-svg($svg, black);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes highlightedMessageAnimation {
|
|
||||||
1% {
|
|
||||||
background-color: #00f782;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -104,27 +104,28 @@
|
||||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.3);
|
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
// TODO is this being used anywhere? Seems not
|
||||||
float: right;
|
// button {
|
||||||
margin-inline-start: 10px;
|
// float: right;
|
||||||
background-color: $color-loki-green;
|
// margin-inline-start: 10px;
|
||||||
border-radius: 100px;
|
// background-color: $color-loki-green;
|
||||||
padding: 5px 15px;
|
// border-radius: 100px;
|
||||||
border: 1px solid $color-loki-green;
|
// padding: 5px 15px;
|
||||||
color: white;
|
// border: 1px solid $color-loki-green;
|
||||||
outline: none;
|
// color: white;
|
||||||
user-select: none;
|
// outline: none;
|
||||||
|
// user-select: none;
|
||||||
|
|
||||||
&:hover,
|
// &:hover,
|
||||||
&:disabled {
|
// &:disabled {
|
||||||
background-color: $color-loki-green-dark;
|
// background-color: $color-loki-green-dark;
|
||||||
border-color: $color-loki-green-dark;
|
// border-color: $color-loki-green-dark;
|
||||||
}
|
// }
|
||||||
|
|
||||||
&:disabled {
|
// &:disabled {
|
||||||
cursor: not-allowed;
|
// cursor: not-allowed;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -207,7 +208,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.session-button {
|
.session-button {
|
||||||
width: 148px;
|
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);
|
background-color: var(--color-modal-background);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: var(--border-session);
|
border: var(--border-session);
|
||||||
|
border-radius: 14px;
|
||||||
box-shadow: var(--color-session-shadow);
|
box-shadow: var(--color-session-shadow);
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -602,10 +603,14 @@ label {
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
box-shadow: 0 10px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19) !important;
|
box-shadow: 0 10px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19) !important;
|
||||||
background: var(--color-cell-background);
|
background-color: var(--color-received-message-background);
|
||||||
|
|
||||||
|
&.react-contexify__theme--dark {
|
||||||
|
background-color: var(--color-received-message-background);
|
||||||
|
}
|
||||||
|
|
||||||
.react-contexify__item {
|
.react-contexify__item {
|
||||||
background: var(--color-cell-background);
|
background: var(--color-received-message-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-contexify__item:not(.react-contexify__item--disabled):hover
|
.react-contexify__item:not(.react-contexify__item--disabled):hover
|
||||||
|
@ -880,7 +885,7 @@ label {
|
||||||
&__description {
|
&__description {
|
||||||
font-family: $session-font-default;
|
font-family: $session-font-default;
|
||||||
font-size: $session-font-sm;
|
font-size: $session-font-sm;
|
||||||
font-weight: 100;
|
font-weight: 400;
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
color: var(--color-text-subtle);
|
color: var(--color-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,24 +14,71 @@ $session-font-mono: 'SpaceMono';
|
||||||
// Roboto is an open replacement for $session-font-default
|
// Roboto is an open replacement for $session-font-default
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: $session-font-default;
|
font-family: $session-font-default;
|
||||||
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
|
src: url('../fonts/Roboto-Thin.ttf') format('truetype');
|
||||||
font-weight: 300;
|
font-weight: 100;
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: $session-font-default;
|
font-family: $session-font-default;
|
||||||
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
|
src: url('../fonts/Roboto-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-style: italic;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-Regular.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-Italic.ttf') format('truetype');
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-Medium.ttf') format('truetype');
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-MediumItalic.ttf') format('truetype');
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: $session-font-default;
|
font-family: $session-font-default;
|
||||||
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
|
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: $session-font-default;
|
font-family: $session-font-default;
|
||||||
src: url('../fonts/Roboto-BoldItalic.ttf') format('truetype');
|
src: url('../fonts/Roboto-BoldItalic.ttf') format('truetype');
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-Black.ttf') format('truetype');
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: $session-font-default;
|
||||||
|
src: url('../fonts/Roboto-BlackItalic.ttf') format('truetype');
|
||||||
|
font-weight: 900;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -142,17 +142,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
position: relative;
|
|
||||||
overflow-x: hidden;
|
|
||||||
min-width: 370px;
|
|
||||||
scrollbar-width: 4px;
|
|
||||||
padding: $session-margin-sm $session-margin-lg $session-margin-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composition-container {
|
.composition-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@ -184,122 +173,6 @@
|
||||||
width: 30px;
|
width: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-message-input {
|
|
||||||
cursor: text;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
min-height: $composition-container-height;
|
|
||||||
padding: $session-margin-xs 0;
|
|
||||||
z-index: 1;
|
|
||||||
background-color: inherit;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
font-family: $session-font-default;
|
|
||||||
min-height: calc($composition-container-height / 3);
|
|
||||||
max-height: 3 * $composition-container-height;
|
|
||||||
margin-right: $session-margin-md;
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
resize: none;
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: $session-font-h2;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__emoji-overlay {
|
|
||||||
// Should have identical properties to the textarea above to line up perfectly.
|
|
||||||
position: absolute;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: $session-font-default;
|
|
||||||
margin-left: 2px;
|
|
||||||
line-height: $session-font-h2;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-emoji-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 68px;
|
|
||||||
right: 0px;
|
|
||||||
padding: $session-margin-lg;
|
|
||||||
|
|
||||||
z-index: 5;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: $session-transition-duration;
|
|
||||||
|
|
||||||
button:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > section.emoji-mart {
|
|
||||||
font-family: $session-font-default;
|
|
||||||
font-size: $session-font-sm;
|
|
||||||
background-color: var(--color-cell-background);
|
|
||||||
border: 1px solid var(--color-session-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding-bottom: $session-margin-sm;
|
|
||||||
|
|
||||||
.emoji-mart-category-label {
|
|
||||||
top: -2px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-family: $session-font-default;
|
|
||||||
padding-top: $session-margin-sm;
|
|
||||||
background-color: var(--color-cell-background);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-mart-scroll {
|
|
||||||
height: 340px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-mart-category .emoji-mart-emoji span {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji-mart-bar:last-child {
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
.emoji-mart-preview {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% - 40px);
|
|
||||||
left: calc(100% - 79px);
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
background-color: var(--color-cell-background);
|
|
||||||
transform: rotate(45deg);
|
|
||||||
border-radius: 3px;
|
|
||||||
transform: scaleY(1.4) rotate(45deg);
|
|
||||||
border: 0.7px solid var(--color-session-border);
|
|
||||||
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-progress {
|
.session-progress {
|
||||||
|
|
|
@ -103,24 +103,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-message-wrapper {
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
margin-top: 3px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
&.message-highlighted {
|
|
||||||
animation: highlightedMessageAnimation 1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.message-selected {
|
|
||||||
.module-message {
|
|
||||||
&__container {
|
|
||||||
box-shadow: var(--color-session-shadow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbox {
|
.inbox {
|
||||||
background: var(--color-inbox-background);
|
background: var(--color-inbox-background);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
// Modules
|
// Modules
|
||||||
@import '../node_modules/emoji-mart/css/emoji-mart.css';
|
|
||||||
@import '../node_modules/react-h5-audio-player/lib/styles.css';
|
@import '../node_modules/react-h5-audio-player/lib/styles.css';
|
||||||
@import '../node_modules/react-contexify/dist/ReactContexify.min.css';
|
@import '../node_modules/react-contexify/dist/ReactContexify.min.css';
|
||||||
@import '../node_modules/react-toastify/dist/ReactToastify.css';
|
@import '../node_modules/react-toastify/dist/ReactToastify.css';
|
||||||
|
|
|
@ -15,6 +15,7 @@ export enum SessionButtonColor {
|
||||||
Green = 'green',
|
Green = 'green',
|
||||||
White = 'white',
|
White = 'white',
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
|
Secondary = 'secondary',
|
||||||
Success = 'success',
|
Success = 'success',
|
||||||
Danger = 'danger',
|
Danger = 'danger',
|
||||||
Warning = 'warning',
|
Warning = 'warning',
|
||||||
|
|
|
@ -1,32 +1,137 @@
|
||||||
import React from 'react';
|
import React, { forwardRef, MutableRefObject, useEffect } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Picker } from 'emoji-mart';
|
import styled from 'styled-components';
|
||||||
import { Constants } from '../../session';
|
import data from '@emoji-mart/data';
|
||||||
|
// @ts-ignore
|
||||||
|
import { Picker } from '../../../node_modules/emoji-mart/dist/index.cjs';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { getTheme } from '../../state/selectors/theme';
|
import { getTheme } from '../../state/selectors/theme';
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
import { loadEmojiPanelI18n } from '../../util/i18n';
|
||||||
|
import { FixedBaseEmoji, FixedPickerProps } from '../../types/Reaction';
|
||||||
|
|
||||||
|
export const StyledEmojiPanel = styled.div<{ isModal: boolean; theme: 'light' | 'dark' }>`
|
||||||
|
padding: var(--margins-lg);
|
||||||
|
z-index: 5;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: var(--default-duration);
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
em-emoji-picker {
|
||||||
|
background-color: var(--color-cell-background);
|
||||||
|
border: 1px solid var(--color-session-border);
|
||||||
|
padding-bottom: var(--margins-sm);
|
||||||
|
--shadow: none;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--color-border: var(--color-session-border);
|
||||||
|
--font-family: var(--font-default);
|
||||||
|
--font-size: var(--font-size-sm);
|
||||||
|
--rgb-accent: 0, 247, 130; // Constants.UI.COLORS.GREEN
|
||||||
|
|
||||||
|
${props => {
|
||||||
|
switch (props.theme) {
|
||||||
|
case 'dark':
|
||||||
|
return `
|
||||||
|
--background-rgb: 27, 27, 27; // var(--color-cell-background)
|
||||||
|
--rgb-background: 27, 27, 27;
|
||||||
|
--rgb-color: 255, 255, 255; // var(--color-text)
|
||||||
|
--rgb-input: 27, 27, 27;
|
||||||
|
`;
|
||||||
|
case 'light':
|
||||||
|
default:
|
||||||
|
return `
|
||||||
|
--background-rgb: 249, 249, 249; // var(--color-cell-background)
|
||||||
|
--rgb-background: 249, 249, 249;
|
||||||
|
--rgb-color: 0, 0, 0; // var(--color-text)
|
||||||
|
--rgb-input: 249, 249, 249;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
${props =>
|
||||||
|
!props.isModal &&
|
||||||
|
`
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - 40px);
|
||||||
|
left: calc(100% - 79px);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background-color: var(--color-cell-background);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
border-radius: 3px;
|
||||||
|
transform: scaleY(1.4) rotate(45deg);
|
||||||
|
border: 0.7px solid var(--color-session-border);
|
||||||
|
clip-path: polygon(100% 100%, 7.2px 100%, 100% 7.2px);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onEmojiClicked: (emoji: any) => void;
|
onEmojiClicked: (emoji: FixedBaseEmoji) => void;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
isModal?: boolean;
|
||||||
|
onKeyDown?: (event: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionEmojiPanel = (props: Props) => {
|
const pickerProps: FixedPickerProps = {
|
||||||
const { onEmojiClicked, show } = props;
|
title: '',
|
||||||
const darkMode = useSelector(getTheme) === 'dark';
|
showPreview: true,
|
||||||
|
autoFocus: true,
|
||||||
|
skinTonePosition: 'preview',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionEmojiPanel = forwardRef<HTMLDivElement, Props>((props: Props, ref) => {
|
||||||
|
const { onEmojiClicked, show, isModal = false, onKeyDown } = props;
|
||||||
|
const theme = useSelector(getTheme);
|
||||||
|
const pickerRef = ref as MutableRefObject<HTMLDivElement>;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
if (pickerRef.current !== null) {
|
||||||
|
if (pickerRef.current.children.length === 0) {
|
||||||
|
loadEmojiPanelI18n()
|
||||||
|
.then(async i18n => {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// tslint:disable-next-line: no-unused-expression
|
||||||
|
new Picker({
|
||||||
|
data,
|
||||||
|
ref,
|
||||||
|
i18n,
|
||||||
|
theme,
|
||||||
|
onEmojiSelect: onEmojiClicked,
|
||||||
|
onKeyDown,
|
||||||
|
...pickerProps,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(noop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [data, pickerProps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('session-emoji-panel', show && 'show')}>
|
<StyledEmojiPanel
|
||||||
<Picker
|
isModal={isModal}
|
||||||
backgroundImageFn={() => './images/emoji/emoji-sheet-twitter-32.png'}
|
theme={theme}
|
||||||
set={'twitter'}
|
className={classNames(show && 'show')}
|
||||||
sheetSize={32}
|
ref={ref}
|
||||||
darkMode={darkMode}
|
|
||||||
color={Constants.UI.COLORS.GREEN}
|
|
||||||
showPreview={true}
|
|
||||||
title={''}
|
|
||||||
onSelect={onEmojiClicked}
|
|
||||||
autoFocus={true}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
getSortedMessagesOfSelectedConversation,
|
getSortedMessagesOfSelectedConversation,
|
||||||
} from '../../state/selectors/conversations';
|
} from '../../state/selectors/conversations';
|
||||||
import { TypingBubble } from './TypingBubble';
|
import { TypingBubble } from './TypingBubble';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
export type SessionMessageListProps = {
|
export type SessionMessageListProps = {
|
||||||
messageContainerRef: React.RefObject<HTMLDivElement>;
|
messageContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
@ -52,6 +53,39 @@ type Props = SessionMessageListProps & {
|
||||||
scrollToNow: () => Promise<void>;
|
scrollToNow: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StyledMessagesContainer = styled.div<{}>`
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-width: 370px;
|
||||||
|
scrollbar-width: 4px;
|
||||||
|
padding: var(--margins-sm) 0 var(--margins-lg);
|
||||||
|
|
||||||
|
.session-icon-button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--color-cell-background);
|
||||||
|
box-shadow: var(--color-session-shadow);
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
transition: var(--default-duration);
|
||||||
|
opacity: 0.6;
|
||||||
|
fill: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg path {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
class SessionMessagesListContainerInner extends React.Component<Props> {
|
class SessionMessagesListContainerInner extends React.Component<Props> {
|
||||||
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
|
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
@ -101,7 +135,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<StyledMessagesContainer
|
||||||
className="messages-container"
|
className="messages-container"
|
||||||
id={messageContainerDomID}
|
id={messageContainerDomID}
|
||||||
onScroll={this.handleScroll}
|
onScroll={this.handleScroll}
|
||||||
|
@ -135,7 +169,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
|
||||||
onClickScrollBottom={this.props.scrollToNow}
|
onClickScrollBottom={this.props.scrollToNow}
|
||||||
key="scroll-down-button"
|
key="scroll-down-button"
|
||||||
/>
|
/>
|
||||||
</div>
|
</StyledMessagesContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import _, { debounce, isEmpty } from 'lodash';
|
||||||
|
|
||||||
import * as MIME from '../../../types/MIME';
|
import * as MIME from '../../../types/MIME';
|
||||||
|
|
||||||
import { SessionEmojiPanel } from '../SessionEmojiPanel';
|
import { SessionEmojiPanel, StyledEmojiPanel } from '../SessionEmojiPanel';
|
||||||
import { SessionRecording } from '../SessionRecording';
|
import { SessionRecording } from '../SessionRecording';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -55,6 +55,8 @@ import {
|
||||||
} from './UserMentions';
|
} from './UserMentions';
|
||||||
import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult';
|
import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResult';
|
||||||
import { LinkPreviews } from '../../../util/linkPreviews';
|
import { LinkPreviews } from '../../../util/linkPreviews';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { FixedBaseEmoji } from '../../../types/Reaction';
|
||||||
|
|
||||||
export interface ReplyingToMessageProps {
|
export interface ReplyingToMessageProps {
|
||||||
convoId: string;
|
convoId: string;
|
||||||
|
@ -203,6 +205,59 @@ const getSelectionBasedOnMentions = (draft: string, index: number) => {
|
||||||
return Number.MAX_SAFE_INTEGER;
|
return Number.MAX_SAFE_INTEGER;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StyledEmojiPanelContainer = styled.div`
|
||||||
|
${StyledEmojiPanel} {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 68px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledSendMessageInput = styled.div`
|
||||||
|
cursor: text;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: var(--compositionContainerHeight);
|
||||||
|
padding: var(--margins-xs) 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: inherit;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: var(--font-default);
|
||||||
|
min-height: calc(var(--compositionContainerHeight) / 3);
|
||||||
|
max-height: 3 * var(--compositionContainerHeight);
|
||||||
|
margin-right: var(--margins-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
resize: none;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: var(--font-size-h2);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji-overlay {
|
||||||
|
// Should have identical properties to the textarea above to line up perfectly.
|
||||||
|
position: absolute;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-default);
|
||||||
|
margin-left: 2px;
|
||||||
|
line-height: var(--font-size-h2);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
class CompositionBoxInner extends React.Component<Props, State> {
|
class CompositionBoxInner extends React.Component<Props, State> {
|
||||||
private readonly textarea: React.RefObject<any>;
|
private readonly textarea: React.RefObject<any>;
|
||||||
private readonly fileInput: React.RefObject<HTMLInputElement>;
|
private readonly fileInput: React.RefObject<HTMLInputElement>;
|
||||||
|
@ -369,8 +424,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
||||||
|
|
||||||
{typingEnabled && <StartRecordingButton onClick={this.onLoadVoiceNoteView} />}
|
{typingEnabled && <StartRecordingButton onClick={this.onLoadVoiceNoteView} />}
|
||||||
|
|
||||||
<div
|
<StyledSendMessageInput
|
||||||
className="send-message-input"
|
|
||||||
role="main"
|
role="main"
|
||||||
onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container
|
onClick={this.focusCompositionBox} // used to focus on the textarea when clicking in its container
|
||||||
ref={el => {
|
ref={el => {
|
||||||
|
@ -379,19 +433,22 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
||||||
data-testid="message-input"
|
data-testid="message-input"
|
||||||
>
|
>
|
||||||
{this.renderTextArea()}
|
{this.renderTextArea()}
|
||||||
</div>
|
</StyledSendMessageInput>
|
||||||
|
|
||||||
{typingEnabled && (
|
{typingEnabled && (
|
||||||
<ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} />
|
<ToggleEmojiButton ref={this.emojiPanelButton} onClick={this.toggleEmojiPanel} />
|
||||||
)}
|
)}
|
||||||
<SendMessageButton onClick={this.onSendMessage} />
|
<SendMessageButton onClick={this.onSendMessage} />
|
||||||
|
|
||||||
{typingEnabled && (
|
{typingEnabled && showEmojiPanel && (
|
||||||
<div ref={this.emojiPanel} onKeyDown={this.onKeyDown} role="button">
|
<StyledEmojiPanelContainer role="button">
|
||||||
{showEmojiPanel && (
|
<SessionEmojiPanel
|
||||||
<SessionEmojiPanel onEmojiClicked={this.onEmojiClick} show={showEmojiPanel} />
|
ref={this.emojiPanel}
|
||||||
)}
|
show={showEmojiPanel}
|
||||||
</div>
|
onEmojiClicked={this.onEmojiClick}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
/>
|
||||||
|
</StyledEmojiPanelContainer>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -978,7 +1035,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
||||||
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft });
|
updateDraftForConversation({ conversationKey: this.props.selectedConversationKey, draft });
|
||||||
}
|
}
|
||||||
|
|
||||||
private onEmojiClick({ native }: any) {
|
private onEmojiClick(emoji: FixedBaseEmoji) {
|
||||||
if (!this.props.selectedConversationKey) {
|
if (!this.props.selectedConversationKey) {
|
||||||
throw new Error('selectedConversationKey is needed');
|
throw new Error('selectedConversationKey is needed');
|
||||||
}
|
}
|
||||||
|
@ -996,7 +1053,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
||||||
const before = draft.slice(0, realSelectionStart);
|
const before = draft.slice(0, realSelectionStart);
|
||||||
const end = draft.slice(realSelectionStart);
|
const end = draft.slice(realSelectionStart);
|
||||||
|
|
||||||
const newMessage = `${before}${native}${end}`;
|
const newMessage = `${before}${emoji.native}${end}`;
|
||||||
this.setState({ draft: newMessage });
|
this.setState({ draft: newMessage });
|
||||||
updateDraftForConversation({
|
updateDraftForConversation({
|
||||||
conversationKey: this.props.selectedConversationKey,
|
conversationKey: this.props.selectedConversationKey,
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SuggestionDataItem } from 'react-mentions';
|
import { SuggestionDataItem } from 'react-mentions';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { BaseEmoji, emojiIndex } from 'emoji-mart';
|
// @ts-ignore
|
||||||
|
import { SearchIndex } from '../../../../node_modules/emoji-mart/dist/index.cjs';
|
||||||
|
import { searchSync } from '../../../util/emoji.js';
|
||||||
|
|
||||||
const EmojiQuickResult = styled.span`
|
const EmojiQuickResult = styled.span`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -25,22 +27,24 @@ export const renderEmojiQuickResultRow = (suggestion: SuggestionDataItem) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchEmojiForQuery = (query: string): Array<SuggestionDataItem> => {
|
export const searchEmojiForQuery = (query: string): Array<SuggestionDataItem> => {
|
||||||
if (query.length === 0 || !emojiIndex) {
|
if (query.length === 0 || !SearchIndex) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const results1 = emojiIndex.search(`:${query}`) || [];
|
|
||||||
const results2 = emojiIndex.search(query) || [];
|
const results1 = searchSync(`:${query}`);
|
||||||
|
const results2 = searchSync(query);
|
||||||
const results = [...new Set(results1.concat(results2))];
|
const results = [...new Set(results1.concat(results2))];
|
||||||
if (!results || !results.length) {
|
if (!results || !results.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return results
|
|
||||||
.map(o => {
|
const cleanResults = results
|
||||||
const onlyBaseEmoji = o as BaseEmoji;
|
.map(emoji => {
|
||||||
return {
|
return {
|
||||||
id: onlyBaseEmoji.native,
|
id: emoji.skins[0].native,
|
||||||
display: onlyBaseEmoji.colons,
|
display: `:${emoji.id}:`,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
return cleanResults;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import styled from 'styled-components';
|
||||||
import { replyToMessage } from '../../../../interactions/conversationInteractions';
|
import { replyToMessage } from '../../../../interactions/conversationInteractions';
|
||||||
import { MessageRenderingProps } from '../../../../models/messageType';
|
import { MessageRenderingProps } from '../../../../models/messageType';
|
||||||
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
|
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
|
||||||
|
import { updateReactListModal } from '../../../../state/ducks/modalDialog';
|
||||||
import {
|
import {
|
||||||
getMessageContentWithStatusesSelectorProps,
|
getMessageContentWithStatusesSelectorProps,
|
||||||
isMessageSelectionMode,
|
isMessageSelectionMode,
|
||||||
} from '../../../../state/selectors/conversations';
|
} from '../../../../state/selectors/conversations';
|
||||||
|
import { sendMessageReaction } from '../../../../util/reactions';
|
||||||
|
|
||||||
import { MessageAuthorText } from './MessageAuthorText';
|
import { MessageAuthorText } from './MessageAuthorText';
|
||||||
import { MessageContent } from './MessageContent';
|
import { MessageContent } from './MessageContent';
|
||||||
import { MessageContextMenu } from './MessageContextMenu';
|
import { MessageContextMenu } from './MessageContextMenu';
|
||||||
|
import { MessageReactions, StyledMessageReactions } from './MessageReactions';
|
||||||
import { MessageStatus } from './MessageStatus';
|
import { MessageStatus } from './MessageStatus';
|
||||||
|
|
||||||
export type MessageContentWithStatusSelectorProps = Pick<
|
export type MessageContentWithStatusSelectorProps = Pick<
|
||||||
|
@ -24,8 +28,21 @@ type Props = {
|
||||||
ctxMenuID: string;
|
ctxMenuID: string;
|
||||||
isDetailView?: boolean;
|
isDetailView?: boolean;
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
|
enableReactions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: ${props => (props.direction === 'left' ? 'flex-start' : 'flex-end')};
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
${StyledMessageReactions} {
|
||||||
|
margin-right: var(--margins-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const MessageContentWithStatuses = (props: Props) => {
|
export const MessageContentWithStatuses = (props: Props) => {
|
||||||
const contentProps = useSelector(state =>
|
const contentProps = useSelector(state =>
|
||||||
getMessageContentWithStatusesSelectorProps(state as any, props.messageId)
|
getMessageContentWithStatusesSelectorProps(state as any, props.messageId)
|
||||||
|
@ -63,20 +80,38 @@ export const MessageContentWithStatuses = (props: Props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { messageId, ctxMenuID, isDetailView, dataTestId } = props;
|
const { messageId, ctxMenuID, isDetailView, dataTestId, enableReactions } = props;
|
||||||
if (!contentProps) {
|
if (!contentProps) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { direction, isDeleted, hasAttachments, isTrustedForAttachmentDownload } = contentProps;
|
const { direction, isDeleted, hasAttachments, isTrustedForAttachmentDownload } = contentProps;
|
||||||
const isIncoming = direction === 'incoming';
|
const isIncoming = direction === 'incoming';
|
||||||
|
|
||||||
|
const [popupReaction, setPopupReaction] = useState('');
|
||||||
|
|
||||||
|
const handleMessageReaction = async (emoji: string) => {
|
||||||
|
await sendMessageReaction(messageId, emoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopupClick = () => {
|
||||||
|
dispatch(updateReactListModal({ reaction: popupReaction, messageId }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<StyledMessageContentContainer
|
||||||
|
direction={isIncoming ? 'left' : 'right'}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setPopupReaction('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames('module-message', `module-message--${direction}`)}
|
className={classNames('module-message', `module-message--${direction}`)}
|
||||||
role="button"
|
role="button"
|
||||||
onClick={onClickOnMessageOuterContainer}
|
onClick={onClickOnMessageOuterContainer}
|
||||||
onDoubleClickCapture={onDoubleClickReplyToMessage}
|
onDoubleClickCapture={onDoubleClickReplyToMessage}
|
||||||
style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }}
|
style={{
|
||||||
|
width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto',
|
||||||
|
}}
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
>
|
>
|
||||||
<MessageStatus
|
<MessageStatus
|
||||||
|
@ -94,7 +129,23 @@ export const MessageContentWithStatuses = (props: Props) => {
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
isCorrectSide={!isIncoming}
|
isCorrectSide={!isIncoming}
|
||||||
/>
|
/>
|
||||||
{!isDeleted && <MessageContextMenu messageId={messageId} contextMenuId={ctxMenuID} />}
|
{!isDeleted && (
|
||||||
|
<MessageContextMenu
|
||||||
|
messageId={messageId}
|
||||||
|
contextMenuId={ctxMenuID}
|
||||||
|
enableReactions={enableReactions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{enableReactions && (
|
||||||
|
<MessageReactions
|
||||||
|
messageId={messageId}
|
||||||
|
onClick={handleMessageReaction}
|
||||||
|
popupReaction={popupReaction}
|
||||||
|
setPopupReaction={setPopupReaction}
|
||||||
|
onPopupClick={handlePopupClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledMessageContentContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { animation, Item, Menu } from 'react-contexify';
|
import { animation, Item, Menu, useContextMenu } from 'react-contexify';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useClickAway, useMouse } from 'react-use';
|
||||||
|
import styled from 'styled-components';
|
||||||
import { Data } from '../../../../data/data';
|
import { Data } from '../../../../data/data';
|
||||||
import { MessageInteraction } from '../../../../interactions';
|
import { MessageInteraction } from '../../../../interactions';
|
||||||
import { replyToMessage } from '../../../../interactions/conversationInteractions';
|
import { replyToMessage } from '../../../../interactions/conversationInteractions';
|
||||||
|
@ -20,8 +22,12 @@ import {
|
||||||
showMessageDetailsView,
|
showMessageDetailsView,
|
||||||
toggleSelectedMessageId,
|
toggleSelectedMessageId,
|
||||||
} from '../../../../state/ducks/conversations';
|
} from '../../../../state/ducks/conversations';
|
||||||
|
import { StateType } from '../../../../state/reducer';
|
||||||
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
|
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
|
||||||
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
|
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
|
||||||
|
import { sendMessageReaction } from '../../../../util/reactions';
|
||||||
|
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
|
||||||
|
import { MessageReactBar } from './MessageReactBar';
|
||||||
|
|
||||||
export type MessageContextMenuSelectorProps = Pick<
|
export type MessageContextMenuSelectorProps = Pick<
|
||||||
MessageRenderingProps,
|
MessageRenderingProps,
|
||||||
|
@ -42,16 +48,43 @@ export type MessageContextMenuSelectorProps = Pick<
|
||||||
| 'isDeletableForEveryone'
|
| 'isDeletableForEveryone'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type Props = { messageId: string; contextMenuId: string };
|
type Props = { messageId: string; contextMenuId: string; enableReactions: boolean };
|
||||||
|
|
||||||
|
const StyledMessageContextMenu = styled.div`
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.react-contexify {
|
||||||
|
margin-left: -104px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledEmojiPanelContainer = styled.div<{ x: number; y: number }>`
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 101;
|
||||||
|
|
||||||
|
${StyledEmojiPanel} {
|
||||||
|
position: absolute;
|
||||||
|
left: ${props => `${props.x}px`};
|
||||||
|
top: ${props => `${props.y}px`};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// tslint:disable: max-func-body-length cyclomatic-complexity
|
// tslint:disable: max-func-body-length cyclomatic-complexity
|
||||||
export const MessageContextMenu = (props: Props) => {
|
export const MessageContextMenu = (props: Props) => {
|
||||||
const selected = useSelector(state => getMessageContextMenuProps(state as any, props.messageId));
|
const { messageId, contextMenuId, enableReactions } = props;
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { hideAll } = useContextMenu();
|
||||||
|
|
||||||
|
const selected = useSelector((state: StateType) => getMessageContextMenuProps(state, messageId));
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attachments,
|
attachments,
|
||||||
sender,
|
sender,
|
||||||
|
@ -68,14 +101,28 @@ export const MessageContextMenu = (props: Props) => {
|
||||||
timestamp,
|
timestamp,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
} = selected;
|
} = selected;
|
||||||
const { messageId, contextMenuId } = props;
|
|
||||||
const isOutgoing = direction === 'outgoing';
|
const isOutgoing = direction === 'outgoing';
|
||||||
const showRetry = status === 'error' && isOutgoing;
|
const showRetry = status === 'error' && isOutgoing;
|
||||||
const isSent = status === 'sent' || status === 'read'; // a read message should be replyable
|
const isSent = status === 'sent' || status === 'read'; // a read message should be replyable
|
||||||
|
|
||||||
const onContextMenuShown = useCallback(() => {
|
const emojiPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||||
|
// emoji-mart v5.1 default dimensions
|
||||||
|
const emojiPanelWidth = 354;
|
||||||
|
const emojiPanelHeight = 435;
|
||||||
|
|
||||||
|
const contextMenuRef = useRef(null);
|
||||||
|
const { docX, docY } = useMouse(contextMenuRef);
|
||||||
|
const [mouseX, setMouseX] = useState(0);
|
||||||
|
const [mouseY, setMouseY] = useState(0);
|
||||||
|
|
||||||
|
const onContextMenuShown = () => {
|
||||||
|
if (showEmojiPanel) {
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
}
|
||||||
window.contextMenuShown = true;
|
window.contextMenuShown = true;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onContextMenuHidden = useCallback(() => {
|
const onContextMenuHidden = useCallback(() => {
|
||||||
// This function will called before the click event
|
// This function will called before the click event
|
||||||
|
@ -174,13 +221,86 @@ export const MessageContextMenu = (props: Props) => {
|
||||||
void deleteMessagesByIdForEveryone([messageId], convoId);
|
void deleteMessagesByIdForEveryone([messageId], convoId);
|
||||||
}, [convoId, messageId]);
|
}, [convoId, messageId]);
|
||||||
|
|
||||||
|
const onShowEmoji = () => {
|
||||||
|
hideAll();
|
||||||
|
setMouseX(docX);
|
||||||
|
setMouseY(docY);
|
||||||
|
setShowEmojiPanel(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCloseEmoji = () => {
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
hideAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEmojiLoseFocus = () => {
|
||||||
|
window.log.info('closed due to lost focus');
|
||||||
|
onCloseEmoji();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEmojiClick = async (args: any) => {
|
||||||
|
const emoji = args.native ?? args;
|
||||||
|
onCloseEmoji();
|
||||||
|
await sendMessageReaction(messageId, emoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEmojiKeyDown = (event: any) => {
|
||||||
|
if (event.key === 'Escape' && showEmojiPanel) {
|
||||||
|
onCloseEmoji();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useClickAway(emojiPanelRef, () => {
|
||||||
|
onEmojiLoseFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emojiPanelRef.current && emojiPanelRef.current) {
|
||||||
|
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
|
||||||
|
|
||||||
|
if (mouseX + emojiPanelWidth > windowWidth) {
|
||||||
|
let x = mouseX;
|
||||||
|
x = (mouseX + emojiPanelWidth - windowWidth) * 2;
|
||||||
|
|
||||||
|
if (x === mouseX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMouseX(mouseX - x);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mouseY + emojiPanelHeight > windowHeight) {
|
||||||
|
const y = mouseY + emojiPanelHeight * 1.25 - windowHeight;
|
||||||
|
|
||||||
|
if (y === mouseY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMouseY(mouseY - y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [emojiPanelRef.current, emojiPanelWidth, emojiPanelHeight, mouseX, mouseY]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<StyledMessageContextMenu ref={contextMenuRef}>
|
||||||
|
{enableReactions && showEmojiPanel && (
|
||||||
|
<StyledEmojiPanelContainer role="button" x={mouseX} y={mouseY}>
|
||||||
|
<SessionEmojiPanel
|
||||||
|
ref={emojiPanelRef}
|
||||||
|
onEmojiClicked={onEmojiClick}
|
||||||
|
show={showEmojiPanel}
|
||||||
|
isModal={true}
|
||||||
|
onKeyDown={onEmojiKeyDown}
|
||||||
|
/>
|
||||||
|
</StyledEmojiPanelContainer>
|
||||||
|
)}
|
||||||
<Menu
|
<Menu
|
||||||
id={contextMenuId}
|
id={contextMenuId}
|
||||||
onShown={onContextMenuShown}
|
onShown={onContextMenuShown}
|
||||||
onHidden={onContextMenuHidden}
|
onHidden={onContextMenuHidden}
|
||||||
animation={animation.fade}
|
animation={animation.fade}
|
||||||
>
|
>
|
||||||
|
{enableReactions && (
|
||||||
|
<MessageReactBar action={onEmojiClick} additionalAction={onShowEmoji} />
|
||||||
|
)}
|
||||||
{attachments?.length ? (
|
{attachments?.length ? (
|
||||||
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
|
<Item onClick={saveAttachment}>{window.i18n('downloadAttachment')}</Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -215,5 +335,6 @@ export const MessageContextMenu = (props: Props) => {
|
||||||
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
|
<Item onClick={removeModerator}>{window.i18n('removeFromModerators')}</Item>
|
||||||
) : null}
|
) : null}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</StyledMessageContextMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { getRecentReactions } from '../../../../util/storage';
|
||||||
|
import { SessionIconButton } from '../../../icon';
|
||||||
|
import { nativeEmojiData } from '../../../../util/emoji';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import { RecentReactions } from '../../../../types/Reaction';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
action: (...args: Array<any>) => void;
|
||||||
|
additionalAction: (...args: Array<any>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledMessageReactBar = styled.div`
|
||||||
|
background-color: var(--color-received-message-background);
|
||||||
|
border-radius: 25px;
|
||||||
|
box-shadow: 0 2px 16px 0 rgba(0, 0, 0, 0.2), 0 0px 20px 0 rgba(0, 0, 0, 0.19);
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: -56px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 302px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.session-icon-button {
|
||||||
|
border-color: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ReactButton = styled.span`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
border-radius: 300px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
:hover {
|
||||||
|
background-color: var(--color-compose-view-button-background);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MessageReactBar = (props: Props): ReactElement => {
|
||||||
|
const { action, additionalAction } = props;
|
||||||
|
const [recentReactions, setRecentReactions] = useState<RecentReactions>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reactions = new RecentReactions(getRecentReactions());
|
||||||
|
if (reactions && !isEqual(reactions, recentReactions)) {
|
||||||
|
setRecentReactions(reactions);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!recentReactions) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMessageReactBar>
|
||||||
|
{recentReactions &&
|
||||||
|
recentReactions.items.map(emoji => (
|
||||||
|
<ReactButton
|
||||||
|
key={emoji}
|
||||||
|
role={'img'}
|
||||||
|
aria-label={nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
action(emoji);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</ReactButton>
|
||||||
|
))}
|
||||||
|
<SessionIconButton
|
||||||
|
iconColor={'var(--color-text)'}
|
||||||
|
iconPadding={'12px'}
|
||||||
|
iconSize={'huge2'}
|
||||||
|
iconType="plusThin"
|
||||||
|
backgroundColor={'var(--color-compose-view-button-background)'}
|
||||||
|
borderRadius="300px"
|
||||||
|
onClick={additionalAction}
|
||||||
|
/>
|
||||||
|
</StyledMessageReactBar>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { MessageRenderingProps } from '../../../../models/messageType';
|
||||||
|
import { isEmpty, isEqual } from 'lodash';
|
||||||
|
import { SortedReactionList } from '../../../../types/Reaction';
|
||||||
|
import { StyledPopupContainer } from '../reactions/ReactionPopup';
|
||||||
|
import { Flex } from '../../../basic/Flex';
|
||||||
|
import { nativeEmojiData } from '../../../../util/emoji';
|
||||||
|
import { Reaction, ReactionProps } from '../reactions/Reaction';
|
||||||
|
import { SessionIcon } from '../../../icon';
|
||||||
|
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
|
||||||
|
|
||||||
|
export const popupXDefault = -101;
|
||||||
|
export const popupYDefault = -90;
|
||||||
|
|
||||||
|
const StyledMessageReactionsContainer = styled(Flex)<{ x: number; y: number }>`
|
||||||
|
${StyledPopupContainer} {
|
||||||
|
position: absolute;
|
||||||
|
top: ${props => `${props.y}px;`};
|
||||||
|
left: ${props => `${props.x}px;`};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const StyledMessageReactions = styled(Flex)<{ inModal: boolean }>`
|
||||||
|
${props => (props.inModal ? '' : 'max-width: 320px;')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledReactionOverflow = styled.button`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-bottom: var(--margins-sm);
|
||||||
|
|
||||||
|
span {
|
||||||
|
background-color: var(--color-received-message-background);
|
||||||
|
border: 1px solid var(--color-inbox-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: -9px;
|
||||||
|
padding: 1px 4.5px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledReadLess = styled.span`
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
margin-top: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type ReactionsProps = Omit<ReactionProps, 'emoji'>;
|
||||||
|
|
||||||
|
const Reactions = (props: ReactionsProps): ReactElement => {
|
||||||
|
const { messageId, reactions, inModal } = props;
|
||||||
|
return (
|
||||||
|
<StyledMessageReactions
|
||||||
|
container={true}
|
||||||
|
flexWrap={inModal ? 'nowrap' : 'wrap'}
|
||||||
|
alignItems={'center'}
|
||||||
|
inModal={inModal}
|
||||||
|
>
|
||||||
|
{reactions.map(([emoji, _]) => (
|
||||||
|
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
|
||||||
|
))}
|
||||||
|
</StyledMessageReactions>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExpandReactionsProps extends ReactionsProps {
|
||||||
|
handleExpand: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompressedReactions = (props: ExpandReactionsProps): ReactElement => {
|
||||||
|
const { messageId, reactions, inModal, handleExpand } = props;
|
||||||
|
return (
|
||||||
|
<StyledMessageReactions
|
||||||
|
container={true}
|
||||||
|
flexWrap={inModal ? 'nowrap' : 'wrap'}
|
||||||
|
alignItems={'center'}
|
||||||
|
inModal={inModal}
|
||||||
|
>
|
||||||
|
{reactions.slice(0, 4).map(([emoji, _]) => (
|
||||||
|
<Reaction key={`${messageId}-${emoji}`} emoji={emoji} {...props} />
|
||||||
|
))}
|
||||||
|
<StyledReactionOverflow onClick={handleExpand}>
|
||||||
|
{reactions
|
||||||
|
.slice(4, 7)
|
||||||
|
.reverse()
|
||||||
|
.map(([emoji, _]) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${messageId}-${emoji}`}
|
||||||
|
role={'img'}
|
||||||
|
aria-label={
|
||||||
|
nativeEmojiData?.ariaLabels ? nativeEmojiData.ariaLabels[emoji] : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</StyledReactionOverflow>
|
||||||
|
</StyledMessageReactions>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpandedReactions = (props: ExpandReactionsProps): ReactElement => {
|
||||||
|
const { handleExpand } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Reactions {...props} />
|
||||||
|
<StyledReadLess onClick={handleExpand}>
|
||||||
|
<SessionIcon iconType="chevron" iconSize="medium" iconRotation={180} />
|
||||||
|
{window.i18n('expandedReactionsText')}
|
||||||
|
</StyledReadLess>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageReactsSelectorProps = Pick<
|
||||||
|
MessageRenderingProps,
|
||||||
|
'convoId' | 'conversationType' | 'isPublic' | 'serverId' | 'reacts' | 'sortedReacts'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
messageId: string;
|
||||||
|
hasReactLimit?: boolean;
|
||||||
|
onClick: (emoji: string) => void;
|
||||||
|
popupReaction?: string;
|
||||||
|
setPopupReaction?: (emoji: string) => void;
|
||||||
|
onPopupClick?: () => void;
|
||||||
|
inModal?: boolean;
|
||||||
|
onSelected?: (emoji: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageReactions = (props: Props): ReactElement => {
|
||||||
|
const {
|
||||||
|
messageId,
|
||||||
|
hasReactLimit = true,
|
||||||
|
onClick,
|
||||||
|
popupReaction,
|
||||||
|
setPopupReaction,
|
||||||
|
onPopupClick,
|
||||||
|
inModal = false,
|
||||||
|
onSelected,
|
||||||
|
} = props;
|
||||||
|
const [reactions, setReactions] = useState<SortedReactionList>([]);
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const handleExpand = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [popupX, setPopupX] = useState(popupXDefault);
|
||||||
|
const [popupY, setPopupY] = useState(popupYDefault);
|
||||||
|
|
||||||
|
const msgProps = useMessageReactsPropsById(messageId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (msgProps?.sortedReacts && !isEqual(reactions, msgProps?.sortedReacts)) {
|
||||||
|
setReactions(msgProps?.sortedReacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmpty(reactions) && isEmpty(msgProps?.sortedReacts)) {
|
||||||
|
setReactions([]);
|
||||||
|
}
|
||||||
|
}, [msgProps?.sortedReacts, reactions]);
|
||||||
|
|
||||||
|
if (!msgProps) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { conversationType, sortedReacts } = msgProps;
|
||||||
|
const inGroup = conversationType === 'group';
|
||||||
|
|
||||||
|
const reactLimit = 6;
|
||||||
|
|
||||||
|
const reactionsProps = {
|
||||||
|
messageId,
|
||||||
|
reactions,
|
||||||
|
inModal,
|
||||||
|
inGroup,
|
||||||
|
handlePopupX: setPopupX,
|
||||||
|
handlePopupY: setPopupY,
|
||||||
|
onClick,
|
||||||
|
popupReaction,
|
||||||
|
onSelected,
|
||||||
|
handlePopupReaction: setPopupReaction,
|
||||||
|
handlePopupClick: onPopupClick,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExtendedReactions = isExpanded ? ExpandedReactions : CompressedReactions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMessageReactionsContainer
|
||||||
|
container={true}
|
||||||
|
flexDirection={'column'}
|
||||||
|
justifyContent={'center'}
|
||||||
|
alignItems={inModal ? 'flex-start' : 'center'}
|
||||||
|
x={popupX}
|
||||||
|
y={popupY}
|
||||||
|
>
|
||||||
|
{sortedReacts &&
|
||||||
|
sortedReacts !== [] &&
|
||||||
|
(!hasReactLimit || sortedReacts.length <= reactLimit ? (
|
||||||
|
<Reactions {...reactionsProps} />
|
||||||
|
) : (
|
||||||
|
<ExtendedReactions handleExpand={handleExpand} {...reactionsProps} />
|
||||||
|
))}
|
||||||
|
</StyledMessageReactionsContainer>
|
||||||
|
);
|
||||||
|
};
|
|
@ -19,6 +19,7 @@ import { ExpireTimer } from '../../ExpireTimer';
|
||||||
import { MessageAvatar } from '../message-content/MessageAvatar';
|
import { MessageAvatar } from '../message-content/MessageAvatar';
|
||||||
import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus';
|
import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus';
|
||||||
import { ReadableMessage } from './ReadableMessage';
|
import { ReadableMessage } from './ReadableMessage';
|
||||||
|
import styled, { keyframes } from 'styled-components';
|
||||||
|
|
||||||
export type GenericReadableMessageSelectorProps = Pick<
|
export type GenericReadableMessageSelectorProps = Pick<
|
||||||
MessageRenderingProps,
|
MessageRenderingProps,
|
||||||
|
@ -99,7 +100,50 @@ type Props = {
|
||||||
};
|
};
|
||||||
// tslint:disable: use-simple-attributes
|
// tslint:disable: use-simple-attributes
|
||||||
|
|
||||||
|
const highlightedMessageAnimation = keyframes`
|
||||||
|
1% {
|
||||||
|
background-color: #00f782;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledReadableMessage = styled(ReadableMessage)<{
|
||||||
|
selected: boolean;
|
||||||
|
isRightClicked: boolean;
|
||||||
|
}>`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 5px var(--margins-lg) 0;
|
||||||
|
|
||||||
|
&.message-highlighted {
|
||||||
|
animation: ${highlightedMessageAnimation} 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
${props =>
|
||||||
|
props.isRightClicked &&
|
||||||
|
`
|
||||||
|
background-color: var(--color-compose-view-button-background);
|
||||||
|
`}
|
||||||
|
|
||||||
|
${props =>
|
||||||
|
props.selected &&
|
||||||
|
`
|
||||||
|
&.message-selected {
|
||||||
|
.module-message {
|
||||||
|
&__container {
|
||||||
|
box-shadow: var(--color-session-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
export const GenericReadableMessage = (props: Props) => {
|
export const GenericReadableMessage = (props: Props) => {
|
||||||
|
const { ctxMenuID, messageId, isDetailView } = props;
|
||||||
|
|
||||||
|
const [enableReactions, setEnableReactions] = useState(true);
|
||||||
|
|
||||||
const msgProps = useSelector(state =>
|
const msgProps = useSelector(state =>
|
||||||
getGenericReadableMessageSelectorProps(state as any, props.messageId)
|
getGenericReadableMessageSelectorProps(state as any, props.messageId)
|
||||||
);
|
);
|
||||||
|
@ -118,6 +162,13 @@ export const GenericReadableMessage = (props: Props) => {
|
||||||
);
|
);
|
||||||
const multiSelectMode = useSelector(isMessageSelectionMode);
|
const multiSelectMode = useSelector(isMessageSelectionMode);
|
||||||
|
|
||||||
|
const [isRightClicked, setIsRightClicked] = useState(false);
|
||||||
|
const onMessageLoseFocus = useCallback(() => {
|
||||||
|
if (isRightClicked) {
|
||||||
|
setIsRightClicked(false);
|
||||||
|
}
|
||||||
|
}, [isRightClicked]);
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
const handleContextMenu = useCallback(
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
(e: React.MouseEvent<HTMLElement>) => {
|
||||||
const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup;
|
const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup;
|
||||||
|
@ -125,15 +176,31 @@ export const GenericReadableMessage = (props: Props) => {
|
||||||
if (enableContextMenu) {
|
if (enableContextMenu) {
|
||||||
contextMenu.hideAll();
|
contextMenu.hideAll();
|
||||||
contextMenu.show({
|
contextMenu.show({
|
||||||
id: props.ctxMenuID,
|
id: ctxMenuID,
|
||||||
event: e,
|
event: e,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setIsRightClicked(enableContextMenu);
|
||||||
},
|
},
|
||||||
[props.ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup]
|
[ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { messageId, isDetailView } = props;
|
useEffect(() => {
|
||||||
|
if (msgProps?.convoId) {
|
||||||
|
const conversationModel = getConversationController().get(msgProps?.convoId);
|
||||||
|
if (conversationModel) {
|
||||||
|
setEnableReactions(conversationModel.hasReactions());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [msgProps?.convoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', onMessageLoseFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', onMessageLoseFocus);
|
||||||
|
};
|
||||||
|
}, [onMessageLoseFocus]);
|
||||||
|
|
||||||
if (!msgProps) {
|
if (!msgProps) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -156,10 +223,11 @@ export const GenericReadableMessage = (props: Props) => {
|
||||||
const isIncoming = direction === 'incoming';
|
const isIncoming = direction === 'incoming';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReadableMessage
|
<StyledReadableMessage
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
|
selected={selected}
|
||||||
|
isRightClicked={isRightClicked}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'session-message-wrapper',
|
|
||||||
selected && 'message-selected',
|
selected && 'message-selected',
|
||||||
isGroup && 'public-chat-message-wrapper',
|
isGroup && 'public-chat-message-wrapper',
|
||||||
isIncoming ? 'session-message-wrapper-incoming' : 'session-message-wrapper-outgoing'
|
isIncoming ? 'session-message-wrapper-incoming' : 'session-message-wrapper-outgoing'
|
||||||
|
@ -178,10 +246,11 @@ export const GenericReadableMessage = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageContentWithStatuses
|
<MessageContentWithStatuses
|
||||||
ctxMenuID={props.ctxMenuID}
|
ctxMenuID={ctxMenuID}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
isDetailView={isDetailView}
|
isDetailView={isDetailView}
|
||||||
dataTestId={`message-content-${messageId}`}
|
dataTestId={`message-content-${messageId}`}
|
||||||
|
enableReactions={enableReactions}
|
||||||
/>
|
/>
|
||||||
{expirationLength && expirationTimestamp && (
|
{expirationLength && expirationTimestamp && (
|
||||||
<ExpireTimer
|
<ExpireTimer
|
||||||
|
@ -190,6 +259,6 @@ export const GenericReadableMessage = (props: Props) => {
|
||||||
expirationTimestamp={expirationTimestamp}
|
expirationTimestamp={expirationTimestamp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ReadableMessage>
|
</StyledReadableMessage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
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,
|
getEditProfileDialog,
|
||||||
getInviteContactModal,
|
getInviteContactModal,
|
||||||
getOnionPathDialog,
|
getOnionPathDialog,
|
||||||
|
getReactClearAllDialog,
|
||||||
|
getReactListDialog,
|
||||||
getRecoveryPhraseDialog,
|
getRecoveryPhraseDialog,
|
||||||
getRemoveModeratorsModal,
|
getRemoveModeratorsModal,
|
||||||
getSessionPasswordDialog,
|
getSessionPasswordDialog,
|
||||||
|
@ -32,6 +34,8 @@ import { UpdateGroupMembersDialog } from './UpdateGroupMembersDialog';
|
||||||
import { UpdateGroupNameDialog } from './UpdateGroupNameDialog';
|
import { UpdateGroupNameDialog } from './UpdateGroupNameDialog';
|
||||||
import { SessionNicknameDialog } from './SessionNicknameDialog';
|
import { SessionNicknameDialog } from './SessionNicknameDialog';
|
||||||
import { BanOrUnBanUserDialog } from './BanOrUnbanUserDialog';
|
import { BanOrUnBanUserDialog } from './BanOrUnbanUserDialog';
|
||||||
|
import { ReactListModal } from './ReactListModal';
|
||||||
|
import { ReactClearAllModal } from './ReactClearAllModal';
|
||||||
|
|
||||||
export const ModalContainer = () => {
|
export const ModalContainer = () => {
|
||||||
const confirmModalState = useSelector(getConfirmModal);
|
const confirmModalState = useSelector(getConfirmModal);
|
||||||
|
@ -49,6 +53,8 @@ export const ModalContainer = () => {
|
||||||
const sessionPasswordModalState = useSelector(getSessionPasswordDialog);
|
const sessionPasswordModalState = useSelector(getSessionPasswordDialog);
|
||||||
const deleteAccountModalState = useSelector(getDeleteAccountModalState);
|
const deleteAccountModalState = useSelector(getDeleteAccountModalState);
|
||||||
const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState);
|
const banOrUnbanUserModalState = useSelector(getBanOrUnbanUserModalState);
|
||||||
|
const reactListModalState = useSelector(getReactListDialog);
|
||||||
|
const reactClearAllModalState = useSelector(getReactClearAllDialog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -71,6 +77,8 @@ export const ModalContainer = () => {
|
||||||
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
|
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
|
||||||
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
|
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
|
||||||
{confirmModalState && <SessionConfirm {...confirmModalState} />}
|
{confirmModalState && <SessionConfirm {...confirmModalState} />}
|
||||||
|
{reactListModalState && <ReactListModal {...reactListModalState} />}
|
||||||
|
{reactClearAllModalState && <ReactClearAllModal {...reactClearAllModalState} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
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,
|
getMessageIdsFromServerIds,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
getMessageBySenderAndSentAt,
|
getMessageBySenderAndSentAt,
|
||||||
|
getMessageByServerId,
|
||||||
filterAlreadyFetchedOpengroupMessage,
|
filterAlreadyFetchedOpengroupMessage,
|
||||||
getMessageBySenderAndTimestamp,
|
getMessageBySenderAndTimestamp,
|
||||||
getUnreadByConversation,
|
getUnreadByConversation,
|
||||||
|
@ -433,6 +434,21 @@ async function getMessageBySenderAndSentAt({
|
||||||
return new MessageModel(messages[0]);
|
return new MessageModel(messages[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMessageByServerId(
|
||||||
|
serverId: number,
|
||||||
|
skipTimerInit: boolean = false
|
||||||
|
): Promise<MessageModel | null> {
|
||||||
|
const message = await channels.getMessageByServerId(serverId);
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (skipTimerInit) {
|
||||||
|
message.skipTimerInit = skipTimerInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MessageModel(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function filterAlreadyFetchedOpengroupMessage(
|
async function filterAlreadyFetchedOpengroupMessage(
|
||||||
msgDetails: MsgDuplicateSearchOpenGroup
|
msgDetails: MsgDuplicateSearchOpenGroup
|
||||||
): Promise<MsgDuplicateSearchOpenGroup> {
|
): Promise<MsgDuplicateSearchOpenGroup> {
|
||||||
|
|
|
@ -50,6 +50,7 @@ const channelsToMake = new Set([
|
||||||
'getMessageIdsFromServerIds',
|
'getMessageIdsFromServerIds',
|
||||||
'getMessageById',
|
'getMessageById',
|
||||||
'getMessagesBySentAt',
|
'getMessagesBySentAt',
|
||||||
|
'getMessageByServerId',
|
||||||
'getExpiredMessages',
|
'getExpiredMessages',
|
||||||
'getOutgoingWithoutExpiresAt',
|
'getOutgoingWithoutExpiresAt',
|
||||||
'getNextExpiringMessage',
|
'getNextExpiringMessage',
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { ConversationModel } from '../models/conversation';
|
||||||
import { PubKey } from '../session/types';
|
import { PubKey } from '../session/types';
|
||||||
import { UserUtils } from '../session/utils';
|
import { UserUtils } from '../session/utils';
|
||||||
import { StateType } from '../state/reducer';
|
import { StateType } from '../state/reducer';
|
||||||
|
import { getMessageReactsProps } from '../state/selectors/conversations';
|
||||||
|
|
||||||
export function useAvatarPath(convoId: string | undefined) {
|
export function useAvatarPath(convoId: string | undefined) {
|
||||||
const convoProps = useConversationPropsById(convoId);
|
const convoProps = useConversationPropsById(convoId);
|
||||||
|
@ -169,3 +170,16 @@ export function useConversationPropsById(convoId?: string) {
|
||||||
return convo;
|
return convo;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMessageReactsPropsById(messageId?: string) {
|
||||||
|
return useSelector((state: StateType) => {
|
||||||
|
if (!messageId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const messageReactsProps = getMessageReactsProps(state, messageId);
|
||||||
|
if (!messageReactsProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return messageReactsProps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,9 @@ import ReactDOM from 'react-dom';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OpenGroupData } from '../data/opengroups';
|
import { OpenGroupData } from '../data/opengroups';
|
||||||
import { loadKnownBlindedKeys } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
import { loadKnownBlindedKeys } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||||
|
import nativeEmojiData from '@emoji-mart/data';
|
||||||
|
import { initialiseEmojiData } from '../util/emoji';
|
||||||
|
import { loadEmojiPanelI18n } from '../util/i18n';
|
||||||
// tslint:disable: max-classes-per-file
|
// tslint:disable: max-classes-per-file
|
||||||
|
|
||||||
// Globally disable drag and drop
|
// Globally disable drag and drop
|
||||||
|
@ -169,6 +172,7 @@ Storage.onready(async () => {
|
||||||
window.Events.setThemeSetting(newThemeSetting);
|
window.Events.setThemeSetting(newThemeSetting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
initialiseEmojiData(nativeEmojiData);
|
||||||
await AttachmentDownloads.initAttachmentPaths();
|
await AttachmentDownloads.initAttachmentPaths();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -176,6 +180,7 @@ Storage.onready(async () => {
|
||||||
BlockedNumberController.load(),
|
BlockedNumberController.load(),
|
||||||
OpenGroupData.opengroupRoomsLoad(),
|
OpenGroupData.opengroupRoomsLoad(),
|
||||||
loadKnownBlindedKeys(),
|
loadKnownBlindedKeys(),
|
||||||
|
loadEmojiPanelI18n(),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
|
|
|
@ -78,18 +78,23 @@ import {
|
||||||
ConversationTypeEnum,
|
ConversationTypeEnum,
|
||||||
fillConvoAttributesWithDefaults,
|
fillConvoAttributesWithDefaults,
|
||||||
} from './conversationAttributes';
|
} from './conversationAttributes';
|
||||||
|
|
||||||
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
|
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
|
||||||
import { from_hex } from 'libsodium-wrappers-sumo';
|
import { from_hex } from 'libsodium-wrappers-sumo';
|
||||||
import { OpenGroupData } from '../data/opengroups';
|
import { OpenGroupData } from '../data/opengroups';
|
||||||
import { roomHasBlindEnabled } from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
|
import {
|
||||||
|
roomHasBlindEnabled,
|
||||||
|
roomHasReactionsEnabled,
|
||||||
|
} from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
|
||||||
import { addMessagePadding } from '../session/crypto/BufferPadding';
|
import { addMessagePadding } from '../session/crypto/BufferPadding';
|
||||||
import { getSodiumRenderer } from '../session/crypto';
|
import { getSodiumRenderer } from '../session/crypto';
|
||||||
import {
|
import {
|
||||||
findCachedOurBlindedPubkeyOrLookItUp,
|
findCachedOurBlindedPubkeyOrLookItUp,
|
||||||
|
getUsBlindedInThatServer,
|
||||||
isUsAnySogsFromCache,
|
isUsAnySogsFromCache,
|
||||||
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||||
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
|
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
|
||||||
|
import { Reaction } from '../types/Reaction';
|
||||||
|
import { handleMessageReaction } from '../util/reactions';
|
||||||
|
|
||||||
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
public updateLastMessage: () => any;
|
public updateLastMessage: () => any;
|
||||||
|
@ -635,6 +640,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
await this.sendBlindedMessageRequest(chatMessageParams);
|
await this.sendBlindedMessageRequest(chatMessageParams);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldApprove) {
|
if (shouldApprove) {
|
||||||
await this.setIsApproved(true);
|
await this.setIsApproved(true);
|
||||||
if (hasIncomingMessages) {
|
if (hasIncomingMessages) {
|
||||||
|
@ -667,6 +673,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const destinationPubkey = new PubKey(destination);
|
const destinationPubkey = new PubKey(destination);
|
||||||
|
|
||||||
if (this.isPrivate()) {
|
if (this.isPrivate()) {
|
||||||
if (this.isMe()) {
|
if (this.isMe()) {
|
||||||
chatMessageParams.syncTarget = this.id;
|
chatMessageParams.syncTarget = this.id;
|
||||||
|
@ -675,7 +682,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
await getMessageQueue().sendSyncMessage(chatMessageMe);
|
await getMessageQueue().sendSyncMessage(chatMessageMe);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Handle Group Invitation Message
|
|
||||||
if (message.get('groupInvitation')) {
|
if (message.get('groupInvitation')) {
|
||||||
const groupInvitation = message.get('groupInvitation');
|
const groupInvitation = message.get('groupInvitation');
|
||||||
const groupInvitMessage = new GroupInvitationMessage({
|
const groupInvitMessage = new GroupInvitationMessage({
|
||||||
|
@ -718,6 +725,120 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sendReactionJob(sourceMessage: MessageModel, reaction: Reaction) {
|
||||||
|
try {
|
||||||
|
const destination = this.id;
|
||||||
|
|
||||||
|
const sentAt = sourceMessage.get('sent_at');
|
||||||
|
if (!sentAt) {
|
||||||
|
throw new Error('sendReactMessageJob() sent_at must be set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPublic() && !this.isOpenGroupV2()) {
|
||||||
|
throw new Error('Only opengroupv2 are supported now');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = UserUtils.getOurPubKeyStrFromCache();
|
||||||
|
|
||||||
|
// an OpenGroupV2 message is just a visible message
|
||||||
|
const chatMessageParams: VisibleMessageParams = {
|
||||||
|
body: '',
|
||||||
|
timestamp: sentAt,
|
||||||
|
reaction,
|
||||||
|
lokiProfile: UserUtils.getOurProfile(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldApprove = !this.isApproved() && this.isPrivate();
|
||||||
|
const incomingMessageCount = await Data.getMessageCountByType(
|
||||||
|
this.id,
|
||||||
|
MessageDirection.incoming
|
||||||
|
);
|
||||||
|
const hasIncomingMessages = incomingMessageCount > 0;
|
||||||
|
|
||||||
|
if (this.id.startsWith('15')) {
|
||||||
|
window.log.info('Sending a blinded message to this user: ', this.id);
|
||||||
|
// TODO confirm this works with Reacts
|
||||||
|
await this.sendBlindedMessageRequest(chatMessageParams);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldApprove) {
|
||||||
|
await this.setIsApproved(true);
|
||||||
|
if (hasIncomingMessages) {
|
||||||
|
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
|
||||||
|
await this.addOutgoingApprovalMessage(Date.now());
|
||||||
|
if (!this.didApproveMe()) {
|
||||||
|
await this.setDidApproveMe(true);
|
||||||
|
}
|
||||||
|
// should only send once
|
||||||
|
await this.sendMessageRequestResponse();
|
||||||
|
void forceSyncConfigurationNowIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isOpenGroupV2()) {
|
||||||
|
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
|
||||||
|
const roomInfos = this.toOpenGroupV2();
|
||||||
|
if (!roomInfos) {
|
||||||
|
throw new Error('Could not find this room in db');
|
||||||
|
}
|
||||||
|
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
|
||||||
|
const blinded = Boolean(roomHasBlindEnabled(openGroup));
|
||||||
|
|
||||||
|
if (blinded) {
|
||||||
|
const blindedSender = getUsBlindedInThatServer(this);
|
||||||
|
if (blindedSender) {
|
||||||
|
sender = blindedSender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMessageReaction(reaction, sender, true);
|
||||||
|
|
||||||
|
// send with blinding if we need to
|
||||||
|
await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos, blinded, []);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await handleMessageReaction(reaction, sender, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationPubkey = new PubKey(destination);
|
||||||
|
|
||||||
|
if (this.isPrivate()) {
|
||||||
|
// TODO is this still fine without isMe?
|
||||||
|
const chatMessageMe = new VisibleMessage({
|
||||||
|
...chatMessageParams,
|
||||||
|
syncTarget: this.id,
|
||||||
|
});
|
||||||
|
await getMessageQueue().sendSyncMessage(chatMessageMe);
|
||||||
|
|
||||||
|
const chatMessagePrivate = new VisibleMessage(chatMessageParams);
|
||||||
|
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMediumGroup()) {
|
||||||
|
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
|
||||||
|
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
|
||||||
|
chatMessage: chatMessageMediumGroup,
|
||||||
|
groupId: destination,
|
||||||
|
});
|
||||||
|
// we need the return await so that errors are caught in the catch {}
|
||||||
|
await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isClosedGroup()) {
|
||||||
|
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
|
||||||
|
} catch (e) {
|
||||||
|
window.log.error(`Reaction job failed id:${reaction.id} error:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Does this conversation contain the properties to be considered a message request
|
* Does this conversation contain the properties to be considered a message request
|
||||||
*/
|
*/
|
||||||
|
@ -908,6 +1029,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sendReaction(sourceId: string, reaction: Reaction) {
|
||||||
|
const sourceMessage = await Data.getMessageById(sourceId);
|
||||||
|
|
||||||
|
if (!sourceMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.queueJob(async () => {
|
||||||
|
await this.sendReactionJob(sourceMessage, reaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async bouncyUpdateLastMessage() {
|
public async bouncyUpdateLastMessage() {
|
||||||
if (!this.id || !this.get('active_at')) {
|
if (!this.id || !this.get('active_at')) {
|
||||||
return;
|
return;
|
||||||
|
@ -1309,27 +1442,27 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
|
|
||||||
public setSessionDisplayNameNoCommit(newDisplayName?: string | null) {
|
public setSessionDisplayNameNoCommit(newDisplayName?: string | null) {
|
||||||
const existingSessionName = this.getRealSessionUsername();
|
const existingSessionName = this.getRealSessionUsername();
|
||||||
if (newDisplayName && newDisplayName !== existingSessionName) {
|
if (newDisplayName !== existingSessionName && newDisplayName) {
|
||||||
this.set({ displayNameInProfile: newDisplayName });
|
this.set({ displayNameInProfile: newDisplayName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns `displayNameInProfile` - the real username as defined by that user/group
|
* @returns `displayNameInProfile` so the real username as defined by that user/group
|
||||||
*/
|
*/
|
||||||
public getRealSessionUsername(): string | undefined {
|
public getRealSessionUsername(): string | undefined {
|
||||||
return this.get('displayNameInProfile');
|
return this.get('displayNameInProfile');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns `nickname` - the nickname we forced for that user. For a group, this returns undefined
|
* @returns `nickname` so the nickname we forced for that user. For a group, this returns `undefined`
|
||||||
*/
|
*/
|
||||||
public getNickname(): string | undefined {
|
public getNickname(): string | undefined {
|
||||||
return this.isPrivate() ? this.get('nickname') : undefined;
|
return this.isPrivate() ? this.get('nickname') : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns `getNickname` - the nickname if a private convo and a nickname is set, or `getRealSessionUsername`
|
* @returns `getNickname` if a private convo and a nickname is set, or `getRealSessionUsername`
|
||||||
*/
|
*/
|
||||||
public getNicknameOrRealUsername(): string | undefined {
|
public getNicknameOrRealUsername(): string | undefined {
|
||||||
return this.getNickname() || this.getRealSessionUsername();
|
return this.getNickname() || this.getRealSessionUsername();
|
||||||
|
@ -1446,6 +1579,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
admins?: Array<string>;
|
admins?: Array<string>;
|
||||||
image_id?: number;
|
image_id?: number;
|
||||||
moderators?: Array<string>;
|
moderators?: Array<string>;
|
||||||
|
hidden_admins?: Array<string>;
|
||||||
|
hidden_moderators?: Array<string>;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
if (!infos || isEmpty(infos)) {
|
if (!infos || isEmpty(infos)) {
|
||||||
|
@ -1477,26 +1612,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
this.set('uploadCapability', Boolean(upload));
|
this.set('uploadCapability', Boolean(upload));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (details.admins && isArray(details.admins)) {
|
const adminChanged = await this.handleModsOrAdminsChanges({
|
||||||
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(details.admins);
|
modsOrAdmins: details.admins,
|
||||||
const adminChanged = await this.updateGroupAdmins(replacedWithOurRealSessionId, false);
|
hiddenModsOrAdmins: details.hidden_admins,
|
||||||
if (adminChanged) {
|
type: 'admins',
|
||||||
hasChange = adminChanged;
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.moderators && isArray(details.moderators)) {
|
hasChange = hasChange || adminChanged;
|
||||||
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
|
|
||||||
details.moderators
|
const modsChanged = await this.handleModsOrAdminsChanges({
|
||||||
);
|
modsOrAdmins: details.moderators,
|
||||||
const moderatorsChanged = await this.updateGroupModerators(
|
hiddenModsOrAdmins: details.hidden_moderators,
|
||||||
replacedWithOurRealSessionId,
|
type: 'mods',
|
||||||
false
|
});
|
||||||
);
|
|
||||||
if (moderatorsChanged) {
|
hasChange = hasChange || modsChanged;
|
||||||
hasChange = moderatorsChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) {
|
if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) {
|
||||||
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
|
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
|
||||||
|
@ -1537,6 +1667,21 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
public hasMember(pubkey: string) {
|
public hasMember(pubkey: string) {
|
||||||
return includes(this.get('members'), pubkey);
|
return includes(this.get('members'), pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasReactions() {
|
||||||
|
// message requests should not have reactions
|
||||||
|
if (this.isPrivate() && !this.isApproved()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// older open group conversations won't have reaction support
|
||||||
|
if (this.isOpenGroupV2()) {
|
||||||
|
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
|
||||||
|
return roomHasReactionsEnabled(openGroup);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// returns true if this is a closed/medium or open group
|
// returns true if this is a closed/medium or open group
|
||||||
public isGroup() {
|
public isGroup() {
|
||||||
return this.get('type') === ConversationTypeEnum.GROUP;
|
return this.get('type') === ConversationTypeEnum.GROUP;
|
||||||
|
@ -1926,6 +2071,34 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||||
);
|
);
|
||||||
return replacedWithOurRealSessionId;
|
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(
|
const throttledAllConversationsDispatch = debounce(
|
||||||
|
|
|
@ -59,7 +59,20 @@ import { OpenGroupData } from '../data/opengroups';
|
||||||
import { isUsFromCache } from '../session/utils/User';
|
import { isUsFromCache } from '../session/utils/User';
|
||||||
import { perfEnd, perfStart } from '../session/utils/Performance';
|
import { perfEnd, perfStart } from '../session/utils/Performance';
|
||||||
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
|
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
|
||||||
import _, { isEmpty, uniq } from 'lodash';
|
import _, {
|
||||||
|
cloneDeep,
|
||||||
|
debounce,
|
||||||
|
groupBy,
|
||||||
|
isEmpty,
|
||||||
|
map,
|
||||||
|
partition,
|
||||||
|
pick,
|
||||||
|
reduce,
|
||||||
|
reject,
|
||||||
|
size as lodashSize,
|
||||||
|
sortBy,
|
||||||
|
uniq,
|
||||||
|
} from 'lodash';
|
||||||
import { SettingsKey } from '../data/settings-key';
|
import { SettingsKey } from '../data/settings-key';
|
||||||
import {
|
import {
|
||||||
deleteExternalMessageFiles,
|
deleteExternalMessageFiles,
|
||||||
|
@ -80,6 +93,7 @@ import {
|
||||||
isUsAnySogsFromCache,
|
isUsAnySogsFromCache,
|
||||||
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||||
import { QUOTED_TEXT_MAX_LENGTH } from '../session/constants';
|
import { QUOTED_TEXT_MAX_LENGTH } from '../session/constants';
|
||||||
|
import { ReactionList } from '../types/Reaction';
|
||||||
// tslint:disable: cyclomatic-complexity
|
// tslint:disable: cyclomatic-complexity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -361,7 +375,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
|
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
|
||||||
const groupUpdate = this.getGroupUpdateAsArray();
|
const groupUpdate = this.getGroupUpdateAsArray();
|
||||||
|
|
||||||
if (!groupUpdate || _.isEmpty(groupUpdate)) {
|
if (!groupUpdate || isEmpty(groupUpdate)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -495,6 +509,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
if (previews && previews.length) {
|
if (previews && previews.length) {
|
||||||
props.previews = previews;
|
props.previews = previews;
|
||||||
}
|
}
|
||||||
|
const reacts = this.getPropsForReacts();
|
||||||
|
if (reacts && Object.keys(reacts).length) {
|
||||||
|
props.reacts = reacts;
|
||||||
|
}
|
||||||
const quote = this.getPropsForQuote(options);
|
const quote = this.getPropsForQuote(options);
|
||||||
if (quote) {
|
if (quote) {
|
||||||
props.quote = quote;
|
props.quote = quote;
|
||||||
|
@ -516,8 +534,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
const nbsp = '\xa0';
|
const nbsp = '\xa0';
|
||||||
const regex = /(\S)( +)(\S+\s*)$/;
|
const regex = /(\S)( +)(\S+\s*)$/;
|
||||||
return text.replace(regex, (_match, start, spaces, end) => {
|
return text.replace(regex, (_match, start, spaces, end) => {
|
||||||
const newSpaces =
|
const newSpaces: any =
|
||||||
end.length < 12 ? _.reduce(spaces, accumulator => accumulator + nbsp, '') : spaces;
|
end.length < 12 ? reduce(spaces, accumulator => accumulator + nbsp, '') : spaces;
|
||||||
return `${start}${newSpaces}${end}`;
|
return `${start}${newSpaces}${end}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -567,6 +585,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPropsForReacts(): ReactionList | null {
|
||||||
|
return this.get('reacts') || null;
|
||||||
|
}
|
||||||
|
|
||||||
public getPropsForQuote(_options: any = {}) {
|
public getPropsForQuote(_options: any = {}) {
|
||||||
const quote = this.get('quote');
|
const quote = this.get('quote');
|
||||||
|
|
||||||
|
@ -702,8 +724,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
|
|
||||||
// If an error has a specific number it's associated with, we'll show it next to
|
// If an error has a specific number it's associated with, we'll show it next to
|
||||||
// that contact. Otherwise, it will be a standalone entry.
|
// that contact. Otherwise, it will be a standalone entry.
|
||||||
const errors = _.reject(allErrors, error => Boolean(error.number));
|
const errors = reject(allErrors, error => Boolean(error.number));
|
||||||
const errorsGroupedById = _.groupBy(allErrors, 'number');
|
const errorsGroupedById = groupBy(allErrors, 'number');
|
||||||
const finalContacts = await Promise.all(
|
const finalContacts = await Promise.all(
|
||||||
(phoneNumbers || []).map(async id => {
|
(phoneNumbers || []).map(async id => {
|
||||||
const errorsForContact = errorsGroupedById[id];
|
const errorsForContact = errorsGroupedById[id];
|
||||||
|
@ -725,7 +747,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
|
|
||||||
// The prefix created here ensures that contacts with errors are listed
|
// The prefix created here ensures that contacts with errors are listed
|
||||||
// first; otherwise it's alphabetical
|
// first; otherwise it's alphabetical
|
||||||
const sortedContacts = _.sortBy(
|
const sortedContacts = sortBy(
|
||||||
finalContacts,
|
finalContacts,
|
||||||
contact => `${contact.isPrimaryDevice ? '0' : '1'}${contact.pubkey}`
|
contact => `${contact.isPrimaryDevice ? '0' : '1'}${contact.pubkey}`
|
||||||
);
|
);
|
||||||
|
@ -827,6 +849,8 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
hasVisualMediaAttachments: 0,
|
hasVisualMediaAttachments: 0,
|
||||||
attachments: undefined,
|
attachments: undefined,
|
||||||
preview: undefined,
|
preview: undefined,
|
||||||
|
reacts: undefined,
|
||||||
|
reactsIndex: undefined,
|
||||||
});
|
});
|
||||||
await this.markRead(Date.now());
|
await this.markRead(Date.now());
|
||||||
await this.commit();
|
await this.commit();
|
||||||
|
@ -884,6 +908,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
expireTimer: this.get('expireTimer'),
|
expireTimer: this.get('expireTimer'),
|
||||||
attachments,
|
attachments,
|
||||||
preview: preview ? [preview] : [],
|
preview: preview ? [preview] : [],
|
||||||
|
reacts: this.get('reacts'),
|
||||||
quote,
|
quote,
|
||||||
lokiProfile: UserUtils.getOurProfile(),
|
lokiProfile: UserUtils.getOurProfile(),
|
||||||
};
|
};
|
||||||
|
@ -925,7 +950,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeOutgoingErrors(number: string) {
|
public removeOutgoingErrors(number: string) {
|
||||||
const errors = _.partition(
|
const errors = partition(
|
||||||
this.get('errors'),
|
this.get('errors'),
|
||||||
e => e.number === number && e.name === 'SendMessageNetworkError'
|
e => e.number === number && e.name === 'SendMessageNetworkError'
|
||||||
);
|
);
|
||||||
|
@ -966,7 +991,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasErrors() {
|
public hasErrors() {
|
||||||
return _.size(this.get('errors')) > 0;
|
return lodashSize(this.get('errors')) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getStatus(pubkey: string) {
|
public getStatus(pubkey: string) {
|
||||||
|
@ -1048,7 +1073,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
e.constructor === TypeError ||
|
e.constructor === TypeError ||
|
||||||
e.constructor === ReferenceError
|
e.constructor === ReferenceError
|
||||||
) {
|
) {
|
||||||
return _.pick(e, 'name', 'message', 'code', 'number', 'reason');
|
return pick(e, 'name', 'message', 'code', 'number', 'reason');
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
|
@ -1065,7 +1090,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
|
|
||||||
perfStart(`messageCommit-${this.attributes.id}`);
|
perfStart(`messageCommit-${this.attributes.id}`);
|
||||||
// because the saving to db calls _cleanData which mutates the field for cleaning, we need to save a copy
|
// because the saving to db calls _cleanData which mutates the field for cleaning, we need to save a copy
|
||||||
const id = await Data.saveMessage(_.cloneDeep(this.attributes));
|
const id = await Data.saveMessage(cloneDeep(this.attributes));
|
||||||
if (triggerUIUpdate) {
|
if (triggerUIUpdate) {
|
||||||
this.dispatchMessageUpdate();
|
this.dispatchMessageUpdate();
|
||||||
}
|
}
|
||||||
|
@ -1182,12 +1207,15 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private findAndFormatContact(pubkey: string): FindAndFormatContactType {
|
public findAndFormatContact(pubkey: string): FindAndFormatContactType {
|
||||||
const contactModel = getConversationController().get(pubkey);
|
const contactModel = getConversationController().get(pubkey);
|
||||||
let profileName: string | null = null;
|
let profileName: string | null = null;
|
||||||
let isMe = false;
|
let isMe = false;
|
||||||
|
|
||||||
if (pubkey === UserUtils.getOurPubKeyStrFromCache()) {
|
if (
|
||||||
|
pubkey === UserUtils.getOurPubKeyStrFromCache() ||
|
||||||
|
(pubkey && pubkey.startsWith('15') && isUsAnySogsFromCache(pubkey))
|
||||||
|
) {
|
||||||
profileName = window.i18n('you');
|
profileName = window.i18n('you');
|
||||||
isMe = true;
|
isMe = true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -1215,7 +1243,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
*/
|
*/
|
||||||
private getGroupUpdateAsArray() {
|
private getGroupUpdateAsArray() {
|
||||||
const groupUpdate = this.get('group_update');
|
const groupUpdate = this.get('group_update');
|
||||||
if (!groupUpdate || _.isEmpty(groupUpdate)) {
|
if (!groupUpdate || isEmpty(groupUpdate)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const left: Array<string> | undefined = Array.isArray(groupUpdate.left)
|
const left: Array<string> | undefined = Array.isArray(groupUpdate.left)
|
||||||
|
@ -1288,7 +1316,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupUpdate.kicked && groupUpdate.kicked.length) {
|
if (groupUpdate.kicked && groupUpdate.kicked.length) {
|
||||||
const names = _.map(
|
const names = map(
|
||||||
groupUpdate.kicked,
|
groupUpdate.kicked,
|
||||||
getConversationController().getContactProfileNameOrShortenedPubKey
|
getConversationController().getContactProfileNameOrShortenedPubKey
|
||||||
);
|
);
|
||||||
|
@ -1337,6 +1365,12 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
||||||
return window.i18n('answeredACall', [displayName]);
|
return window.i18n('answeredACall', [displayName]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.get('reaction')) {
|
||||||
|
const reaction = this.get('reaction');
|
||||||
|
if (reaction && reaction.emoji && reaction.emoji !== '') {
|
||||||
|
return window.i18n('reactionNotification', [reaction.emoji]);
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.get('body');
|
return this.get('body');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1349,7 +1383,7 @@ export function sliceQuoteText(quotedText: string | undefined | null) {
|
||||||
return quotedText.slice(0, QUOTED_TEXT_MAX_LENGTH);
|
return quotedText.slice(0, QUOTED_TEXT_MAX_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
const throttledAllMessagesDispatch = _.debounce(
|
const throttledAllMessagesDispatch = debounce(
|
||||||
() => {
|
() => {
|
||||||
if (updatesToDispatch.size === 0) {
|
if (updatesToDispatch.size === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { defaultsDeep } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { CallNotificationType, PropsForMessageWithConvoProps } from '../state/ducks/conversations';
|
import { CallNotificationType, PropsForMessageWithConvoProps } from '../state/ducks/conversations';
|
||||||
import { AttachmentTypeWithPath } from '../types/Attachment';
|
import { AttachmentTypeWithPath } from '../types/Attachment';
|
||||||
|
import { Reaction, ReactionList, SortedReactionList } from '../types/Reaction';
|
||||||
|
|
||||||
export type MessageModelType = 'incoming' | 'outgoing';
|
export type MessageModelType = 'incoming' | 'outgoing';
|
||||||
export type MessageDeliveryStatus = 'sending' | 'sent' | 'read' | 'error';
|
export type MessageDeliveryStatus = 'sending' | 'sent' | 'read' | 'error';
|
||||||
|
@ -16,6 +17,9 @@ export interface MessageAttributes {
|
||||||
received_at?: number;
|
received_at?: number;
|
||||||
sent_at?: number;
|
sent_at?: number;
|
||||||
preview?: any;
|
preview?: any;
|
||||||
|
reaction?: Reaction;
|
||||||
|
reacts?: ReactionList;
|
||||||
|
reactsIndex?: number;
|
||||||
body?: string;
|
body?: string;
|
||||||
expirationStartTimestamp: number;
|
expirationStartTimestamp: number;
|
||||||
read_by: Array<string>; // we actually only care about the length of this. values are not used for anything
|
read_by: Array<string>; // we actually only care about the length of this. values are not used for anything
|
||||||
|
@ -157,6 +161,9 @@ export interface MessageAttributesOptionals {
|
||||||
received_at?: number;
|
received_at?: number;
|
||||||
sent_at?: number;
|
sent_at?: number;
|
||||||
preview?: any;
|
preview?: any;
|
||||||
|
reaction?: Reaction;
|
||||||
|
reacts?: ReactionList;
|
||||||
|
reactsIndex?: number;
|
||||||
body?: string;
|
body?: string;
|
||||||
expirationStartTimestamp?: number;
|
expirationStartTimestamp?: number;
|
||||||
read_by?: Array<string>; // we actually only care about the length of this. values are not used for anything
|
read_by?: Array<string>; // we actually only care about the length of this. values are not used for anything
|
||||||
|
@ -241,4 +248,6 @@ export type MessageRenderingProps = PropsForMessageWithConvoProps & {
|
||||||
multiSelectMode: boolean;
|
multiSelectMode: boolean;
|
||||||
firstMessageOfSeries: boolean;
|
firstMessageOfSeries: boolean;
|
||||||
lastMessageOfSeries: boolean;
|
lastMessageOfSeries: boolean;
|
||||||
|
|
||||||
|
sortedReacts?: SortedReactionList;
|
||||||
};
|
};
|
||||||
|
|
|
@ -997,6 +997,20 @@ function getMessageBySenderAndSentAt({ source, sentAt }: { source: string; sentA
|
||||||
return map(rows, row => jsonToObject(row.json));
|
return map(rows, row => jsonToObject(row.json));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMessageByServerId(serverId: number) {
|
||||||
|
const row = assertGlobalInstance()
|
||||||
|
.prepare(`SELECT * FROM ${MESSAGES_TABLE} WHERE serverId = $serverId;`)
|
||||||
|
.get({
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonToObject(row.json);
|
||||||
|
}
|
||||||
|
|
||||||
function getMessagesCountBySender({ source }: { source: string }) {
|
function getMessagesCountBySender({ source }: { source: string }) {
|
||||||
if (!source) {
|
if (!source) {
|
||||||
throw new Error('source must be set');
|
throw new Error('source must be set');
|
||||||
|
@ -2373,6 +2387,7 @@ export const sqlNode = {
|
||||||
getMessageIdsFromServerIds,
|
getMessageIdsFromServerIds,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
getMessagesBySentAt,
|
getMessagesBySentAt,
|
||||||
|
getMessageByServerId,
|
||||||
getSeenMessagesByHashList,
|
getSeenMessagesByHashList,
|
||||||
getLastHashBySnode,
|
getLastHashBySnode,
|
||||||
getExpiredMessages,
|
getExpiredMessages,
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { isUsFromCache } from '../session/utils/User';
|
||||||
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
||||||
import { toLogFormat } from '../types/attachments/Errors';
|
import { toLogFormat } from '../types/attachments/Errors';
|
||||||
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
||||||
|
import { handleMessageReaction } from '../util/reactions';
|
||||||
|
|
||||||
function cleanAttachment(attachment: any) {
|
function cleanAttachment(attachment: any) {
|
||||||
return {
|
return {
|
||||||
|
@ -79,7 +80,16 @@ function cleanAttachments(decrypted: SignalService.DataMessage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMessageEmpty(message: SignalService.DataMessage) {
|
export function isMessageEmpty(message: SignalService.DataMessage) {
|
||||||
const { flags, body, attachments, group, quote, preview, openGroupInvitation } = message;
|
const {
|
||||||
|
flags,
|
||||||
|
body,
|
||||||
|
attachments,
|
||||||
|
group,
|
||||||
|
quote,
|
||||||
|
preview,
|
||||||
|
openGroupInvitation,
|
||||||
|
reaction,
|
||||||
|
} = message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!flags &&
|
!flags &&
|
||||||
|
@ -89,7 +99,8 @@ export function isMessageEmpty(message: SignalService.DataMessage) {
|
||||||
isEmpty(group) &&
|
isEmpty(group) &&
|
||||||
isEmpty(quote) &&
|
isEmpty(quote) &&
|
||||||
isEmpty(preview) &&
|
isEmpty(preview) &&
|
||||||
isEmpty(openGroupInvitation)
|
isEmpty(openGroupInvitation) &&
|
||||||
|
isEmpty(reaction)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,6 +229,7 @@ export async function handleSwarmDataMessage(
|
||||||
cleanDataMessage.profileKey
|
cleanDataMessage.profileKey
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMessageEmpty(cleanDataMessage)) {
|
if (isMessageEmpty(cleanDataMessage)) {
|
||||||
window?.log?.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
|
window?.log?.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
|
||||||
return removeFromCache(envelope);
|
return removeFromCache(envelope);
|
||||||
|
@ -306,15 +318,28 @@ async function handleSwarmMessage(
|
||||||
|
|
||||||
void convoToAddMessageTo.queueJob(async () => {
|
void convoToAddMessageTo.queueJob(async () => {
|
||||||
// this call has to be made inside the queueJob!
|
// this call has to be made inside the queueJob!
|
||||||
|
if (!msgModel.get('isPublic') && rawDataMessage.reaction && rawDataMessage.syncTarget) {
|
||||||
|
await handleMessageReaction(
|
||||||
|
rawDataMessage.reaction,
|
||||||
|
msgModel.get('source'),
|
||||||
|
false,
|
||||||
|
messageHash
|
||||||
|
);
|
||||||
|
confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isDuplicate = await isSwarmMessageDuplicate({
|
const isDuplicate = await isSwarmMessageDuplicate({
|
||||||
source: msgModel.get('source'),
|
source: msgModel.get('source'),
|
||||||
sentAt,
|
sentAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
window?.log?.info('Received duplicate message. Dropping it.');
|
window?.log?.info('Received duplicate message. Dropping it.');
|
||||||
confirm();
|
confirm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleMessageJob(
|
await handleMessageJob(
|
||||||
msgModel,
|
msgModel,
|
||||||
convoToAddMessageTo,
|
convoToAddMessageTo,
|
||||||
|
|
|
@ -20,8 +20,11 @@ export const handleOpenGroupV4Message = async (
|
||||||
roomInfos: OpenGroupRequestCommonType
|
roomInfos: OpenGroupRequestCommonType
|
||||||
) => {
|
) => {
|
||||||
const { data, id, posted, session_id } = message;
|
const { data, id, posted, session_id } = message;
|
||||||
|
if (data && posted && session_id) {
|
||||||
await handleOpenGroupMessage(roomInfos, data, posted, session_id, id);
|
await handleOpenGroupMessage(roomInfos, data, posted, session_id, id);
|
||||||
|
} else {
|
||||||
|
throw Error('Missing data passed to handleOpenGroupV4Message.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -16,6 +16,8 @@ import { GoogleChrome } from '../util';
|
||||||
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
||||||
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
||||||
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||||
|
import { handleMessageReaction } from '../util/reactions';
|
||||||
|
import { Action, Reaction } from '../types/Reaction';
|
||||||
|
|
||||||
function contentTypeSupported(type: string): boolean {
|
function contentTypeSupported(type: string): boolean {
|
||||||
const Chrome = GoogleChrome;
|
const Chrome = GoogleChrome;
|
||||||
|
@ -179,6 +181,7 @@ export type RegularMessageType = Pick<
|
||||||
| 'openGroupInvitation'
|
| 'openGroupInvitation'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'preview'
|
| 'preview'
|
||||||
|
| 'reaction'
|
||||||
| 'profile'
|
| 'profile'
|
||||||
| 'profileKey'
|
| 'profileKey'
|
||||||
| 'expireTimer'
|
| 'expireTimer'
|
||||||
|
@ -192,6 +195,7 @@ export function toRegularMessage(rawDataMessage: SignalService.DataMessage): Reg
|
||||||
..._.pick(rawDataMessage, [
|
..._.pick(rawDataMessage, [
|
||||||
'attachments',
|
'attachments',
|
||||||
'preview',
|
'preview',
|
||||||
|
'reaction',
|
||||||
'body',
|
'body',
|
||||||
'flags',
|
'flags',
|
||||||
'profileKey',
|
'profileKey',
|
||||||
|
@ -336,6 +340,20 @@ export async function handleMessageJob(
|
||||||
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
|
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
|
||||||
|
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
|
||||||
|
|
||||||
|
if (
|
||||||
|
regularDataMessage.reaction.action === Action.REACT &&
|
||||||
|
conversation.isPrivate() &&
|
||||||
|
messageModel.get('unread')
|
||||||
|
) {
|
||||||
|
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
|
||||||
|
conversation.throttledNotify(messageModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm?.();
|
||||||
|
} else {
|
||||||
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
||||||
source,
|
source,
|
||||||
ConversationTypeEnum.PRIVATE
|
ConversationTypeEnum.PRIVATE
|
||||||
|
@ -430,10 +448,10 @@ export async function handleMessageJob(
|
||||||
if (messageModel.get('unread')) {
|
if (messageModel.get('unread')) {
|
||||||
conversation.throttledNotify(messageModel);
|
conversation.throttledNotify(messageModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm?.();
|
confirm?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorForLog = error && error.stack ? error.stack : error;
|
const errorForLog = error && error.stack ? error.stack : error;
|
||||||
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,18 +19,20 @@ import {
|
||||||
fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl,
|
fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl,
|
||||||
roomHasBlindEnabled,
|
roomHasBlindEnabled,
|
||||||
} from '../sogsv3/sogsV3Capabilities';
|
} from '../sogsv3/sogsV3Capabilities';
|
||||||
|
import { OpenGroupReaction } from '../../../../types/Reaction';
|
||||||
|
|
||||||
export type OpenGroupMessageV4 = {
|
export type OpenGroupMessageV4 = {
|
||||||
/** AFAIK: indicates the number of the message in the group. e.g. 2nd message will be 1 or 2 */
|
/** AFAIK: indicates the number of the message in the group. e.g. 2nd message will be 1 or 2 */
|
||||||
seqno: number;
|
seqno: number;
|
||||||
session_id: string;
|
session_id?: string;
|
||||||
/** base64 */
|
/** base64 */
|
||||||
signature: string;
|
signature?: string;
|
||||||
/** timestamp number with decimal */
|
/** timestamp number with decimal */
|
||||||
posted: number;
|
posted?: number;
|
||||||
id: number;
|
id: number;
|
||||||
data: string;
|
data?: string;
|
||||||
deleted: boolean;
|
deleted?: boolean;
|
||||||
|
reactions: Record<string, OpenGroupReaction>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollForEverythingInterval = DURATION.SECONDS * 10;
|
const pollForEverythingInterval = DURATION.SECONDS * 10;
|
||||||
|
@ -208,7 +210,6 @@ export class OpenGroupServerPoller {
|
||||||
pollInfo: {
|
pollInfo: {
|
||||||
roomId,
|
roomId,
|
||||||
infoUpdated: 0,
|
infoUpdated: 0,
|
||||||
// infoUpdated: -1,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -52,12 +52,14 @@ export const filterDuplicatesFromDbAndIncomingV4 = async (
|
||||||
a.posted === b.posted
|
a.posted === b.posted
|
||||||
);
|
);
|
||||||
// make sure a sender is set, as we cast it just below
|
// make sure a sender is set, as we cast it just below
|
||||||
}).filter(m => Boolean(m.session_id));
|
}).filter(m => Boolean(m.session_id && m.posted));
|
||||||
|
|
||||||
// now, check database to make sure those messages are not already fetched
|
// now, check database to make sure those messages are not already fetched
|
||||||
const filteredInDb = await Data.filterAlreadyFetchedOpengroupMessage(
|
const filteredInDb = await Data.filterAlreadyFetchedOpengroupMessage(
|
||||||
filtered.map(m => {
|
filtered.map(m => {
|
||||||
return { sender: m.session_id as string, serverTimestamp: m.posted };
|
// We have confirmed these exist by filtering above.
|
||||||
|
// tslint:disable-next-line no-non-null-assertion
|
||||||
|
return { sender: m.session_id! as string, serverTimestamp: m.posted! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ import { handleOutboxMessageModel } from '../../../../receiver/dataMessage';
|
||||||
import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
|
import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
|
||||||
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
|
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
|
||||||
import { Data } from '../../../../data/data';
|
import { Data } from '../../../../data/data';
|
||||||
|
import { handleOpenGroupMessageReactions } from '../../../../util/reactions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the convo matching those criteria and make sure it is an opengroup convo, or return null.
|
* Get the convo matching those criteria and make sure it is an opengroup convo, or return null.
|
||||||
|
@ -73,7 +74,13 @@ async function handlePollInfoResponse(
|
||||||
token: string;
|
token: string;
|
||||||
upload: boolean;
|
upload: boolean;
|
||||||
write: 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,
|
serverUrl: string,
|
||||||
roomIdsStillPolled: Set<string>
|
roomIdsStillPolled: Set<string>
|
||||||
|
@ -109,7 +116,14 @@ async function handlePollInfoResponse(
|
||||||
write,
|
write,
|
||||||
upload,
|
upload,
|
||||||
subscriberCount: active_users,
|
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,
|
serverUrl: string,
|
||||||
roomId: string
|
roomId: string
|
||||||
) => {
|
) => {
|
||||||
// FIXME those 2 `m.data === null` test should be removed when we add support for emoji-reacts
|
const deletions = messages.filter(m => Boolean(m.deleted));
|
||||||
const deletions = messages.filter(m => Boolean(m.deleted) || m.data === null);
|
const exceptDeletion = messages.filter(m => !m.deleted);
|
||||||
const exceptDeletion = messages.filter(m => !(Boolean(m.deleted) || m.data === null));
|
|
||||||
if (!deletions.length) {
|
if (!deletions.length) {
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
@ -156,6 +169,7 @@ const handleSogsV3DeletedMessages = async (
|
||||||
const messageIds = await Data.getMessageIdsFromServerIds(allIdsRemoved, convo.id);
|
const messageIds = await Data.getMessageIdsFromServerIds(allIdsRemoved, convo.id);
|
||||||
|
|
||||||
// we shouldn't get too many messages to delete at a time, so no need to add a function to remove multiple messages for now
|
// we shouldn't get too many messages to delete at a time, so no need to add a function to remove multiple messages for now
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(messageIds || []).map(async id => {
|
(messageIds || []).map(async id => {
|
||||||
if (convo) {
|
if (convo) {
|
||||||
|
@ -205,12 +219,27 @@ const handleMessagesResponseV4 = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messagesWithoutReactionOnlyUpdates = messages.filter(m => {
|
||||||
|
const keys = Object.keys(m);
|
||||||
|
if (
|
||||||
|
keys.length === 3 &&
|
||||||
|
keys.includes('id') &&
|
||||||
|
keys.includes('seqno') &&
|
||||||
|
keys.includes('reactions')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Incoming messages from sogvs v3 are returned in descending order from the latest seqno, we need to sort it chronologically
|
||||||
// Incoming messages for sogs v3 have a timestamp in seconds and not ms.
|
// Incoming messages for sogs v3 have a timestamp in seconds and not ms.
|
||||||
// Session works with timestamp in ms, for a lot of things, so first, lets fix this.
|
// Session works with timestamp in ms, for a lot of things, so first, lets fix this.
|
||||||
|
const messagesWithMsTimestamp = messagesWithoutReactionOnlyUpdates
|
||||||
const messagesWithMsTimestamp = messages.map(m => ({
|
.sort((a, b) => (a.seqno < b.seqno ? -1 : a.seqno > b.seqno ? 1 : 0))
|
||||||
|
.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
posted: Math.floor(m.posted * 1000),
|
posted: m.posted ? Math.floor(m.posted * 1000) : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const messagesWithoutDeleted = await handleSogsV3DeletedMessages(
|
const messagesWithoutDeleted = await handleSogsV3DeletedMessages(
|
||||||
|
@ -235,6 +264,7 @@ const handleMessagesResponseV4 = async (
|
||||||
const messagesWithResolvedBlindedIdsIfFound = [];
|
const messagesWithResolvedBlindedIdsIfFound = [];
|
||||||
for (let index = 0; index < messagesFilteredBlindedIds.length; index++) {
|
for (let index = 0; index < messagesFilteredBlindedIds.length; index++) {
|
||||||
const newMessage = messagesFilteredBlindedIds[index];
|
const newMessage = messagesFilteredBlindedIds[index];
|
||||||
|
if (newMessage.session_id) {
|
||||||
const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id);
|
const unblindedIdFound = getCachedNakedKeyFromBlindedNoServerPubkey(newMessage.session_id);
|
||||||
|
|
||||||
// override the sender in the message itself if we are the sender
|
// override the sender in the message itself if we are the sender
|
||||||
|
@ -242,9 +272,13 @@ const handleMessagesResponseV4 = async (
|
||||||
newMessage.session_id = unblindedIdFound;
|
newMessage.session_id = unblindedIdFound;
|
||||||
}
|
}
|
||||||
messagesWithResolvedBlindedIdsIfFound.push(newMessage);
|
messagesWithResolvedBlindedIdsIfFound.push(newMessage);
|
||||||
|
} else {
|
||||||
|
throw Error('session_id is missing so we cannot resolve the blinded id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use the unverified newMessages seqno and id as last polled because we actually did poll up to those ids.
|
// we use the unverified newMessages seqno and id as last polled because we actually did poll up to those ids.
|
||||||
|
|
||||||
const incomingMessageSeqNo = compact(messages.map(n => n.seqno));
|
const incomingMessageSeqNo = compact(messages.map(n => n.seqno));
|
||||||
const maxNewMessageSeqNo = Math.max(...incomingMessageSeqNo);
|
const maxNewMessageSeqNo = Math.max(...incomingMessageSeqNo);
|
||||||
for (let index = 0; index < messagesWithResolvedBlindedIdsIfFound.length; index++) {
|
for (let index = 0; index < messagesWithResolvedBlindedIdsIfFound.length; index++) {
|
||||||
|
@ -270,6 +304,19 @@ const handleMessagesResponseV4 = async (
|
||||||
}
|
}
|
||||||
roomInfosRefreshed.lastFetchTimestamp = Date.now();
|
roomInfosRefreshed.lastFetchTimestamp = Date.now();
|
||||||
await OpenGroupData.saveV2OpenGroupRoom(roomInfosRefreshed);
|
await OpenGroupData.saveV2OpenGroupRoom(roomInfosRefreshed);
|
||||||
|
|
||||||
|
const messagesWithReactions = messages.filter(m => m.reactions !== undefined);
|
||||||
|
if (messagesWithReactions.length > 0) {
|
||||||
|
const conversationId = getOpenGroupV2ConversationId(serverUrl, roomId);
|
||||||
|
const groupConvo = getConversationController().get(conversationId);
|
||||||
|
if (groupConvo && groupConvo.isOpenGroupV2()) {
|
||||||
|
for (const message of messagesWithReactions) {
|
||||||
|
void groupConvo.queueJob(async () => {
|
||||||
|
await handleOpenGroupMessageReactions(message.reactions, message.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
window?.log?.warn('handleNewMessages failed:', e);
|
window?.log?.warn('handleNewMessages failed:', e);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { fromUInt8ArrayToBase64, stringToUint8Array, toHex } from '../../../utils/String';
|
import { encode, fromUInt8ArrayToBase64, stringToUint8Array, toHex } from '../../../utils/String';
|
||||||
import { concatUInt8Array, getSodiumRenderer, LibSodiumWrappers } from '../../../crypto';
|
import { concatUInt8Array, getSodiumRenderer, LibSodiumWrappers } from '../../../crypto';
|
||||||
import { crypto_hash_sha512, from_hex, to_hex } from 'libsodium-wrappers-sumo';
|
import { crypto_hash_sha512, from_hex, to_hex } from 'libsodium-wrappers-sumo';
|
||||||
import { ByteKeyPair } from '../../../utils/User';
|
import { ByteKeyPair } from '../../../utils/User';
|
||||||
|
@ -12,6 +12,7 @@ import {
|
||||||
toX25519,
|
toX25519,
|
||||||
} from '../../../utils/SodiumUtils';
|
} from '../../../utils/SodiumUtils';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
import { OnionSending } from '../../../onions/onionSend';
|
||||||
|
|
||||||
async function getSogsSignature({
|
async function getSogsSignature({
|
||||||
blinded,
|
blinded,
|
||||||
|
@ -67,14 +68,18 @@ async function getOpenGroupHeaders(data: {
|
||||||
pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`;
|
pubkey = `${KeyPrefixType.unblinded}${toHex(signingKeys.pubKeyBytes)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawPath = OnionSending.endpointRequiresDecoding(path); // this gets a string of the path wioth potentially emojis in it
|
||||||
|
const encodedPath = new Uint8Array(encode(rawPath, 'utf8')); // this gets the binary content of that utf8 string
|
||||||
|
|
||||||
// SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HASHED_BODY
|
// SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HASHED_BODY
|
||||||
let toSign = concatUInt8Array(
|
let toSign = concatUInt8Array(
|
||||||
serverPK,
|
serverPK,
|
||||||
nonce,
|
nonce,
|
||||||
stringToUint8Array(timestamp.toString()),
|
stringToUint8Array(timestamp.toString()),
|
||||||
stringToUint8Array(method),
|
stringToUint8Array(method),
|
||||||
stringToUint8Array(path)
|
encodedPath
|
||||||
);
|
);
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
const bodyHashed = sodium.crypto_generichash(64, body);
|
const bodyHashed = sodium.crypto_generichash(64, body);
|
||||||
|
|
||||||
|
|
|
@ -198,6 +198,15 @@ export type SubRequestUpdateRoomType = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubRequestDeleteReactionType = {
|
||||||
|
type: 'deleteReaction';
|
||||||
|
deleteReaction: {
|
||||||
|
reaction: string;
|
||||||
|
messageId: number;
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenGroupBatchRow =
|
export type OpenGroupBatchRow =
|
||||||
| SubRequestCapabilitiesType
|
| SubRequestCapabilitiesType
|
||||||
| SubRequestMessagesType
|
| SubRequestMessagesType
|
||||||
|
@ -208,7 +217,8 @@ export type OpenGroupBatchRow =
|
||||||
| SubRequestAddRemoveModeratorType
|
| SubRequestAddRemoveModeratorType
|
||||||
| SubRequestBanUnbanUserType
|
| SubRequestBanUnbanUserType
|
||||||
| SubRequestDeleteAllUserPostsType
|
| SubRequestDeleteAllUserPostsType
|
||||||
| SubRequestUpdateRoomType;
|
| SubRequestUpdateRoomType
|
||||||
|
| SubRequestDeleteReactionType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -228,8 +238,9 @@ const makeBatchRequestPayload = (
|
||||||
if (options.messages) {
|
if (options.messages) {
|
||||||
return {
|
return {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
// TODO Consistency across platforms with fetching reactors
|
||||||
path: isNumber(options.messages.sinceSeqNo)
|
path: isNumber(options.messages.sinceSeqNo)
|
||||||
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}`
|
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r`
|
||||||
: `/room/${options.messages.roomId}/messages/recent`,
|
: `/room/${options.messages.roomId}/messages/recent`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -303,6 +314,11 @@ const makeBatchRequestPayload = (
|
||||||
path: `/room/${options.updateRoom.roomId}`,
|
path: `/room/${options.updateRoom.roomId}`,
|
||||||
json: { image: options.updateRoom.imageId },
|
json: { image: options.updateRoom.imageId },
|
||||||
};
|
};
|
||||||
|
case 'deleteReaction':
|
||||||
|
return {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: `/room/${options.deleteReaction.roomId}/reactions/${options.deleteReaction.messageId}/${options.deleteReaction.reaction}`,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
throw new Error('Invalid batch request row');
|
throw new Error('Invalid batch request row');
|
||||||
}
|
}
|
||||||
|
@ -394,7 +410,7 @@ const sendSogsBatchRequestOnionV4 = async (
|
||||||
if (isObject(batchResponse.body)) {
|
if (isObject(batchResponse.body)) {
|
||||||
return batchResponse as BatchSogsReponse;
|
return batchResponse as BatchSogsReponse;
|
||||||
}
|
}
|
||||||
window?.log?.warn('sogsbatch: batch response decoded body is not object. Returning null');
|
|
||||||
|
|
||||||
|
window?.log?.warn('sogsbatch: batch response decoded body is not object. Returning null');
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
@ -76,6 +76,10 @@ export function capabilitiesListHasBlindEnabled(caps?: Array<string> | null) {
|
||||||
return Boolean(caps?.includes('blind'));
|
return Boolean(caps?.includes('blind'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function roomHasReactionsEnabled(openGroup?: OpenGroupV2Room) {
|
||||||
|
return Boolean(openGroup?.capabilities?.includes('reactions'));
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl(serverUrl: string) {
|
export async function fetchCapabilitiesAndUpdateRelatedRoomsOfServerUrl(serverUrl: string) {
|
||||||
let relatedRooms = OpenGroupData.getV2OpenGroupRoomsByServerUrl(serverUrl);
|
let relatedRooms = OpenGroupData.getV2OpenGroupRoomsByServerUrl(serverUrl);
|
||||||
if (!relatedRooms || relatedRooms.length === 0) {
|
if (!relatedRooms || relatedRooms.length === 0) {
|
||||||
|
|
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
|
// we keep 150 chars, because quoting someone with 66 hex chars need to be kept in full so we can render it in the quote with its name
|
||||||
export const QUOTED_TEXT_MAX_LENGTH = 150;
|
export const QUOTED_TEXT_MAX_LENGTH = 150;
|
||||||
|
|
||||||
|
export const DEFAULT_RECENT_REACTS = ['😂', '🥰', '😢', '😡', '😮', '😈'];
|
||||||
|
|
|
@ -2,6 +2,7 @@ import ByteBuffer from 'bytebuffer';
|
||||||
import { DataMessage } from '..';
|
import { DataMessage } from '..';
|
||||||
import { SignalService } from '../../../../protobuf';
|
import { SignalService } from '../../../../protobuf';
|
||||||
import { LokiProfile } from '../../../../types/Message';
|
import { LokiProfile } from '../../../../types/Message';
|
||||||
|
import { Reaction } from '../../../../types/Reaction';
|
||||||
import { MessageParams } from '../Message';
|
import { MessageParams } from '../Message';
|
||||||
|
|
||||||
interface AttachmentPointerCommon {
|
interface AttachmentPointerCommon {
|
||||||
|
@ -67,11 +68,13 @@ export interface VisibleMessageParams extends MessageParams {
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
lokiProfile?: LokiProfile;
|
lokiProfile?: LokiProfile;
|
||||||
preview?: Array<PreviewWithAttachmentUrl>;
|
preview?: Array<PreviewWithAttachmentUrl>;
|
||||||
|
reaction?: Reaction;
|
||||||
syncTarget?: string; // undefined means it is not a synced message
|
syncTarget?: string; // undefined means it is not a synced message
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VisibleMessage extends DataMessage {
|
export class VisibleMessage extends DataMessage {
|
||||||
public readonly expireTimer?: number;
|
public readonly expireTimer?: number;
|
||||||
|
public readonly reaction?: Reaction;
|
||||||
|
|
||||||
private readonly attachments?: Array<AttachmentPointerWithUrl>;
|
private readonly attachments?: Array<AttachmentPointerWithUrl>;
|
||||||
private readonly body?: string;
|
private readonly body?: string;
|
||||||
|
@ -107,6 +110,7 @@ export class VisibleMessage extends DataMessage {
|
||||||
this.displayName = params.lokiProfile && params.lokiProfile.displayName;
|
this.displayName = params.lokiProfile && params.lokiProfile.displayName;
|
||||||
this.avatarPointer = params.lokiProfile && params.lokiProfile.avatarPointer;
|
this.avatarPointer = params.lokiProfile && params.lokiProfile.avatarPointer;
|
||||||
this.preview = params.preview;
|
this.preview = params.preview;
|
||||||
|
this.reaction = params.reaction;
|
||||||
this.syncTarget = params.syncTarget;
|
this.syncTarget = params.syncTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,6 +130,9 @@ export class VisibleMessage extends DataMessage {
|
||||||
if (this.preview) {
|
if (this.preview) {
|
||||||
dataMessage.preview = this.preview;
|
dataMessage.preview = this.preview;
|
||||||
}
|
}
|
||||||
|
if (this.reaction) {
|
||||||
|
dataMessage.reaction = this.reaction;
|
||||||
|
}
|
||||||
if (this.syncTarget) {
|
if (this.syncTarget) {
|
||||||
dataMessage.syncTarget = this.syncTarget;
|
dataMessage.syncTarget = this.syncTarget;
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,14 +30,30 @@ export type OnionFetchOptions = {
|
||||||
useV4: boolean;
|
useV4: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NOTE some endpoints require decoded strings
|
||||||
|
const endpointExceptions = ['/reaction'];
|
||||||
|
const endpointRequiresDecoding = (url: string): string => {
|
||||||
|
// tslint:disable-next-line: prefer-for-of
|
||||||
|
for (let i = 0; i < endpointExceptions.length; i++) {
|
||||||
|
if (url.includes(endpointExceptions[i])) {
|
||||||
|
return decodeURIComponent(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
const buildSendViaOnionPayload = (
|
const buildSendViaOnionPayload = (
|
||||||
url: URL,
|
url: URL,
|
||||||
fetchOptions: OnionFetchOptions
|
fetchOptions: OnionFetchOptions
|
||||||
): FinalDestNonSnodeOptions => {
|
): FinalDestNonSnodeOptions => {
|
||||||
|
const endpoint = OnionSending.endpointRequiresDecoding(
|
||||||
|
url.search ? `${url.pathname}${url.search}` : url.pathname
|
||||||
|
);
|
||||||
|
|
||||||
const payloadObj: FinalDestNonSnodeOptions = {
|
const payloadObj: FinalDestNonSnodeOptions = {
|
||||||
method: fetchOptions.method || 'GET',
|
method: fetchOptions.method || 'GET',
|
||||||
body: fetchOptions.body,
|
body: fetchOptions.body,
|
||||||
endpoint: url.search ? `${url.pathname}${url.search}` : url.pathname,
|
endpoint,
|
||||||
headers: fetchOptions.headers || {},
|
headers: fetchOptions.headers || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -86,6 +102,7 @@ export type OnionV4BinarySnodeResponse = {
|
||||||
* Build & send an onion v4 request to a non snode, and handle retries.
|
* Build & send an onion v4 request to a non snode, and handle retries.
|
||||||
* We actually can only send v4 request to non snode, as the snodes themselves do not support v4 request as destination.
|
* We actually can only send v4 request to non snode, as the snodes themselves do not support v4 request as destination.
|
||||||
*/
|
*/
|
||||||
|
// tslint:disable-next-line: max-func-body-length
|
||||||
const sendViaOnionV4ToNonSnodeWithRetries = async (
|
const sendViaOnionV4ToNonSnodeWithRetries = async (
|
||||||
destinationX25519Key: string,
|
destinationX25519Key: string,
|
||||||
url: URL,
|
url: URL,
|
||||||
|
@ -152,6 +169,7 @@ const sendViaOnionV4ToNonSnodeWithRetries = async (
|
||||||
useV4: true,
|
useV4: true,
|
||||||
throwErrors,
|
throwErrors,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) {
|
if (window.sessionFeatureFlags?.debug.debugNonSnodeRequests) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ',
|
'sendViaOnionV4ToNonSnodeWithRetries: sendOnionRequestHandlingSnodeEject returned: ',
|
||||||
|
@ -285,6 +303,7 @@ async function sendJsonViaOnionV4ToSogs(sendOptions: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded };
|
headersWithSogsHeadersIfNeeded = { ...includedHeaders, ...headersWithSogsHeadersIfNeeded };
|
||||||
|
|
||||||
const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
|
const res = await OnionSending.sendViaOnionV4ToNonSnodeWithRetries(
|
||||||
serverPubkey,
|
serverPubkey,
|
||||||
builtUrl,
|
builtUrl,
|
||||||
|
@ -500,7 +519,9 @@ async function sendJsonViaOnionV4ToFileServer(sendOptions: {
|
||||||
return res as OnionV4JSONSnodeResponse;
|
return res as OnionV4JSONSnodeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we export these methods for stubbing during testing
|
||||||
export const OnionSending = {
|
export const OnionSending = {
|
||||||
|
endpointRequiresDecoding,
|
||||||
sendViaOnionV4ToNonSnodeWithRetries,
|
sendViaOnionV4ToNonSnodeWithRetries,
|
||||||
getOnionPathForSending,
|
getOnionPathForSending,
|
||||||
sendJsonViaOnionV4ToSogs,
|
sendJsonViaOnionV4ToSogs,
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { OpenGroupRequestCommonType } from '../apis/open_group_api/opengroupV2/A
|
||||||
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
|
import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
|
||||||
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
|
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
|
||||||
import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage';
|
import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage';
|
||||||
|
import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2';
|
||||||
|
|
||||||
type ClosedGroupMessageType =
|
type ClosedGroupMessageType =
|
||||||
| ClosedGroupVisibleMessage
|
| ClosedGroupVisibleMessage
|
||||||
|
@ -74,15 +75,23 @@ export class MessageQueue {
|
||||||
// Skipping the queue for Open Groups v2; the message is sent directly
|
// Skipping the queue for Open Groups v2; the message is sent directly
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sentTimestamp, serverId } = await MessageSender.sendToOpenGroupV2(
|
const result = await MessageSender.sendToOpenGroupV2(
|
||||||
message,
|
message,
|
||||||
roomInfos,
|
roomInfos,
|
||||||
blinded,
|
blinded,
|
||||||
filesToLink
|
filesToLink
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOTE Reactions are handled in the MessageSender
|
||||||
|
if (message.reaction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sentTimestamp, serverId } = result as OpenGroupMessageV2;
|
||||||
if (!serverId || serverId === -1) {
|
if (!serverId || serverId === -1) {
|
||||||
throw new Error(`Invalid serverId returned by server: ${serverId}`);
|
throw new Error(`Invalid serverId returned by server: ${serverId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await MessageSentHandler.handlePublicMessageSentSuccess(message.identifier, {
|
await MessageSentHandler.handlePublicMessageSentSuccess(message.identifier, {
|
||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
serverTimestamp: sentTimestamp,
|
serverTimestamp: sentTimestamp,
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {
|
||||||
sendSogsMessageOnionV4,
|
sendSogsMessageOnionV4,
|
||||||
} from '../apis/open_group_api/sogsv3/sogsV3SendMessage';
|
} from '../apis/open_group_api/sogsv3/sogsV3SendMessage';
|
||||||
import { AbortController } from 'abort-controller';
|
import { AbortController } from 'abort-controller';
|
||||||
|
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
|
||||||
|
|
||||||
const DEFAULT_CONNECTIONS = 1;
|
const DEFAULT_CONNECTIONS = 1;
|
||||||
|
|
||||||
|
@ -278,7 +279,7 @@ export async function sendToOpenGroupV2(
|
||||||
roomInfos: OpenGroupRequestCommonType,
|
roomInfos: OpenGroupRequestCommonType,
|
||||||
blinded: boolean,
|
blinded: boolean,
|
||||||
filesToLink: Array<number>
|
filesToLink: Array<number>
|
||||||
): Promise<OpenGroupMessageV2> {
|
): Promise<OpenGroupMessageV2 | boolean> {
|
||||||
// we agreed to pad message for opengroupv2
|
// we agreed to pad message for opengroupv2
|
||||||
const paddedBody = addMessagePadding(rawMessage.plainTextBuffer());
|
const paddedBody = addMessagePadding(rawMessage.plainTextBuffer());
|
||||||
const v2Message = new OpenGroupMessageV2({
|
const v2Message = new OpenGroupMessageV2({
|
||||||
|
@ -287,6 +288,16 @@ export async function sendToOpenGroupV2(
|
||||||
filesToLink,
|
filesToLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (rawMessage.reaction) {
|
||||||
|
const msg = await sendSogsReactionOnionV4(
|
||||||
|
roomInfos.serverUrl,
|
||||||
|
roomInfos.roomId,
|
||||||
|
new AbortController().signal,
|
||||||
|
rawMessage.reaction,
|
||||||
|
blinded
|
||||||
|
);
|
||||||
|
return msg;
|
||||||
|
} else {
|
||||||
const msg = await sendSogsMessageOnionV4(
|
const msg = await sendSogsMessageOnionV4(
|
||||||
roomInfos.serverUrl,
|
roomInfos.serverUrl,
|
||||||
roomInfos.roomId,
|
roomInfos.roomId,
|
||||||
|
@ -296,6 +307,7 @@ export async function sendToOpenGroupV2(
|
||||||
);
|
);
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to an open group v2.
|
* Send a message to an open group v2.
|
||||||
|
|
|
@ -324,6 +324,10 @@ export const SessionGlobalStyles = createGlobalStyle`
|
||||||
--font-size-sm: 13px;
|
--font-size-sm: 13px;
|
||||||
--font-size-md: 15px;
|
--font-size-md: 15px;
|
||||||
--font-size-lg: 17px;
|
--font-size-lg: 17px;
|
||||||
|
--font-size-h1: 30px;
|
||||||
|
--font-size-h2: 24px;
|
||||||
|
--font-size-h3: 20px;
|
||||||
|
--font-size-h4: 16px;
|
||||||
|
|
||||||
/* MARGINS */
|
/* MARGINS */
|
||||||
--margins-xs: 5px;
|
--margins-xs: 5px;
|
||||||
|
@ -339,6 +343,9 @@ export const SessionGlobalStyles = createGlobalStyle`
|
||||||
--border-unread: ${lightUnreadBorder};
|
--border-unread: ${lightUnreadBorder};
|
||||||
--border-session: ${lightColorSessionBorder};
|
--border-session: ${lightColorSessionBorder};
|
||||||
|
|
||||||
|
/* CONSTANTS */
|
||||||
|
--compositionContainerHeight: 60px;
|
||||||
|
|
||||||
/* COLORS NOT CHANGING BETWEEN THEMES */
|
/* COLORS NOT CHANGING BETWEEN THEMES */
|
||||||
--color-warning: ${warning};
|
--color-warning: ${warning};
|
||||||
--color-destructive: ${destructive};
|
--color-destructive: ${destructive};
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ConversationNotificationSettingType,
|
ConversationNotificationSettingType,
|
||||||
ConversationTypeEnum,
|
ConversationTypeEnum,
|
||||||
} from '../../models/conversationAttributes';
|
} from '../../models/conversationAttributes';
|
||||||
|
import { ReactionList } from '../../types/Reaction';
|
||||||
|
|
||||||
export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call';
|
export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call';
|
||||||
export type PropsForCallNotification = {
|
export type PropsForCallNotification = {
|
||||||
|
@ -175,6 +176,8 @@ export type PropsForMessageWithoutConvoProps = {
|
||||||
serverId?: number;
|
serverId?: number;
|
||||||
status?: LastMessageStatusType;
|
status?: LastMessageStatusType;
|
||||||
attachments?: Array<PropsForAttachment>;
|
attachments?: Array<PropsForAttachment>;
|
||||||
|
reacts?: ReactionList;
|
||||||
|
reactsIndex?: number;
|
||||||
previews?: Array<any>;
|
previews?: Array<any>;
|
||||||
quote?: {
|
quote?: {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|
|
@ -29,6 +29,11 @@ export type UserDetailsModalState = {
|
||||||
userName: string;
|
userName: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type ReactModalsState = {
|
||||||
|
reaction: string;
|
||||||
|
messageId: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export type ModalState = {
|
export type ModalState = {
|
||||||
confirmModal: ConfirmModalState;
|
confirmModal: ConfirmModalState;
|
||||||
inviteContactModal: InviteContactModalState;
|
inviteContactModal: InviteContactModalState;
|
||||||
|
@ -45,6 +50,8 @@ export type ModalState = {
|
||||||
adminLeaveClosedGroup: AdminLeaveClosedGroupModalState;
|
adminLeaveClosedGroup: AdminLeaveClosedGroupModalState;
|
||||||
sessionPasswordModal: SessionPasswordModalState;
|
sessionPasswordModal: SessionPasswordModalState;
|
||||||
deleteAccountModal: DeleteAccountModalState;
|
deleteAccountModal: DeleteAccountModalState;
|
||||||
|
reactListModalState: ReactModalsState;
|
||||||
|
reactClearAllModalState: ReactModalsState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialModalState: ModalState = {
|
export const initialModalState: ModalState = {
|
||||||
|
@ -63,6 +70,8 @@ export const initialModalState: ModalState = {
|
||||||
adminLeaveClosedGroup: null,
|
adminLeaveClosedGroup: null,
|
||||||
sessionPasswordModal: null,
|
sessionPasswordModal: null,
|
||||||
deleteAccountModal: null,
|
deleteAccountModal: null,
|
||||||
|
reactListModalState: null,
|
||||||
|
reactClearAllModalState: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModalSlice = createSlice({
|
const ModalSlice = createSlice({
|
||||||
|
@ -114,6 +123,12 @@ const ModalSlice = createSlice({
|
||||||
updateDeleteAccountModal(state, action: PayloadAction<DeleteAccountModalState>) {
|
updateDeleteAccountModal(state, action: PayloadAction<DeleteAccountModalState>) {
|
||||||
return { ...state, deleteAccountModal: action.payload };
|
return { ...state, deleteAccountModal: action.payload };
|
||||||
},
|
},
|
||||||
|
updateReactListModal(state, action: PayloadAction<ReactModalsState>) {
|
||||||
|
return { ...state, reactListModalState: action.payload };
|
||||||
|
},
|
||||||
|
updateReactClearAllModal(state, action: PayloadAction<ReactModalsState>) {
|
||||||
|
return { ...state, reactClearAllModalState: action.payload };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -134,5 +149,7 @@ export const {
|
||||||
sessionPassword,
|
sessionPassword,
|
||||||
updateDeleteAccountModal,
|
updateDeleteAccountModal,
|
||||||
updateBanOrUnbanUserModal,
|
updateBanOrUnbanUserModal,
|
||||||
|
updateReactListModal,
|
||||||
|
updateReactClearAllModal,
|
||||||
} = actions;
|
} = actions;
|
||||||
export const modalReducer = reducer;
|
export const modalReducer = reducer;
|
||||||
|
|
|
@ -34,7 +34,9 @@ import { getConversationController } from '../../session/conversations';
|
||||||
import { UserUtils } from '../../session/utils';
|
import { UserUtils } from '../../session/utils';
|
||||||
import { Storage } from '../../util/storage';
|
import { Storage } from '../../util/storage';
|
||||||
import { ConversationTypeEnum } from '../../models/conversationAttributes';
|
import { ConversationTypeEnum } from '../../models/conversationAttributes';
|
||||||
import { 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;
|
export const getConversations = (state: StateType): ConversationsStateType => state.conversations;
|
||||||
|
|
||||||
|
@ -912,34 +914,48 @@ export const getMessageAvatarProps = createSelector(getMessagePropsByMessageId,
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
authorAvatarPath,
|
|
||||||
authorName,
|
|
||||||
sender,
|
|
||||||
authorProfileName,
|
|
||||||
conversationType,
|
|
||||||
direction,
|
|
||||||
isPublic,
|
|
||||||
isSenderAdmin,
|
|
||||||
} = props.propsForMessage;
|
|
||||||
|
|
||||||
const { lastMessageOfSeries } = props;
|
|
||||||
|
|
||||||
const messageAvatarProps: MessageAvatarSelectorProps = {
|
const messageAvatarProps: MessageAvatarSelectorProps = {
|
||||||
authorAvatarPath,
|
lastMessageOfSeries: props.lastMessageOfSeries,
|
||||||
authorName,
|
...pick(props.propsForMessage, [
|
||||||
sender,
|
'authorAvatarPath',
|
||||||
authorProfileName,
|
'authorName',
|
||||||
conversationType,
|
'sender',
|
||||||
direction,
|
'authorProfileName',
|
||||||
isPublic,
|
'conversationType',
|
||||||
isSenderAdmin,
|
'direction',
|
||||||
lastMessageOfSeries,
|
'isPublic',
|
||||||
|
'isSenderAdmin',
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
return messageAvatarProps;
|
return messageAvatarProps;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getMessageReactsProps = createSelector(getMessagePropsByMessageId, (props):
|
||||||
|
| MessageReactsSelectorProps
|
||||||
|
| undefined => {
|
||||||
|
if (!props || isEmpty(props)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgProps: MessageReactsSelectorProps = pick(props.propsForMessage, [
|
||||||
|
'convoId',
|
||||||
|
'conversationType',
|
||||||
|
'isPublic',
|
||||||
|
'reacts',
|
||||||
|
'serverId',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (msgProps.reacts) {
|
||||||
|
const sortedReacts = Object.entries(msgProps.reacts).sort((a, b) => {
|
||||||
|
return a[1].index < b[1].index ? -1 : a[1].index > b[1].index ? 1 : 0;
|
||||||
|
});
|
||||||
|
msgProps.sortedReacts = sortedReacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgProps;
|
||||||
|
});
|
||||||
|
|
||||||
export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, (props):
|
export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId, (props):
|
||||||
| MessagePreviewSelectorProps
|
| MessagePreviewSelectorProps
|
||||||
| undefined => {
|
| undefined => {
|
||||||
|
@ -947,12 +963,10 @@ export const getMessagePreviewProps = createSelector(getMessagePropsByMessageId,
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { attachments, previews } = props.propsForMessage;
|
const msgProps: MessagePreviewSelectorProps = pick(props.propsForMessage, [
|
||||||
|
'attachments',
|
||||||
const msgProps: MessagePreviewSelectorProps = {
|
'previews',
|
||||||
attachments,
|
]);
|
||||||
previews,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
});
|
});
|
||||||
|
@ -964,12 +978,7 @@ export const getMessageQuoteProps = createSelector(getMessagePropsByMessageId, (
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { direction, quote } = props.propsForMessage;
|
const msgProps: MessageQuoteSelectorProps = pick(props.propsForMessage, ['direction', 'quote']);
|
||||||
|
|
||||||
const msgProps: MessageQuoteSelectorProps = {
|
|
||||||
direction,
|
|
||||||
quote,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
});
|
});
|
||||||
|
@ -981,12 +990,7 @@ export const getMessageStatusProps = createSelector(getMessagePropsByMessageId,
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { direction, status } = props.propsForMessage;
|
const msgProps: MessageStatusSelectorProps = pick(props.propsForMessage, ['direction', 'status']);
|
||||||
|
|
||||||
const msgProps: MessageStatusSelectorProps = {
|
|
||||||
direction,
|
|
||||||
status,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
});
|
});
|
||||||
|
@ -998,15 +1002,13 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { direction, status, text, isDeleted, conversationType } = props.propsForMessage;
|
const msgProps: MessageTextSelectorProps = pick(props.propsForMessage, [
|
||||||
|
'direction',
|
||||||
const msgProps: MessageTextSelectorProps = {
|
'status',
|
||||||
direction,
|
'text',
|
||||||
status,
|
'isDeleted',
|
||||||
text,
|
'conversationType',
|
||||||
isDeleted,
|
]);
|
||||||
conversationType,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
});
|
});
|
||||||
|
@ -1018,41 +1020,23 @@ export const getMessageContextMenuProps = createSelector(getMessagePropsByMessag
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const msgProps: MessageContextMenuSelectorProps = pick(props.propsForMessage, [
|
||||||
attachments,
|
'attachments',
|
||||||
sender,
|
'sender',
|
||||||
convoId,
|
'convoId',
|
||||||
direction,
|
'direction',
|
||||||
status,
|
'status',
|
||||||
isDeletable,
|
'isDeletable',
|
||||||
isPublic,
|
'isPublic',
|
||||||
isOpenGroupV2,
|
'isOpenGroupV2',
|
||||||
weAreAdmin,
|
'weAreAdmin',
|
||||||
isSenderAdmin,
|
'isSenderAdmin',
|
||||||
text,
|
'text',
|
||||||
serverTimestamp,
|
'serverTimestamp',
|
||||||
timestamp,
|
'timestamp',
|
||||||
isBlocked,
|
'isBlocked',
|
||||||
isDeletableForEveryone,
|
'isDeletableForEveryone',
|
||||||
} = props.propsForMessage;
|
]);
|
||||||
|
|
||||||
const msgProps: MessageContextMenuSelectorProps = {
|
|
||||||
attachments,
|
|
||||||
sender,
|
|
||||||
convoId,
|
|
||||||
direction,
|
|
||||||
status,
|
|
||||||
isDeletable,
|
|
||||||
isPublic,
|
|
||||||
isOpenGroupV2,
|
|
||||||
weAreAdmin,
|
|
||||||
isSenderAdmin,
|
|
||||||
text,
|
|
||||||
serverTimestamp,
|
|
||||||
timestamp,
|
|
||||||
isBlocked,
|
|
||||||
isDeletableForEveryone,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
});
|
});
|
||||||
|
@ -1064,15 +1048,9 @@ export const getMessageAuthorProps = createSelector(getMessagePropsByMessageId,
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { authorName, sender, authorProfileName, direction } = props.propsForMessage;
|
|
||||||
const { firstMessageOfSeries } = props;
|
|
||||||
|
|
||||||
const msgProps: MessageAuthorSelectorProps = {
|
const msgProps: MessageAuthorSelectorProps = {
|
||||||
authorName,
|
firstMessageOfSeries: props.firstMessageOfSeries,
|
||||||
sender,
|
...pick(props.propsForMessage, ['authorName', 'sender', 'authorProfileName', 'direction']),
|
||||||
authorProfileName,
|
|
||||||
direction,
|
|
||||||
firstMessageOfSeries,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
|
@ -1096,23 +1074,16 @@ export const getMessageAttachmentProps = createSelector(getMessagePropsByMessage
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
attachments,
|
|
||||||
direction,
|
|
||||||
isTrustedForAttachmentDownload,
|
|
||||||
timestamp,
|
|
||||||
serverTimestamp,
|
|
||||||
sender,
|
|
||||||
convoId,
|
|
||||||
} = props.propsForMessage;
|
|
||||||
const msgProps: MessageAttachmentSelectorProps = {
|
const msgProps: MessageAttachmentSelectorProps = {
|
||||||
attachments: attachments || [],
|
attachments: props.propsForMessage.attachments || [],
|
||||||
direction,
|
...pick(props.propsForMessage, [
|
||||||
isTrustedForAttachmentDownload,
|
'direction',
|
||||||
timestamp,
|
'isTrustedForAttachmentDownload',
|
||||||
serverTimestamp,
|
'timestamp',
|
||||||
sender,
|
'serverTimestamp',
|
||||||
convoId,
|
'sender',
|
||||||
|
'convoId',
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
|
@ -1139,27 +1110,18 @@ export const getMessageContentSelectorProps = createSelector(getMessagePropsByMe
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
text,
|
|
||||||
direction,
|
|
||||||
timestamp,
|
|
||||||
serverTimestamp,
|
|
||||||
previews,
|
|
||||||
attachments,
|
|
||||||
quote,
|
|
||||||
} = props.propsForMessage;
|
|
||||||
|
|
||||||
const { firstMessageOfSeries, lastMessageOfSeries } = props;
|
|
||||||
const msgProps: MessageContentSelectorProps = {
|
const msgProps: MessageContentSelectorProps = {
|
||||||
direction,
|
firstMessageOfSeries: props.firstMessageOfSeries,
|
||||||
firstMessageOfSeries,
|
lastMessageOfSeries: props.lastMessageOfSeries,
|
||||||
lastMessageOfSeries,
|
...pick(props.propsForMessage, [
|
||||||
serverTimestamp,
|
'direction',
|
||||||
text,
|
'serverTimestamp',
|
||||||
timestamp,
|
'text',
|
||||||
previews,
|
'timestamp',
|
||||||
quote,
|
'previews',
|
||||||
attachments,
|
'quote',
|
||||||
|
'attachments',
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
|
@ -1172,18 +1134,9 @@ export const getMessageContentWithStatusesSelectorProps = createSelector(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
direction,
|
|
||||||
isDeleted,
|
|
||||||
attachments,
|
|
||||||
isTrustedForAttachmentDownload,
|
|
||||||
} = props.propsForMessage;
|
|
||||||
|
|
||||||
const msgProps: MessageContentWithStatusSelectorProps = {
|
const msgProps: MessageContentWithStatusSelectorProps = {
|
||||||
direction,
|
hasAttachments: Boolean(props.propsForMessage.attachments?.length) || false,
|
||||||
isDeleted,
|
...pick(props.propsForMessage, ['direction', 'isDeleted', 'isTrustedForAttachmentDownload']),
|
||||||
hasAttachments: Boolean(attachments?.length) || false,
|
|
||||||
isTrustedForAttachmentDownload,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
|
@ -1197,30 +1150,18 @@ export const getGenericReadableMessageSelectorProps = createSelector(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const msgProps: GenericReadableMessageSelectorProps = pick(props.propsForMessage, [
|
||||||
direction,
|
'convoId',
|
||||||
conversationType,
|
'direction',
|
||||||
expirationLength,
|
'conversationType',
|
||||||
expirationTimestamp,
|
'expirationLength',
|
||||||
isExpired,
|
'expirationTimestamp',
|
||||||
isUnread,
|
'isExpired',
|
||||||
receivedAt,
|
'isUnread',
|
||||||
isKickedFromGroup,
|
'receivedAt',
|
||||||
isDeleted,
|
'isKickedFromGroup',
|
||||||
} = props.propsForMessage;
|
'isDeleted',
|
||||||
|
]);
|
||||||
const msgProps: GenericReadableMessageSelectorProps = {
|
|
||||||
direction,
|
|
||||||
conversationType,
|
|
||||||
expirationLength,
|
|
||||||
expirationTimestamp,
|
|
||||||
isUnread,
|
|
||||||
isExpired,
|
|
||||||
convoId: props.propsForMessage.convoId,
|
|
||||||
receivedAt,
|
|
||||||
isKickedFromGroup,
|
|
||||||
isDeleted,
|
|
||||||
};
|
|
||||||
|
|
||||||
return msgProps;
|
return msgProps;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
InviteContactModalState,
|
InviteContactModalState,
|
||||||
ModalState,
|
ModalState,
|
||||||
OnionPathModalState,
|
OnionPathModalState,
|
||||||
|
ReactModalsState,
|
||||||
RecoveryPhraseModalState,
|
RecoveryPhraseModalState,
|
||||||
RemoveModeratorsModalState,
|
RemoveModeratorsModalState,
|
||||||
SessionPasswordModalState,
|
SessionPasswordModalState,
|
||||||
|
@ -98,3 +99,13 @@ export const getDeleteAccountModalState = createSelector(
|
||||||
getModal,
|
getModal,
|
||||||
(state: ModalState): DeleteAccountModalState => state.deleteAccountModal
|
(state: ModalState): DeleteAccountModalState => state.deleteAccountModal
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getReactListDialog = createSelector(
|
||||||
|
getModal,
|
||||||
|
(state: ModalState): ReactModalsState => state.reactListModalState
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getReactClearAllDialog = createSelector(
|
||||||
|
getModal,
|
||||||
|
(state: ModalState): ReactModalsState => state.reactClearAllModalState
|
||||||
|
);
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
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');
|
stubUtilWorker('arrayBufferToStringBase64', 'ba64');
|
||||||
Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any);
|
Sinon.stub(OnionSending, 'getOnionPathForSending').resolves([{}] as any);
|
||||||
|
Sinon.stub(OnionSending, 'endpointRequiresDecoding').returnsArg(0);
|
||||||
|
|
||||||
stubData('getGuardNodes').resolves([]);
|
stubData('getGuardNodes').resolves([]);
|
||||||
|
|
||||||
Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([
|
Sinon.stub(OpenGroupPollingUtils, 'getAllValidRoomInfos').returns([
|
||||||
{ roomId: 'room', serverPublicKey: 'whatever', serverUrl: 'serverUrl' },
|
{ roomId: 'room', serverPublicKey: 'whatever', serverUrl: 'serverUrl' },
|
||||||
]);
|
]);
|
||||||
|
Sinon.stub(OpenGroupPollingUtils, 'getOurOpenGroupHeaders').resolves({
|
||||||
|
'X-SOGS-Pubkey': '00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc',
|
||||||
|
'X-SOGS-Timestamp': '1642472103',
|
||||||
|
'X-SOGS-Nonce': 'CdB5nyKVmQGCw6s0Bvv8Ww==',
|
||||||
|
'X-SOGS-Signature':
|
||||||
|
'gYqpWZX6fnF4Gb2xQM3xaXs0WIYEI49+B8q4mUUEg8Rw0ObaHUWfoWjMHMArAtP9QlORfiydsKWz1o6zdPVeCQ==',
|
||||||
|
});
|
||||||
stubCreateObjectUrl();
|
stubCreateObjectUrl();
|
||||||
|
|
||||||
Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves();
|
Sinon.stub(OpenGroupMessageV2, 'fromJson').resolves();
|
||||||
|
|
|
@ -75,10 +75,12 @@ export type LocalizerKeys =
|
||||||
| 'mustBeApproved'
|
| 'mustBeApproved'
|
||||||
| 'appMenuHideOthers'
|
| 'appMenuHideOthers'
|
||||||
| 'sendFailed'
|
| 'sendFailed'
|
||||||
|
| 'expandedReactionsText'
|
||||||
| 'openMessageRequestInbox'
|
| 'openMessageRequestInbox'
|
||||||
| 'enterPassword'
|
| 'enterPassword'
|
||||||
| 'enterSessionIDOfRecipient'
|
| 'enterSessionIDOfRecipient'
|
||||||
| 'dialogClearAllDataDeletionFailedMultiple'
|
| 'dialogClearAllDataDeletionFailedMultiple'
|
||||||
|
| 'clearAllReactions'
|
||||||
| 'pinConversationLimitToastDescription'
|
| 'pinConversationLimitToastDescription'
|
||||||
| 'appMenuQuit'
|
| 'appMenuQuit'
|
||||||
| 'windowMenuZoom'
|
| 'windowMenuZoom'
|
||||||
|
@ -126,6 +128,7 @@ export type LocalizerKeys =
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
| 'hideRequestBannerDescription'
|
| 'hideRequestBannerDescription'
|
||||||
| 'noBlockedContacts'
|
| 'noBlockedContacts'
|
||||||
|
| 'reactionNotification'
|
||||||
| 'leaveGroupConfirmation'
|
| 'leaveGroupConfirmation'
|
||||||
| 'banUserAndDeleteAll'
|
| 'banUserAndDeleteAll'
|
||||||
| 'joinOpenGroupAfterInvitationConfirmationDesc'
|
| 'joinOpenGroupAfterInvitationConfirmationDesc'
|
||||||
|
@ -135,6 +138,7 @@ export type LocalizerKeys =
|
||||||
| 'banUser'
|
| 'banUser'
|
||||||
| 'answeredACall'
|
| 'answeredACall'
|
||||||
| 'sendMessage'
|
| 'sendMessage'
|
||||||
|
| 'readableListCounterSingular'
|
||||||
| 'recoveryPhraseRevealMessage'
|
| 'recoveryPhraseRevealMessage'
|
||||||
| 'showRecoveryPhrase'
|
| 'showRecoveryPhrase'
|
||||||
| 'autoUpdateSettingDescription'
|
| 'autoUpdateSettingDescription'
|
||||||
|
@ -180,6 +184,7 @@ export type LocalizerKeys =
|
||||||
| 'nameAndMessage'
|
| 'nameAndMessage'
|
||||||
| 'autoUpdateDownloadedMessage'
|
| 'autoUpdateDownloadedMessage'
|
||||||
| 'onionPathIndicatorTitle'
|
| 'onionPathIndicatorTitle'
|
||||||
|
| 'readableListCounterPlural'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
| 'mediaMessage'
|
| 'mediaMessage'
|
||||||
| 'addAsModerator'
|
| 'addAsModerator'
|
||||||
|
@ -227,6 +232,7 @@ export type LocalizerKeys =
|
||||||
| 'messageDeletedPlaceholder'
|
| 'messageDeletedPlaceholder'
|
||||||
| 'notificationFrom'
|
| 'notificationFrom'
|
||||||
| 'displayName'
|
| 'displayName'
|
||||||
|
| 'clear'
|
||||||
| 'invalidSessionId'
|
| 'invalidSessionId'
|
||||||
| 'audioPermissionNeeded'
|
| 'audioPermissionNeeded'
|
||||||
| 'createGroup'
|
| 'createGroup'
|
||||||
|
@ -319,6 +325,7 @@ export type LocalizerKeys =
|
||||||
| 'media'
|
| 'media'
|
||||||
| 'noMembersInThisGroup'
|
| 'noMembersInThisGroup'
|
||||||
| 'saveLogToDesktop'
|
| 'saveLogToDesktop'
|
||||||
|
| 'reactionTooltip'
|
||||||
| 'copyErrorAndQuit'
|
| 'copyErrorAndQuit'
|
||||||
| 'onlyAdminCanRemoveMembers'
|
| 'onlyAdminCanRemoveMembers'
|
||||||
| 'passwordTypeError'
|
| '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';
|
export type SizeClassType = 'default' | 'small' | 'medium' | 'large' | 'jumbo';
|
||||||
|
|
||||||
function getRegexUnicodeEmojis() {
|
function getRegexUnicodeEmojis() {
|
||||||
|
@ -36,3 +38,133 @@ export function getEmojiSizeClass(str: string): SizeClassType {
|
||||||
return 'jumbo';
|
return 'jumbo';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export let nativeEmojiData: NativeEmojiData | null = null;
|
||||||
|
|
||||||
|
export function initialiseEmojiData(data: any) {
|
||||||
|
const ariaLabels: Record<string, string> = {};
|
||||||
|
Object.entries(data.emojis).forEach(([key, value]: [string, any]) => {
|
||||||
|
value.search = `,${[
|
||||||
|
[value.id, false],
|
||||||
|
[value.name, true],
|
||||||
|
[value.keywords, false],
|
||||||
|
[value.emoticons, false],
|
||||||
|
]
|
||||||
|
.map(([strings, split]) => {
|
||||||
|
if (!strings) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (Array.isArray(strings) ? strings : [strings])
|
||||||
|
.map(string =>
|
||||||
|
(split ? string.split(/[-|_|\s]+/) : [string]).map((s: string) => s.toLowerCase())
|
||||||
|
)
|
||||||
|
.flat();
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
.filter(a => a && a.trim())
|
||||||
|
.join(',')})}`;
|
||||||
|
|
||||||
|
(value as FixedBaseEmoji).skins.forEach(skin => {
|
||||||
|
ariaLabels[skin.native] = value.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
data.emojis[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
data.ariaLabels = ariaLabels;
|
||||||
|
nativeEmojiData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous version of Emoji Mart's SearchIndex.search()
|
||||||
|
// If you upgrade the package things will probably break
|
||||||
|
export function searchSync(query: string, args?: any): Array<any> {
|
||||||
|
if (!nativeEmojiData) {
|
||||||
|
window.log.error('No native emoji data found');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query || !query.trim().length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxResults = args && args.maxResults ? args.maxResults : 90;
|
||||||
|
const values = query
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/(\w)-/, '$1 ')
|
||||||
|
.split(/[\s|,]+/)
|
||||||
|
.filter((word: string, i: number, words: Array<string>) => {
|
||||||
|
return word.trim() && words.indexOf(word) === i;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!values.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool: any = Object.values(nativeEmojiData.emojis);
|
||||||
|
let results: Array<FixedBaseEmoji> = [];
|
||||||
|
let scores: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (!pool.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
results = [];
|
||||||
|
scores = {};
|
||||||
|
|
||||||
|
for (const emoji of pool) {
|
||||||
|
if (!emoji.search) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const score: number = emoji.search.indexOf(`,${value}`);
|
||||||
|
if (score === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(emoji);
|
||||||
|
scores[emoji.id] = scores[emoji.id] ? scores[emoji.id] : 0;
|
||||||
|
scores[emoji.id] += emoji.id === value ? 0 : score + 1;
|
||||||
|
}
|
||||||
|
pool = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length < 2) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a: FixedBaseEmoji, b: FixedBaseEmoji) => {
|
||||||
|
const aScore = scores[a.id];
|
||||||
|
const bScore = scores[b.id];
|
||||||
|
|
||||||
|
if (aScore === bScore) {
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aScore - bScore;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length > maxResults) {
|
||||||
|
results = results.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No longer exists on emoji-mart v5.1
|
||||||
|
export function getEmojiDataFromNative(nativeString: string): FixedBaseEmoji | null {
|
||||||
|
if (!nativeEmojiData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = Object.values(nativeEmojiData.emojis).filter((emoji: any) => {
|
||||||
|
const skinMatches = (emoji as FixedBaseEmoji).skins.filter((skin: any) => {
|
||||||
|
return skin.native === nativeString;
|
||||||
|
});
|
||||||
|
return skinMatches.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0] as FixedBaseEmoji;
|
||||||
|
}
|
||||||
|
|
|
@ -40,3 +40,26 @@ export const setupi18n = (locale: string, messages: LocaleMessagesType) => {
|
||||||
|
|
||||||
return getMessage;
|
return getMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export let langNotSupportedMessageShown = false;
|
||||||
|
|
||||||
|
export const loadEmojiPanelI18n = async () => {
|
||||||
|
if (!window) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = (window.i18n as any).getLocale();
|
||||||
|
if (lang !== 'en') {
|
||||||
|
try {
|
||||||
|
const langData = await import(`@emoji-mart/data/i18n/${lang}.json`);
|
||||||
|
return langData;
|
||||||
|
} catch (err) {
|
||||||
|
if (!langNotSupportedMessageShown) {
|
||||||
|
window?.log?.warn(
|
||||||
|
'Language is not supported by emoji-mart package. See https://github.com/missive/emoji-mart/tree/main/packages/emoji-mart-data/i18n'
|
||||||
|
);
|
||||||
|
langNotSupportedMessageShown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
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 { Data } from '../data/data';
|
||||||
import { SessionKeyPair } from '../receiver/keypairs';
|
import { SessionKeyPair } from '../receiver/keypairs';
|
||||||
|
import { DEFAULT_RECENT_REACTS } from '../session/constants';
|
||||||
|
|
||||||
let ready = false;
|
let ready = false;
|
||||||
|
|
||||||
|
@ -136,4 +137,17 @@ export async function saveRecoveryPhrase(mnemonic: string) {
|
||||||
return Storage.put('mnemonic', mnemonic);
|
return Storage.put('mnemonic', mnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRecentReactions(): Array<string> {
|
||||||
|
const reactions = Storage.get('recent_reactions') as string;
|
||||||
|
if (reactions) {
|
||||||
|
return reactions.split(' ');
|
||||||
|
} else {
|
||||||
|
return DEFAULT_RECENT_REACTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRecentReations(reactions: Array<string>) {
|
||||||
|
return Storage.put('recent_reactions', reactions.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
export const Storage = { fetch, put, get, remove, onready, reset };
|
export const Storage = { fetch, put, get, remove, onready, reset };
|
||||||
|
|
27
yarn.lock
27
yarn.lock
|
@ -645,6 +645,11 @@
|
||||||
global-agent "^3.0.0"
|
global-agent "^3.0.0"
|
||||||
global-tunnel-ng "^2.7.1"
|
global-tunnel-ng "^2.7.1"
|
||||||
|
|
||||||
|
"@emoji-mart/data@1.0.2":
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.0.2.tgz#2b94c5b5f2c79611c12238438dad9516576a09ab"
|
||||||
|
integrity sha512-+ZdzBM4llDJJvjuCEsdOYVoSlNA16MMmxKG3oF5LARkwhx6N5clr6phzneWV1qIwJsywqwG7NaBjH8DV6yzjcA==
|
||||||
|
|
||||||
"@emotion/is-prop-valid@^0.8.8":
|
"@emotion/is-prop-valid@^0.8.8":
|
||||||
version "0.8.8"
|
version "0.8.8"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
||||||
|
@ -1764,10 +1769,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
electron "*"
|
electron "*"
|
||||||
|
|
||||||
"@types/emoji-mart@^2.11.3":
|
"@types/emoji-mart@3.0.9":
|
||||||
version "2.11.3"
|
version "3.0.9"
|
||||||
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-2.11.3.tgz#9949f6a8a231aea47aac1b2d4212597b41140b07"
|
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.9.tgz#2f7ef5d9ec194f28029c46c81a5fc1e5b0efa73c"
|
||||||
integrity sha512-pRlU6+CFIB+9+FwjGGCVtDQq78u7N0iUijrO0Qh1j9RJ6T23DSNNfe0X6kf81N4ubVhF9jVckCI1M3kHpkwjqA==
|
integrity sha512-qdBo/2Y8MXaJ/2spKjDZocuq79GpnOhkwMHnK2GnVFa8WYFgfA+ei6sil3aeWQPCreOKIx9ogPpR5+7MaOqYAA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
@ -3818,12 +3823,10 @@ elliptic@^6.5.3:
|
||||||
minimalistic-assert "^1.0.1"
|
minimalistic-assert "^1.0.1"
|
||||||
minimalistic-crypto-utils "^1.0.1"
|
minimalistic-crypto-utils "^1.0.1"
|
||||||
|
|
||||||
emoji-mart@^2.11.2:
|
emoji-mart@5.1.0:
|
||||||
version "2.11.2"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-2.11.2.tgz#ed331867f7f55bb33c8421c9a493090fa4a378c7"
|
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.1.0.tgz#8a36a872e1297747342d1385bd7b7141ac2f4365"
|
||||||
integrity sha512-IdHZR5hc3mipTY/r0ergtqBgQ96XxmRdQDSg7fsL+GiJQQ4akMws6+cjLSyIhGQxtvNuPVNaEQiAlU00NsyZUg==
|
integrity sha512-ytXgeemyw4FormPQqWd35Vh06ZSnQFhVUqW51kASZzzjhQOPSGtiN3VCC7vDq94Pkxmsbet+Gps/qj5N90mEnw==
|
||||||
dependencies:
|
|
||||||
prop-types "^15.6.0"
|
|
||||||
|
|
||||||
emoji-regex@^8.0.0:
|
emoji-regex@^8.0.0:
|
||||||
version "8.0.0"
|
version "8.0.0"
|
||||||
|
@ -7340,7 +7343,7 @@ progress@^2.0.3:
|
||||||
promise-inflight@^1.0.1:
|
promise-inflight@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
|
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
|
||||||
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
|
integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==
|
||||||
|
|
||||||
promise-retry@^2.0.1:
|
promise-retry@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
|
@ -7350,7 +7353,7 @@ promise-retry@^2.0.1:
|
||||||
err-code "^2.0.2"
|
err-code "^2.0.2"
|
||||||
retry "^0.12.0"
|
retry "^0.12.0"
|
||||||
|
|
||||||
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||||
version "15.8.1"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
|
|
Loading…
Reference in a new issue