mirror of
https://github.com/oxen-io/session-desktop.git
synced 2023-12-14 02:12:57 +01:00
Merge branch 'dev' into menu-redesign
This commit is contained in:
commit
0eac74dd07
58 changed files with 1564 additions and 781 deletions
2
.github/workflows/build-binaries.yml
vendored
2
.github/workflows/build-binaries.yml
vendored
|
@ -61,7 +61,7 @@ jobs:
|
|||
run: yarn install --frozen-lockfile --network-timeout 600000 --force
|
||||
|
||||
- name: Generate and concat files
|
||||
run: yarn generate && yarn transpile
|
||||
run: yarn build-everything
|
||||
|
||||
- name: Lint Files
|
||||
# no need to lint files on all platforms. Just do it once on the quicker one
|
||||
|
|
2
.github/workflows/pull-request.yml
vendored
2
.github/workflows/pull-request.yml
vendored
|
@ -58,7 +58,7 @@ jobs:
|
|||
run: yarn install --frozen-lockfile --network-timeout 600000 --force
|
||||
|
||||
- name: Generate and concat files
|
||||
run: yarn generate && yarn transpile
|
||||
run: yarn build-everything
|
||||
|
||||
- name: Lint Files
|
||||
# no need to lint files on all platforms. Just do it once on the quicker one
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -58,7 +58,7 @@ jobs:
|
|||
run: yarn install --frozen-lockfile --network-timeout 600000 --force
|
||||
|
||||
- name: Generate and concat files
|
||||
run: yarn generate && yarn transpile
|
||||
run: yarn build-everything
|
||||
|
||||
- name: Lint Files
|
||||
# no need to lint files on all platforms. Just do it once on the quicker one
|
||||
|
|
|
@ -66,7 +66,7 @@ nvm install # install the current node version used in this project
|
|||
nvm use # use the current node version used in this project
|
||||
npm install -g yarn # install yarn globally for this node version
|
||||
yarn install --frozen-lockfile # install all dependecies of this project
|
||||
yarn grunt # transpile and assemble files
|
||||
yarn build-everything # transpile and assemble files
|
||||
yarn start-prod # start the app on production mode (currently this is the only one supported)
|
||||
```
|
||||
|
||||
|
@ -116,7 +116,7 @@ nvm install # install the current node version used in this project
|
|||
nvm use # use the current node version used in this project
|
||||
npm install -g yarn # install yarn globally for this node version
|
||||
yarn install --frozen-lockfile # install all dependecies of this project
|
||||
yarn grunt # transpile and assemble files
|
||||
yarn build-everything # transpile and assemble files
|
||||
yarn start-prod # start the app on production mode (currently this is the only one supported)
|
||||
```
|
||||
|
||||
|
@ -125,20 +125,21 @@ yarn start-prod # start the app on production mode (currently this is the only o
|
|||
### Commands
|
||||
|
||||
The `rpm` package is required for running the build-release script. Run the appropriate command to install the `rpm` package:
|
||||
|
||||
```sh
|
||||
sudo pacman -S rpm # Arch
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo apt install rpm # Ubuntu/Debian
|
||||
```
|
||||
|
||||
|
||||
Run the following to build the binaries for your specific system OS.
|
||||
|
||||
```
|
||||
npm install yarn --no-save
|
||||
yarn install --frozen-lockfile
|
||||
yarn generate
|
||||
yarn build-everything
|
||||
yarn build-release
|
||||
```
|
||||
|
||||
|
|
|
@ -60,10 +60,9 @@ git clone https://github.com/oxen-io/session-desktop.git
|
|||
cd session-desktop
|
||||
npm install --global yarn # (only if you don’t already have `yarn`)
|
||||
yarn install --frozen-lockfile # Install and build dependencies (this will take a while)
|
||||
yarn grunt # Generate final JS and CSS assets
|
||||
yarn icon-gen # Generate full set of icons for Electron
|
||||
yarn build-everything
|
||||
yarn test # A good idea to make sure tests run first
|
||||
yarn start # Start Session!
|
||||
yarn start-prod # Start Session!
|
||||
```
|
||||
|
||||
You'll need to restart the application regularly to see your changes, as there
|
||||
|
@ -72,14 +71,10 @@ is no automatic restart mechanism. Alternatively, keep the developer tools open
|
|||
<kbd>Cmd</kbd> + <kbd>R</kbd> (macOS) or <kbd>Ctrl</kbd> + <kbd>R</kbd>
|
||||
(Windows & Linux).
|
||||
|
||||
Also, note that the assets loaded by the application are not necessarily the same files
|
||||
you’re touching. You may not see your changes until you run `yarn grunt` on the
|
||||
command-line like you did during setup. You can make it easier on yourself by generating
|
||||
the latest built assets when you change a file. Run this in its own terminal instance
|
||||
while you make changes:
|
||||
|
||||
```
|
||||
yarn grunt dev # runs until you stop it, re-generating built assets on file changes
|
||||
yarn build-everything:watch # runs until you stop it, re-generating built assets on file changes
|
||||
# Once this command is waiting for changes, you will need to run in another terminal `yarn parcel-util-worker` to fix the "exports undefined" error on start.
|
||||
# If you do change the sass while this command is running, it won't pick it up. You need to either run `yarn sass` or have `yarn sass:watch` running in a separate terminal.
|
||||
```
|
||||
|
||||
## Multiple instances
|
||||
|
@ -93,8 +88,6 @@ directory from `%appData%/Session` to `%appData%/Session-{environment}-{instance
|
|||
There are a few scripts which you can use:
|
||||
|
||||
```
|
||||
yarn start - Start development
|
||||
MULTI=1 yarn start - Start second instance of development
|
||||
yarn start-prod - Start production but in development mode
|
||||
MULTI=1 yarn start-prod - Start another instance of production
|
||||
```
|
||||
|
@ -103,7 +96,7 @@ For more than 2 clients, you may run the above command with `NODE_APP_INSTANCE`
|
|||
For example, running:
|
||||
|
||||
```
|
||||
NODE_APP_INSTANCE=alice yarn start
|
||||
NODE_APP_INSTANCE=alice yarn start-prod
|
||||
```
|
||||
|
||||
Will run the development environment with the `alice` instance and thus create a seperate storage profile.
|
||||
|
@ -188,6 +181,6 @@ see how they did things.
|
|||
You can build a production binary by running the following:
|
||||
|
||||
```
|
||||
yarn generate
|
||||
yarn build-everything
|
||||
yarn build-release
|
||||
```
|
||||
|
|
10
Gruntfile.js
10
Gruntfile.js
|
@ -3,14 +3,6 @@
|
|||
module.exports = grunt => {
|
||||
grunt.initConfig({
|
||||
pkg: grunt.file.readJSON('package.json'),
|
||||
exec: {
|
||||
transpile: {
|
||||
cmd: 'yarn transpile',
|
||||
},
|
||||
'build-protobuf': {
|
||||
cmd: 'yarn build-protobuf',
|
||||
},
|
||||
},
|
||||
gitinfo: {}, // to be populated by grunt gitinfo
|
||||
});
|
||||
|
||||
|
@ -45,5 +37,5 @@ module.exports = grunt => {
|
|||
});
|
||||
|
||||
grunt.registerTask('date', ['gitinfo']);
|
||||
grunt.registerTask('default', ['exec:build-protobuf', 'exec:transpile', 'date', 'getCommitHash']);
|
||||
grunt.registerTask('default', ['date', 'getCommitHash']);
|
||||
};
|
||||
|
|
|
@ -141,11 +141,7 @@
|
|||
"zoomFactorSettingTitle": "Zoom Factor",
|
||||
"pruneSettingTitle": "Prune Old Community Messages",
|
||||
"pruneSettingDescription": "Prune messages older than 6 months from Communities on start",
|
||||
"pruningOpengroupDialogTitle": "Community pruning",
|
||||
"pruningOpengroupDialogMessage": "Pruning old communities messages improves performance. Enable pruning for communities messages older than 6 months?",
|
||||
"pruningOpengroupDialogSubMessage": "You can change this setting in the Session settings menu",
|
||||
"enable": "Enable",
|
||||
"keepDisabled": "Keep disabled",
|
||||
"notificationSettingsDialog": "When messages arrive, display notifications that reveal...",
|
||||
"disableNotifications": "Mute notifications",
|
||||
"nameAndMessage": "Name and content",
|
||||
|
@ -244,8 +240,8 @@
|
|||
"cannotRemoveCreatorFromGroup": "Cannot remove this user",
|
||||
"cannotRemoveCreatorFromGroupDesc": "You cannot remove this user as they are the creator of the group.",
|
||||
"noContactsForGroup": "You don't have any contacts yet",
|
||||
"failedToAddAsModerator": "Failed to add user as moderator",
|
||||
"failedToRemoveFromModerator": "Failed to remove user from the moderator list",
|
||||
"failedToAddAsModerator": "Failed to add user as admin",
|
||||
"failedToRemoveFromModerator": "Failed to remove user from the admin list",
|
||||
"copyMessage": "Copy message text",
|
||||
"selectMessage": "Select message",
|
||||
"editGroup": "Edit group",
|
||||
|
@ -301,15 +297,15 @@
|
|||
"editProfileModalTitle": "Profile",
|
||||
"groupNamePlaceholder": "Group Name",
|
||||
"inviteContacts": "Invite Contacts",
|
||||
"addModerators": "Add Moderators",
|
||||
"removeModerators": "Remove Moderators",
|
||||
"addAsModerator": "Add as Moderator",
|
||||
"removeFromModerators": "Remove From Moderators",
|
||||
"addModerators": "Add Admins",
|
||||
"removeModerators": "Remove Admins",
|
||||
"addAsModerator": "Add as Admin",
|
||||
"removeFromModerators": "Remove From Admins",
|
||||
"add": "Add",
|
||||
"addingContacts": "Adding contacts to $name$",
|
||||
"noContactsToAdd": "No contacts to add",
|
||||
"noMembersInThisGroup": "No other members in this group",
|
||||
"noModeratorsToRemove": "no moderators to remove",
|
||||
"noModeratorsToRemove": "no admins to remove",
|
||||
"onlyAdminCanRemoveMembers": "You are not the creator",
|
||||
"onlyAdminCanRemoveMembersDesc": "Only the creator of the group can remove users",
|
||||
"createAccount": "Create account",
|
||||
|
@ -357,8 +353,8 @@
|
|||
"pickClosedGroupMember": "Please pick at least 1 group member",
|
||||
"closedGroupMaxSize": "A group cannot have more than 100 members",
|
||||
"noBlockedContacts": "No blocked contacts",
|
||||
"userAddedToModerators": "User added to moderator list",
|
||||
"userRemovedFromModerators": "User removed from moderator list",
|
||||
"userAddedToModerators": "User added to admin list",
|
||||
"userRemovedFromModerators": "User removed from admin list",
|
||||
"orJoinOneOfThese": "Or join one of these...",
|
||||
"helpUsTranslateSession": "Help us Translate Session",
|
||||
"translation": "Translation",
|
||||
|
@ -459,9 +455,15 @@
|
|||
"hideBanner": "Hide",
|
||||
"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"
|
||||
"otherSingular": "$number$ other",
|
||||
"otherPlural": "$number$ others",
|
||||
"reactionPopup": "reacted with",
|
||||
"reactionPopupOne": "$name$",
|
||||
"reactionPopupTwo": "$name$ & $name2$",
|
||||
"reactionPopupThree": "$name$, $name2$ & $name3$",
|
||||
"reactionPopupMany": "$name$, $name2$, $name3$ &",
|
||||
"reactionListCountSingular": "And $otherSingular$ has reacted <span>$emoji$</span> to this message",
|
||||
"reactionListCountPlural": "And $otherPlural$ have reacted <span>$emoji$</span> to this message"
|
||||
}
|
||||
|
|
43
package.json
43
package.json
|
@ -2,7 +2,7 @@
|
|||
"name": "session-desktop",
|
||||
"productName": "Session",
|
||||
"description": "Private messaging from your desktop",
|
||||
"version": "1.9.1",
|
||||
"version": "1.10.0",
|
||||
"license": "GPL-3.0",
|
||||
"author": {
|
||||
"name": "Oxen Labs",
|
||||
|
@ -45,12 +45,27 @@
|
|||
"jpeg-js": "^0.4.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "yarn patch-package && yarn electron-builder install-app-deps && yarn rebuild-curve25519-js",
|
||||
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod$MULTI electron .",
|
||||
"grunt": "yarn clean-transpile && yarn sass && grunt",
|
||||
"generate": "yarn grunt --force",
|
||||
|
||||
"build-everything": "yarn clean && yarn protobuf && grunt && yarn sass && tsc && yarn parcel-util-worker",
|
||||
"build-everything:watch": "yarn clean && yarn protobuf && grunt && yarn sass && yarn parcel-util-worker && tsc -w",
|
||||
|
||||
"protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js --force-long",
|
||||
"sass": "rimraf 'stylesheets/dist/' && parcel build --target sass --no-autoinstall --no-cache",
|
||||
"sass:watch": "rimraf 'stylesheets/dist/' && parcel watch --target sass --no-autoinstall --no-cache",
|
||||
"parcel-util-worker": "rimraf ts/webworker/workers/util.worker.js && parcel build --target util-worker --no-autoinstall --no-cache",
|
||||
"clean": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
|
||||
|
||||
"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}\"",
|
||||
|
||||
"integration-test": "npx playwright test",
|
||||
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots",
|
||||
"test": "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\"",
|
||||
|
||||
"build-release": "run-script-os",
|
||||
"build-release-non-linux": "yarn sass && yarn generate && cross-env SIGNAL_ENV=production electron-builder --config.extraMetadata.environment=production --publish=never --config.directories.output=release",
|
||||
"build-release-non-linux": "yarn build-everything && cross-env SIGNAL_ENV=production electron-builder --config.extraMetadata.environment=production --publish=never --config.directories.output=release",
|
||||
"build-release:win32": "yarn build-release-non-linux",
|
||||
"build-release:macos": "yarn build-release-non-linux",
|
||||
"build-release:linux": "yarn sedtoDeb; yarn build-release-non-linux && yarn sedtoAppImage && yarn build-release-non-linux && yarn sedtoDeb",
|
||||
|
@ -60,22 +75,11 @@
|
|||
"build-release-publish:macos": "yarn build-release-publish-non-linux",
|
||||
"build-release-publish:linux": "yarn sedtoDeb; yarn build-release-publish-non-linux && yarn sedtoAppImage && yarn build-release-publish-non-linux && yarn sedtoDeb",
|
||||
"appImage": "yarn sedtoAppImage; yarn build-release-non-linux; yarn sedtoDeb",
|
||||
"build-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js --force-long",
|
||||
"test": "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 .",
|
||||
"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 && yarn sass",
|
||||
"transpile:watch": "yarn grunt --force; tsc -w",
|
||||
"integration-test": "npx playwright test",
|
||||
"integration-test-snapshots": "npx playwright test -g 'profile picture' --update-snapshots",
|
||||
"clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
|
||||
"ready": "yarn grunt && yarn lint-full && yarn test",
|
||||
"sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
|
||||
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json",
|
||||
"sass": "rimraf 'stylesheets/dist/' && parcel build --target sass --no-autoinstall --no-cache",
|
||||
"sass:watch": "rimraf 'stylesheets/dist/' && parcel watch --target sass --no-autoinstall --no-cache",
|
||||
"parcel-util-worker": "rimraf ts/webworker/workers/util.worker.js && parcel build --target util-worker --no-autoinstall --no-cache",
|
||||
"ready": "yarn build-everything && yarn lint-full && yarn test",
|
||||
|
||||
"postinstall": "yarn patch-package && yarn electron-builder install-app-deps && yarn rebuild-curve25519-js",
|
||||
"rebuild-curve25519-js": "cd node_modules/curve25519-js && yarn install && yarn build && cd ../../"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -307,6 +311,7 @@
|
|||
"package.json",
|
||||
"config/default.json",
|
||||
"config/${env.SIGNAL_ENV}.json",
|
||||
"config/local-${env.SIGNAL_ENV}.json",
|
||||
"background.html",
|
||||
"about.html",
|
||||
"password.html",
|
||||
|
|
|
@ -339,14 +339,6 @@ label {
|
|||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.module-message__container--outgoing--first-of-series {
|
||||
border-top-right-radius: $session_message-container-border-radius;
|
||||
}
|
||||
|
||||
.module-message__container--outgoing--last-of-series {
|
||||
border-bottom-right-radius: $session_message-container-border-radius;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
.module-avatar img {
|
||||
box-shadow: 0px 0px 5px 0px rgba(255, 255, 255, 0.2);
|
||||
|
|
|
@ -40,19 +40,6 @@
|
|||
background: none;
|
||||
}
|
||||
|
||||
border-top-right-radius: $message-container-border-radius;
|
||||
border-bottom-right-radius: $message-container-border-radius;
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
|
||||
&--first-of-series {
|
||||
border-top-left-radius: $message-container-border-radius;
|
||||
}
|
||||
|
||||
&--last-of-series {
|
||||
border-bottom-left-radius: $message-container-border-radius;
|
||||
}
|
||||
|
||||
.module-message__text {
|
||||
color: var(--color-received-message-text);
|
||||
display: flex;
|
||||
|
@ -71,11 +58,6 @@
|
|||
}
|
||||
|
||||
&__container--outgoing {
|
||||
border-top-left-radius: $message-container-border-radius;
|
||||
border-bottom-left-radius: $message-container-border-radius;
|
||||
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
&--opaque {
|
||||
background: var(--color-sent-message-background);
|
||||
}
|
||||
|
@ -84,14 +66,6 @@
|
|||
background: none;
|
||||
}
|
||||
|
||||
&--first-of-series {
|
||||
border-top-right-radius: $message-container-border-radius;
|
||||
}
|
||||
|
||||
&--last-of-series {
|
||||
border-bottom-right-radius: $message-container-border-radius;
|
||||
}
|
||||
|
||||
.module-message__text {
|
||||
color: var(--color-sent-message-text);
|
||||
|
||||
|
|
|
@ -74,7 +74,6 @@ $header-height: 55px;
|
|||
$button-height: 24px;
|
||||
|
||||
$border-radius: 5px;
|
||||
$message-container-border-radius: 16px;
|
||||
|
||||
$font-size: 14px;
|
||||
$font-size-small: calc(13 / 14) + em;
|
||||
|
|
|
@ -88,6 +88,8 @@ export const Image = (props: Props) => {
|
|||
style={{
|
||||
maxHeight: `${height}px`,
|
||||
maxWidth: `${width}px`,
|
||||
minHeight: `${height}px`,
|
||||
minWidth: `${width}px`,
|
||||
}}
|
||||
data-attachmentindex={attachmentIndex}
|
||||
>
|
||||
|
@ -116,6 +118,8 @@ export const Image = (props: Props) => {
|
|||
style={{
|
||||
maxHeight: `${height}px`,
|
||||
maxWidth: `${width}px`,
|
||||
minHeight: `${height}px`,
|
||||
minWidth: `${width}px`,
|
||||
width: forceSquare ? `${width}px` : '',
|
||||
height: forceSquare ? `${height}px` : '',
|
||||
}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useContext, useLayoutEffect, useState } from 'react';
|
||||
import React, { useContext, useLayoutEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { getQuotedMessageToAnimate } from '../../state/selectors/conversations';
|
||||
|
@ -35,19 +35,28 @@ const LastSeenText = styled.div`
|
|||
color: var(--color-last-seen-indicator);
|
||||
`;
|
||||
|
||||
export const SessionLastSeenIndicator = (props: { messageId: string }) => {
|
||||
export const SessionLastSeenIndicator = (props: {
|
||||
messageId: string;
|
||||
didScroll: boolean;
|
||||
setDidScroll: (scroll: boolean) => void;
|
||||
}) => {
|
||||
// if this unread-indicator is not unique it's going to cause issues
|
||||
const [didScroll, setDidScroll] = useState(false);
|
||||
const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate);
|
||||
|
||||
const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext);
|
||||
|
||||
// if this unread-indicator is rendered,
|
||||
// we want to scroll here only if the conversation was not opened to a specific message
|
||||
const { messageId, didScroll, setDidScroll } = props;
|
||||
|
||||
/**
|
||||
* If this unread-indicator is rendered, we want to scroll here only if:
|
||||
* 1. the conversation was not opened to a specific message (quoted message)
|
||||
* 2. we already scrolled to this unread banner once for this convo https://github.com/oxen-io/session-desktop/issues/2308
|
||||
*
|
||||
* To achieve 2. we store the didScroll state in the parent and track the last rendered conversation in it.
|
||||
*/
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!quotedMessageToAnimate && !didScroll) {
|
||||
scrollToLoadedMessage(props.messageId, 'unread-indicator');
|
||||
scrollToLoadedMessage(messageId, 'unread-indicator');
|
||||
setDidScroll(true);
|
||||
} else if (quotedMessageToAnimate) {
|
||||
setDidScroll(true);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useLayoutEffect } from 'react';
|
||||
import React, { useLayoutEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
// tslint:disable-next-line: no-submodule-imports
|
||||
import useKey from 'react-use/lib/useKey';
|
||||
|
@ -15,6 +15,7 @@ import {
|
|||
import {
|
||||
getOldBottomMessageId,
|
||||
getOldTopMessageId,
|
||||
getSelectedConversationKey,
|
||||
getSortedMessagesTypesOfSelectedConversation,
|
||||
} from '../../state/selectors/conversations';
|
||||
import { GroupUpdateMessage } from './message/message-item/GroupUpdateMessage';
|
||||
|
@ -32,6 +33,8 @@ function isNotTextboxEvent(e: KeyboardEvent) {
|
|||
return (e?.target as any)?.type === undefined;
|
||||
}
|
||||
|
||||
let previousRenderedConvo: string | undefined;
|
||||
|
||||
export const SessionMessagesList = (props: {
|
||||
scrollAfterLoadMore: (
|
||||
messageIdToScrollTo: string,
|
||||
|
@ -43,6 +46,9 @@ export const SessionMessagesList = (props: {
|
|||
onEndPressed: () => void;
|
||||
}) => {
|
||||
const messagesProps = useSelector(getSortedMessagesTypesOfSelectedConversation);
|
||||
const convoKey = useSelector(getSelectedConversationKey);
|
||||
|
||||
const [didScroll, setDidScroll] = useState(false);
|
||||
const oldTopMessageId = useSelector(getOldTopMessageId);
|
||||
const oldBottomMessageId = useSelector(getOldBottomMessageId);
|
||||
|
||||
|
@ -84,12 +90,22 @@ export const SessionMessagesList = (props: {
|
|||
}
|
||||
});
|
||||
|
||||
if (didScroll && previousRenderedConvo !== convoKey) {
|
||||
setDidScroll(false);
|
||||
previousRenderedConvo = convoKey;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messagesProps.map(messageProps => {
|
||||
const messageId = messageProps.message.props.messageId;
|
||||
const unreadIndicator = messageProps.showUnreadIndicator ? (
|
||||
<SessionLastSeenIndicator key={`unread-indicator-${messageId}`} messageId={messageId} />
|
||||
<SessionLastSeenIndicator
|
||||
key={'unread-indicator'}
|
||||
messageId={messageId}
|
||||
didScroll={didScroll}
|
||||
setDidScroll={setDidScroll}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const dateBreak =
|
||||
|
@ -100,24 +116,22 @@ export const SessionMessagesList = (props: {
|
|||
messageId={messageId}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const componentToMerge = [dateBreak, unreadIndicator];
|
||||
if (messageProps.message?.messageType === 'group-notification') {
|
||||
const msgProps = messageProps.message.props as PropsForGroupUpdate;
|
||||
return [<GroupUpdateMessage key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
|
||||
return [<GroupUpdateMessage key={messageId} {...msgProps} />, ...componentToMerge];
|
||||
}
|
||||
|
||||
if (messageProps.message?.messageType === 'group-invitation') {
|
||||
const msgProps = messageProps.message.props as PropsForGroupInvitation;
|
||||
return [<GroupInvitation key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
|
||||
return [<GroupInvitation key={messageId} {...msgProps} />, ...componentToMerge];
|
||||
}
|
||||
|
||||
if (messageProps.message?.messageType === 'message-request-response') {
|
||||
const msgProps = messageProps.message.props as PropsForMessageRequestResponse;
|
||||
|
||||
return [
|
||||
<MessageRequestResponse key={messageId} {...msgProps} />,
|
||||
dateBreak,
|
||||
unreadIndicator,
|
||||
];
|
||||
return [<MessageRequestResponse key={messageId} {...msgProps} />, ...componentToMerge];
|
||||
}
|
||||
|
||||
if (messageProps.message?.messageType === 'data-extraction') {
|
||||
|
@ -125,28 +139,27 @@ export const SessionMessagesList = (props: {
|
|||
|
||||
return [
|
||||
<DataExtractionNotification key={messageId} {...msgProps} />,
|
||||
dateBreak,
|
||||
unreadIndicator,
|
||||
...componentToMerge,
|
||||
];
|
||||
}
|
||||
|
||||
if (messageProps.message?.messageType === 'timer-notification') {
|
||||
const msgProps = messageProps.message.props as PropsForExpirationTimer;
|
||||
|
||||
return [<TimerNotification key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
|
||||
return [<TimerNotification key={messageId} {...msgProps} />, ...componentToMerge];
|
||||
}
|
||||
|
||||
if (messageProps.message?.messageType === 'call-notification') {
|
||||
const msgProps = messageProps.message.props as PropsForCallNotification;
|
||||
|
||||
return [<CallNotification key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
|
||||
return [<CallNotification key={messageId} {...msgProps} />, ...componentToMerge];
|
||||
}
|
||||
|
||||
if (!messageProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [<Message messageId={messageId} key={messageId} />, dateBreak, unreadIndicator];
|
||||
return [<Message messageId={messageId} key={messageId} />, ...componentToMerge];
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { SessionIcon, SessionIconButton } from '../icon';
|
||||
import styled from 'styled-components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
@ -8,6 +8,8 @@ import { getAlt, isAudio } from '../../types/Attachment';
|
|||
import { AUDIO_MP3 } from '../../types/MIME';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { Image } from '../../../ts/components/conversation/Image';
|
||||
// tslint:disable-next-line: no-submodule-imports
|
||||
import useKey from 'react-use/lib/useKey';
|
||||
|
||||
const QuotedMessageComposition = styled.div`
|
||||
width: 100%;
|
||||
|
@ -58,9 +60,11 @@ export const SessionQuotedMessageComposition = () => {
|
|||
const hasAudioAttachment =
|
||||
hasAttachments && attachments && attachments.length > 0 && isAudio(attachments);
|
||||
|
||||
const removeQuotedMessage = useCallback(() => {
|
||||
const removeQuotedMessage = () => {
|
||||
dispatch(quoteMessage(undefined));
|
||||
}, []);
|
||||
};
|
||||
|
||||
useKey('Escape', removeQuotedMessage, undefined, []);
|
||||
|
||||
if (!quotedMessageProps?.id) {
|
||||
return null;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { shell } from 'electron';
|
||||
import LinkifyIt from 'linkify-it';
|
||||
|
||||
import { RenderTextCallbackType } from '../../../../types/Util';
|
||||
|
@ -8,9 +7,8 @@ import { getEmojiSizeClass, SizeClassType } from '../../../../util/emoji';
|
|||
import { AddMentions } from '../../AddMentions';
|
||||
import { AddNewLines } from '../../AddNewLines';
|
||||
import { Emojify } from '../../Emojify';
|
||||
import { MessageInteraction } from '../../../../interactions';
|
||||
import { updateConfirmModal } from '../../../../state/ducks/modalDialog';
|
||||
import { LinkPreviews } from '../../../../util/linkPreviews';
|
||||
import { showLinkVisitWarningDialog } from '../../../dialog/SessionConfirm';
|
||||
|
||||
const linkify = LinkifyIt();
|
||||
|
||||
|
@ -152,27 +150,7 @@ const Linkify = (props: LinkifyProps): JSX.Element => {
|
|||
|
||||
const url = e.target.href;
|
||||
|
||||
const openLink = () => {
|
||||
void shell.openExternal(url);
|
||||
};
|
||||
|
||||
dispatch(
|
||||
updateConfirmModal({
|
||||
title: window.i18n('linkVisitWarningTitle'),
|
||||
message: window.i18n('linkVisitWarningMessage', url),
|
||||
okText: window.i18n('open'),
|
||||
cancelText: window.i18n('editMenuCopy'),
|
||||
showExitIcon: true,
|
||||
onClickOk: openLink,
|
||||
onClickClose: () => {
|
||||
dispatch(updateConfirmModal(null));
|
||||
},
|
||||
|
||||
onClickCancel: () => {
|
||||
MessageInteraction.copyBodyToClipboard(url);
|
||||
},
|
||||
})
|
||||
);
|
||||
showLinkVisitWarningDialog(url, dispatch);
|
||||
}, []);
|
||||
|
||||
if (matchData.length === 0) {
|
||||
|
|
|
@ -28,18 +28,11 @@ import { MessagePreview } from './MessagePreview';
|
|||
import { MessageQuote } from './MessageQuote';
|
||||
import { MessageText } from './MessageText';
|
||||
import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type MessageContentSelectorProps = Pick<
|
||||
MessageRenderingProps,
|
||||
| 'text'
|
||||
| 'direction'
|
||||
| 'timestamp'
|
||||
| 'serverTimestamp'
|
||||
| 'firstMessageOfSeries'
|
||||
| 'lastMessageOfSeries'
|
||||
| 'previews'
|
||||
| 'quote'
|
||||
| 'attachments'
|
||||
'text' | 'direction' | 'timestamp' | 'serverTimestamp' | 'previews' | 'quote' | 'attachments'
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
|
@ -95,6 +88,10 @@ function onClickOnMessageInnerContainer(event: React.MouseEvent<HTMLDivElement>)
|
|||
}
|
||||
}
|
||||
|
||||
const StyledMessageContent = styled.div`
|
||||
border-radius: 18px;
|
||||
`;
|
||||
|
||||
export const IsMessageVisibleContext = createContext(false);
|
||||
|
||||
export const MessageContent = (props: Props) => {
|
||||
|
@ -159,8 +156,6 @@ export const MessageContent = (props: Props) => {
|
|||
text,
|
||||
timestamp,
|
||||
serverTimestamp,
|
||||
firstMessageOfSeries,
|
||||
lastMessageOfSeries,
|
||||
previews,
|
||||
quote,
|
||||
attachments,
|
||||
|
@ -181,21 +176,17 @@ export const MessageContent = (props: Props) => {
|
|||
|
||||
const bgShouldBeTransparent = isShowingImage && !hasText && !hasQuote;
|
||||
const toolTipTitle = moment(serverTimestamp || timestamp).format('llll');
|
||||
// tslint:disable: use-simple-attributes
|
||||
|
||||
return (
|
||||
<div
|
||||
<StyledMessageContent
|
||||
className={classNames(
|
||||
'module-message__container',
|
||||
`module-message__container--${direction}`,
|
||||
bgShouldBeTransparent
|
||||
? `module-message__container--${direction}--transparent`
|
||||
: `module-message__container--${direction}--opaque`,
|
||||
firstMessageOfSeries || props.isDetailView
|
||||
? `module-message__container--${direction}--first-of-series`
|
||||
: '',
|
||||
lastMessageOfSeries || props.isDetailView
|
||||
? `module-message__container--${direction}--last-of-series`
|
||||
: '',
|
||||
|
||||
flashGreen && 'flash-green-once'
|
||||
)}
|
||||
style={{
|
||||
|
@ -228,14 +219,14 @@ export const MessageContent = (props: Props) => {
|
|||
{!isDeleted && (
|
||||
<MessagePreview messageId={props.messageId} handleImageError={handleImageError} />
|
||||
)}
|
||||
<Flex padding="7px" container={true} flexDirection="column">
|
||||
<Flex padding="7px 13px" container={true} flexDirection="column">
|
||||
<MessageText messageId={props.messageId} />
|
||||
</Flex>
|
||||
</>
|
||||
) : null}
|
||||
</IsMessageVisibleContext.Provider>
|
||||
</InView>
|
||||
</div>
|
||||
</StyledMessageContent>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
getMessageContentWithStatusesSelectorProps,
|
||||
isMessageSelectionMode,
|
||||
} from '../../../../state/selectors/conversations';
|
||||
import { sendMessageReaction } from '../../../../util/reactions';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
|
||||
import { MessageAuthorText } from './MessageAuthorText';
|
||||
import { MessageContent } from './MessageContent';
|
||||
|
@ -30,6 +30,7 @@ type Props = {
|
|||
dataTestId?: string;
|
||||
enableReactions: boolean;
|
||||
};
|
||||
// tslint:disable: use-simple-attributes
|
||||
|
||||
const StyledMessageContentContainer = styled.div<{ direction: 'left' | 'right' }>`
|
||||
display: flex;
|
||||
|
@ -66,17 +67,19 @@ export const MessageContentWithStatuses = (props: Props) => {
|
|||
const currentSelection = window.getSelection();
|
||||
const currentSelectionString = currentSelection?.toString() || undefined;
|
||||
|
||||
// if multiple word are selected, consider that this double click was actually NOT used to reply to
|
||||
// but to select
|
||||
if (
|
||||
!currentSelectionString ||
|
||||
currentSelectionString.length === 0 ||
|
||||
!currentSelectionString.includes(' ')
|
||||
) {
|
||||
void replyToMessage(messageId);
|
||||
currentSelection?.empty();
|
||||
e.preventDefault();
|
||||
return;
|
||||
if ((e.target as any).localName !== 'em-emoji-picker') {
|
||||
if (
|
||||
!currentSelectionString ||
|
||||
currentSelectionString.length === 0 ||
|
||||
!/\s/.test(currentSelectionString)
|
||||
) {
|
||||
// if multiple word are selected, consider that this double click was actually NOT used to reply to
|
||||
// but to select
|
||||
void replyToMessage(messageId);
|
||||
currentSelection?.empty();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -90,7 +93,7 @@ export const MessageContentWithStatuses = (props: Props) => {
|
|||
const [popupReaction, setPopupReaction] = useState('');
|
||||
|
||||
const handleMessageReaction = async (emoji: string) => {
|
||||
await sendMessageReaction(messageId, emoji);
|
||||
await Reactions.sendMessageReaction(messageId, emoji);
|
||||
};
|
||||
|
||||
const handlePopupClick = () => {
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
import { StateType } from '../../../../state/reducer';
|
||||
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
|
||||
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
|
||||
import { sendMessageReaction } from '../../../../util/reactions';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
|
||||
import { MessageReactBar } from './MessageReactBar';
|
||||
|
||||
|
@ -241,7 +241,7 @@ export const MessageContextMenu = (props: Props) => {
|
|||
const onEmojiClick = async (args: any) => {
|
||||
const emoji = args.native ?? args;
|
||||
onCloseEmoji();
|
||||
await sendMessageReaction(messageId, emoji);
|
||||
await Reactions.sendMessageReaction(messageId, emoji);
|
||||
};
|
||||
|
||||
const onEmojiKeyDown = (event: any) => {
|
||||
|
|
|
@ -4,10 +4,11 @@ import { isImageAttachment } from '../../../../types/Attachment';
|
|||
import { ImageGrid } from '../../ImageGrid';
|
||||
import { Image } from '../../Image';
|
||||
import { MessageRenderingProps } from '../../../../models/messageType';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getMessagePreviewProps } from '../../../../state/selectors/conversations';
|
||||
import { SessionIcon } from '../../../icon';
|
||||
import { MINIMUM_LINK_PREVIEW_IMAGE_WIDTH } from '../message-item/Message';
|
||||
import { showLinkVisitWarningDialog } from '../../../dialog/SessionConfirm';
|
||||
|
||||
export type MessagePreviewSelectorProps = Pick<MessageRenderingProps, 'attachments' | 'previews'>;
|
||||
|
||||
|
@ -18,6 +19,8 @@ type Props = {
|
|||
|
||||
export const MessagePreview = (props: Props) => {
|
||||
const selected = useSelector(state => getMessagePreviewProps(state as any, props.messageId));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
|
@ -41,8 +44,18 @@ export const MessagePreview = (props: Props) => {
|
|||
const width = first.image && first.image.width;
|
||||
const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
|
||||
|
||||
function openLinkFromPreview() {
|
||||
if (previews?.length && previews[0].url) {
|
||||
showLinkVisitWarningDialog(previews[0].url, dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="button" className={classNames('module-message__link-preview')}>
|
||||
<div
|
||||
role="button"
|
||||
className={classNames('module-message__link-preview')}
|
||||
onClick={openLinkFromPreview}
|
||||
>
|
||||
{first.image && previewHasImage && isFullSizeImage ? (
|
||||
<ImageGrid attachments={[first.image]} onError={props.handleImageError} />
|
||||
) : null}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Reaction, ReactionProps } from '../reactions/Reaction';
|
|||
import { SessionIcon } from '../../../icon';
|
||||
import { useMessageReactsPropsById } from '../../../../hooks/useParamSelector';
|
||||
|
||||
export const popupXDefault = -101;
|
||||
export const popupXDefault = -81;
|
||||
export const popupYDefault = -90;
|
||||
|
||||
const StyledMessageReactionsContainer = styled(Flex)<{ x: number; y: number }>`
|
||||
|
|
|
@ -114,7 +114,7 @@ const StyledReadableMessage = styled(ReadableMessage)<{
|
|||
align-items: center;
|
||||
width: 100%;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 5px var(--margins-lg) 0;
|
||||
padding: var(--margins-xs) var(--margins-lg) 0;
|
||||
|
||||
&.message-highlighted {
|
||||
animation: ${highlightedMessageAnimation} 1s ease-in-out;
|
||||
|
|
|
@ -5,7 +5,7 @@ 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 { POPUP_WIDTH, ReactionPopup, TipPosition } from './ReactionPopup';
|
||||
import { popupXDefault, popupYDefault } from '../message-content/MessageReactions';
|
||||
import { isUsAnySogsFromCache } from '../../../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
|
||||
|
@ -24,7 +24,6 @@ const StyledReaction = styled.button<{ selected: boolean; inModal: boolean; show
|
|||
margin: 0 4px var(--margins-sm);
|
||||
height: 24px;
|
||||
min-width: ${props => (props.showCount ? '48px' : '24px')};
|
||||
${props => props.inModal && 'width: 100%;'}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
|
@ -35,7 +34,7 @@ const StyledReactionContainer = styled.div<{
|
|||
inModal: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
${props => props.inModal && 'margin-right: 8px;'}
|
||||
${props => props.inModal && 'white-space: nowrap; margin-right: 8px;'}
|
||||
`;
|
||||
|
||||
export type ReactionProps = {
|
||||
|
@ -70,15 +69,15 @@ export const Reaction = (props: ReactionProps): ReactElement => {
|
|||
handlePopupClick,
|
||||
} = props;
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const senders = reactionsMap[emoji].senders ? Object.keys(reactionsMap[emoji].senders) : [];
|
||||
const count = reactionsMap[emoji].count;
|
||||
const senders = 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 tooltipMidPoint = POPUP_WIDTH / 2; // px
|
||||
const [tooltipPosition, setTooltipPosition] = useState<TipPosition>('center');
|
||||
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
@ -109,7 +108,7 @@ export const Reaction = (props: ReactionProps): ReactElement => {
|
|||
const { innerWidth: windowWidth } = window;
|
||||
if (handlePopupReaction) {
|
||||
// overflow on far right means we shift left
|
||||
if (docX + tooltipMidPoint > windowWidth) {
|
||||
if (docX + elW + tooltipMidPoint > windowWidth) {
|
||||
handlePopupX(Math.abs(popupXDefault) * 1.5 * -1);
|
||||
setTooltipPosition('right');
|
||||
// overflow onto conversations means we lock to the right
|
||||
|
@ -139,7 +138,8 @@ export const Reaction = (props: ReactionProps): ReactElement => {
|
|||
<ReactionPopup
|
||||
messageId={messageId}
|
||||
emoji={popupReaction}
|
||||
senders={Object.keys(reactionsMap[popupReaction].senders)}
|
||||
count={reactionsMap[popupReaction]?.count}
|
||||
senders={reactionsMap[popupReaction]?.senders}
|
||||
tooltipPosition={tooltipPosition}
|
||||
onClick={() => {
|
||||
if (handlePopupReaction) {
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { PubKey } from '../../../../session/types/PubKey';
|
||||
import { getTheme } from '../../../../state/selectors/theme';
|
||||
import { nativeEmojiData } from '../../../../util/emoji';
|
||||
import { readableList } from '../../../../util/readableList';
|
||||
|
||||
export type TipPosition = 'center' | 'left' | 'right';
|
||||
|
||||
export const POPUP_WIDTH = 216; // px
|
||||
|
||||
export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 216px;
|
||||
width: ${POPUP_WIDTH}px;
|
||||
height: 72px;
|
||||
z-index: 5;
|
||||
|
||||
|
@ -34,10 +37,10 @@ export const StyledPopupContainer = styled.div<{ tooltipPosition: TipPosition }>
|
|||
case 'left':
|
||||
return '24px';
|
||||
case 'right':
|
||||
return 'calc(100% - 48px)';
|
||||
return 'calc(100% - 78px)';
|
||||
case 'center':
|
||||
default:
|
||||
return 'calc(100% - 100px)';
|
||||
return 'calc(100% - 118px)';
|
||||
}
|
||||
}};
|
||||
width: 22px;
|
||||
|
@ -55,12 +58,22 @@ const StyledEmoji = styled.span`
|
|||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const StyledOthers = styled.span`
|
||||
color: var(--color-accent);
|
||||
const StyledContacts = styled.span`
|
||||
word-break: break-all;
|
||||
span {
|
||||
word-break: keep-all;
|
||||
}
|
||||
`;
|
||||
|
||||
const generateContacts = async (messageId: string, senders: Array<string>) => {
|
||||
let results = null;
|
||||
const StyledOthers = styled.span<{ darkMode: boolean }>`
|
||||
color: ${props => (props.darkMode ? 'var(--color-accent)' : 'var(--color-text)')};
|
||||
`;
|
||||
|
||||
const generateContactsString = async (
|
||||
messageId: string,
|
||||
senders: Array<string>
|
||||
): Promise<Array<string> | null> => {
|
||||
let results = [];
|
||||
const message = await Data.getMessageById(messageId);
|
||||
if (message) {
|
||||
let meIndex = -1;
|
||||
|
@ -75,53 +88,73 @@ const generateContacts = async (messageId: string, senders: Array<string>) => {
|
|||
results.splice(meIndex, 1);
|
||||
results = [window.i18n('you'), ...results];
|
||||
}
|
||||
if (results && results.length > 0) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
return null;
|
||||
};
|
||||
|
||||
const Contacts = (contacts: string) => {
|
||||
if (!contacts) {
|
||||
const Contacts = (contacts: Array<string>, count: number) => {
|
||||
const darkMode = useSelector(getTheme) === 'dark';
|
||||
|
||||
if (!Boolean(contacts?.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.includes('&') && contacts.includes('other')) {
|
||||
const [names, others] = contacts.split('&');
|
||||
const reactors = contacts.length;
|
||||
if (reactors === 1 || reactors === 2 || reactors === 3) {
|
||||
return (
|
||||
<span>
|
||||
{names} & <StyledOthers>{others}</StyledOthers> {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
<StyledContacts>
|
||||
{window.i18n(
|
||||
reactors === 1
|
||||
? 'reactionPopupOne'
|
||||
: reactors === 2
|
||||
? 'reactionPopupTwo'
|
||||
: 'reactionPopupThree',
|
||||
contacts
|
||||
)}{' '}
|
||||
<span>{window.i18n('reactionPopup')}</span>
|
||||
</StyledContacts>
|
||||
);
|
||||
} else if (reactors > 3) {
|
||||
return (
|
||||
<StyledContacts>
|
||||
{window.i18n('reactionPopupMany', [contacts[0], contacts[1], contacts[3]])}{' '}
|
||||
<StyledOthers darkMode={darkMode}>
|
||||
{window.i18n(reactors === 4 ? 'otherSingular' : 'otherPlural', [`${count - 3}`])}
|
||||
</StyledOthers>{' '}
|
||||
<span>{window.i18n('reactionPopup')}</span>
|
||||
</StyledContacts>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{contacts} {window.i18n('reactionTooltip')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
count: number;
|
||||
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 { messageId, emoji, count, senders, tooltipPosition = 'center', onClick } = props;
|
||||
|
||||
const [contacts, setContacts] = useState('');
|
||||
const [contacts, setContacts] = useState<Array<string>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
generateContacts(messageId, senders)
|
||||
generateContactsString(messageId, senders)
|
||||
.then(async results => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
if (results && results.length > 0) {
|
||||
setContacts(readableList(results));
|
||||
if (results) {
|
||||
setContacts(results);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
@ -133,11 +166,11 @@ export const ReactionPopup = (props: Props): ReactElement => {
|
|||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [generateContacts]);
|
||||
}, [count, messageId, senders]);
|
||||
|
||||
return (
|
||||
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
|
||||
{Contacts(contacts)}
|
||||
{Contacts(contacts, count)}
|
||||
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
|
||||
{emoji}
|
||||
</StyledEmoji>
|
||||
|
|
|
@ -3,9 +3,8 @@ 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 { useMessageReactsPropsById, useWeAreModerator } 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,
|
||||
|
@ -14,9 +13,10 @@ import {
|
|||
} from '../../state/ducks/modalDialog';
|
||||
import { SortedReactionList } from '../../types/Reaction';
|
||||
import { nativeEmojiData } from '../../util/emoji';
|
||||
import { sendMessageReaction } from '../../util/reactions';
|
||||
import { Reactions } from '../../util/reactions';
|
||||
import { Avatar, AvatarSize } from '../avatar/Avatar';
|
||||
import { Flex } from '../basic/Flex';
|
||||
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
|
||||
import { SessionIconButton } from '../icon';
|
||||
|
@ -36,12 +36,12 @@ const StyledReactionsContainer = styled.div`
|
|||
|
||||
const StyledSendersContainer = styled(Flex)`
|
||||
width: 100%;
|
||||
min-height: 350px;
|
||||
min-height: 332px;
|
||||
height: 100%;
|
||||
max-height: 496px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 32px;
|
||||
padding: 0 16px 16px;
|
||||
`;
|
||||
|
||||
const StyledReactionBar = styled(Flex)`
|
||||
|
@ -110,7 +110,11 @@ const ReactionSenders = (props: ReactionSendersProps) => {
|
|||
};
|
||||
|
||||
const handleRemoveReaction = async () => {
|
||||
await sendMessageReaction(messageId, currentReact);
|
||||
await Reactions.sendMessageReaction(messageId, currentReact);
|
||||
|
||||
if (senders.length <= 1) {
|
||||
dispatch(updateReactListModal(null));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -155,13 +159,43 @@ const ReactionSenders = (props: ReactionSendersProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
const StyledCountText = styled.p`
|
||||
color: var(--color-text-subtle);
|
||||
text-align: center;
|
||||
margin: 16px auto 0;
|
||||
|
||||
span {
|
||||
color: var(--color-text);
|
||||
}
|
||||
`;
|
||||
|
||||
const CountText = ({ count, emoji }: { count: number; emoji: string }) => {
|
||||
return (
|
||||
<StyledCountText>
|
||||
<SessionHtmlRenderer
|
||||
html={
|
||||
count > Reactions.SOGSReactorsFetchCount + 1
|
||||
? window.i18n('reactionListCountPlural', [
|
||||
window.i18n('otherPlural', [String(count - Reactions.SOGSReactorsFetchCount)]),
|
||||
emoji,
|
||||
])
|
||||
: window.i18n('reactionListCountSingular', [
|
||||
window.i18n('otherSingular', [String(count - Reactions.SOGSReactorsFetchCount)]),
|
||||
emoji,
|
||||
])
|
||||
}
|
||||
/>
|
||||
</StyledCountText>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reaction: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const handleSenders = (senders: Array<string>, me: string) => {
|
||||
let updatedSenders = senders;
|
||||
let updatedSenders = [...senders];
|
||||
const blindedMe = updatedSenders.filter(isUsAnySogsFromCache);
|
||||
|
||||
let meIndex = -1;
|
||||
|
@ -178,17 +212,21 @@ const handleSenders = (senders: Array<string>, me: string) => {
|
|||
return updatedSenders;
|
||||
};
|
||||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
export const ReactListModal = (props: Props): ReactElement => {
|
||||
const { reaction, messageId } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [reactions, setReactions] = useState<SortedReactionList>([]);
|
||||
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
|
||||
const [currentReact, setCurrentReact] = useState('');
|
||||
const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>();
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [senders, setSenders] = useState<Array<string>>([]);
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
||||
const msgProps = useMessageReactsPropsById(messageId);
|
||||
const weAreModerator = useWeAreModerator(msgProps?.convoId);
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
||||
// tslint:disable: cyclomatic-complexity
|
||||
useEffect(() => {
|
||||
|
@ -213,7 +251,7 @@ export const ReactListModal = (props: Props): ReactElement => {
|
|||
|
||||
let _senders =
|
||||
reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders
|
||||
? Object.keys(reactionsMap[currentReact].senders)
|
||||
? reactionsMap[currentReact].senders
|
||||
: null;
|
||||
|
||||
if (_senders && !isEqual(senders, _senders)) {
|
||||
|
@ -226,18 +264,26 @@ export const ReactListModal = (props: Props): ReactElement => {
|
|||
if (senders.length > 0 && (!reactionsMap[currentReact]?.senders || isEmpty(_senders))) {
|
||||
setSenders([]);
|
||||
}
|
||||
}, [currentReact, me, reaction, msgProps?.sortedReacts, reactionsMap, senders]);
|
||||
|
||||
if (reactionsMap[currentReact]?.count && count !== reactionsMap[currentReact]?.count) {
|
||||
setCount(reactionsMap[currentReact].count);
|
||||
}
|
||||
}, [
|
||||
count,
|
||||
currentReact,
|
||||
me,
|
||||
reaction,
|
||||
reactionsMap[currentReact]?.count,
|
||||
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 { isPublic } = msgProps;
|
||||
|
||||
const handleSelectedReaction = (emoji: string): boolean => {
|
||||
return currentReact === emoji;
|
||||
|
@ -316,6 +362,9 @@ export const ReactListModal = (props: Props): ReactElement => {
|
|||
handleClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{isPublic && currentReact && count && count > Reactions.SOGSReactorsFetchCount && (
|
||||
<CountText count={count} emoji={currentReact} />
|
||||
)}
|
||||
</StyledSendersContainer>
|
||||
)}
|
||||
</StyledReactListContainer>
|
||||
|
|
|
@ -6,6 +6,9 @@ import { SessionButton, SessionButtonColor } from '../basic/SessionButton';
|
|||
import { SessionSpinner } from '../basic/SessionSpinner';
|
||||
import { SessionIcon, SessionIconSize, SessionIconType } from '../icon';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
import { Dispatch } from 'redux';
|
||||
import { shell } from 'electron';
|
||||
import { MessageInteraction } from '../../interactions';
|
||||
|
||||
export interface SessionConfirmDialogProps {
|
||||
message?: string;
|
||||
|
@ -145,3 +148,26 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
|
|||
</SessionWrapperModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const showLinkVisitWarningDialog = (urlToOpen: string, dispatch: Dispatch<any>) => {
|
||||
function onClickOk() {
|
||||
void shell.openExternal(urlToOpen);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateConfirmModal({
|
||||
title: window.i18n('linkVisitWarningTitle'),
|
||||
message: window.i18n('linkVisitWarningMessage', [urlToOpen]),
|
||||
okText: window.i18n('open'),
|
||||
cancelText: window.i18n('editMenuCopy'),
|
||||
showExitIcon: true,
|
||||
onClickOk,
|
||||
onClickClose: () => {
|
||||
dispatch(updateConfirmModal(null));
|
||||
},
|
||||
onClickCancel: () => {
|
||||
MessageInteraction.copyBodyToClipboard(urlToOpen);
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -27,11 +27,7 @@ import { cleanUpOldDecryptedMedias } from '../../session/crypto/DecryptedAttachm
|
|||
|
||||
import { DURATION } from '../../session/constants';
|
||||
|
||||
import {
|
||||
editProfileModal,
|
||||
onionPathModal,
|
||||
updateConfirmModal,
|
||||
} from '../../state/ducks/modalDialog';
|
||||
import { editProfileModal, onionPathModal } from '../../state/ducks/modalDialog';
|
||||
import { uploadOurAvatar } from '../../interactions/conversationInteractions';
|
||||
import { ModalContainer } from '../dialog/ModalContainer';
|
||||
import { debounce, isEmpty, isString } from 'lodash';
|
||||
|
@ -54,8 +50,6 @@ import { LeftPaneSectionContainer } from './LeftPaneSectionContainer';
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { UserUtils } from '../../session/utils';
|
||||
|
||||
import { Storage } from '../../util/storage';
|
||||
import { SettingsKey } from '../../data/settings-key';
|
||||
import { getLatestReleaseFromFileServer } from '../../session/apis/file_server_api/FileServerApi';
|
||||
|
||||
const Section = (props: { type: SectionType }) => {
|
||||
|
@ -224,8 +218,6 @@ const doAppStartUp = () => {
|
|||
void loadDefaultRooms();
|
||||
|
||||
debounce(triggerAvatarReUploadIfNeeded, 200);
|
||||
|
||||
void askEnablingOpengroupPruningIfNeeded();
|
||||
};
|
||||
|
||||
const CallContainer = () => {
|
||||
|
@ -254,36 +246,6 @@ async function fetchReleaseFromFSAndUpdateMain() {
|
|||
}
|
||||
}
|
||||
|
||||
async function askEnablingOpengroupPruningIfNeeded() {
|
||||
if (Storage.get(SettingsKey.settingsOpengroupPruning) === undefined) {
|
||||
const setSettingsAndCloseDialog = async (valueToSetPruningTo: boolean) => {
|
||||
window.setSettingValue(SettingsKey.settingsOpengroupPruning, valueToSetPruningTo);
|
||||
await window.setOpengroupPruning(valueToSetPruningTo);
|
||||
window.inboxStore?.dispatch(updateConfirmModal(null));
|
||||
};
|
||||
window.inboxStore?.dispatch(
|
||||
updateConfirmModal({
|
||||
onClickOk: async () => {
|
||||
await setSettingsAndCloseDialog(true);
|
||||
},
|
||||
onClickClose: async () => {
|
||||
await setSettingsAndCloseDialog(false);
|
||||
},
|
||||
onClickCancel: async () => {
|
||||
await setSettingsAndCloseDialog(false);
|
||||
},
|
||||
title: window.i18n('pruningOpengroupDialogTitle'),
|
||||
message: window.i18n('pruningOpengroupDialogMessage'),
|
||||
messageSub: window.i18n('pruningOpengroupDialogSubMessage'),
|
||||
okText: window.i18n('enable'),
|
||||
cancelText: window.i18n('keepDisabled'),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// otherwise nothing to do. the settings is already on or off, but as expected by the user
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionsPanel is the far left banner (not the left pane).
|
||||
* The panel with buttons to switch between the message/contact/settings/theme views
|
||||
|
|
|
@ -89,12 +89,11 @@ import { addMessagePadding } from '../session/crypto/BufferPadding';
|
|||
import { getSodiumRenderer } from '../session/crypto';
|
||||
import {
|
||||
findCachedOurBlindedPubkeyOrLookItUp,
|
||||
getUsBlindedInThatServer,
|
||||
isUsAnySogsFromCache,
|
||||
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
|
||||
import { Reaction } from '../types/Reaction';
|
||||
import { handleMessageReaction } from '../util/reactions';
|
||||
import { Reactions } from '../util/reactions';
|
||||
|
||||
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
||||
public updateLastMessage: () => any;
|
||||
|
@ -194,7 +193,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
}
|
||||
|
||||
if (this.isPublic()) {
|
||||
return `opengroup(${this.id})`;
|
||||
const opengroup = this.toOpenGroupV2();
|
||||
return `${opengroup.serverUrl}/${opengroup.roomId}`;
|
||||
}
|
||||
|
||||
return `group(${ed25519Str(this.id)})`;
|
||||
|
@ -479,10 +479,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
);
|
||||
}
|
||||
|
||||
public async getUnread() {
|
||||
return Data.getUnreadByConversation(this.id);
|
||||
}
|
||||
|
||||
public async getUnreadCount() {
|
||||
const unreadCount = await Data.getUnreadCountByConversation(this.id);
|
||||
|
||||
|
@ -507,62 +503,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
return current;
|
||||
}
|
||||
|
||||
public async getQuoteAttachment(attachments: any, preview: any) {
|
||||
if (attachments && attachments.length) {
|
||||
return Promise.all(
|
||||
attachments
|
||||
.filter(
|
||||
(attachment: any) =>
|
||||
attachment && attachment.contentType && !attachment.pending && !attachment.error
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(async (attachment: any) => {
|
||||
const { fileName, thumbnail, contentType } = attachment;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: fileName || null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...(await loadAttachmentData(thumbnail)),
|
||||
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (preview && preview.length) {
|
||||
return Promise.all(
|
||||
preview
|
||||
.filter((item: any) => item && item.image)
|
||||
.slice(0, 1)
|
||||
.map(async (attachment: any) => {
|
||||
const { image } = attachment;
|
||||
const { contentType } = image;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: null,
|
||||
thumbnail: image
|
||||
? {
|
||||
...(await loadAttachmentData(image)),
|
||||
objectUrl: getAbsoluteAttachmentPath(image.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public async makeQuote(quotedMessage: MessageModel): Promise<ReplyingToMessageProps | null> {
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
const preview = quotedMessage.get('preview');
|
||||
|
@ -738,8 +678,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
throw new Error('Only opengroupv2 are supported now');
|
||||
}
|
||||
|
||||
let sender = UserUtils.getOurPubKeyStrFromCache();
|
||||
|
||||
// an OpenGroupV2 message is just a visible message
|
||||
const chatMessageParams: VisibleMessageParams = {
|
||||
body: '',
|
||||
|
@ -757,7 +695,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
|
||||
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;
|
||||
}
|
||||
|
@ -785,26 +722,14 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
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,
|
||||
|
@ -813,7 +738,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
|
||||
const chatMessagePrivate = new VisibleMessage(chatMessageParams);
|
||||
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
|
||||
|
||||
await Reactions.handleMessageReaction({
|
||||
reaction,
|
||||
sender: UserUtils.getOurPubKeyStrFromCache(),
|
||||
you: true,
|
||||
isOpenGroup: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -825,6 +755,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
});
|
||||
// we need the return await so that errors are caught in the catch {}
|
||||
await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
|
||||
await Reactions.handleMessageReaction({
|
||||
reaction,
|
||||
sender: UserUtils.getOurPubKeyStrFromCache(),
|
||||
you: true,
|
||||
isOpenGroup: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1908,6 +1844,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
: null;
|
||||
}
|
||||
|
||||
private async getUnread() {
|
||||
return Data.getUnreadByConversation(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns The open group conversationId this conversation originated from
|
||||
|
@ -2099,6 +2039,66 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getQuoteAttachment(attachments: any, preview: any) {
|
||||
if (attachments?.length) {
|
||||
return Promise.all(
|
||||
attachments
|
||||
.filter(
|
||||
(attachment: any) =>
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
!attachment.pending &&
|
||||
!attachment.error &&
|
||||
attachment?.thumbnail?.path // loadAttachmentData throws if the thumbnail.path is not set
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(async (attachment: any) => {
|
||||
const { fileName, thumbnail, contentType } = attachment;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: fileName || null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...(await loadAttachmentData(thumbnail)),
|
||||
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (preview?.length) {
|
||||
return Promise.all(
|
||||
preview
|
||||
.filter((attachment: any) => attachment?.image?.path) // loadAttachmentData throws if the image.path is not set
|
||||
.slice(0, 1)
|
||||
.map(async (attachment: any) => {
|
||||
const { image } = attachment;
|
||||
const { contentType } = image;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: null,
|
||||
thumbnail: image
|
||||
? {
|
||||
...(await loadAttachmentData(image)),
|
||||
objectUrl: getAbsoluteAttachmentPath(image.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const throttledAllConversationsDispatch = debounce(
|
||||
|
|
|
@ -94,6 +94,7 @@ import {
|
|||
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { QUOTED_TEXT_MAX_LENGTH } from '../session/constants';
|
||||
import { ReactionList } from '../types/Reaction';
|
||||
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
|
||||
// tslint:disable: cyclomatic-complexity
|
||||
|
||||
/**
|
||||
|
@ -780,6 +781,12 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
|
||||
const { hasAttachments, hasVisualMediaAttachments, hasFileAttachments } = getAttachmentMetadata(
|
||||
this
|
||||
);
|
||||
this.set({ hasAttachments, hasVisualMediaAttachments, hasFileAttachments });
|
||||
await this.commit();
|
||||
|
||||
const conversation = this.getConversation();
|
||||
|
||||
let attachmentPromise;
|
||||
|
@ -823,6 +830,12 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
fileIdsToLink.push(firstQuoteAttachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstAttachmentVoiceMessage = finalAttachments?.[0]?.isVoiceMessage;
|
||||
if (isFirstAttachmentVoiceMessage) {
|
||||
attachments[0].flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
}
|
||||
|
||||
window.log.info(`Upload of message data for message ${this.idForLogging()} is finished.`);
|
||||
return {
|
||||
body,
|
||||
|
|
|
@ -579,6 +579,12 @@ function updateToSessionSchemaVersion20(currentVersion: number, db: BetterSqlite
|
|||
console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`);
|
||||
|
||||
db.transaction(() => {
|
||||
// First we want to drop the column friendRequestStatus if it is there, otherwise the transaction fails
|
||||
const rows = db.pragma(`table_info(${CONVERSATIONS_TABLE});`);
|
||||
if (rows.some((m: any) => m.name === 'friendRequestStatus')) {
|
||||
console.info('found column friendRequestStatus. Dropping it');
|
||||
db.exec(`ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN friendRequestStatus;`);
|
||||
}
|
||||
// looking for all private conversations, with a nickname set
|
||||
const rowsToUpdate = db
|
||||
.prepare(
|
||||
|
@ -917,6 +923,13 @@ function updateToSessionSchemaVersion27(currentVersion: number, db: BetterSqlite
|
|||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
db.transaction(() => {
|
||||
// First we want to drop the column friendRequestStatus if it is there, otherwise the transaction fails
|
||||
const rows = db.pragma(`table_info(${CONVERSATIONS_TABLE});`);
|
||||
if (rows.some((m: any) => m.name === 'friendRequestStatus')) {
|
||||
console.info('found column friendRequestStatus. Dropping it');
|
||||
db.exec(`ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN friendRequestStatus;`);
|
||||
}
|
||||
|
||||
// We want to replace all the occurrences of the sogs server ip url (116.203.70.33 || http://116.203.70.33 || https://116.203.70.33) by its hostname: https://open.getsession.org
|
||||
// This includes change the conversationTable, the openGroupRooms tables and every single message associated with them.
|
||||
// Because the conversationId is used to link messages to conversation includes the ip/url in it...
|
||||
|
@ -1157,17 +1170,12 @@ function updateToSessionSchemaVersion28(currentVersion: number, db: BetterSqlite
|
|||
if (currentVersion >= targetVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`);
|
||||
|
||||
// some very old databases have the column friendRequestStatus still there but we are not using it anymore. So drop it if we find it.
|
||||
db.transaction(() => {
|
||||
const rows = db.pragma(`table_info(${CONVERSATIONS_TABLE});`);
|
||||
if (rows.some((m: any) => m.name === 'friendRequestStatus')) {
|
||||
console.info('found column friendRequestStatus. Dropping it');
|
||||
db.exec(`ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN friendRequestStatus;`);
|
||||
}
|
||||
// Keeping this empty migration because some people updated to this already, even if it is not needed anymore
|
||||
writeSessionSchemaVersion(targetVersion, db);
|
||||
console.log('... done');
|
||||
})();
|
||||
|
||||
console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
|
||||
|
|
|
@ -52,6 +52,7 @@ import {
|
|||
openAndMigrateDatabase,
|
||||
updateSchema,
|
||||
} from './migration/signalMigrations';
|
||||
import { SettingsKey } from '../data/settings-key';
|
||||
|
||||
// tslint:disable: no-console function-name non-literal-fs-path
|
||||
|
||||
|
@ -420,6 +421,7 @@ function getConversationCount() {
|
|||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
function saveConversation(data: ConversationAttributes, instance?: BetterSqlite3.Database) {
|
||||
const formatted = assertValidConversationAttributes(data);
|
||||
|
||||
|
@ -458,10 +460,13 @@ function saveConversation(data: ConversationAttributes, instance?: BetterSqlite3
|
|||
conversationIdOrigin,
|
||||
} = formatted;
|
||||
|
||||
// shorten the last message as we never need more than 60 chars (and it bloats the redux/ipc calls uselessly
|
||||
const maxLength = 300;
|
||||
// shorten the last message as we never need more than `maxLength` chars (and it bloats the redux/ipc calls uselessly.
|
||||
|
||||
const shortenedLastMessage =
|
||||
isString(lastMessage) && lastMessage.length > 60 ? lastMessage.substring(60) : lastMessage;
|
||||
isString(lastMessage) && lastMessage.length > maxLength
|
||||
? lastMessage.substring(0, maxLength)
|
||||
: lastMessage;
|
||||
assertGlobalInstanceOrInstance(instance)
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ${CONVERSATIONS_TABLE} (
|
||||
|
@ -1090,7 +1095,7 @@ function getUnreadByConversation(conversationId: string) {
|
|||
conversationId,
|
||||
});
|
||||
|
||||
return rows;
|
||||
return map(rows, row => jsonToObject(row.json));
|
||||
}
|
||||
|
||||
function getUnreadCountByConversation(conversationId: string) {
|
||||
|
@ -2060,13 +2065,12 @@ function cleanUpOldOpengroupsOnStart() {
|
|||
console.info('cleanUpOldOpengroups: ourNumber is not set');
|
||||
return;
|
||||
}
|
||||
const pruneSetting = getItemById('prune-setting')?.value;
|
||||
let pruneSetting = getItemById(SettingsKey.settingsOpengroupPruning)?.value;
|
||||
|
||||
if (pruneSetting === undefined) {
|
||||
console.info(
|
||||
'Prune settings is undefined, skipping cleanUpOldOpengroups but we will need to ask user'
|
||||
);
|
||||
return;
|
||||
console.info('Prune settings is undefined (and not explicitely false), forcing it to true.');
|
||||
createOrUpdateItem({ id: SettingsKey.settingsOpengroupPruning, value: true });
|
||||
pruneSetting = true;
|
||||
}
|
||||
|
||||
if (!pruneSetting) {
|
||||
|
@ -2080,12 +2084,19 @@ function cleanUpOldOpengroupsOnStart() {
|
|||
return;
|
||||
}
|
||||
console.info(`Count of v2 opengroup convos to clean: ${v2ConvosIds.length}`);
|
||||
|
||||
// For each opengroups, if it has more than 1000 messages, we remove all the messages older than 2 months.
|
||||
// So this does not limit the size of opengroup history to 1000 messages but to 2 months.
|
||||
// This is the only way we can cleanup conversations objects from users which just sent messages a while ago and with whom we never interacted.
|
||||
// This is only for opengroups, and is because ALL the conversations are cached in the redux store. Having a very large number of conversations (unused) is deteriorating a lot the performance of the app.
|
||||
// Another fix would be to not cache all the conversations in the redux store, but it ain't going to happen anytime soon as it would a pretty big change of the way we do things and would break a lot of the app.
|
||||
// For each open group, if it has more than 2000 messages, we remove all the messages
|
||||
// older than 6 months. So this does not limit the size of open group history to 2000
|
||||
// messages but to 6 months.
|
||||
//
|
||||
// This is the only way we can clean up conversations objects from users which just
|
||||
// sent messages a while ago and with whom we never interacted. This is only for open
|
||||
// groups, and is because ALL the conversations are cached in the redux store. Having
|
||||
// a very large number of conversations (unused) is causing the performance of the app
|
||||
// to deteriorate a lot.
|
||||
//
|
||||
// Another fix would be to not cache all the conversations in the redux store, but it
|
||||
// ain't going to happen any time soon as it would a pretty big change of the way we
|
||||
// do things and would break a lot of the app.
|
||||
const maxMessagePerOpengroupConvo = 2000;
|
||||
|
||||
// first remove very old messages for each opengroups
|
||||
|
|
|
@ -285,20 +285,22 @@ async function decrypt(envelope: EnvelopePlus, ciphertext: ArrayBuffer): Promise
|
|||
}
|
||||
}
|
||||
|
||||
function shouldDropBlockedUserMessage(content: SignalService.Content): boolean {
|
||||
function shouldDropBlockedUserMessage(
|
||||
content: SignalService.Content,
|
||||
groupPubkey: string
|
||||
): boolean {
|
||||
// Even if the user is blocked, we should allow the message if:
|
||||
// - it is a group message AND
|
||||
// - the group exists already on the db (to not join a closed group created by a blocked user) AND
|
||||
// - the group is not blocked AND
|
||||
// - the message is only control (no body/attachments/quote/groupInvitation/contact/preview)
|
||||
|
||||
if (!content?.dataMessage?.group?.id) {
|
||||
if (!groupPubkey) {
|
||||
return true;
|
||||
}
|
||||
const groupId = toHex(content.dataMessage.group.id);
|
||||
|
||||
const groupConvo = getConversationController().get(groupId);
|
||||
if (!groupConvo) {
|
||||
const groupConvo = getConversationController().get(groupPubkey);
|
||||
if (!groupConvo || !groupConvo.isClosedGroup()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -317,7 +319,7 @@ function shouldDropBlockedUserMessage(content: SignalService.Content): boolean {
|
|||
if (!isMessageDataMessageOnly) {
|
||||
return true;
|
||||
}
|
||||
const data = content.dataMessage;
|
||||
const data = content.dataMessage as SignalService.DataMessage; // forcing it as we do know this field is set based on last line
|
||||
const isControlDataMessageOnly =
|
||||
!data.body &&
|
||||
!data.preview?.length &&
|
||||
|
@ -343,11 +345,21 @@ export async function innerHandleSwarmContentMessage(
|
|||
const content = SignalService.Content.decode(new Uint8Array(plaintext));
|
||||
perfEnd(`SignalService.Content.decode-${envelope.id}`, 'SignalService.Content.decode');
|
||||
|
||||
const blocked = await isBlocked(envelope.source);
|
||||
/**
|
||||
* senderIdentity is set ONLY if that message is a closed group message.
|
||||
* If the current message is a closed group message,
|
||||
* envelope.source is going to be the real sender of that message.
|
||||
*
|
||||
* When receiving a message from a user which we blocked, we need to make let
|
||||
* a control message through (if the associated closed group is not blocked)
|
||||
*/
|
||||
|
||||
const blocked = await isBlocked(envelope.senderIdentity || envelope.source);
|
||||
perfEnd(`isBlocked-${envelope.id}`, 'isBlocked');
|
||||
if (blocked) {
|
||||
const envelopeSource = envelope.source;
|
||||
// We want to allow a blocked user message if that's a control message for a known group and the group is not blocked
|
||||
if (shouldDropBlockedUserMessage(content)) {
|
||||
if (shouldDropBlockedUserMessage(content, envelopeSource)) {
|
||||
window?.log?.info('Dropping blocked user message');
|
||||
return;
|
||||
} else {
|
||||
|
|
|
@ -21,7 +21,8 @@ import { isUsFromCache } from '../session/utils/User';
|
|||
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
||||
import { toLogFormat } from '../types/attachments/Errors';
|
||||
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
||||
import { handleMessageReaction } from '../util/reactions';
|
||||
import { Reactions } from '../util/reactions';
|
||||
import { Action, Reaction } from '../types/Reaction';
|
||||
|
||||
function cleanAttachment(attachment: any) {
|
||||
return {
|
||||
|
@ -36,14 +37,13 @@ function cleanAttachment(attachment: any) {
|
|||
}
|
||||
|
||||
function cleanAttachments(decrypted: SignalService.DataMessage) {
|
||||
const { quote, group } = decrypted;
|
||||
const { quote } = decrypted;
|
||||
|
||||
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
|
||||
|
||||
// we do not care about group.avatar on Session
|
||||
if (group && group.avatar !== null) {
|
||||
group.avatar = null;
|
||||
}
|
||||
// we do not care about group on Session
|
||||
|
||||
decrypted.group = null;
|
||||
|
||||
decrypted.attachments = (decrypted.attachments || []).map(cleanAttachment);
|
||||
decrypted.preview = (decrypted.preview || []).map((item: any) => {
|
||||
|
@ -79,35 +79,20 @@ function cleanAttachments(decrypted: SignalService.DataMessage) {
|
|||
}
|
||||
}
|
||||
|
||||
export function isMessageEmpty(message: SignalService.DataMessage) {
|
||||
const {
|
||||
flags,
|
||||
body,
|
||||
attachments,
|
||||
group,
|
||||
quote,
|
||||
preview,
|
||||
openGroupInvitation,
|
||||
reaction,
|
||||
} = message;
|
||||
export function messageHasVisibleContent(message: SignalService.DataMessage) {
|
||||
const { flags, body, attachments, quote, preview, openGroupInvitation, reaction } = message;
|
||||
|
||||
return (
|
||||
!flags &&
|
||||
// FIXME remove this hack to drop auto friend requests messages in a few weeks 15/07/2020
|
||||
isBodyEmpty(body) &&
|
||||
isEmpty(attachments) &&
|
||||
isEmpty(group) &&
|
||||
isEmpty(quote) &&
|
||||
isEmpty(preview) &&
|
||||
isEmpty(openGroupInvitation) &&
|
||||
isEmpty(reaction)
|
||||
!!flags ||
|
||||
!isEmpty(body) ||
|
||||
!isEmpty(attachments) ||
|
||||
!isEmpty(quote) ||
|
||||
!isEmpty(preview) ||
|
||||
!isEmpty(openGroupInvitation) ||
|
||||
!isEmpty(reaction)
|
||||
);
|
||||
}
|
||||
|
||||
function isBodyEmpty(body: string) {
|
||||
return isEmpty(body);
|
||||
}
|
||||
|
||||
export function cleanIncomingDataMessage(
|
||||
rawDataMessage: SignalService.DataMessage,
|
||||
envelope?: EnvelopePlus
|
||||
|
@ -230,7 +215,7 @@ export async function handleSwarmDataMessage(
|
|||
);
|
||||
}
|
||||
|
||||
if (isMessageEmpty(cleanDataMessage)) {
|
||||
if (!messageHasVisibleContent(cleanDataMessage)) {
|
||||
window?.log?.warn(`Message ${getEnvelopeId(envelope)} ignored; it was empty`);
|
||||
return removeFromCache(envelope);
|
||||
}
|
||||
|
@ -318,17 +303,26 @@ async function handleSwarmMessage(
|
|||
|
||||
void convoToAddMessageTo.queueJob(async () => {
|
||||
// this call has to be made inside the queueJob!
|
||||
if (!msgModel.get('isPublic') && rawDataMessage.reaction && rawDataMessage.syncTarget) {
|
||||
await handleMessageReaction(
|
||||
rawDataMessage.reaction,
|
||||
msgModel.get('source'),
|
||||
false,
|
||||
messageHash
|
||||
);
|
||||
// We handle reaction DataMessages separately
|
||||
if (!msgModel.get('isPublic') && rawDataMessage.reaction) {
|
||||
await Reactions.handleMessageReaction({
|
||||
reaction: rawDataMessage.reaction,
|
||||
sender: msgModel.get('source'),
|
||||
you: isUsFromCache(msgModel.get('source')),
|
||||
isOpenGroup: false,
|
||||
});
|
||||
if (
|
||||
convoToAddMessageTo.isPrivate() &&
|
||||
msgModel.get('unread') &&
|
||||
rawDataMessage.reaction.action === Action.REACT
|
||||
) {
|
||||
msgModel.set('reaction', rawDataMessage.reaction as Reaction);
|
||||
convoToAddMessageTo.throttledNotify(msgModel);
|
||||
}
|
||||
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate = await isSwarmMessageDuplicate({
|
||||
source: msgModel.get('source'),
|
||||
sentAt,
|
||||
|
|
|
@ -12,7 +12,7 @@ import { removeMessagePadding } from '../session/crypto/BufferPadding';
|
|||
import { UserUtils } from '../session/utils';
|
||||
import { perfEnd, perfStart } from '../session/utils/Performance';
|
||||
import { fromBase64ToArray } from '../session/utils/String';
|
||||
import { cleanIncomingDataMessage, isMessageEmpty } from './dataMessage';
|
||||
import { cleanIncomingDataMessage, messageHasVisibleContent } from './dataMessage';
|
||||
import { handleMessageJob, toRegularMessage } from './queuedJob';
|
||||
|
||||
export const handleOpenGroupV4Message = async (
|
||||
|
@ -63,8 +63,7 @@ const handleOpenGroupMessage = async (
|
|||
return;
|
||||
}
|
||||
|
||||
if (isMessageEmpty(idataMessage as SignalService.DataMessage)) {
|
||||
// empty message, drop it
|
||||
if (!messageHasVisibleContent(idataMessage as SignalService.DataMessage)) {
|
||||
window.log.info('received an empty message for sogs');
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -16,8 +16,6 @@ import { GoogleChrome } from '../util';
|
|||
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
|
||||
import { ConversationTypeEnum } from '../models/conversationAttributes';
|
||||
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
||||
import { handleMessageReaction } from '../util/reactions';
|
||||
import { Action, Reaction } from '../types/Reaction';
|
||||
|
||||
function contentTypeSupported(type: string): boolean {
|
||||
const Chrome = GoogleChrome;
|
||||
|
@ -340,118 +338,103 @@ export async function handleMessageJob(
|
|||
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
|
||||
);
|
||||
|
||||
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
|
||||
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
|
||||
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
||||
source,
|
||||
ConversationTypeEnum.PRIVATE
|
||||
);
|
||||
try {
|
||||
messageModel.set({ flags: regularDataMessage.flags });
|
||||
if (messageModel.isExpirationTimerUpdate()) {
|
||||
const { expireTimer } = regularDataMessage;
|
||||
const oldValue = conversation.get('expireTimer');
|
||||
if (expireTimer === oldValue) {
|
||||
confirm?.();
|
||||
window?.log?.info(
|
||||
'Dropping ExpireTimerUpdate message as we already have the same one set.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
|
||||
} else {
|
||||
// this does not commit to db nor UI unless we need to approve a convo
|
||||
await handleRegularMessage(
|
||||
conversation,
|
||||
sendingDeviceConversation,
|
||||
messageModel,
|
||||
regularDataMessage,
|
||||
source,
|
||||
messageHash
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
regularDataMessage.reaction.action === Action.REACT &&
|
||||
conversation.isPrivate() &&
|
||||
messageModel.get('unread')
|
||||
) {
|
||||
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
|
||||
// save the message model to the db and it save the messageId generated to our in-memory copy
|
||||
const id = await messageModel.commit();
|
||||
messageModel.set({ id });
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
|
||||
const unreadCount = await conversation.getUnreadCount();
|
||||
conversation.set({ unreadCount });
|
||||
conversation.set({
|
||||
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
|
||||
});
|
||||
// this is a throttled call and will only run once every 1 sec at most
|
||||
conversation.updateLastMessage();
|
||||
await conversation.commit();
|
||||
|
||||
if (conversation.id !== sendingDeviceConversation.id) {
|
||||
await sendingDeviceConversation.commit();
|
||||
}
|
||||
|
||||
void queueAttachmentDownloads(messageModel, conversation);
|
||||
// Check if we need to update any profile names
|
||||
// the only profile we don't update with what is coming here is ours,
|
||||
// as our profile is shared accross our devices with a ConfigurationMessage
|
||||
if (messageModel.isIncoming() && regularDataMessage.profile) {
|
||||
void appendFetchAvatarAndProfileJob(
|
||||
sendingDeviceConversation,
|
||||
regularDataMessage.profile,
|
||||
regularDataMessage.profileKey
|
||||
);
|
||||
}
|
||||
|
||||
// even with all the warnings, I am very sus about if this is usefull or not
|
||||
// try {
|
||||
// // We go to the database here because, between the message save above and
|
||||
// // the previous line's trigger() call, we might have marked all messages
|
||||
// // unread in the database. This message might already be read!
|
||||
// const fetched = await getMessageById(messageModel.get('id'));
|
||||
|
||||
// const previousUnread = messageModel.get('unread');
|
||||
|
||||
// // Important to update message with latest read state from database
|
||||
// messageModel.merge(fetched);
|
||||
|
||||
// if (previousUnread !== messageModel.get('unread')) {
|
||||
// window?.log?.warn(
|
||||
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
|
||||
// );
|
||||
// // We call markRead() even though the message is already
|
||||
// // marked read because we need to start expiration
|
||||
// // timers, etc.
|
||||
// await messageModel.markRead(Date.now());
|
||||
// }
|
||||
// } catch (error) {
|
||||
// window?.log?.warn(
|
||||
// 'handleMessageJob: Message',
|
||||
// messageModel.idForLogging(),
|
||||
// 'was deleted'
|
||||
// );
|
||||
// }
|
||||
|
||||
if (messageModel.get('unread')) {
|
||||
conversation.throttledNotify(messageModel);
|
||||
}
|
||||
|
||||
confirm?.();
|
||||
} else {
|
||||
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
||||
source,
|
||||
ConversationTypeEnum.PRIVATE
|
||||
);
|
||||
try {
|
||||
messageModel.set({ flags: regularDataMessage.flags });
|
||||
if (messageModel.isExpirationTimerUpdate()) {
|
||||
const { expireTimer } = regularDataMessage;
|
||||
const oldValue = conversation.get('expireTimer');
|
||||
if (expireTimer === oldValue) {
|
||||
confirm?.();
|
||||
window?.log?.info(
|
||||
'Dropping ExpireTimerUpdate message as we already have the same one set.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
|
||||
} else {
|
||||
// this does not commit to db nor UI unless we need to approve a convo
|
||||
await handleRegularMessage(
|
||||
conversation,
|
||||
sendingDeviceConversation,
|
||||
messageModel,
|
||||
regularDataMessage,
|
||||
source,
|
||||
messageHash
|
||||
);
|
||||
}
|
||||
|
||||
// save the message model to the db and it save the messageId generated to our in-memory copy
|
||||
const id = await messageModel.commit();
|
||||
messageModel.set({ id });
|
||||
|
||||
// Note that this can save the message again, if jobs were queued. We need to
|
||||
// call it after we have an id for this message, because the jobs refer back
|
||||
// to their source message.
|
||||
|
||||
const unreadCount = await conversation.getUnreadCount();
|
||||
conversation.set({ unreadCount });
|
||||
conversation.set({
|
||||
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
|
||||
});
|
||||
// this is a throttled call and will only run once every 1 sec at most
|
||||
conversation.updateLastMessage();
|
||||
await conversation.commit();
|
||||
|
||||
if (conversation.id !== sendingDeviceConversation.id) {
|
||||
await sendingDeviceConversation.commit();
|
||||
}
|
||||
|
||||
void queueAttachmentDownloads(messageModel, conversation);
|
||||
// Check if we need to update any profile names
|
||||
// the only profile we don't update with what is coming here is ours,
|
||||
// as our profile is shared accross our devices with a ConfigurationMessage
|
||||
if (messageModel.isIncoming() && regularDataMessage.profile) {
|
||||
void appendFetchAvatarAndProfileJob(
|
||||
sendingDeviceConversation,
|
||||
regularDataMessage.profile,
|
||||
regularDataMessage.profileKey
|
||||
);
|
||||
}
|
||||
|
||||
// even with all the warnings, I am very sus about if this is usefull or not
|
||||
// try {
|
||||
// // We go to the database here because, between the message save above and
|
||||
// // the previous line's trigger() call, we might have marked all messages
|
||||
// // unread in the database. This message might already be read!
|
||||
// const fetched = await getMessageById(messageModel.get('id'));
|
||||
|
||||
// const previousUnread = messageModel.get('unread');
|
||||
|
||||
// // Important to update message with latest read state from database
|
||||
// messageModel.merge(fetched);
|
||||
|
||||
// if (previousUnread !== messageModel.get('unread')) {
|
||||
// window?.log?.warn(
|
||||
// 'Caught race condition on new message read state! ' + 'Manually starting timers.'
|
||||
// );
|
||||
// // We call markRead() even though the message is already
|
||||
// // marked read because we need to start expiration
|
||||
// // timers, etc.
|
||||
// await messageModel.markRead(Date.now());
|
||||
// }
|
||||
// } catch (error) {
|
||||
// window?.log?.warn(
|
||||
// 'handleMessageJob: Message',
|
||||
// messageModel.idForLogging(),
|
||||
// 'was deleted'
|
||||
// );
|
||||
// }
|
||||
|
||||
if (messageModel.get('unread')) {
|
||||
conversation.throttledNotify(messageModel);
|
||||
}
|
||||
confirm?.();
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorForLog = error && error.stack ? error.stack : error;
|
||||
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,11 @@ export type OpenGroupMessageV4 = {
|
|||
reactions: Record<string, OpenGroupReaction>;
|
||||
};
|
||||
|
||||
// seqno is not set for SOGS < 1.3.4
|
||||
export type OpenGroupReactionMessageV4 = Omit<OpenGroupMessageV4, 'seqno'> & {
|
||||
seqno: number | undefined;
|
||||
};
|
||||
|
||||
const pollForEverythingInterval = DURATION.SECONDS * 10;
|
||||
|
||||
export const invalidAuthRequiresBlinding =
|
||||
|
@ -312,12 +317,7 @@ export class OpenGroupServerPoller {
|
|||
}
|
||||
|
||||
// ==> At this point all those results need to trigger conversation updates, so update what we have to update
|
||||
await handleBatchPollResults(
|
||||
this.serverUrl,
|
||||
batchPollResults,
|
||||
subrequestOptions,
|
||||
this.roomIdsToPoll
|
||||
);
|
||||
await handleBatchPollResults(this.serverUrl, batchPollResults, subrequestOptions);
|
||||
|
||||
if (this.serverUrl === defaultServer) {
|
||||
for (const room of subrequestOptions) {
|
||||
|
|
|
@ -34,7 +34,7 @@ import { handleOutboxMessageModel } from '../../../../receiver/dataMessage';
|
|||
import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
|
||||
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { handleOpenGroupMessageReactions } from '../../../../util/reactions';
|
||||
import { processMessagesUsingCache } from './sogsV3MutationCache';
|
||||
|
||||
/**
|
||||
* Get the convo matching those criteria and make sure it is an opengroup convo, or return null.
|
||||
|
@ -82,8 +82,7 @@ async function handlePollInfoResponse(
|
|||
hidden_moderators?: Array<string>;
|
||||
};
|
||||
},
|
||||
serverUrl: string,
|
||||
roomIdsStillPolled: Set<string>
|
||||
serverUrl: string
|
||||
) {
|
||||
if (statusCode !== 200) {
|
||||
window.log.info('handlePollInfoResponse subRequest status code is not 200:', statusCode);
|
||||
|
@ -101,8 +100,9 @@ async function handlePollInfoResponse(
|
|||
window.log.info('handlePollInfoResponse token and serverUrl must be set');
|
||||
return;
|
||||
}
|
||||
const stillPolledRooms = OpenGroupData.getV2OpenGroupRoomsByServerUrl(serverUrl);
|
||||
|
||||
if (!roomIdsStillPolled.has(token)) {
|
||||
if (!stillPolledRooms?.some(r => r.roomId === token && r.serverUrl === serverUrl)) {
|
||||
window.log.info('handlePollInfoResponse room is no longer polled: ', token);
|
||||
return;
|
||||
}
|
||||
|
@ -188,8 +188,7 @@ const handleSogsV3DeletedMessages = async (
|
|||
const handleMessagesResponseV4 = async (
|
||||
messages: Array<OpenGroupMessageV4>,
|
||||
serverUrl: string,
|
||||
subrequestOption: SubRequestMessagesType,
|
||||
roomIdsStillPolled: Set<string>
|
||||
subrequestOption: SubRequestMessagesType
|
||||
) => {
|
||||
if (!subrequestOption || !subrequestOption.messages) {
|
||||
window?.log?.error('handleBatchPollResults - missing fields required for message subresponse');
|
||||
|
@ -199,7 +198,9 @@ const handleMessagesResponseV4 = async (
|
|||
try {
|
||||
const { roomId } = subrequestOption.messages;
|
||||
|
||||
if (!roomIdsStillPolled.has(roomId)) {
|
||||
const stillPolledRooms = OpenGroupData.getV2OpenGroupRoomsByServerUrl(serverUrl);
|
||||
|
||||
if (!stillPolledRooms?.some(r => r.roomId === roomId && r.serverUrl === serverUrl)) {
|
||||
window.log.info(`handleMessagesResponseV4: we are no longer polling for ${roomId}: skipping`);
|
||||
return;
|
||||
}
|
||||
|
@ -312,7 +313,7 @@ const handleMessagesResponseV4 = async (
|
|||
if (groupConvo && groupConvo.isOpenGroupV2()) {
|
||||
for (const message of messagesWithReactions) {
|
||||
void groupConvo.queueJob(async () => {
|
||||
await handleOpenGroupMessageReactions(message.reactions, message.id);
|
||||
await processMessagesUsingCache(serverUrl, roomId, message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -490,8 +491,7 @@ export const handleBatchPollResults = async (
|
|||
serverUrl: string,
|
||||
batchPollResults: BatchSogsReponse,
|
||||
/** using this as explicit way to ensure order */
|
||||
subrequestOptionsLookup: Array<OpenGroupBatchRow>,
|
||||
roomIdsStillPolled: Set<string> // if we get anything for a room we stopped polling, we need to skip it.
|
||||
subrequestOptionsLookup: Array<OpenGroupBatchRow>
|
||||
) => {
|
||||
// @@: Might not need the explicit type field.
|
||||
// pro: prevents cases where accidentally two fields for the opt. e.g. capability and message fields truthy.
|
||||
|
@ -520,20 +520,10 @@ export const handleBatchPollResults = async (
|
|||
break;
|
||||
case 'messages':
|
||||
// this will also include deleted messages explicitly with `data: null` & edited messages with a new data field & react changes with data not existing
|
||||
await handleMessagesResponseV4(
|
||||
subResponse.body,
|
||||
serverUrl,
|
||||
subrequestOption,
|
||||
roomIdsStillPolled
|
||||
);
|
||||
await handleMessagesResponseV4(subResponse.body, serverUrl, subrequestOption);
|
||||
break;
|
||||
case 'pollInfo':
|
||||
await handlePollInfoResponse(
|
||||
subResponse.code,
|
||||
subResponse.body,
|
||||
serverUrl,
|
||||
roomIdsStillPolled
|
||||
);
|
||||
await handlePollInfoResponse(subResponse.code, subResponse.body, serverUrl);
|
||||
break;
|
||||
case 'inbox':
|
||||
await handleInboxOutboxMessages(subResponse.body, serverUrl, false);
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { addJsonContentTypeToHeaders } from './sogsV3SendMessage';
|
||||
import { AbortSignal } from 'abort-controller';
|
||||
import { roomHasBlindEnabled } from './sogsV3Capabilities';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
|
||||
type BatchFetchRequestOptions = {
|
||||
method: 'POST' | 'PUT' | 'GET' | 'DELETE';
|
||||
|
@ -238,10 +239,9 @@ const makeBatchRequestPayload = (
|
|||
if (options.messages) {
|
||||
return {
|
||||
method: 'GET',
|
||||
// TODO Consistency across platforms with fetching reactors
|
||||
path: isNumber(options.messages.sinceSeqNo)
|
||||
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r`
|
||||
: `/room/${options.messages.roomId}/messages/recent`,
|
||||
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r&reactors=${Reactions.SOGSReactorsFetchCount}`
|
||||
: `/room/${options.messages.roomId}/messages/recent?reactors=${Reactions.SOGSReactorsFetchCount}`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
@ -282,12 +282,15 @@ const makeBatchRequestPayload = (
|
|||
method: 'POST',
|
||||
path: `/user/${sessionId}/moderator`,
|
||||
|
||||
// An admin has moderator permissions automatically, but removing his admin permissions only will keep him as a moderator.
|
||||
// We do not want this currently. When removing an admin from Session Desktop we want to remove all his permissions server side.
|
||||
// We'll need to build a complete dialog with options to make the whole admins/moderator/global/visible/hidden logic work as the server was built for.
|
||||
json: {
|
||||
rooms: [options.addRemoveModerators.roomId],
|
||||
global: false,
|
||||
// moderator: isAddMod, // currently we only support adding/removing visible admins
|
||||
visible: true,
|
||||
admin: isAddMod,
|
||||
moderator: isAddMod,
|
||||
},
|
||||
}));
|
||||
case 'banUnbanUser':
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import AbortController from 'abort-controller';
|
||||
import { OpenGroupReactionResponse } from '../../../../types/Reaction';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
|
||||
import {
|
||||
batchFirstSubIsSuccess,
|
||||
|
@ -6,6 +8,12 @@ import {
|
|||
OpenGroupBatchRow,
|
||||
sogsBatchSend,
|
||||
} from './sogsV3BatchPoll';
|
||||
import {
|
||||
addToMutationCache,
|
||||
ChangeType,
|
||||
SogsV3Mutation,
|
||||
updateMutationCache,
|
||||
} from './sogsV3MutationCache';
|
||||
import { hasReactionSupport } from './sogsV3SendReaction';
|
||||
|
||||
/**
|
||||
|
@ -18,15 +26,41 @@ export const clearSogsReactionByServerId = async (
|
|||
serverId: number,
|
||||
roomInfos: OpenGroupRequestCommonType
|
||||
): Promise<boolean> => {
|
||||
const canReact = await hasReactionSupport(serverId);
|
||||
if (!canReact) {
|
||||
const { supported, conversation } = await hasReactionSupport(serverId);
|
||||
if (!supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
window.log.warn(`Conversation for ${reaction} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheEntry: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: null,
|
||||
metadata: {
|
||||
messageId: serverId,
|
||||
emoji: reaction,
|
||||
action: 'CLEAR',
|
||||
},
|
||||
};
|
||||
|
||||
addToMutationCache(cacheEntry);
|
||||
|
||||
// Since responses can take a long time we immediately update the moderators's UI and if there is a problem it is overwritten by handleOpenGroupMessageReactions later.
|
||||
await Reactions.handleClearReaction(serverId, reaction);
|
||||
|
||||
const options: Array<OpenGroupBatchRow> = [
|
||||
{
|
||||
type: 'deleteReaction',
|
||||
deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId },
|
||||
deleteReaction: {
|
||||
reaction,
|
||||
messageId: serverId,
|
||||
roomId: roomInfos.roomId,
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = await sogsBatchSend(
|
||||
|
@ -37,8 +71,22 @@ export const clearSogsReactionByServerId = async (
|
|||
'batch'
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Could not deleteReaction, res is invalid');
|
||||
}
|
||||
|
||||
const rawMessage = (result.body && (result.body[0].body as OpenGroupReactionResponse)) || null;
|
||||
if (!rawMessage) {
|
||||
throw new Error('deleteReaction parsing failed');
|
||||
}
|
||||
|
||||
try {
|
||||
return batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result);
|
||||
if (batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result)) {
|
||||
updateMutationCache(cacheEntry, rawMessage.seqno);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
window?.log?.error("clearSogsReactionByServerId Can't decode JSON body");
|
||||
}
|
||||
|
|
148
ts/session/apis/open_group_api/sogsv3/sogsV3MutationCache.ts
Normal file
148
ts/session/apis/open_group_api/sogsv3/sogsV3MutationCache.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* This is strictly use to resolve conflicts between local state and the opengroup poll updates
|
||||
* Currently only supports message reactions 26/08/2022
|
||||
*/
|
||||
|
||||
import { filter, findIndex, remove } from 'lodash';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import { OpenGroupReactionMessageV4 } from '../opengroupV2/OpenGroupServerPoller';
|
||||
|
||||
export enum ChangeType {
|
||||
REACTIONS = 0,
|
||||
}
|
||||
|
||||
type ReactionAction = 'ADD' | 'REMOVE' | 'CLEAR';
|
||||
|
||||
type ReactionChange = {
|
||||
messageId: number; // will be serverId of the reacted message
|
||||
emoji: string;
|
||||
action: ReactionAction;
|
||||
};
|
||||
|
||||
export type SogsV3Mutation = {
|
||||
seqno: number | null; // null until mutating API request returns
|
||||
server: string; // serverUrl
|
||||
room: string; // roomId
|
||||
changeType: ChangeType;
|
||||
metadata: ReactionChange; // For now we only support message reactions
|
||||
};
|
||||
|
||||
// we don't want to export this, we want to export functions that manipulate it
|
||||
const sogsMutationCache: Array<SogsV3Mutation> = [];
|
||||
|
||||
// for testing purposes only
|
||||
export function getMutationCache() {
|
||||
return sogsMutationCache;
|
||||
}
|
||||
|
||||
function verifyEntry(entry: SogsV3Mutation): boolean {
|
||||
return Boolean(
|
||||
entry.server &&
|
||||
entry.room &&
|
||||
entry.changeType === ChangeType.REACTIONS &&
|
||||
entry.metadata.messageId &&
|
||||
entry.metadata.emoji &&
|
||||
(entry.metadata.action === 'ADD' ||
|
||||
entry.metadata.action === 'REMOVE' ||
|
||||
entry.metadata.action === 'CLEAR')
|
||||
);
|
||||
}
|
||||
|
||||
export function addToMutationCache(entry: SogsV3Mutation) {
|
||||
if (!verifyEntry(entry)) {
|
||||
window.log.error('SOGS Mutation Cache: Entry verification on add failed!', entry);
|
||||
} else {
|
||||
sogsMutationCache.push(entry);
|
||||
window.log.info('SOGS Mutation Cache: Entry added!', entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMutationCache(entry: SogsV3Mutation, seqno: number) {
|
||||
if (!verifyEntry(entry)) {
|
||||
window.log.error('SOGS Mutation Cache: Entry verification on update failed!', entry);
|
||||
} else {
|
||||
const entryIndex = findIndex(sogsMutationCache, entry);
|
||||
if (entryIndex >= 0) {
|
||||
sogsMutationCache[entryIndex].seqno = seqno;
|
||||
window.log.info('SOGS Mutation Cache: Entry updated!', sogsMutationCache[entryIndex]);
|
||||
} else {
|
||||
window.log.error('SOGS Mutation Cache: Updated failed! Cannot find entry', entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return is for testing purposes only
|
||||
export async function processMessagesUsingCache(
|
||||
server: string,
|
||||
room: string,
|
||||
message: OpenGroupReactionMessageV4
|
||||
): Promise<OpenGroupReactionMessageV4> {
|
||||
const updatedReactions = message.reactions;
|
||||
|
||||
const roomMatches: Array<SogsV3Mutation> = filter(sogsMutationCache, { server, room });
|
||||
for (let i = 0; i < roomMatches.length; i++) {
|
||||
const matchSeqno = roomMatches[i].seqno;
|
||||
if (message.seqno && matchSeqno && matchSeqno <= message.seqno) {
|
||||
const removedEntry = roomMatches.splice(i, 1)[0];
|
||||
window.log.info(
|
||||
`SOGS Mutation Cache: Entry ignored and removed in ${server}/${room} for message ${message.id}`,
|
||||
removedEntry
|
||||
);
|
||||
remove(sogsMutationCache, removedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const reaction of Object.keys(message.reactions)) {
|
||||
const reactionMatches = filter(roomMatches, {
|
||||
server,
|
||||
room,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
metadata: {
|
||||
messageId: message.id,
|
||||
emoji: reaction,
|
||||
},
|
||||
});
|
||||
|
||||
for (const reactionMatch of reactionMatches) {
|
||||
switch (reactionMatch.metadata.action) {
|
||||
case 'ADD':
|
||||
updatedReactions[reaction].you = true;
|
||||
updatedReactions[reaction].count += 1;
|
||||
window.log.info(
|
||||
`SOGS Mutation Cache: Added our reaction based on the cache in ${server}/${room} for message ${message.id}`,
|
||||
updatedReactions[reaction]
|
||||
);
|
||||
break;
|
||||
case 'REMOVE':
|
||||
updatedReactions[reaction].you = false;
|
||||
updatedReactions[reaction].count -= 1;
|
||||
window.log.info(
|
||||
`SOGS Mutation Cache: Removed our reaction based on the cache in ${server}/${room} for message ${message.id}`,
|
||||
updatedReactions[reaction]
|
||||
);
|
||||
break;
|
||||
case 'CLEAR':
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete updatedReactions[reaction];
|
||||
window.log.info(
|
||||
`SOGS Mutation Cache: Cleared all ${reaction} reactions based on the cache in ${server}/${room} for message ${message.id}`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
window.log.warn(
|
||||
`SOGS Mutation Cache: Unsupported metadata action in OpenGroupMessageV4 in ${server}/${room} for message ${message.id}`,
|
||||
reactionMatch
|
||||
);
|
||||
}
|
||||
const removedEntry = remove(sogsMutationCache, reactionMatch);
|
||||
window.log.info(
|
||||
`SOGS Mutation Cache: Entry removed in ${server}/${room} for message ${message.id}`,
|
||||
removedEntry
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
message.reactions = updatedReactions;
|
||||
await Reactions.handleOpenGroupMessageReactions(message.reactions, message.id);
|
||||
return message;
|
||||
}
|
|
@ -1,35 +1,47 @@
|
|||
import { AbortSignal } from 'abort-controller';
|
||||
import { Data } from '../../../../data/data';
|
||||
import { ConversationModel } from '../../../../models/conversation';
|
||||
import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction';
|
||||
import { getEmojiDataFromNative } from '../../../../util/emoji';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import { OnionSending } from '../../../onions/onionSend';
|
||||
import { UserUtils } from '../../../utils';
|
||||
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
|
||||
import { getUsBlindedInThatServer } from './knownBlindedkeys';
|
||||
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
|
||||
import {
|
||||
addToMutationCache,
|
||||
ChangeType,
|
||||
SogsV3Mutation,
|
||||
updateMutationCache,
|
||||
} from './sogsV3MutationCache';
|
||||
|
||||
export const hasReactionSupport = async (serverId: number): Promise<boolean> => {
|
||||
export const hasReactionSupport = async (
|
||||
serverId: number
|
||||
): Promise<{ supported: boolean; conversation: ConversationModel | null }> => {
|
||||
const found = await Data.getMessageByServerId(serverId);
|
||||
if (!found) {
|
||||
window.log.warn(`Open Group Message ${serverId} not found in db`);
|
||||
return false;
|
||||
return { supported: false, conversation: null };
|
||||
}
|
||||
|
||||
const conversationModel = found?.getConversation();
|
||||
if (!conversationModel) {
|
||||
window.log.warn(`Conversation for ${serverId} not found in db`);
|
||||
return false;
|
||||
return { supported: false, conversation: null };
|
||||
}
|
||||
|
||||
if (!conversationModel.hasReactions()) {
|
||||
window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId);
|
||||
return false;
|
||||
return { supported: false, conversation: null };
|
||||
}
|
||||
|
||||
return true;
|
||||
return { supported: true, conversation: conversationModel };
|
||||
};
|
||||
|
||||
export const sendSogsReactionOnionV4 = async (
|
||||
serverUrl: string,
|
||||
room: string,
|
||||
room: string, // this is the roomId
|
||||
abortSignal: AbortSignal,
|
||||
reaction: Reaction,
|
||||
blinded: boolean
|
||||
|
@ -40,17 +52,50 @@ export const sendSogsReactionOnionV4 = async (
|
|||
throw new Error(`Could not find sogs pubkey of url:${serverUrl}`);
|
||||
}
|
||||
|
||||
const canReact = await hasReactionSupport(reaction.id);
|
||||
if (!canReact) {
|
||||
const { supported, conversation } = await hasReactionSupport(reaction.id);
|
||||
if (!supported) {
|
||||
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
|
||||
if (Reactions.hitRateLimit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
window.log.warn(`Conversation for ${reaction.id} not found in db`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The SOGS endpoint supports any text input so we need to make sure we are sending a valid unicode emoji
|
||||
// for an invalid input we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji but is valid unicode
|
||||
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;
|
||||
|
||||
const cacheEntry: SogsV3Mutation = {
|
||||
server: serverUrl,
|
||||
room: room,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: null,
|
||||
metadata: {
|
||||
messageId: reaction.id,
|
||||
emoji,
|
||||
action: reaction.action === Action.REACT ? 'ADD' : 'REMOVE',
|
||||
},
|
||||
};
|
||||
|
||||
addToMutationCache(cacheEntry);
|
||||
|
||||
// Since responses can take a long time we immediately update the sender's UI and if there is a problem it is overwritten by handleOpenGroupMessageReactions later.
|
||||
const me = UserUtils.getOurPubKeyStrFromCache();
|
||||
await Reactions.handleMessageReaction({
|
||||
reaction,
|
||||
sender: blinded ? getUsBlindedInThatServer(conversation) || me : me,
|
||||
you: true,
|
||||
isOpenGroup: true,
|
||||
});
|
||||
|
||||
// reaction endpoint requires an empty dict {}
|
||||
const stringifiedBody = null;
|
||||
const result = await OnionSending.sendJsonViaOnionV4ToSogs({
|
||||
|
@ -81,11 +126,11 @@ export const sendSogsReactionOnionV4 = async (
|
|||
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);
|
||||
|
||||
if (success) {
|
||||
updateMutationCache(cacheEntry, rawMessage.seqno);
|
||||
}
|
||||
|
||||
return success;
|
||||
};
|
||||
|
|
|
@ -23,6 +23,8 @@ import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/Ope
|
|||
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
|
||||
import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage';
|
||||
import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2';
|
||||
import { AbortController } from 'abort-controller';
|
||||
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
|
||||
|
||||
type ClosedGroupMessageType =
|
||||
| ClosedGroupVisibleMessage
|
||||
|
@ -75,6 +77,18 @@ export class MessageQueue {
|
|||
// Skipping the queue for Open Groups v2; the message is sent directly
|
||||
|
||||
try {
|
||||
// NOTE Reactions are handled separately
|
||||
if (message.reaction) {
|
||||
await sendSogsReactionOnionV4(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
new AbortController().signal,
|
||||
message.reaction,
|
||||
blinded
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await MessageSender.sendToOpenGroupV2(
|
||||
message,
|
||||
roomInfos,
|
||||
|
@ -82,11 +96,6 @@ export class MessageQueue {
|
|||
filesToLink
|
||||
);
|
||||
|
||||
// NOTE Reactions are handled in the MessageSender
|
||||
if (message.reaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sentTimestamp, serverId } = result as OpenGroupMessageV2;
|
||||
if (!serverId || serverId === -1) {
|
||||
throw new Error(`Invalid serverId returned by server: ${serverId}`);
|
||||
|
|
|
@ -26,7 +26,6 @@ import {
|
|||
sendSogsMessageOnionV4,
|
||||
} from '../apis/open_group_api/sogsv3/sogsV3SendMessage';
|
||||
import { AbortController } from 'abort-controller';
|
||||
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
|
||||
|
||||
const DEFAULT_CONNECTIONS = 1;
|
||||
|
||||
|
@ -37,6 +36,7 @@ function overwriteOutgoingTimestampWithNetworkTimestamp(message: RawMessage) {
|
|||
|
||||
const { plainTextBuffer } = message;
|
||||
const contentDecoded = SignalService.Content.decode(plainTextBuffer);
|
||||
|
||||
const { dataMessage, dataExtractionNotification, typingMessage } = contentDecoded;
|
||||
if (dataMessage && dataMessage.timestamp && dataMessage.timestamp > 0) {
|
||||
// this is a sync message, do not overwrite the message timestamp
|
||||
|
@ -288,25 +288,14 @@ export async function sendToOpenGroupV2(
|
|||
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(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
new AbortController().signal,
|
||||
v2Message,
|
||||
blinded
|
||||
);
|
||||
return msg;
|
||||
}
|
||||
const msg = await sendSogsMessageOnionV4(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
new AbortController().signal,
|
||||
v2Message,
|
||||
blinded
|
||||
);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -56,6 +56,8 @@ async function uploadToFileServer(params: UploadParams): Promise<AttachmentPoint
|
|||
fileName: attachment.fileName,
|
||||
flags: attachment.flags,
|
||||
caption: attachment.caption,
|
||||
width: attachment.width,
|
||||
height: attachment.height,
|
||||
};
|
||||
|
||||
let attachmentData: ArrayBuffer;
|
||||
|
|
|
@ -36,6 +36,8 @@ async function uploadV3(params: UploadParamsV2): Promise<AttachmentPointerWithUr
|
|||
fileName: attachment.fileName,
|
||||
flags: attachment.flags,
|
||||
caption: attachment.caption,
|
||||
width: attachment.width && isFinite(attachment.width) ? attachment.width : undefined,
|
||||
height: attachment.height && isFinite(attachment.height) ? attachment.height : undefined,
|
||||
};
|
||||
|
||||
const paddedAttachment: ArrayBuffer = !openGroup
|
||||
|
|
|
@ -366,6 +366,7 @@ type FetchedTopMessageResults = {
|
|||
conversationKey: string;
|
||||
messagesProps: Array<MessageModelPropsWithoutConvoProps>;
|
||||
oldTopMessageId: string | null;
|
||||
newMostRecentMessageIdInConversation: string | null;
|
||||
} | null;
|
||||
|
||||
export const fetchTopMessagesForConversation = createAsyncThunk(
|
||||
|
@ -379,6 +380,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
|
|||
}): Promise<FetchedTopMessageResults> => {
|
||||
// no need to load more top if we are already at the top
|
||||
const oldestMessage = await Data.getOldestMessageInConversation(conversationKey);
|
||||
const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey);
|
||||
|
||||
if (!oldestMessage || oldestMessage.id === oldTopMessageId) {
|
||||
window.log.info('fetchTopMessagesForConversation: we are already at the top');
|
||||
|
@ -393,6 +395,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
|
|||
conversationKey,
|
||||
messagesProps,
|
||||
oldTopMessageId,
|
||||
newMostRecentMessageIdInConversation: mostRecentMessage?.id || null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -845,7 +848,12 @@ const conversationsSlice = createSlice({
|
|||
return { ...state, areMoreMessagesBeingFetched: false };
|
||||
}
|
||||
// this is called once the messages are loaded from the db for the currently selected conversation
|
||||
const { messagesProps, conversationKey, oldTopMessageId } = action.payload;
|
||||
const {
|
||||
messagesProps,
|
||||
conversationKey,
|
||||
oldTopMessageId,
|
||||
newMostRecentMessageIdInConversation,
|
||||
} = action.payload;
|
||||
// double check that this update is for the shown convo
|
||||
if (conversationKey === state.selectedConversation) {
|
||||
return {
|
||||
|
@ -853,6 +861,7 @@ const conversationsSlice = createSlice({
|
|||
oldTopMessageId,
|
||||
messages: messagesProps,
|
||||
areMoreMessagesBeingFetched: false,
|
||||
mostRecentMessageId: newMostRecentMessageIdInConversation,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
|
|
|
@ -173,11 +173,12 @@ export const hasSelectedConversationIncomingMessages = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
const getFirstUnreadMessageId = createSelector(getConversations, (state: ConversationsStateType):
|
||||
| string
|
||||
| undefined => {
|
||||
return state.firstUnreadMessageId;
|
||||
});
|
||||
export const getFirstUnreadMessageId = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): string | undefined => {
|
||||
return state.firstUnreadMessageId;
|
||||
}
|
||||
);
|
||||
|
||||
export const getConversationHasUnread = createSelector(getFirstUnreadMessageId, unreadId => {
|
||||
return Boolean(unreadId);
|
||||
|
@ -216,10 +217,11 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
? messageTimestamp
|
||||
: undefined;
|
||||
|
||||
const common = { showUnreadIndicator: isFirstUnread, showDateBreak };
|
||||
|
||||
if (msg.propsForDataExtractionNotification) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'data-extraction',
|
||||
props: { ...msg.propsForDataExtractionNotification, messageId: msg.propsForMessage.id },
|
||||
|
@ -229,8 +231,7 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
|
||||
if (msg.propsForMessageRequestResponse) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'message-request-response',
|
||||
props: { ...msg.propsForMessageRequestResponse, messageId: msg.propsForMessage.id },
|
||||
|
@ -240,8 +241,7 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
|
||||
if (msg.propsForGroupInvitation) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'group-invitation',
|
||||
props: { ...msg.propsForGroupInvitation, messageId: msg.propsForMessage.id },
|
||||
|
@ -251,8 +251,7 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
|
||||
if (msg.propsForGroupUpdateMessage) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'group-notification',
|
||||
props: { ...msg.propsForGroupUpdateMessage, messageId: msg.propsForMessage.id },
|
||||
|
@ -262,8 +261,7 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
|
||||
if (msg.propsForTimerNotification) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'timer-notification',
|
||||
props: { ...msg.propsForTimerNotification, messageId: msg.propsForMessage.id },
|
||||
|
@ -273,8 +271,7 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
|
|||
|
||||
if (msg.propsForCallNotification) {
|
||||
return {
|
||||
showUnreadIndicator: isFirstUnread,
|
||||
showDateBreak,
|
||||
...common,
|
||||
message: {
|
||||
messageType: 'call-notification',
|
||||
props: {
|
||||
|
@ -947,6 +944,16 @@ export const getMessageReactsProps = createSelector(getMessagePropsByMessageId,
|
|||
]);
|
||||
|
||||
if (msgProps.reacts) {
|
||||
// NOTE we don't want to render reactions that have 'senders' as an object this is a deprecated type used during development 25/08/2022
|
||||
const oldReactions = Object.values(msgProps.reacts).filter(
|
||||
reaction => !Array.isArray(reaction.senders)
|
||||
);
|
||||
|
||||
if (oldReactions.length > 0) {
|
||||
msgProps.reacts = undefined;
|
||||
return msgProps;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
@ -1111,8 +1118,6 @@ export const getMessageContentSelectorProps = createSelector(getMessagePropsByMe
|
|||
}
|
||||
|
||||
const msgProps: MessageContentSelectorProps = {
|
||||
firstMessageOfSeries: props.firstMessageOfSeries,
|
||||
lastMessageOfSeries: props.lastMessageOfSeries,
|
||||
...pick(props.propsForMessage, [
|
||||
'direction',
|
||||
'serverTimestamp',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import chai, { expect } from 'chai';
|
||||
import Sinon, { useFakeTimers } from 'sinon';
|
||||
import { handleMessageReaction, sendMessageReaction } from '../../../../util/reactions';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import { Data } from '../../../../data/data';
|
||||
import * as Storage from '../../../../util/storage';
|
||||
import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils';
|
||||
|
@ -40,7 +40,7 @@ describe('ReactionMessage', () => {
|
|||
|
||||
it('can react to a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
const reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
|
@ -52,12 +52,12 @@ describe('ReactionMessage', () => {
|
|||
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')
|
||||
);
|
||||
const updatedMessage = await Reactions.handleMessageReaction({
|
||||
reaction: reaction as SignalService.DataMessage.IReaction,
|
||||
sender: ourNumber,
|
||||
you: true,
|
||||
isOpenGroup: false,
|
||||
});
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
|
||||
.undefined;
|
||||
|
@ -65,7 +65,7 @@ describe('ReactionMessage', () => {
|
|||
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],
|
||||
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);
|
||||
|
@ -73,7 +73,7 @@ describe('ReactionMessage', () => {
|
|||
|
||||
it('can remove a reaction from a message', async () => {
|
||||
// Send reaction
|
||||
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
const reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '😄');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
|
@ -85,12 +85,12 @@ describe('ReactionMessage', () => {
|
|||
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')
|
||||
);
|
||||
const updatedMessage = await Reactions.handleMessageReaction({
|
||||
reaction: reaction as SignalService.DataMessage.IReaction,
|
||||
sender: ourNumber,
|
||||
you: true,
|
||||
isOpenGroup: false,
|
||||
});
|
||||
|
||||
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be
|
||||
.undefined;
|
||||
|
@ -100,10 +100,10 @@ describe('ReactionMessage', () => {
|
|||
// we have already sent 2 messages when this test runs
|
||||
for (let i = 0; i < 18; i++) {
|
||||
// Send reaction
|
||||
await sendMessageReaction(originalMessage.get('id'), '👍');
|
||||
await Reactions.sendMessageReaction(originalMessage.get('id'), '👍');
|
||||
}
|
||||
|
||||
let reaction = await sendMessageReaction(originalMessage.get('id'), '👎');
|
||||
let reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '👎');
|
||||
|
||||
expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be
|
||||
.undefined;
|
||||
|
@ -113,7 +113,7 @@ describe('ReactionMessage', () => {
|
|||
// Wait a miniute for the rate limit to clear
|
||||
clock.tick(1 * 60 * 1000);
|
||||
|
||||
reaction = await sendMessageReaction(originalMessage.get('id'), '👋');
|
||||
reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '👋');
|
||||
|
||||
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
|
||||
Number(originalMessage.get('sent_at'))
|
||||
|
|
|
@ -93,20 +93,6 @@ describe('filterDuplicatesFromDbAndIncoming', () => {
|
|||
expect(filtered.length).to.be.eq(1);
|
||||
expect(filtered[0]).to.be.deep.eq(msg1);
|
||||
});
|
||||
|
||||
it('three duplicates in the same poll', async () => {
|
||||
const msg1 = TestUtils.generateOpenGroupMessageV2();
|
||||
const msg2 = TestUtils.generateOpenGroupMessageV2();
|
||||
|
||||
const msg3 = TestUtils.generateOpenGroupMessageV2();
|
||||
msg2.sentTimestamp = msg1.sentTimestamp;
|
||||
msg2.sender = msg1.sender;
|
||||
msg3.sentTimestamp = msg1.sentTimestamp;
|
||||
msg3.sender = msg1.sender;
|
||||
const filtered = await filterDuplicatesFromDbAndIncoming([msg1, msg2, msg3]);
|
||||
expect(filtered.length).to.be.eq(1);
|
||||
expect(filtered[0]).to.be.deep.eq(msg1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters duplicated message from database', () => {
|
||||
|
|
365
ts/test/session/unit/sogsv3/MutationCache_test.ts
Normal file
365
ts/test/session/unit/sogsv3/MutationCache_test.ts
Normal file
|
@ -0,0 +1,365 @@
|
|||
import { expect } from 'chai';
|
||||
import Sinon from 'sinon';
|
||||
import {
|
||||
addToMutationCache,
|
||||
ChangeType,
|
||||
getMutationCache,
|
||||
processMessagesUsingCache,
|
||||
SogsV3Mutation,
|
||||
updateMutationCache,
|
||||
} from '../../../../session/apis/open_group_api/sogsv3/sogsV3MutationCache';
|
||||
import { TestUtils } from '../../../test-utils';
|
||||
import { Reactions } from '../../../../util/reactions';
|
||||
import {
|
||||
OpenGroupMessageV4,
|
||||
OpenGroupReactionMessageV4,
|
||||
} from '../../../../session/apis/open_group_api/opengroupV2/OpenGroupServerPoller';
|
||||
// tslint:disable: chai-vague-errors
|
||||
|
||||
describe('mutationCache', () => {
|
||||
TestUtils.stubWindowLog();
|
||||
|
||||
const roomInfos = TestUtils.generateOpenGroupV2RoomInfos();
|
||||
const originalMessage = TestUtils.generateOpenGroupMessageV2WithServerId(111);
|
||||
const originalMessage2 = TestUtils.generateOpenGroupMessageV2WithServerId(112);
|
||||
const reactor1 = TestUtils.generateFakePubKey().key;
|
||||
const reactor2 = TestUtils.generateFakePubKey().key;
|
||||
|
||||
beforeEach(() => {
|
||||
// stubs
|
||||
Sinon.stub(Reactions, 'handleOpenGroupMessageReactions').resolves();
|
||||
});
|
||||
|
||||
afterEach(Sinon.restore);
|
||||
|
||||
describe('add entry to cache', () => {
|
||||
it('add entry to cache that is valid', () => {
|
||||
const entry: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: null,
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
addToMutationCache(entry);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'should not empty').to.not.equal([]);
|
||||
expect(cache.length, 'should have one entry').to.be.equal(1);
|
||||
expect(cache[0], 'the entry should match the input').to.be.deep.equal(entry);
|
||||
});
|
||||
it('add entry to cache that is invalid and fail', () => {
|
||||
const entry: SogsV3Mutation = {
|
||||
server: '', // this is invalid
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 100,
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
addToMutationCache(entry);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'should not empty').to.not.equal([]);
|
||||
expect(cache.length, 'should have one entry').to.be.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update entry in cache', () => {
|
||||
it('update entry in cache with a valid source entry', () => {
|
||||
const entry: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: null, // mutation before we have received a response
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage.serverId,
|
||||
seqno: 200,
|
||||
reactions: {
|
||||
'😄': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor1],
|
||||
},
|
||||
'❤️': {
|
||||
index: 1,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor1],
|
||||
},
|
||||
'😈': {
|
||||
index: 2,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupMessageV4;
|
||||
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'should not empty').to.not.equal([]);
|
||||
expect(cache.length, 'should have one entry').to.be.equal(1);
|
||||
expect(
|
||||
cache[0].seqno,
|
||||
'should have an entry with a matching seqno to the message response'
|
||||
).to.be.equal(messageResponse.seqno);
|
||||
});
|
||||
it('update entry in cache with an invalid source entry', () => {
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage.serverId,
|
||||
seqno: 200,
|
||||
reactions: {
|
||||
'😄': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor1],
|
||||
},
|
||||
'❤️': {
|
||||
index: 1,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor1],
|
||||
},
|
||||
'😈': {
|
||||
index: 2,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupMessageV4;
|
||||
const entry: SogsV3Mutation = {
|
||||
server: '',
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 100,
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'should not empty').to.not.equal([]);
|
||||
expect(cache.length, 'should have one entry').to.be.equal(1);
|
||||
expect(
|
||||
cache[0].seqno,
|
||||
'should have an entry with a matching seqno to the message response'
|
||||
).to.be.equal(messageResponse.seqno);
|
||||
});
|
||||
it('update entry in cache with a valid source entry but its not stored in the cache', () => {
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage.serverId,
|
||||
seqno: 200,
|
||||
reactions: {
|
||||
'😄': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor1],
|
||||
},
|
||||
'❤️': {
|
||||
index: 1,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor1],
|
||||
},
|
||||
'😈': {
|
||||
index: 2,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupMessageV4;
|
||||
const entry: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 400,
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'should not empty').to.not.equal([]);
|
||||
expect(cache.length, 'should have one entry').to.be.equal(1);
|
||||
expect(
|
||||
cache[0].seqno,
|
||||
'should have an entry with a matching seqno to the message response'
|
||||
).to.be.equal(messageResponse.seqno);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process opengroup messages using the cache', () => {
|
||||
it('processing a message with valid serverUrl, roomId and message should return the same message response', async () => {
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage.serverId,
|
||||
seqno: 200,
|
||||
reactions: {
|
||||
'😄': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor1],
|
||||
},
|
||||
'❤️': {
|
||||
index: 1,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor1],
|
||||
},
|
||||
'😈': {
|
||||
index: 2,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupMessageV4;
|
||||
const message = await processMessagesUsingCache(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
messageResponse
|
||||
);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'cache should be empty').to.be.empty;
|
||||
expect(message, 'message response should match').to.be.deep.equal(messageResponse);
|
||||
});
|
||||
it('processing a message with valid serverUrl, roomId and message (from SOGS < 1.3.4) should return the same message response', async () => {
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage2.serverId,
|
||||
// in version less than 1.3.4 there is no a seqno set
|
||||
reactions: {
|
||||
'🤣': {
|
||||
index: 0,
|
||||
count: 3,
|
||||
you: true,
|
||||
reactors: [reactor1, reactor2, originalMessage2.sender],
|
||||
},
|
||||
'😈': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupReactionMessageV4;
|
||||
const message = await processMessagesUsingCache(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
messageResponse
|
||||
);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'cache should be empty').to.be.empty;
|
||||
expect(message, 'message response should match').to.be.deep.equal(messageResponse);
|
||||
});
|
||||
it('processing a message with valid entries in the cache should calculate the optimistic state if there is no message seqo or the cached entry seqno is larger than the message seqno', async () => {
|
||||
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
|
||||
id: originalMessage.serverId,
|
||||
seqno: 200,
|
||||
reactions: {
|
||||
'😄': {
|
||||
index: 0,
|
||||
count: 1,
|
||||
you: false,
|
||||
reactors: [reactor1],
|
||||
},
|
||||
'❤️': {
|
||||
index: 1,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor1],
|
||||
},
|
||||
'😈': {
|
||||
index: 2,
|
||||
count: 2,
|
||||
you: true,
|
||||
reactors: [originalMessage.sender, reactor2],
|
||||
},
|
||||
},
|
||||
}) as OpenGroupMessageV4;
|
||||
const entry: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 100, // less than response messageResponse seqno should be ignored
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '❤️',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
const entry2: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 300, // greater than response messageResponse seqno should be procesed
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😄',
|
||||
action: 'ADD',
|
||||
},
|
||||
};
|
||||
const entry3: SogsV3Mutation = {
|
||||
server: roomInfos.serverUrl,
|
||||
room: roomInfos.roomId,
|
||||
changeType: ChangeType.REACTIONS,
|
||||
seqno: 301, //// greater than response messageResponse seqno should be procesed
|
||||
metadata: {
|
||||
messageId: originalMessage.serverId,
|
||||
emoji: '😈',
|
||||
action: 'REMOVE',
|
||||
},
|
||||
};
|
||||
addToMutationCache(entry);
|
||||
addToMutationCache(entry2);
|
||||
addToMutationCache(entry3);
|
||||
|
||||
const message = await processMessagesUsingCache(
|
||||
roomInfos.serverUrl,
|
||||
roomInfos.roomId,
|
||||
messageResponse
|
||||
);
|
||||
const cache = getMutationCache();
|
||||
expect(cache, 'cache should be empty').to.be.empty;
|
||||
expect(
|
||||
message.reactions['❤️'].count,
|
||||
'message response reaction count for ❤️ should be unchanged with 2'
|
||||
).to.equal(2);
|
||||
expect(
|
||||
message.reactions['😄'].count,
|
||||
'message response reaction count for 😄 should be 2'
|
||||
).to.equal(2);
|
||||
expect(
|
||||
message.reactions['😄'].you,
|
||||
'message response reaction for 😄 should have you = true'
|
||||
).to.equal(true);
|
||||
expect(
|
||||
message.reactions['😈'].count,
|
||||
'message response reaction count for 😈 should be 1'
|
||||
).to.equal(1);
|
||||
expect(
|
||||
message.reactions['😈'].you,
|
||||
'message response reaction for 😈 should have you = false'
|
||||
).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,6 +7,11 @@ import { TestUtils } from '..';
|
|||
import { OpenGroupRequestCommonType } from '../../../session/apis/open_group_api/opengroupV2/ApiUtil';
|
||||
import { OpenGroupVisibleMessage } from '../../../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
|
||||
import { MessageModel } from '../../../models/message';
|
||||
import {
|
||||
OpenGroupMessageV4,
|
||||
OpenGroupReactionMessageV4,
|
||||
} from '../../../session/apis/open_group_api/opengroupV2/OpenGroupServerPoller';
|
||||
import { OpenGroupReaction } from '../../../types/Reaction';
|
||||
|
||||
export function generateVisibleMessage({
|
||||
identifier,
|
||||
|
@ -35,6 +40,23 @@ export function generateOpenGroupMessageV2(): OpenGroupMessageV2 {
|
|||
});
|
||||
}
|
||||
|
||||
// this is for test purposes only
|
||||
type OpenGroupMessageV2WithServerId = Omit<OpenGroupMessageV2, 'sender' | 'serverId'> & {
|
||||
sender: string;
|
||||
serverId: number;
|
||||
};
|
||||
|
||||
export function generateOpenGroupMessageV2WithServerId(
|
||||
serverId: number
|
||||
): OpenGroupMessageV2WithServerId {
|
||||
return new OpenGroupMessageV2({
|
||||
serverId,
|
||||
sentTimestamp: Date.now(),
|
||||
sender: TestUtils.generateFakePubKey().key,
|
||||
base64EncodedData: 'whatever',
|
||||
}) as OpenGroupMessageV2WithServerId;
|
||||
}
|
||||
|
||||
export function generateOpenGroupVisibleMessage(): OpenGroupVisibleMessage {
|
||||
return new OpenGroupVisibleMessage({
|
||||
timestamp: Date.now(),
|
||||
|
@ -62,3 +84,23 @@ export function generateFakeIncomingPrivateMessage(): MessageModel {
|
|||
type: 'incoming',
|
||||
});
|
||||
}
|
||||
|
||||
export function generateFakeIncomingOpenGroupMessageV4({
|
||||
id,
|
||||
reactions,
|
||||
seqno,
|
||||
}: {
|
||||
id: number;
|
||||
seqno?: number;
|
||||
reactions?: Record<string, OpenGroupReaction>;
|
||||
}): OpenGroupMessageV4 | OpenGroupReactionMessageV4 {
|
||||
return {
|
||||
id, // serverId
|
||||
seqno: seqno ?? undefined,
|
||||
/** base64 */
|
||||
signature: 'whatever',
|
||||
/** timestamp number with decimal */
|
||||
posted: Date.now(),
|
||||
reactions: reactions ?? {},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -264,6 +264,8 @@ export type Attachment = {
|
|||
flags?: SignalService.AttachmentPointer.Flags;
|
||||
contentType?: MIME.MIMEType;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
data: ArrayBuffer;
|
||||
} & Partial<AttachmentSchemaVersion3>;
|
||||
|
||||
|
|
|
@ -6,8 +6,8 @@ export type LocalizerKeys =
|
|||
| 'startedACall'
|
||||
| 'mainMenuWindow'
|
||||
| 'unblocked'
|
||||
| 'keepDisabled'
|
||||
| 'userAddedToModerators'
|
||||
| 'otherSingular'
|
||||
| 'to'
|
||||
| 'sent'
|
||||
| 'requestsPlaceholder'
|
||||
|
@ -38,6 +38,7 @@ export type LocalizerKeys =
|
|||
| 'autoUpdateLaterButtonLabel'
|
||||
| 'maximumAttachments'
|
||||
| 'deviceOnly'
|
||||
| 'reactionPopupTwo'
|
||||
| 'beginYourSession'
|
||||
| 'typingIndicatorsSettingDescription'
|
||||
| 'changePasswordToastDescription'
|
||||
|
@ -67,6 +68,7 @@ export type LocalizerKeys =
|
|||
| 'notificationsSettingsTitle'
|
||||
| 'ringing'
|
||||
| 'tookAScreenshot'
|
||||
| 'reactionListCountPlural'
|
||||
| 'from'
|
||||
| 'thisMonth'
|
||||
| 'chooseAnAction'
|
||||
|
@ -114,7 +116,6 @@ export type LocalizerKeys =
|
|||
| 'deleteJustForMe'
|
||||
| 'changeAccountPasswordTitle'
|
||||
| 'onionPathIndicatorDescription'
|
||||
| 'pruningOpengroupDialogSubMessage'
|
||||
| 'mediaPermissionsTitle'
|
||||
| 'replyingToMessage'
|
||||
| 'welcomeToYourSession'
|
||||
|
@ -141,7 +142,6 @@ export type LocalizerKeys =
|
|||
| 'banUser'
|
||||
| 'answeredACall'
|
||||
| 'sendMessage'
|
||||
| 'readableListCounterSingular'
|
||||
| 'recoveryPhraseRevealMessage'
|
||||
| 'showRecoveryPhrase'
|
||||
| 'autoUpdateSettingDescription'
|
||||
|
@ -188,7 +188,6 @@ export type LocalizerKeys =
|
|||
| 'nameAndMessage'
|
||||
| 'autoUpdateDownloadedMessage'
|
||||
| 'onionPathIndicatorTitle'
|
||||
| 'readableListCounterPlural'
|
||||
| 'unknown'
|
||||
| 'mediaMessage'
|
||||
| 'addAsModerator'
|
||||
|
@ -249,7 +248,6 @@ export type LocalizerKeys =
|
|||
| 'goToSupportPage'
|
||||
| 'passwordsDoNotMatch'
|
||||
| 'createClosedGroupNamePrompt'
|
||||
| 'pruningOpengroupDialogMessage'
|
||||
| 'audioMessageAutoplayDescription'
|
||||
| 'leaveAndRemoveForEveryone'
|
||||
| 'previewThumbnail'
|
||||
|
@ -257,6 +255,7 @@ export type LocalizerKeys =
|
|||
| 'setPassword'
|
||||
| 'editMenuDeleteContact'
|
||||
| 'hideMenuBarTitle'
|
||||
| 'reactionPopupOne'
|
||||
| 'imageCaptionIconAlt'
|
||||
| 'sendRecoveryPhraseTitle'
|
||||
| 'joinACommunity'
|
||||
|
@ -273,6 +272,7 @@ export type LocalizerKeys =
|
|||
| 'editMenuRedo'
|
||||
| 'hideRequestBanner'
|
||||
| 'changeNicknameMessage'
|
||||
| 'reactionPopupThree'
|
||||
| 'close'
|
||||
| 'deleteMessageQuestion'
|
||||
| 'newMessage'
|
||||
|
@ -280,6 +280,7 @@ export type LocalizerKeys =
|
|||
| 'mainMenuFile'
|
||||
| 'callMissed'
|
||||
| 'getStarted'
|
||||
| 'reactionListCountSingular'
|
||||
| 'unblockUser'
|
||||
| 'blockUser'
|
||||
| 'clearAllConfirmationTitle'
|
||||
|
@ -302,6 +303,7 @@ export type LocalizerKeys =
|
|||
| 'timerOption_6_hours_abbreviated'
|
||||
| 'timerOption_1_week_abbreviated'
|
||||
| 'timerSetTo'
|
||||
| 'otherPlural'
|
||||
| 'enable'
|
||||
| 'notificationSubtitle'
|
||||
| 'youChangedTheTimer'
|
||||
|
@ -313,12 +315,12 @@ export type LocalizerKeys =
|
|||
| 'noNameOrMessage'
|
||||
| 'pinConversationLimitTitle'
|
||||
| 'noSearchResults'
|
||||
| 'reactionPopup'
|
||||
| 'changeNickname'
|
||||
| 'userUnbanned'
|
||||
| 'respondingToRequestWarning'
|
||||
| 'error'
|
||||
| 'clearAllData'
|
||||
| 'pruningOpengroupDialogTitle'
|
||||
| 'createConversationNewGroup'
|
||||
| 'disappearingMessages'
|
||||
| 'autoUpdateNewVersionTitle'
|
||||
|
@ -331,7 +333,6 @@ export type LocalizerKeys =
|
|||
| 'media'
|
||||
| 'noMembersInThisGroup'
|
||||
| 'saveLogToDesktop'
|
||||
| 'reactionTooltip'
|
||||
| 'copyErrorAndQuit'
|
||||
| 'onlyAdminCanRemoveMembers'
|
||||
| 'passwordTypeError'
|
||||
|
@ -438,6 +439,7 @@ export type LocalizerKeys =
|
|||
| 'settingsHeader'
|
||||
| 'autoUpdateNewVersionMessage'
|
||||
| 'oneNonImageAtATimeToast'
|
||||
| 'reactionPopupMany'
|
||||
| 'removePasswordTitle'
|
||||
| 'iAmSure'
|
||||
| 'selectMessage'
|
||||
|
|
|
@ -123,21 +123,21 @@ export type ReactionList = Record<
|
|||
{
|
||||
count: number;
|
||||
index: number; // relies on reactsIndex in the message model
|
||||
senders: Record<string, string>; // <sender pubkey, messageHash or serverId>
|
||||
senders: Array<string>;
|
||||
you: boolean; // whether we are in the senders list, used within 1-1 and closed groups for ignoring duplicate data messages, used within opengroups since we dont always have the full list of senders.
|
||||
}
|
||||
>;
|
||||
|
||||
// used when rendering reactions to guarantee sorted order using the index
|
||||
export type SortedReactionList = Array<
|
||||
[string, { count: number; index: number; senders: Record<string, string> }]
|
||||
[string, { count: number; index: number; senders: Array<string>; you?: boolean }]
|
||||
>;
|
||||
|
||||
export interface OpenGroupReaction {
|
||||
index: number;
|
||||
count: number;
|
||||
first: number;
|
||||
reactors: Array<string>;
|
||||
you: boolean;
|
||||
reactors: Array<string>;
|
||||
}
|
||||
|
||||
export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
|
||||
|
@ -145,4 +145,5 @@ export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
|
|||
export interface OpenGroupReactionResponse {
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
seqno: number;
|
||||
}
|
||||
|
|
|
@ -332,8 +332,6 @@ export async function getFileAndStoreLocally(
|
|||
screenshot: attachmentSavedLocally.screenshot,
|
||||
thumbnail: attachmentSavedLocally.thumbnail,
|
||||
size: attachmentSavedLocally.size,
|
||||
|
||||
// url: undefined,
|
||||
flags: attachmentFlags || undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -76,7 +76,7 @@ export function initialiseEmojiData(data: any) {
|
|||
}
|
||||
|
||||
// Synchronous version of Emoji Mart's SearchIndex.search()
|
||||
// If you upgrade the package things will probably break
|
||||
// If we 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');
|
||||
|
|
|
@ -2,16 +2,37 @@ 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 {
|
||||
getUsBlindedInThatServer,
|
||||
isUsAnySogsFromCache,
|
||||
} 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 SOGSReactorsFetchCount = 5;
|
||||
const rateCountLimit = 20;
|
||||
const rateTimeLimit = 60 * 1000;
|
||||
const latestReactionTimestamps: Array<number> = [];
|
||||
|
||||
function hitRateLimit(): boolean {
|
||||
const timestamp = Date.now();
|
||||
latestReactionTimestamps.push(timestamp);
|
||||
|
||||
if (latestReactionTimestamps.length > rateCountLimit) {
|
||||
const firstTimestamp = latestReactionTimestamps[0];
|
||||
if (timestamp - firstTimestamp < rateTimeLimit) {
|
||||
latestReactionTimestamps.pop();
|
||||
window.log.warn('Only 20 reactions are allowed per minute');
|
||||
return true;
|
||||
} else {
|
||||
latestReactionTimestamps.shift();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the original message of a reaction
|
||||
*/
|
||||
|
@ -48,9 +69,9 @@ const getMessageByReaction = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Sends a Reaction Data Message, don't use for OpenGroups
|
||||
* Sends a Reaction Data Message
|
||||
*/
|
||||
export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
||||
const sendMessageReaction = async (messageId: string, emoji: string) => {
|
||||
const found = await Data.getMessageById(messageId);
|
||||
if (found) {
|
||||
const conversationModel = found?.getConversation();
|
||||
|
@ -64,34 +85,29 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
latestReactionTimestamps.push(timestamp);
|
||||
if (hitRateLimit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestReactionTimestamps.length > rateCountLimit) {
|
||||
const firstTimestamp = latestReactionTimestamps[0];
|
||||
if (timestamp - firstTimestamp < rateTimeLimit) {
|
||||
latestReactionTimestamps.pop();
|
||||
return;
|
||||
let me = UserUtils.getOurPubKeyStrFromCache();
|
||||
let id = Number(found.get('sent_at'));
|
||||
|
||||
if (found.get('isPublic')) {
|
||||
if (found.get('serverId')) {
|
||||
id = found.get('serverId') || id;
|
||||
me = getUsBlindedInThatServer(conversationModel) || me;
|
||||
} else {
|
||||
latestReactionTimestamps.shift();
|
||||
window.log.warn(`Server Id was not found in message ${messageId} for opengroup reaction`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
if (reacts?.[emoji]?.senders?.includes(me)) {
|
||||
window.log.info('Found matching reaction removing it');
|
||||
action = Action.REMOVE;
|
||||
} else {
|
||||
const reactions = getRecentReactions();
|
||||
|
@ -113,7 +129,12 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
|||
`You ${action === Action.REACT ? 'added' : 'removed'} a`,
|
||||
emoji,
|
||||
'reaction for message',
|
||||
id
|
||||
id,
|
||||
found.get('isPublic')
|
||||
? `on ${conversationModel.toOpenGroupV2().serverUrl}/${
|
||||
conversationModel.toOpenGroupV2().roomId
|
||||
}`
|
||||
: ''
|
||||
);
|
||||
return reaction;
|
||||
} else {
|
||||
|
@ -124,15 +145,21 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
|
|||
|
||||
/**
|
||||
* Handle reactions on the client by updating the state of the source message
|
||||
* Used in OpenGroups for sending reactions only, not handling responses
|
||||
*/
|
||||
export const handleMessageReaction = async (
|
||||
reaction: SignalService.DataMessage.IReaction,
|
||||
sender: string,
|
||||
isOpenGroup: boolean,
|
||||
messageId?: string
|
||||
) => {
|
||||
const handleMessageReaction = async ({
|
||||
reaction,
|
||||
sender,
|
||||
you,
|
||||
isOpenGroup,
|
||||
}: {
|
||||
reaction: SignalService.DataMessage.IReaction;
|
||||
sender: string;
|
||||
you: boolean;
|
||||
isOpenGroup: boolean;
|
||||
}) => {
|
||||
if (!reaction.emoji) {
|
||||
window?.log?.warn(`There is no emoji for the reaction ${messageId}.`);
|
||||
window?.log?.warn(`There is no emoji for the reaction ${reaction}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -142,44 +169,48 @@ export const handleMessageReaction = async (
|
|||
}
|
||||
|
||||
const reacts: ReactionList = originalMessage.get('reacts') ?? {};
|
||||
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: {} };
|
||||
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: [] };
|
||||
const details = reacts[reaction.emoji] ?? {};
|
||||
const senders = Object.keys(details.senders);
|
||||
const senders = details.senders;
|
||||
let count = details.count || 0;
|
||||
|
||||
window.log.info(
|
||||
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
|
||||
reaction.emoji
|
||||
} reaction`
|
||||
);
|
||||
if (details.you && senders.includes(sender)) {
|
||||
if (reaction.action === Action.REACT) {
|
||||
window.log.warn('Received duplicate message for your reaction. Ignoring it');
|
||||
return;
|
||||
} else {
|
||||
details.you = false;
|
||||
}
|
||||
} else {
|
||||
details.you = you;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
case Action.REACT:
|
||||
if (senders.includes(sender)) {
|
||||
window.log.warn('Received duplicate reaction message. Ignoring it', reaction, sender);
|
||||
return;
|
||||
}
|
||||
details.senders[sender] = messageId ?? '';
|
||||
details.senders.push(sender);
|
||||
count += 1;
|
||||
break;
|
||||
case SignalService.DataMessage.Reaction.Action.REMOVE:
|
||||
case Action.REMOVE:
|
||||
default:
|
||||
if (senders.length > 0) {
|
||||
if (senders.indexOf(sender) >= 0) {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete details.senders[sender];
|
||||
if (senders?.length > 0) {
|
||||
const sendersIndex = senders.indexOf(sender);
|
||||
if (sendersIndex >= 0) {
|
||||
details.senders.splice(sendersIndex, 1);
|
||||
count -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = Object.keys(details.senders).length;
|
||||
if (count > 0) {
|
||||
reacts[reaction.emoji].count = count;
|
||||
reacts[reaction.emoji].senders = details.senders;
|
||||
reacts[reaction.emoji].you = details.you;
|
||||
|
||||
// sorting for open groups convos is handled by SOGS
|
||||
if (!isOpenGroup && details && details.index === undefined) {
|
||||
if (details && details.index === undefined) {
|
||||
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
|
||||
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
|
||||
}
|
||||
|
@ -193,13 +224,48 @@ export const handleMessageReaction = async (
|
|||
});
|
||||
|
||||
await originalMessage.commit();
|
||||
|
||||
if (!you) {
|
||||
window.log.info(
|
||||
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
|
||||
reaction.emoji
|
||||
} reaction`
|
||||
);
|
||||
}
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle all updates to messages reactions from the SOGS API
|
||||
* Handles updating the UI when clearing all reactions for a certain emoji
|
||||
* Only usable by moderators in opengroups and runs on their client
|
||||
*/
|
||||
export const handleOpenGroupMessageReactions = async (
|
||||
const handleClearReaction = async (serverId: number, emoji: string) => {
|
||||
const originalMessage = await Data.getMessageByServerId(serverId);
|
||||
if (!originalMessage) {
|
||||
window?.log?.warn(`Cannot find the original reacted message ${serverId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reacts: ReactionList | undefined = originalMessage.get('reacts');
|
||||
if (reacts) {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete reacts[emoji];
|
||||
}
|
||||
|
||||
originalMessage.set({
|
||||
reacts: !isEmpty(reacts) ? reacts : undefined,
|
||||
});
|
||||
|
||||
await originalMessage.commit();
|
||||
|
||||
window.log.info(`You cleared all ${emoji} reactions on message ${serverId}`);
|
||||
return originalMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all message reaction updates/responses for opengroups
|
||||
*/
|
||||
const handleOpenGroupMessageReactions = async (
|
||||
reactions: OpenGroupReactionList,
|
||||
serverId: number
|
||||
) => {
|
||||
|
@ -209,6 +275,11 @@ export const handleOpenGroupMessageReactions = async (
|
|||
return;
|
||||
}
|
||||
|
||||
if (!originalMessage.get('isPublic')) {
|
||||
window.log.warn('handleOpenGroupMessageReactions() should only be used in opengroups');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty(reactions)) {
|
||||
if (originalMessage.get('reacts')) {
|
||||
originalMessage.set({
|
||||
|
@ -219,11 +290,44 @@ export const handleOpenGroupMessageReactions = async (
|
|||
const reacts: ReactionList = {};
|
||||
Object.keys(reactions).forEach(key => {
|
||||
const emoji = decodeURI(key);
|
||||
const senders: Record<string, string> = {};
|
||||
const you = reactions[key].you || false;
|
||||
|
||||
if (you) {
|
||||
if (reactions[key]?.reactors.length > 0) {
|
||||
const reactorsWithoutMe = reactions[key].reactors.filter(
|
||||
reactor => !isUsAnySogsFromCache(reactor)
|
||||
);
|
||||
|
||||
// If we aren't included in the reactors then remove the extra reactor to match with the SOGSReactorsFetchCount.
|
||||
if (reactorsWithoutMe.length === SOGSReactorsFetchCount) {
|
||||
reactorsWithoutMe.pop();
|
||||
}
|
||||
|
||||
const conversationModel = originalMessage?.getConversation();
|
||||
if (conversationModel) {
|
||||
const me =
|
||||
getUsBlindedInThatServer(conversationModel) || UserUtils.getOurPubKeyStrFromCache();
|
||||
reactions[key].reactors = [me, ...reactorsWithoutMe];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const senders: Array<string> = [];
|
||||
reactions[key].reactors.forEach(reactor => {
|
||||
senders[reactor] = String(serverId);
|
||||
senders.push(reactor);
|
||||
});
|
||||
reacts[emoji] = { count: reactions[key].count, index: reactions[key].index, senders };
|
||||
|
||||
if (reactions[key].count > 0) {
|
||||
reacts[emoji] = {
|
||||
count: reactions[key].count,
|
||||
index: reactions[key].index,
|
||||
senders,
|
||||
you,
|
||||
};
|
||||
} else {
|
||||
// tslint:disable-next-line: no-dynamic-delete
|
||||
delete reacts[key];
|
||||
}
|
||||
});
|
||||
|
||||
originalMessage.set({
|
||||
|
@ -235,7 +339,7 @@ export const handleOpenGroupMessageReactions = async (
|
|||
return originalMessage;
|
||||
};
|
||||
|
||||
export const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
|
||||
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);
|
||||
|
@ -249,3 +353,14 @@ export const updateRecentReactions = async (reactions: Array<string>, newReactio
|
|||
}
|
||||
await saveRecentReations(recentReactions.items);
|
||||
};
|
||||
|
||||
// exported for testing purposes
|
||||
export const Reactions = {
|
||||
SOGSReactorsFetchCount,
|
||||
hitRateLimit,
|
||||
sendMessageReaction,
|
||||
handleMessageReaction,
|
||||
handleClearReaction,
|
||||
handleOpenGroupMessageReactions,
|
||||
updateRecentReactions,
|
||||
};
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
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;
|
||||
}
|
||||
};
|
Loading…
Reference in a new issue