From cf44896a037be7ca3b12b827373fd7c7de7674d1 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 29 Nov 2021 17:40:46 +1100 Subject: [PATCH] Minor call tweaks (#2051) * show missed-call,started-call and answered call notification in chat * fix types for createLastMessageUpdate * show incoming dialog if we have a pending call when enable call receptio * simplify a bit the avatar component * move disableDrag to a custom hook * speed up hash colors of avatarPlaceHolders * fixup text selection and double click reply on message * keep avatar decoded items longer before releasing memory * add incoming/outgoing/missed call notification also, merge that notification with the timer and group notification component * hangup call if no answer after 30sec * refactor SessionInput using hook + add testid field for recovery * disable message request feature flag for now * fix merge issue * force loading screen to be black instead of white for our dark theme user's eyes safety --- _locales/en/messages.json | 4 +- js/modules/types/conversation.js | 2 - package.json | 1 - preload.js | 2 +- stylesheets/_global.scss | 3 + stylesheets/_modules.scss | 183 ---------------- stylesheets/_session.scss | 2 - stylesheets/_theme_dark.scss | 20 -- ts/components/Avatar.tsx | 41 ++-- .../AvatarPlaceHolder/AvatarPlaceHolder.tsx | 73 +++++-- .../AvatarPlaceHolder/ClosedGroupAvatar.tsx | 4 +- ts/components/AvatarPlaceHolder/index.ts | 2 - ts/components/ConversationListItem.tsx | 8 +- ts/components/Intl.tsx | 76 ------- ts/components/Lightbox.tsx | 13 +- .../conversation/ConversationHeader.tsx | 21 +- .../DataExtractionNotification.tsx | 2 +- ts/components/conversation/ExpireTimer.tsx | 2 +- .../conversation/GroupNotification.tsx | 44 ++-- ts/components/conversation/H5AudioPlayer.tsx | 6 +- ts/components/conversation/Image.tsx | 13 +- ts/components/conversation/MessageDetail.tsx | 2 +- .../conversation/MissedCallNotification.tsx | 43 ---- ts/components/conversation/Quote.tsx | 15 +- .../conversation/ReadableMessage.tsx | 1 + .../conversation/TimerNotification.tsx | 50 ++--- .../media-gallery/MediaGridItem.tsx | 14 +- .../message/ClickToTrustSender.tsx | 2 +- .../message/MessageContentWithStatus.tsx | 20 +- .../conversation/message/MessagePreview.tsx | 2 +- .../message/OutgoingMessageStatus.tsx | 8 +- .../notification-bubble/CallNotification.tsx | 66 ++++++ .../NotificationBubble.tsx | 52 +++++ ts/components/dialog/SessionModal.tsx | 2 +- .../session/LeftPaneSectionHeader.tsx | 3 +- ts/components/session/SessionButton.tsx | 8 +- .../session/SessionClosableOverlay.tsx | 2 +- ts/components/session/SessionDropdown.tsx | 2 +- ts/components/session/SessionDropdownItem.tsx | 2 +- ts/components/session/SessionInput.tsx | 195 ++++++++---------- .../session/SessionJoinableDefaultRooms.tsx | 1 + .../session/SessionMemberListItem.tsx | 2 +- ts/components/session/SessionWrapperModal.tsx | 2 +- .../calling/DraggableCallContainer.tsx | 2 +- .../calling/InConversationCallContainer.tsx | 2 +- .../session/calling/IncomingCallDialog.tsx | 15 +- .../conversation/SessionMessagesList.tsx | 14 +- .../SessionQuotedMessageComposition.tsx | 2 +- ts/components/session/icon/Icons.tsx | 21 ++ .../registration/RegistrationUserDetails.tsx | 2 + .../session/registration/SignInTab.tsx | 1 + .../settings/section/CategoryPrivacy.tsx | 2 + ts/hooks/useDisableDrag.ts | 13 ++ ts/hooks/useEncryptedFileFetch.ts | 4 +- ts/hooks/useParamSelector.ts | 13 ++ ts/interactions/conversationInteractions.ts | 2 +- ts/models/conversation.ts | 15 +- ts/models/message.ts | 25 ++- ts/models/messageType.ts | 6 +- ts/receiver/callMessage.ts | 6 +- .../crypto/DecryptedAttachmentsManager.ts | 36 +++- ts/session/utils/RingingManager.ts | 4 + ts/session/utils/User.ts | 9 +- ts/session/utils/calling/CallManager.ts | 161 ++++++++++++--- ts/state/ducks/conversations.ts | 7 +- ts/state/selectors/conversations.ts | 21 +- ts/test/types/Conversation_test.ts | 11 +- ts/types/Conversation.ts | 15 +- ts/types/Message.ts | 2 +- ts/util/attachmentsUtil.ts | 2 +- 70 files changed, 692 insertions(+), 737 deletions(-) delete mode 100644 ts/components/AvatarPlaceHolder/index.ts delete mode 100644 ts/components/Intl.tsx delete mode 100644 ts/components/conversation/MissedCallNotification.tsx create mode 100644 ts/components/conversation/notification-bubble/CallNotification.tsx create mode 100644 ts/components/conversation/notification-bubble/NotificationBubble.tsx create mode 100644 ts/hooks/useDisableDrag.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a6242ffb4..50b27172b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -460,5 +460,7 @@ "callMissedCausePermission": "Call missed from '$name$' because you need to enable the 'Voice and video calls' permission in the Privacy Settings.", "callMediaPermissionsDescription": "Allows access to accept voice and video calls from other users", "callMediaPermissionsDialogContent": "The current implementation of voice/video calls will expose your IP address to the Oxen Foundation servers and the calling/called user.", - "menuCall": "Call" + "menuCall": "Call", + "startedACall": "You called $name$", + "answeredACall": "Call with $name$" } diff --git a/js/modules/types/conversation.js b/js/modules/types/conversation.js index efca69dfd..82beb2b87 100644 --- a/js/modules/types/conversation.js +++ b/js/modules/types/conversation.js @@ -1,7 +1,6 @@ /* global crypto */ const { isFunction } = require('lodash'); -const { createLastMessageUpdate } = require('../../../ts/types/Conversation'); const { arrayBufferToBase64 } = require('../crypto'); async function computeHash(arraybuffer) { @@ -80,6 +79,5 @@ async function deleteExternalFiles(conversation, options = {}) { module.exports = { deleteExternalFiles, maybeUpdateAvatar, - createLastMessageUpdate, arrayBufferToBase64, }; diff --git a/package.json b/package.json index 98441bd91..5ae1ae014 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "integration-test": "mocha --recursive --exit --timeout 30000 \"./ts/test-integration/**/*.test.js\" \"./ts/test/*.test.js\"", "clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;", "ready": "yarn clean-transpile; yarn grunt && yarn lint-full && yarn test", - "build:webpack:sql-worker": "cross-env NODE_ENV=production webpack -c webpack-sql-worker.config.ts", "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" }, diff --git a/preload.js b/preload.js index ebe50c750..cd6fa895e 100644 --- a/preload.js +++ b/preload.js @@ -39,7 +39,7 @@ window.isBehindProxy = () => Boolean(config.proxyUrl); window.lokiFeatureFlags = { useOnionRequests: true, - useMessageRequests: true, + useMessageRequests: false, useCallMessage: true, }; diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 06d10b1a5..8d3db0e71 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -13,6 +13,7 @@ body { margin: 0; font-family: $session-font-default; font-size: 14px; + letter-spacing: 0.3px; } // scrollbars @@ -230,6 +231,8 @@ $loading-height: 16px; display: flex; align-items: center; user-select: none; + // force this to black, to stay consistent with the password prompt being in dark mode too. + background-color: black; .content { margin-inline-start: auto; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index badeba855..95ae355e8 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -276,185 +276,6 @@ font-style: italic; } -.module-message__typing-container { - height: 16px; - padding-bottom: 20px; - display: flex; - flex-direction: row; - align-items: center; -} - -// Module: Contact Detail - -.module-contact-detail { - text-align: center; - max-width: 300px; - margin-inline-start: auto; - margin-inline-end: auto; -} - -.module-contact-detail__avatar { - margin-bottom: 4px; -} - -.module-contact-detail__contact-name { - font-size: 20px; - font-weight: bold; -} - -.module-contact-detail__contact-method { - font-size: 14px; - margin-top: 10px; -} - -.module-contact-detail__send-message { - cursor: pointer; - - border-radius: 4px; - background-color: $blue; - display: inline-block; - padding: 6px; - margin-top: 20px; - - color: $color-white; - - flex-direction: column; - align-items: center; - - button { - @include button-reset; - } -} - -.module-contact-detail__send-message__inner { - display: flex; - align-items: center; -} - -.module-contact-detail__additional-contact { - text-align: left; - border-top: 1px solid $color-light-10; - margin-top: 15px; - padding-top: 8px; -} - -.module-contact-detail__additional-contact__type { - color: $color-light-45; - font-size: 12px; - margin-bottom: 3px; -} - -// Module: Group Notification - -.module-group-notification { - margin-top: 14px; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.3px; - color: $color-gray-60; - text-align: center; -} - -.module-group-notification__change, -.module-timer-notification__message { - background: var(--color-fake-chat-bubble-background); - color: var(--color-text); - - width: 90%; - max-width: 700px; - margin: 10px auto; - padding: 5px 0px; - border-radius: 4px; - word-break: break-word; -} - -.module-group-notification__contact { - font-family: $session-font-default; - font-weight: bold; -} - -// Module: Timer Notification - -.module-timer-notification { - margin-top: 20px; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.3px; - color: $color-gray-60; - text-align: center; -} - -.module-timer-notification__contact { - font-family: $session-font-default; - font-weight: bold; - padding-inline-end: $session-margin-xs; -} - -.module-timer-notification__icon-container { - margin-inline-start: auto; - margin-inline-end: auto; - display: inline-flex; - flex-direction: row; - align-items: center; - margin-bottom: 4px; -} - -.module-timer-notification__icon { - height: 20px; - width: 20px; - display: inline-block; - @include color-svg('../images/timer.svg', $color-gray-60); -} - -.module-timer-notification__icon--disabled { - @include color-svg('../images/timer-disabled.svg', $color-gray-60); -} - -.module-timer-notification__icon-label { - font-size: 11px; - line-height: 16px; - letter-spacing: 0.3px; - margin-inline-start: 6px; - text-transform: uppercase; - - // Didn't seem centered otherwise - margin-top: 1px; -} - -.module-timer-notification__message { - display: grid; - grid-template-columns: 40px auto 40px; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.3px; - - & > div { - align-self: center; - justify-self: center; - align-items: center; - justify-content: center; - } - - .module-contact-name__profile-name { - text-align: center; - } - - .module-message__author__profile-name { - margin-inline-end: $session-margin-xs; - } -} - -.module-notification--with-click-handler { - cursor: pointer; -} - -.module-notification__icon { - height: 24px; - width: 24px; - margin-inline-start: auto; - margin-inline-end: auto; -} - // Module: Contact List Item .module-contact-list-item { @@ -549,10 +370,6 @@ } } -.module-conversation-header__title__profile-name { - font-style: italic; -} - .module-conversation-header__expiration { display: flex; flex-direction: row; diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 20241ba3a..b60aa76a2 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -269,7 +269,6 @@ textarea { .module-conversation-header__title-flex, .module-conversation-header__title { - font-family: $session-font-accent; font-weight: bold; width: 100%; display: flex; @@ -278,7 +277,6 @@ textarea { &-text { @include session-color-subtle(var(--color-text)); - font-family: $session-font-default; font-weight: 300; font-size: $session-font-sm; line-height: $session-font-sm; diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 93ffc3116..2d4c6df91 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -225,26 +225,6 @@ color: $color-light-45; } - // Module: Group Notification - - .module-group-notification { - color: $color-dark-30; - } - - // Module: Timer Notification - - .module-timer-notification { - color: $color-dark-30; - } - - .module-timer-notification__icon { - @include color-svg('../images/timer.svg', $color-dark-30); - } - - .module-timer-notification__icon--disabled { - @include color-svg('../images/timer-disabled.svg', $color-dark-30); - } - // Module: Contact List Item .module-contact-list-item { diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 6b3537155..3a4bfc1ca 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -1,11 +1,15 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; - -import { AvatarPlaceHolder, ClosedGroupAvatar } from './AvatarPlaceHolder'; import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch'; import _ from 'underscore'; -import { useMembersAvatars } from '../hooks/useMembersAvatars'; -import { useAvatarPath, useConversationUsername } from '../hooks/useParamSelector'; +import { + useAvatarPath, + useConversationUsername, + useIsClosedGroup, +} from '../hooks/useParamSelector'; +import { AvatarPlaceHolder } from './AvatarPlaceHolder/AvatarPlaceHolder'; +import { ClosedGroupAvatar } from './AvatarPlaceHolder/ClosedGroupAvatar'; +import { useDisableDrag } from '../hooks/useDisableDrag'; export enum AvatarSize { XS = 28, @@ -19,7 +23,7 @@ export enum AvatarSize { type Props = { forcedAvatarPath?: string | null; forcedName?: string; - pubkey?: string; + pubkey: string; size: AvatarSize; base64Data?: string; // if this is not empty, it will be used to render the avatar with base64 encoded data onAvatarClick?: () => void; @@ -28,17 +32,10 @@ type Props = { const Identicon = (props: Props) => { const { size, forcedName, pubkey } = props; - const userName = forcedName || '0'; + const displayName = useConversationUsername(pubkey); + const userName = forcedName || displayName || '0'; - return ( - - ); + return ; }; const NoImage = ( @@ -66,10 +63,7 @@ const AvatarImage = (props: { }) => { const { avatarPath, base64Data, name, imageBroken, handleImageError } = props; - const onDragStart = useCallback((e: any) => { - e.preventDefault(); - return false; - }, []); + const disableDrag = useDisableDrag(); if ((!avatarPath && !base64Data) || imageBroken) { return null; @@ -79,7 +73,7 @@ const AvatarImage = (props: { return ( {window.i18n('contactAvatarAlt', @@ -90,13 +84,13 @@ const AvatarInner = (props: Props) => { const { base64Data, size, pubkey, forcedAvatarPath, forcedName, dataTestId } = props; const [imageBroken, setImageBroken] = useState(false); - const closedGroupMembers = useMembersAvatars(pubkey); + const isClosedGroupAvatar = useIsClosedGroup(pubkey); const avatarPath = useAvatarPath(pubkey); const name = useConversationUsername(pubkey); // contentType is not important - const { urlToLoad } = useEncryptedFileFetch(forcedAvatarPath || avatarPath || '', ''); + const { urlToLoad } = useEncryptedFileFetch(forcedAvatarPath || avatarPath || '', '', true); const handleImageError = () => { window.log.warn( 'Avatar: Image failed to load; failing over to placeholder', @@ -106,7 +100,6 @@ const AvatarInner = (props: Props) => { setImageBroken(true); }; - const isClosedGroupAvatar = Boolean(closedGroupMembers?.length); const hasImage = (base64Data || urlToLoad) && !imageBroken && !isClosedGroupAvatar; const isClickable = !!props.onAvatarClick; diff --git a/ts/components/AvatarPlaceHolder/AvatarPlaceHolder.tsx b/ts/components/AvatarPlaceHolder/AvatarPlaceHolder.tsx index fed8d3d41..2aa30a550 100644 --- a/ts/components/AvatarPlaceHolder/AvatarPlaceHolder.tsx +++ b/ts/components/AvatarPlaceHolder/AvatarPlaceHolder.tsx @@ -4,13 +4,10 @@ import { getInitials } from '../../util/getInitials'; type Props = { diameter: number; name: string; - pubkey?: string; - colors: Array; - borderColor: string; + pubkey: string; }; const sha512FromPubkey = async (pubkey: string): Promise => { - // tslint:disable-next-line: await-promise const buf = await crypto.subtle.digest('SHA-512', new TextEncoder().encode(pubkey)); // tslint:disable: prefer-template restrict-plus-operands @@ -19,34 +16,69 @@ const sha512FromPubkey = async (pubkey: string): Promise => { .join(''); }; -export const AvatarPlaceHolder = (props: Props) => { - const { borderColor, colors, pubkey, diameter, name } = props; - const [sha512Seed, setSha512Seed] = useState(undefined as string | undefined); +// do not do this on every avatar, just cache the values so we can reuse them accross the app +// key is the pubkey, value is the hash +const cachedHashes = new Map(); + +const avatarPlaceholderColors = ['#5ff8b0', '#26cdb9', '#f3c615', '#fcac5a']; +const avatarBorderColor = '#00000059'; + +function useHashBasedOnPubkey(pubkey: string) { + const [hash, setHash] = useState(undefined); + const [loading, setIsLoading] = useState(true); + useEffect(() => { - let isSubscribed = true; + const cachedHash = cachedHashes.get(pubkey); + + if (cachedHash) { + setHash(cachedHash); + setIsLoading(false); + return; + } + setIsLoading(true); + let isInProgress = true; if (!pubkey) { - if (isSubscribed) { - setSha512Seed(undefined); + if (isInProgress) { + setIsLoading(false); + + setHash(undefined); } return; } void sha512FromPubkey(pubkey).then(sha => { - if (isSubscribed) { - setSha512Seed(sha); + if (isInProgress) { + setIsLoading(false); + // Generate the seed simulate the .hashCode as Java + if (sha) { + const hashed = parseInt(sha.substring(0, 12), 16) || 0; + setHash(hashed); + cachedHashes.set(pubkey, hashed); + + return; + } + setHash(undefined); } }); return () => { - isSubscribed = false; + isInProgress = false; }; - }, [pubkey, name]); + }, [pubkey]); + + return { loading, hash }; +} + +export const AvatarPlaceHolder = (props: Props) => { + const { pubkey, diameter, name } = props; + + const { hash, loading } = useHashBasedOnPubkey(pubkey); const diameterWithoutBorder = diameter - 2; const viewBox = `0 0 ${diameter} ${diameter}`; const r = diameter / 2; const rWithoutBorder = diameterWithoutBorder / 2; - if (!sha512Seed) { + if (loading || !hash) { // return grey circle return ( @@ -57,7 +89,7 @@ export const AvatarPlaceHolder = (props: Props) => { r={rWithoutBorder} fill="#d2d2d3" shapeRendering="geometricPrecision" - stroke={borderColor} + stroke={avatarBorderColor} strokeWidth="1" /> @@ -68,12 +100,9 @@ export const AvatarPlaceHolder = (props: Props) => { const initial = getInitials(name)?.toLocaleUpperCase() || '0'; const fontSize = diameter * 0.5; - // Generate the seed simulate the .hashCode as Java - const hash = parseInt(sha512Seed.substring(0, 12), 16) || 0; + const bgColorIndex = hash % avatarPlaceholderColors.length; - const bgColorIndex = hash % colors.length; - - const bgColor = colors[bgColorIndex]; + const bgColor = avatarPlaceholderColors[bgColorIndex]; return ( @@ -84,7 +113,7 @@ export const AvatarPlaceHolder = (props: Props) => { r={rWithoutBorder} fill={bgColor} shapeRendering="geometricPrecision" - stroke={borderColor} + stroke={avatarBorderColor} strokeWidth="1" /> { return (
- - + +
); }; diff --git a/ts/components/AvatarPlaceHolder/index.ts b/ts/components/AvatarPlaceHolder/index.ts deleted file mode 100644 index f6f819bd6..000000000 --- a/ts/components/AvatarPlaceHolder/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AvatarPlaceHolder } from './AvatarPlaceHolder'; -export { ClosedGroupAvatar } from './ClosedGroupAvatar'; diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index d6e308cb1..f1c56e0a1 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -20,7 +20,7 @@ import { ReduxConversationType, } from '../state/ducks/conversations'; import _ from 'underscore'; -import { SessionIcon } from './session/icon'; +import { SessionIcon, SessionIconButton } from './session/icon'; import { useDispatch, useSelector } from 'react-redux'; import { SectionType } from '../state/ducks/section'; import { getFocusedSection } from '../state/selectors/section'; @@ -83,7 +83,7 @@ const HeaderItem = (props: { const pinIcon = isMessagesSection && isPinned ? ( - + ) : null; const NotificationSettingIcon = () => { @@ -96,11 +96,11 @@ const HeaderItem = (props: { return null; case 'disabled': return ( - + ); case 'mentions_only': return ( - + ); default: return null; diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx deleted file mode 100644 index f8ddfb5df..000000000 --- a/ts/components/Intl.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; - -import { RenderTextCallbackType } from '../types/Util'; - -type FullJSX = Array | JSX.Element | string; - -interface Props { - /** The translation string id */ - id: string; - components?: Array; - renderText?: RenderTextCallbackType; -} - -export class Intl extends React.Component { - public static defaultProps: Partial = { - renderText: ({ text }) => text, - }; - - public getComponent(index: number): FullJSX | undefined { - const { id, components } = this.props; - - if (!components || !components.length || components.length <= index) { - // tslint:disable-next-line no-console - console.log(`Error: Intl missing provided components for id ${id}, index ${index}`); - - return; - } - - return components[index]; - } - - public render() { - const { id, renderText } = this.props; - - const text = window.i18n(id); - const results: Array = []; - const FIND_REPLACEMENTS = /\$[^$]+\$/g; - - // We have to do this, because renderText is not required in our Props object, - // but it is always provided via defaultProps. - if (!renderText) { - return; - } - - let componentIndex = 0; - let key = 0; - let lastTextIndex = 0; - let match = FIND_REPLACEMENTS.exec(text); - - if (!match) { - return renderText({ text, key: 0 }); - } - - while (match) { - if (lastTextIndex < match.index) { - const textWithNoReplacements = text.slice(lastTextIndex, match.index); - results.push(renderText({ text: textWithNoReplacements, key: key })); - key += 1; - } - - results.push(this.getComponent(componentIndex)); - componentIndex += 1; - - // @ts-ignore - lastTextIndex = FIND_REPLACEMENTS.lastIndex; - match = FIND_REPLACEMENTS.exec(text); - } - - if (lastTextIndex < text.length) { - results.push(renderText({ text: text.slice(lastTextIndex), key: key })); - key += 1; - } - - return results; - } -} diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index e777d43ff..6096a2381 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -1,6 +1,6 @@ // tslint:disable:react-a11y-anchors -import React, { useCallback, useRef } from 'react'; +import React, { useRef } from 'react'; import is from '@sindresorhus/is'; @@ -14,6 +14,7 @@ import useUnmount from 'react-use/lib/useUnmount'; import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch'; import { useDispatch } from 'react-redux'; import { showLightBox } from '../state/ducks/conversations'; +import { useDisableDrag } from '../hooks/useDisableDrag'; const Colors = { TEXT_SECONDARY: '#bbb', @@ -204,15 +205,10 @@ export const LightboxObject = ({ renderedRef: React.MutableRefObject; onObjectClick: (event: any) => any; }) => { - const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType); + const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType, false); const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType); - const onDragStart = useCallback((e: any) => { - e.preventDefault(); - return false; - }, []); - // auto play video on showing a video attachment useUnmount(() => { if (!renderedRef?.current) { @@ -220,12 +216,13 @@ export const LightboxObject = ({ } renderedRef.current.pause.pause(); }); + const disableDrag = useDisableDrag(); if (isImageTypeSupported) { return ( {window.i18n('lightboxImageAlt')} void; showBackButton: boolean }) => } return ( - + ); }; @@ -249,22 +250,14 @@ const ConversationHeaderTitle = () => { const headerTitleProps = useSelector(getConversationHeaderTitleProps); const notificationSetting = useSelector(getCurrentNotificationSettingText); const isRightPanelOn = useSelector(isRightPanelShowing); + + const convoName = useConversationUsername(headerTitleProps?.conversationKey); const dispatch = useDispatch(); if (!headerTitleProps) { return null; } - const { - conversationKey, - profileName, - isGroup, - isPublic, - members, - subscriberCount, - isMe, - isKickedFromGroup, - name, - } = headerTitleProps; + const { isGroup, isPublic, members, subscriberCount, isMe, isKickedFromGroup } = headerTitleProps; const { i18n } = window; @@ -294,8 +287,6 @@ const ConversationHeaderTitle = () => { ? `${memberCountText} ● ${notificationSubtitle}` : `${notificationSubtitle}`; - const title = profileName || name || conversationKey; - return (
{ }} role="button" > - {title} + {convoName} diff --git a/ts/components/conversation/DataExtractionNotification.tsx b/ts/components/conversation/DataExtractionNotification.tsx index 1ac60d806..c19ddba9e 100644 --- a/ts/components/conversation/DataExtractionNotification.tsx +++ b/ts/components/conversation/DataExtractionNotification.tsx @@ -31,7 +31,7 @@ export const DataExtractionNotification = (props: PropsForDataExtractionNotifica margin={'var(--margins-sm)'} id={`msg-${messageId}`} > - + diff --git a/ts/components/conversation/ExpireTimer.tsx b/ts/components/conversation/ExpireTimer.tsx index 08e02a523..d49ebb33a 100644 --- a/ts/components/conversation/ExpireTimer.tsx +++ b/ts/components/conversation/ExpireTimer.tsx @@ -64,7 +64,7 @@ export const ExpireTimer = (props: Props) => { return ( - + ); }; diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx index b74263483..d82e37f3a 100644 --- a/ts/components/conversation/GroupNotification.tsx +++ b/ts/components/conversation/GroupNotification.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import { flatten } from 'lodash'; -import { Intl } from '../Intl'; import { PropsForGroupUpdate, PropsForGroupUpdateAdd, @@ -11,6 +9,7 @@ import { } from '../../state/ducks/conversations'; import _ from 'underscore'; import { ReadableMessage } from './ReadableMessage'; +import { NotificationBubble } from './notification-bubble/NotificationBubble'; // This component is used to display group updates in the conversation view. // This is a not a "notification" as the name suggests, but a message inside the conversation @@ -25,34 +24,22 @@ function isTypeWithContact(change: PropsForGroupUpdateType): change is TypeWithC } function getPeople(change: TypeWithContacts) { - return _.compact( - flatten( - (change.contacts || []).map((contact, index) => { - const element = ( - - {contact.profileName || contact.pubkey} - - ); - - return [index > 0 ? ', ' : null, element]; - }) - ) - ); + return change.contacts?.map(c => c.profileName || c.pubkey).join(', '); } -function renderChange(change: PropsForGroupUpdateType) { +const ChangeItem = (change: PropsForGroupUpdateType): string => { const people = isTypeWithContact(change) ? getPeople(change) : []; + switch (change.type) { case 'name': - return `${window.i18n('titleIsNow', [change.newName || ''])}`; + return window.i18n('titleIsNow', change.newName || ''); case 'add': if (!change.contacts || !change.contacts.length) { throw new Error('Group update add is missing contacts'); } const joinKey = change.contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'; - - return ; + return window.i18n(joinKey, people); case 'remove': if (change.isMe) { return window.i18n('youLeftTheGroup'); @@ -63,8 +50,8 @@ function renderChange(change: PropsForGroupUpdateType) { } const leftKey = change.contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; + return window.i18n(leftKey, people); - return ; case 'kicked': if (change.isMe) { return window.i18n('youGotKickedFromGroup'); @@ -76,17 +63,20 @@ function renderChange(change: PropsForGroupUpdateType) { const kickedKey = change.contacts.length > 1 ? 'multipleKickedFromTheGroup' : 'kickedFromTheGroup'; + return window.i18n(kickedKey, people); - return ; case 'general': return window.i18n('updatedTheGroup'); default: - window.log.error('Missing case error'); + throw new Error('Missing case error'); } -} +}; export const GroupNotification = (props: PropsForGroupUpdate) => { const { changes, messageId, receivedAt, isUnread } = props; + + const textChange = changes.map(ChangeItem)[0]; + return ( { isUnread={isUnread} key={`readable-message-${messageId}`} > -
- {(changes || []).map((change, index) => ( -
- {renderChange(change)} -
- ))} -
+
); }; diff --git a/ts/components/conversation/H5AudioPlayer.tsx b/ts/components/conversation/H5AudioPlayer.tsx index 4f2c23248..f8a3ecd8f 100644 --- a/ts/components/conversation/H5AudioPlayer.tsx +++ b/ts/components/conversation/H5AudioPlayer.tsx @@ -19,7 +19,7 @@ export const AudioPlayerWithEncryptedFile = (props: { }) => { const dispatch = useDispatch(); const [playbackSpeed, setPlaybackSpeed] = useState(1.0); - const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType); + const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType, false); const player = useRef(null); const autoPlaySetting = useSelector(getAudioAutoplay); @@ -104,10 +104,10 @@ export const AudioPlayerWithEncryptedFile = (props: { ]} customIcons={{ play: ( - + ), pause: ( - + ), }} /> diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index d052213ca..73b8f2866 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { Spinner } from '../basic/Spinner'; import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment'; import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; +import { useDisableDrag } from '../../hooks/useDisableDrag'; type Props = { alt: string; @@ -48,17 +49,13 @@ export const Image = (props: Props) => { width, } = props; - const onDragStart = useCallback((e: any) => { - e.preventDefault(); - return false; - }, []); - const onErrorUrlFilterering = useCallback(() => { if (url && onError) { onError(); } return; }, [url, onError]); + const disableDrag = useDisableDrag(); const { caption } = attachment || { caption: null }; let { pending } = attachment || { pending: true }; @@ -68,7 +65,7 @@ export const Image = (props: Props) => { } const canClick = onClick && !pending; const role = canClick ? 'button' : undefined; - const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType); + const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType, false); // data will be url if loading is finished and '' if not const srcData = !loading ? urlToLoad : ''; @@ -118,7 +115,7 @@ export const Image = (props: Props) => { height: forceSquare ? `${height}px` : '', }} src={srcData} - onDragStart={onDragStart} + onDragStart={disableDrag} /> )} {caption ? ( @@ -126,7 +123,7 @@ export const Image = (props: Props) => { className="module-image__caption-icon" src="images/caption-shadow.svg" alt={window.i18n('imageCaptionIconAlt')} - onDragStart={onDragStart} + onDragStart={disableDrag} /> ) : null}
{ +const AvatarItem = (props: { pubkey: string }) => { const { pubkey } = props; return ; diff --git a/ts/components/conversation/MissedCallNotification.tsx b/ts/components/conversation/MissedCallNotification.tsx deleted file mode 100644 index ce7ad87f7..000000000 --- a/ts/components/conversation/MissedCallNotification.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import styled from 'styled-components'; -import { PubKey } from '../../session/types'; - -import { PropsForMissedCallNotification } from '../../state/ducks/conversations'; -import { getSelectedConversation } from '../../state/selectors/conversations'; -import { ReadableMessage } from './ReadableMessage'; - -export const StyledFakeMessageBubble = styled.div` - background: var(--color-fake-chat-bubble-background); - color: var(--color-text); - - width: 90%; - max-width: 700px; - margin: 10px auto; - padding: 5px 0px; - border-radius: 4px; - word-break: break-word; - text-align: center; -`; - -export const MissedCallNotification = (props: PropsForMissedCallNotification) => { - const { messageId, receivedAt, isUnread } = props; - - const selectedConvoProps = useSelector(getSelectedConversation); - - const displayName = - selectedConvoProps?.profileName || - selectedConvoProps?.name || - (selectedConvoProps?.id && PubKey.shorten(selectedConvoProps?.id)); - - return ( - - {window.i18n('callMissed', displayName)} - - ); -}; diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index c2c456241..b65a979cf 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -1,6 +1,4 @@ -// tslint:disable:react-this-binding-issue - -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import * as MIME from '../../../ts/types/MIME'; @@ -18,6 +16,7 @@ import { isPublicGroupConversation, } from '../../state/selectors/conversations'; import { noop } from 'underscore'; +import { useDisableDrag } from '../../hooks/useDisableDrag'; export type QuotePropsWithoutListener = { attachment?: QuotedAttachmentType; @@ -116,15 +115,11 @@ export const QuoteImage = (props: { icon?: string; }) => { const { url, icon, contentType, handleImageErrorBound } = props; + const disableDrag = useDisableDrag(); - const { loading, urlToLoad } = useEncryptedFileFetch(url, contentType); + const { loading, urlToLoad } = useEncryptedFileFetch(url, contentType, false); const srcData = !loading ? urlToLoad : ''; - const onDragStart = useCallback((e: any) => { - e.preventDefault(); - return false; - }, []); - const iconElement = icon ? (
@@ -143,7 +138,7 @@ export const QuoteImage = (props: { {window.i18n('quoteThumbnailAlt')} {iconElement} diff --git a/ts/components/conversation/ReadableMessage.tsx b/ts/components/conversation/ReadableMessage.tsx index c4f580279..8ee296ee3 100644 --- a/ts/components/conversation/ReadableMessage.tsx +++ b/ts/components/conversation/ReadableMessage.tsx @@ -131,6 +131,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => { onChange={haveDoneFirstScroll && isAppFocused ? onVisible : noop} triggerOnce={false} trackVisibility={true} + key={`inview-msg-${messageId}`} > {props.children} diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 0f06a86de..f20b54c81 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -1,41 +1,39 @@ import React from 'react'; -import { Intl } from '../Intl'; - import { missingCaseError } from '../../util/missingCaseError'; -import { SessionIcon } from '../session/icon'; import { PropsForExpirationTimer } from '../../state/ducks/conversations'; import { ReadableMessage } from './ReadableMessage'; +import { NotificationBubble } from './notification-bubble/NotificationBubble'; -const TimerNotificationContent = (props: PropsForExpirationTimer) => { - const { pubkey, profileName, timespan, type, disabled } = props; - const changeKey = disabled ? 'disabledDisappearingMessages' : 'theyChangedTheTimer'; +export const TimerNotification = (props: PropsForExpirationTimer) => { + const { messageId, receivedAt, isUnread, pubkey, profileName, timespan, type, disabled } = props; - const contact = ( - - {profileName || pubkey} - - ); + const contact = profileName || pubkey; + let textToRender: string | undefined; switch (type) { case 'fromOther': - return ; + textToRender = disabled + ? window.i18n('disabledDisappearingMessages', [contact, timespan]) + : window.i18n('theyChangedTheTimer', [contact, timespan]); + break; case 'fromMe': - return disabled + textToRender = disabled ? window.i18n('youDisabledDisappearingMessages') : window.i18n('youChangedTheTimer', [timespan]); + break; case 'fromSync': - return disabled + textToRender = disabled ? window.i18n('disappearingMessagesDisabled') : window.i18n('timerSetOnSync', [timespan]); + break; default: throw missingCaseError(type); } -}; - -export const TimerNotification = (props: PropsForExpirationTimer) => { - const { messageId, receivedAt, isUnread } = props; + if (!textToRender || textToRender.length === 0) { + throw new Error('textToRender invalid key used TimerNotification'); + } return ( { isUnread={isUnread} key={`readable-message-${messageId}`} > -
-
-
- -
- -
- -
-
-
+
); }; diff --git a/ts/components/conversation/media-gallery/MediaGridItem.tsx b/ts/components/conversation/media-gallery/MediaGridItem.tsx index 9383a3467..fb444f1d3 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { isImageTypeSupported, isVideoTypeSupported } from '../../../util/GoogleChrome'; @@ -6,6 +6,7 @@ import { MediaItemType } from '../../LightboxGallery'; import { useEncryptedFileFetch } from '../../../hooks/useEncryptedFileFetch'; import { showLightBox } from '../../../state/ducks/conversations'; import { LightBoxOptions } from '../../session/conversation/SessionConversation'; +import { useDisableDrag } from '../../../hooks/useDisableDrag'; type Props = { mediaItem: MediaItemType; @@ -20,14 +21,11 @@ const MediaGridItemContent = (props: Props) => { const urlToDecrypt = mediaItem.thumbnailObjectUrl || ''; const [imageBroken, setImageBroken] = useState(false); - const { loading, urlToLoad } = useEncryptedFileFetch(urlToDecrypt, contentType); + const { loading, urlToLoad } = useEncryptedFileFetch(urlToDecrypt, contentType, false); - const onDragStart = useCallback((e: any) => { - e.preventDefault(); - return false; - }, []); // data will be url if loading is finished and '' if not const srcData = !loading ? urlToLoad : ''; + const disableDrag = useDisableDrag(); const onImageError = () => { // tslint:disable-next-line no-console @@ -57,7 +55,7 @@ const MediaGridItemContent = (props: Props) => { className="module-media-grid-item__image" src={srcData} onError={onImageError} - onDragStart={onDragStart} + onDragStart={disableDrag} /> ); } else if (contentType && isVideoTypeSupported(contentType)) { @@ -79,7 +77,7 @@ const MediaGridItemContent = (props: Props) => { className="module-media-grid-item__image" src={srcData} onError={onImageError} - onDragStart={onDragStart} + onDragStart={disableDrag} />
diff --git a/ts/components/conversation/message/ClickToTrustSender.tsx b/ts/components/conversation/message/ClickToTrustSender.tsx index f7ecd86f9..831e8c56f 100644 --- a/ts/components/conversation/message/ClickToTrustSender.tsx +++ b/ts/components/conversation/message/ClickToTrustSender.tsx @@ -110,7 +110,7 @@ export const ClickToTrustSender = (props: { messageId: string }) => { return ( - + {window.i18n('clickToTrustContact')} ); diff --git a/ts/components/conversation/message/MessageContentWithStatus.tsx b/ts/components/conversation/message/MessageContentWithStatus.tsx index 217881cc6..4b3751920 100644 --- a/ts/components/conversation/message/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/MessageContentWithStatus.tsx @@ -45,8 +45,22 @@ export const MessageContentWithStatuses = (props: Props) => { [window.contextMenuShown, props?.messageId, multiSelectMode, props?.isDetailView] ); - const onDoubleClickReplyToMessage = () => { - void replyToMessage(messageId); + const onDoubleClickReplyToMessage = (e: React.MouseEvent) => { + 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; + } }; const { messageId, onQuoteClick, ctxMenuID, isDetailView } = props; @@ -61,7 +75,7 @@ export const MessageContentWithStatuses = (props: Props) => { className={classNames('module-message', `module-message--${direction}`)} role="button" onClick={onClickOnMessageOuterContainer} - onDoubleClick={onDoubleClickReplyToMessage} + onDoubleClickCapture={onDoubleClickReplyToMessage} style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }} > diff --git a/ts/components/conversation/message/MessagePreview.tsx b/ts/components/conversation/message/MessagePreview.tsx index e4d49110a..39dadc596 100644 --- a/ts/components/conversation/message/MessagePreview.tsx +++ b/ts/components/conversation/message/MessagePreview.tsx @@ -63,7 +63,7 @@ export const MessagePreview = (props: Props) => {
- +
diff --git a/ts/components/conversation/message/OutgoingMessageStatus.tsx b/ts/components/conversation/message/OutgoingMessageStatus.tsx index c710adfde..9508b708c 100644 --- a/ts/components/conversation/message/OutgoingMessageStatus.tsx +++ b/ts/components/conversation/message/OutgoingMessageStatus.tsx @@ -16,7 +16,7 @@ const MessageStatusSending = () => { const iconColor = 'var(--color-text)'; return ( - + ); }; @@ -26,7 +26,7 @@ const MessageStatusSent = () => { return ( - + ); }; @@ -36,7 +36,7 @@ const MessageStatusRead = () => { return ( - + ); }; @@ -48,7 +48,7 @@ const MessageStatusError = () => { return ( - + ); }; diff --git a/ts/components/conversation/notification-bubble/CallNotification.tsx b/ts/components/conversation/notification-bubble/CallNotification.tsx new file mode 100644 index 000000000..9302a157a --- /dev/null +++ b/ts/components/conversation/notification-bubble/CallNotification.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { PubKey } from '../../../session/types'; + +import { CallNotificationType, PropsForCallNotification } from '../../../state/ducks/conversations'; +import { getSelectedConversation } from '../../../state/selectors/conversations'; +import { SessionIconType } from '../../session/icon'; +import { ReadableMessage } from '../ReadableMessage'; +import { NotificationBubble } from './NotificationBubble'; + +type StyleType = Record< + CallNotificationType, + { notificationTextKey: string; iconType: SessionIconType; iconColor: string } +>; + +const style: StyleType = { + 'missed-call': { + notificationTextKey: 'callMissed', + iconType: 'callMissed', + iconColor: 'var(--color-destructive)', + }, + 'started-call': { + notificationTextKey: 'startedACall', + iconType: 'callOutgoing', + iconColor: 'inherit', + }, + 'answered-a-call': { + notificationTextKey: 'answeredACall', + iconType: 'callIncoming', + iconColor: 'inherit', + }, +}; + +export const CallNotification = (props: PropsForCallNotification) => { + const { messageId, receivedAt, isUnread, notificationType } = props; + + const selectedConvoProps = useSelector(getSelectedConversation); + + const displayName = + selectedConvoProps?.profileName || + selectedConvoProps?.name || + (selectedConvoProps?.id && PubKey.shorten(selectedConvoProps?.id)); + + const styleItem = style[notificationType]; + const notificationText = window.i18n(styleItem.notificationTextKey, displayName); + if (!window.i18n(styleItem.notificationTextKey)) { + throw new Error(`invalid i18n key ${styleItem.notificationTextKey}`); + } + const iconType = styleItem.iconType; + const iconColor = styleItem.iconColor; + + return ( + + + + ); +}; diff --git a/ts/components/conversation/notification-bubble/NotificationBubble.tsx b/ts/components/conversation/notification-bubble/NotificationBubble.tsx new file mode 100644 index 000000000..39d00a697 --- /dev/null +++ b/ts/components/conversation/notification-bubble/NotificationBubble.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import styled from 'styled-components'; +import { SessionIcon, SessionIconType } from '../../session/icon'; + +const NotificationBubbleFlex = styled.div` + display: flex; + background: var(--color-fake-chat-bubble-background); + color: var(--color-text); + width: 90%; + max-width: 700px; + margin: 10px auto; + padding: 5px 10px; + border-radius: 16px; + word-break: break-word; + text-align: center; + align-items: center; +`; + +const NotificationBubbleText = styled.div` + color: inherit; + margin: auto auto; +`; + +const NotificationBubbleIconContainer = styled.div` + margin: auto 10px; + width: 15px; + height: 25px; +`; + +export const NotificationBubble = (props: { + notificationText: string; + iconType?: SessionIconType; + iconColor?: string; +}) => { + const { notificationText, iconType, iconColor } = props; + return ( + + {iconType && ( + + + + )} + {notificationText} + {iconType && } + + ); +}; diff --git a/ts/components/dialog/SessionModal.tsx b/ts/components/dialog/SessionModal.tsx index 3e94fc625..22746bb83 100644 --- a/ts/components/dialog/SessionModal.tsx +++ b/ts/components/dialog/SessionModal.tsx @@ -79,7 +79,7 @@ export class SessionModal extends React.PureComponent {
{showExitIcon ? ( - + ) : null}
{title}
diff --git a/ts/components/session/LeftPaneSectionHeader.tsx b/ts/components/session/LeftPaneSectionHeader.tsx index acadc1fce..3e734e827 100644 --- a/ts/components/session/LeftPaneSectionHeader.tsx +++ b/ts/components/session/LeftPaneSectionHeader.tsx @@ -56,7 +56,7 @@ export const LeftPaneSectionHeader = (props: Props) => { {label && } {buttonIcon && ( - + )}
@@ -80,6 +80,7 @@ const BannerInner = () => { buttonType={SessionButtonType.Default} text={window.i18n('recoveryPhraseRevealButtonText')} onClick={showRecoveryPhraseModal} + dataTestId="reveal-recovery-phrase" /> ); diff --git a/ts/components/session/SessionButton.tsx b/ts/components/session/SessionButton.tsx index 313aaa5f0..38aea1905 100644 --- a/ts/components/session/SessionButton.tsx +++ b/ts/components/session/SessionButton.tsx @@ -28,15 +28,16 @@ type Props = { buttonColor: SessionButtonColor; onClick: any; children?: ReactNode; + dataTestId?: string; }; export const SessionButton = (props: Props) => { - const { buttonType, buttonColor, text, disabled } = props; + const { buttonType, dataTestId, buttonColor, text, disabled, onClick } = props; const clickHandler = (e: any) => { - if (props.onClick) { + if (onClick) { e.stopPropagation(); - props.onClick(); + onClick(); } }; @@ -53,6 +54,7 @@ export const SessionButton = (props: Props) => { className={classNames('session-button', ...buttonTypes, buttonColor, disabled && 'disabled')} role="button" onClick={onClickFn} + data-testid={dataTestId} > {props.children || text}
diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index 967dc1b77..eddc9450f 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -156,7 +156,7 @@ export class SessionClosableOverlay extends React.Component { return (
- +
diff --git a/ts/components/session/SessionDropdown.tsx b/ts/components/session/SessionDropdown.tsx index f151bc3ee..4c678251f 100644 --- a/ts/components/session/SessionDropdown.tsx +++ b/ts/components/session/SessionDropdown.tsx @@ -34,7 +34,7 @@ export const SessionDropdown = (props: Props) => { role="button" > {label} - +
{expanded && ( diff --git a/ts/components/session/SessionDropdownItem.tsx b/ts/components/session/SessionDropdownItem.tsx index 94d722c25..7fccb5b98 100644 --- a/ts/components/session/SessionDropdownItem.tsx +++ b/ts/components/session/SessionDropdownItem.tsx @@ -36,7 +36,7 @@ export const SessionDropdownItem = (props: Props) => { role="button" onClick={clickHandler} > - {icon ? : ''} + {icon ? : ''}
{content}
); diff --git a/ts/components/session/SessionInput.tsx b/ts/components/session/SessionInput.tsx index 611ae6450..f02580cdc 100644 --- a/ts/components/session/SessionInput.tsx +++ b/ts/components/session/SessionInput.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { SessionIconButton } from './icon'; -interface Props { +type Props = { label?: string; error?: string; type?: string; @@ -15,117 +15,100 @@ interface Props { onEnterPressed?: any; autoFocus?: boolean; ref?: any; -} + inputDataTestId?: string; +}; -interface State { - inputValue: string; - forceShow: boolean; -} +const LabelItem = (props: { inputValue: string; label?: string }) => { + return ( + + ); +}; -export class SessionInput extends React.PureComponent { - constructor(props: any) { - super(props); +const ErrorItem = (props: { error: string | undefined }) => { + return ( + + ); +}; - this.updateInputValue = this.updateInputValue.bind(this); - this.renderShowHideButton = this.renderShowHideButton.bind(this); +const ShowHideButton = (props: { toggleForceShow: () => void }) => { + return ; +}; - this.state = { - inputValue: '', - forceShow: false, - }; - } +export const SessionInput = (props: Props) => { + const { + autoFocus, + placeholder, + type, + value, + maxLength, + enableShowHide, + error, + label, + onValueChanged, + inputDataTestId, + } = props; + const [inputValue, setInputValue] = useState(''); + const [forceShow, setForceShow] = useState(false); - public render() { - const { autoFocus, placeholder, type, value, maxLength, enableShowHide, error } = this.props; - const { forceShow } = this.state; + const correctType = forceShow ? 'text' : type; - const correctType = forceShow ? 'text' : type; + const updateInputValue = (e: React.ChangeEvent) => { + e.preventDefault(); + const val = e.target.value; + setInputValue(val); + if (onValueChanged) { + onValueChanged(val); + } + }; - return ( -
- {error ? this.renderError() : this.renderLabel()} - { - this.updateInputValue(e); - }} - className={classNames(enableShowHide ? 'session-input-floating-label-show-hide' : '')} - // just incase onChange isn't triggered - onBlur={e => { - this.updateInputValue(e); - }} - onKeyPress={event => { - if (event.key === 'Enter' && this.props.onEnterPressed) { - this.props.onEnterPressed(); - } - }} - /> - - {enableShowHide && this.renderShowHideButton()} - -
-
- ); - } - - private renderLabel() { - const { inputValue } = this.state; - const { label } = this.props; - - return ( - - ); - } - - private renderError() { - const { error } = this.props; - - return ( - - ); - } - - private renderShowHideButton() { - return ( - { - this.setState({ - forceShow: !this.state.forceShow, - }); + return ( +
+ {error ? ( + + ) : ( + + )} + { + if (event.key === 'Enter' && props.onEnterPressed) { + props.onEnterPressed(); + } }} /> - ); - } - private updateInputValue(e: any) { - e.preventDefault(); - this.setState({ - inputValue: e.target.value, - }); - - if (this.props.onValueChanged) { - this.props.onValueChanged(e.target.value); - } - } -} + {enableShowHide && ( + { + setForceShow(!forceShow); + }} + /> + )} +
+
+ ); +}; diff --git a/ts/components/session/SessionJoinableDefaultRooms.tsx b/ts/components/session/SessionJoinableDefaultRooms.tsx index e9abccea7..0925c0b71 100644 --- a/ts/components/session/SessionJoinableDefaultRooms.tsx +++ b/ts/components/session/SessionJoinableDefaultRooms.tsx @@ -74,6 +74,7 @@ const SessionJoinableRoomAvatar = (props: JoinableRoomProps) => { size={AvatarSize.XS} base64Data={props.base64Data} {...props} + pubkey="" onAvatarClick={() => props.onClick(props.completeUrl)} /> ); diff --git a/ts/components/session/SessionMemberListItem.tsx b/ts/components/session/SessionMemberListItem.tsx index f90a03f19..83efcd136 100644 --- a/ts/components/session/SessionMemberListItem.tsx +++ b/ts/components/session/SessionMemberListItem.tsx @@ -27,7 +27,7 @@ type Props = { onUnselect?: (selectedMember: ContactType) => void; }; -const AvatarItem = (props: { memberPubkey?: string }) => { +const AvatarItem = (props: { memberPubkey: string }) => { return ; }; diff --git a/ts/components/session/SessionWrapperModal.tsx b/ts/components/session/SessionWrapperModal.tsx index b03c71bb0..ff615e5c6 100644 --- a/ts/components/session/SessionWrapperModal.tsx +++ b/ts/components/session/SessionWrapperModal.tsx @@ -81,7 +81,7 @@ export const SessionWrapperModal = (props: SessionWrapperModalType) => {
{showExitIcon ? ( - + ) : null}
{title}
diff --git a/ts/components/session/calling/DraggableCallContainer.tsx b/ts/components/session/calling/DraggableCallContainer.tsx index 04b0e9194..194e662eb 100644 --- a/ts/components/session/calling/DraggableCallContainer.tsx +++ b/ts/components/session/calling/DraggableCallContainer.tsx @@ -135,7 +135,7 @@ export const DraggableCallContainer = () => { autoPlay={true} isVideoMuted={remoteStreamVideoIsMuted} /> - {remoteStreamVideoIsMuted && ( + {remoteStreamVideoIsMuted && ongoingCallPubkey && ( diff --git a/ts/components/session/calling/InConversationCallContainer.tsx b/ts/components/session/calling/InConversationCallContainer.tsx index 87fa01175..e0b5f1d67 100644 --- a/ts/components/session/calling/InConversationCallContainer.tsx +++ b/ts/components/session/calling/InConversationCallContainer.tsx @@ -158,7 +158,7 @@ export const InConversationCallContainer = () => { videoRefRemote.current.muted = true; } - if (!ongoingCallWithFocused) { + if (!ongoingCallWithFocused || !ongoingCallPubkey) { return null; } diff --git a/ts/components/session/calling/IncomingCallDialog.tsx b/ts/components/session/calling/IncomingCallDialog.tsx index 3e6dd5500..d812044a1 100644 --- a/ts/components/session/calling/IncomingCallDialog.tsx +++ b/ts/components/session/calling/IncomingCallDialog.tsx @@ -6,6 +6,7 @@ import _ from 'underscore'; import { useConversationUsername } from '../../../hooks/useParamSelector'; import { ed25519Str } from '../../../session/onions/onionPath'; import { CallManager } from '../../../session/utils'; +import { callTimeoutMs } from '../../../session/utils/calling/CallManager'; import { getHasIncomingCall, getHasIncomingCallFrom } from '../../../state/selectors/call'; import { Avatar, AvatarSize } from '../../Avatar'; import { SessionButton, SessionButtonColor } from '../SessionButton'; @@ -24,12 +25,10 @@ export const CallWindow = styled.div` border: var(--session-border); `; -const IncomingCallAvatatContainer = styled.div` +const IncomingCallAvatarContainer = styled.div` padding: 0 0 2rem 0; `; -const timeoutMs = 60000; - export const IncomingCallDialog = () => { const hasIncomingCall = useSelector(getHasIncomingCall); const incomingCallFromPubkey = useSelector(getHasIncomingCallFrom); @@ -42,11 +41,11 @@ export const IncomingCallDialog = () => { window.log.info( `call missed with ${ed25519Str( incomingCallFromPubkey - )} as dialog was not interacted with for ${timeoutMs} ms` + )} as the dialog was not interacted with for ${callTimeoutMs} ms` ); await CallManager.USER_rejectIncomingCallRequest(incomingCallFromPubkey); } - }, timeoutMs); + }, callTimeoutMs); } return () => { @@ -70,16 +69,16 @@ export const IncomingCallDialog = () => { } }; const from = useConversationUsername(incomingCallFromPubkey); - if (!hasIncomingCall) { + if (!hasIncomingCall || !incomingCallFromPubkey) { return null; } if (hasIncomingCall) { return ( - + - +
, dateBreak, unreadIndicator]; } - if (messageProps.message?.messageType === 'missed-call-notification') { - const msgProps = messageProps.message.props as PropsForMissedCallNotification; + if (messageProps.message?.messageType === 'call-notification') { + const msgProps = messageProps.message.props as PropsForCallNotification; - return [ - , - dateBreak, - unreadIndicator, - ]; + return [, dateBreak, unreadIndicator]; } if (!messageProps) { diff --git a/ts/components/session/conversation/SessionQuotedMessageComposition.tsx b/ts/components/session/conversation/SessionQuotedMessageComposition.tsx index 591a55860..786e08349 100644 --- a/ts/components/session/conversation/SessionQuotedMessageComposition.tsx +++ b/ts/components/session/conversation/SessionQuotedMessageComposition.tsx @@ -75,7 +75,7 @@ export const SessionQuotedMessageComposition = () => { margin={'var(--margins-xs)'} > {window.i18n('replyingToMessage')} - + diff --git a/ts/components/session/icon/Icons.tsx b/ts/components/session/icon/Icons.tsx index 2079d9427..b83b15bd0 100644 --- a/ts/components/session/icon/Icons.tsx +++ b/ts/components/session/icon/Icons.tsx @@ -3,6 +3,9 @@ export type SessionIconType = | 'arrow' | 'bell' | 'brand' + | 'callIncoming' + | 'callMissed' + | 'callOutgoing' | 'caret' | 'chatBubble' | 'check' @@ -95,6 +98,24 @@ export const icons = { viewBox: '0 0 404.085 448.407', ratio: 1, }, + callIncoming: { + path: + 'M14.414 7l3.293-3.293a1 1 0 00-1.414-1.414L13 5.586V4a1 1 0 10-2 0v4.003a.996.996 0 00.617.921A.997.997 0 0012 9h4a1 1 0 100-2h-1.586zM2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z', + viewBox: '0 0 20 20', + ratio: 1, + }, + callOutgoing: { + path: + 'M17.924 2.617a.997.997 0 00-.215-.322l-.004-.004A.997.997 0 0017 2h-4a1 1 0 100 2h1.586l-3.293 3.293a1 1 0 001.414 1.414L16 5.414V7a1 1 0 102 0V3a.997.997 0 00-.076-.383zM2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z', + viewBox: '0 0 20 20', + ratio: 1, + }, + callMissed: { + path: + 'M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3zM16.707 3.293a1 1 0 010 1.414L15.414 6l1.293 1.293a1 1 0 01-1.414 1.414L14 7.414l-1.293 1.293a1 1 0 11-1.414-1.414L12.586 6l-1.293-1.293a1 1 0 011.414-1.414L14 4.586l1.293-1.293a1 1 0 011.414 0z', + viewBox: '0 0 20 20', + ratio: 1, + }, caret: { path: 'M127.5 191.25L255 63.75L0 63.75L127.5 191.25Z', viewBox: '-200 -200 640 640', diff --git a/ts/components/session/registration/RegistrationUserDetails.tsx b/ts/components/session/registration/RegistrationUserDetails.tsx index abf5905ba..f54aebf75 100644 --- a/ts/components/session/registration/RegistrationUserDetails.tsx +++ b/ts/components/session/registration/RegistrationUserDetails.tsx @@ -20,6 +20,7 @@ const DisplayNameInput = (props: { maxLength={MAX_USERNAME_LENGTH} onValueChanged={props.onDisplayNameChanged} onEnterPressed={props.handlePressEnter} + inputDataTestId="display-name-input" /> ); }; @@ -41,6 +42,7 @@ const RecoveryPhraseInput = (props: { enableShowHide={true} onValueChanged={props.onSeedChanged} onEnterPressed={props.handlePressEnter} + inputDataTestId="recovery-phrase-input" /> ); }; diff --git a/ts/components/session/registration/SignInTab.tsx b/ts/components/session/registration/SignInTab.tsx index ed77d61ed..d23918749 100644 --- a/ts/components/session/registration/SignInTab.tsx +++ b/ts/components/session/registration/SignInTab.tsx @@ -39,6 +39,7 @@ const RestoreUsingRecoveryPhraseButton = (props: { onRecoveryButtonClicked: () = buttonType={SessionButtonType.BrandOutline} buttonColor={SessionButtonColor.Green} text={window.i18n('restoreUsingRecoveryPhrase')} + dataTestId="restore-using-recovery" /> ); }; diff --git a/ts/components/session/settings/section/CategoryPrivacy.tsx b/ts/components/session/settings/section/CategoryPrivacy.tsx index e876bbc2b..7b8d87a34 100644 --- a/ts/components/session/settings/section/CategoryPrivacy.tsx +++ b/ts/components/session/settings/section/CategoryPrivacy.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; // tslint:disable-next-line: no-submodule-imports import useUpdate from 'react-use/lib/useUpdate'; +import { CallManager } from '../../../../session/utils'; import { sessionPassword, updateConfirmModal } from '../../../../state/ducks/modalDialog'; import { toggleMessageRequests } from '../../../../state/ducks/userConfig'; import { getIsMessageRequestsEnabled } from '../../../../state/selectors/userConfig'; @@ -23,6 +24,7 @@ const toggleCallMediaPermissions = async (triggerUIUpdate: () => void) => { onClickOk: async () => { await window.toggleCallMediaPermissionsTo(true); triggerUIUpdate(); + CallManager.onTurnedOnCallMediaPermissions(); }, onClickCancel: async () => { await window.toggleCallMediaPermissionsTo(false); diff --git a/ts/hooks/useDisableDrag.ts b/ts/hooks/useDisableDrag.ts new file mode 100644 index 000000000..b0844f4b3 --- /dev/null +++ b/ts/hooks/useDisableDrag.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react'; + +/** + * This memoized function just returns a callback which can be used to disable the onDragStart event + */ +export const useDisableDrag = () => { + const cb = useCallback((e: any) => { + e.preventDefault(); + return false; + }, []); + + return cb; +}; diff --git a/ts/hooks/useEncryptedFileFetch.ts b/ts/hooks/useEncryptedFileFetch.ts index b71f51168..07d6c4bca 100644 --- a/ts/hooks/useEncryptedFileFetch.ts +++ b/ts/hooks/useEncryptedFileFetch.ts @@ -6,7 +6,7 @@ import { } from '../session/crypto/DecryptedAttachmentsManager'; import { perfEnd, perfStart } from '../session/utils/Performance'; -export const useEncryptedFileFetch = (url: string, contentType: string) => { +export const useEncryptedFileFetch = (url: string, contentType: string, isAvatar: boolean) => { // tslint:disable-next-line: no-bitwise const [urlToLoad, setUrlToLoad] = useState(''); const [loading, setLoading] = useState(false); @@ -16,7 +16,7 @@ export const useEncryptedFileFetch = (url: string, contentType: string) => { async function fetchUrl() { perfStart(`getDecryptedMediaUrl-${url}`); - const decryptedUrl = await getDecryptedMediaUrl(url, contentType); + const decryptedUrl = await getDecryptedMediaUrl(url, contentType, isAvatar); perfEnd(`getDecryptedMediaUrl-${url}`, `getDecryptedMediaUrl-${url}`); if (mountedRef.current) { diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index e9ab569d4..c60f8b353 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -39,3 +39,16 @@ export function useOurConversationUsername() { export function useIsMe(pubkey?: string) { return pubkey && pubkey === UserUtils.getOurPubKeyStrFromCache(); } + +export function useIsClosedGroup(convoId?: string) { + return useSelector((state: StateType) => { + if (!convoId) { + return false; + } + const convo = state.conversations.conversationLookup[convoId]; + if (!convo) { + return false; + } + return (convo.isGroup && !convo.isPublic) || false; + }); +} diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index fad5af236..044aba6a3 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -331,7 +331,7 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) { return; } - const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG); + const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG, true); if (!decryptedAvatarUrl) { window.log.warn('Could not decrypt avatar stored locally..'); diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 7a95a9979..20307bf5b 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -50,6 +50,7 @@ import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsMana import { IMAGE_JPEG } from '../types/MIME'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; import { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI'; +import { createLastMessageUpdate } from '../types/Conversation'; export enum ConversationTypeEnum { GROUP = 'group', @@ -89,7 +90,7 @@ export interface ConversationAttributes { sessionRestoreSeen?: boolean; is_medium_group?: boolean; type: string; - avatarPointer?: any; + avatarPointer?: string; avatar?: any; /* Avatar hash is currently used for opengroupv2. it's sha256 hash of the base64 avatar data. */ avatarHash?: string; @@ -131,7 +132,7 @@ export interface ConversationAttributesOptionals { sessionRestoreSeen?: boolean; is_medium_group?: boolean; type: string; - avatarPointer?: any; + avatarPointer?: string; avatar?: any; avatarHash?: string; server?: any; @@ -900,9 +901,9 @@ export class ConversationModel extends Backbone.Model { const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null; const lastMessageStatusModel = lastMessageModel ? lastMessageModel.getMessagePropStatus() - : null; - const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate({ - currentTimestamp: this.get('active_at') || null, + : undefined; + const lastMessageUpdate = createLastMessageUpdate({ + currentTimestamp: this.get('active_at'), lastMessage: lastMessageJSON, lastMessageStatus: lastMessageStatusModel, lastMessageNotificationText: lastMessageModel ? lastMessageModel.getNotificationText() : null, @@ -1057,6 +1058,8 @@ export class ConversationModel extends Backbone.Model { ); const unreadCount = await this.getUnreadCount(); this.set({ unreadCount }); + this.updateLastMessage(); + await this.commit(); return model; } @@ -1487,7 +1490,7 @@ export class ConversationModel extends Backbone.Model { const avatarUrl = this.getAvatarPath(); const noIconUrl = 'images/session/session_icon_32.png'; if (avatarUrl) { - const decryptedAvatarUrl = await getDecryptedMediaUrl(avatarUrl, IMAGE_JPEG); + const decryptedAvatarUrl = await getDecryptedMediaUrl(avatarUrl, IMAGE_JPEG, true); if (!decryptedAvatarUrl) { window.log.warn('Could not decrypt avatar stored locally for getNotificationIcon..'); diff --git a/ts/models/message.ts b/ts/models/message.ts index 43b5a7f07..b4f0c7a9e 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -88,7 +88,7 @@ export class MessageModel extends Backbone.Model { const propsForGroupInvitation = this.getPropsForGroupInvitation(); const propsForGroupNotification = this.getPropsForGroupNotification(); const propsForTimerNotification = this.getPropsForTimerNotification(); - const isMissedCall = this.get('isMissedCall'); + const callNotificationType = this.get('callNotificationType'); const messageProps: MessageModelPropsWithoutConvoProps = { propsForMessage: this.getPropsForMessage(), }; @@ -105,9 +105,9 @@ export class MessageModel extends Backbone.Model { messageProps.propsForTimerNotification = propsForTimerNotification; } - if (isMissedCall) { - messageProps.propsForMissedCall = { - isMissedCall, + if (callNotificationType) { + messageProps.propsForCallNotification = { + notificationType: callNotificationType, messageId: this.id, receivedAt: this.get('received_at') || Date.now(), isUnread: this.isUnread(), @@ -239,6 +239,21 @@ export class MessageModel extends Backbone.Model { getConversationController().getContactProfileNameOrShortenedPubKey(dataExtraction.source) ); } + if (this.get('callNotificationType')) { + const displayName = getConversationController().getContactProfileNameOrShortenedPubKey( + this.get('conversationId') + ); + const callNotificationType = this.get('callNotificationType'); + if (callNotificationType === 'missed-call') { + return window.i18n('callMissed', displayName); + } + if (callNotificationType === 'started-call') { + return window.i18n('startedACall', displayName); + } + if (callNotificationType === 'answered-a-call') { + return window.i18n('answeredACall', displayName); + } + } return this.get('body'); } @@ -498,7 +513,7 @@ export class MessageModel extends Backbone.Model { return undefined; } - if (this.isDataExtractionNotification()) { + if (this.isDataExtractionNotification() || this.get('callNotificationType')) { return undefined; } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index f880be5be..643b511d2 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -1,6 +1,6 @@ import _ from 'underscore'; import { v4 as uuidv4 } from 'uuid'; -import { PropsForMessageWithConvoProps } from '../state/ducks/conversations'; +import { CallNotificationType, PropsForMessageWithConvoProps } from '../state/ducks/conversations'; import { AttachmentTypeWithPath } from '../types/Attachment'; export type MessageModelType = 'incoming' | 'outgoing'; @@ -109,7 +109,7 @@ export interface MessageAttributes { */ isDeleted?: boolean; - isMissedCall?: boolean; + callNotificationType?: CallNotificationType; } export interface DataExtractionNotificationMsg { @@ -179,7 +179,7 @@ export interface MessageAttributesOptionals { direction?: any; messageHash?: string; isDeleted?: boolean; - isMissedCall?: boolean; + callNotificationType?: CallNotificationType; } /** diff --git a/ts/receiver/callMessage.ts b/ts/receiver/callMessage.ts index cbf0d4891..a8d099312 100644 --- a/ts/receiver/callMessage.ts +++ b/ts/receiver/callMessage.ts @@ -74,19 +74,19 @@ export async function handleCallMessage( if (type === SignalService.CallMessage.Type.ANSWER) { await removeFromCache(envelope); - await CallManager.handleCallTypeAnswer(sender, callMessage); + await CallManager.handleCallTypeAnswer(sender, callMessage, sentTimestamp); return; } if (type === SignalService.CallMessage.Type.ICE_CANDIDATES) { await removeFromCache(envelope); - await CallManager.handleCallTypeIceCandidates(sender, callMessage); + await CallManager.handleCallTypeIceCandidates(sender, callMessage, sentTimestamp); return; } await removeFromCache(envelope); // if this another type of call message, just add it to the manager - await CallManager.handleOtherCallTypes(sender, callMessage); + await CallManager.handleOtherCallTypes(sender, callMessage, sentTimestamp); } diff --git a/ts/session/crypto/DecryptedAttachmentsManager.ts b/ts/session/crypto/DecryptedAttachmentsManager.ts index 54b22b390..41f3c8433 100644 --- a/ts/session/crypto/DecryptedAttachmentsManager.ts +++ b/ts/session/crypto/DecryptedAttachmentsManager.ts @@ -11,20 +11,29 @@ import * as fse from 'fs-extra'; import { decryptAttachmentBuffer } from '../../types/Attachment'; import { DURATION } from '../constants'; -// FIXME. -// add a way to remove the blob when the attachment file path is removed (message removed?) -// do not hardcode the password -const urlToDecryptedBlobMap = new Map(); +const urlToDecryptedBlobMap = new Map< + string, + { decrypted: string; lastAccessTimestamp: number; forceRetain: boolean } +>(); const urlToDecryptingPromise = new Map>(); export const cleanUpOldDecryptedMedias = () => { const currentTimestamp = Date.now(); let countCleaned = 0; let countKept = 0; + let keptAsAvatars = 0; + window?.log?.info('Starting cleaning of medias blobs...'); for (const iterator of urlToDecryptedBlobMap) { - // if the last access is older than one hour, revoke the url and remove it. - if (iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.HOURS * 1) { + if ( + iterator[1].forceRetain && + iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.DAYS * 7 + ) { + // keep forceRetained items for at most 7 days + keptAsAvatars++; + } else if (iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.HOURS * 1) { + // if the last access is older than one hour, revoke the url and remove it. + URL.revokeObjectURL(iterator[1].decrypted); urlToDecryptedBlobMap.delete(iterator[0]); countCleaned++; @@ -32,10 +41,16 @@ export const cleanUpOldDecryptedMedias = () => { countKept++; } } - window?.log?.info(`Clean medias blobs: cleaned/kept: ${countCleaned}:${countKept}`); + window?.log?.info( + `Clean medias blobs: cleaned/kept/keptAsAvatars: ${countCleaned}:${countKept}:${keptAsAvatars}` + ); }; -export const getDecryptedMediaUrl = async (url: string, contentType: string): Promise => { +export const getDecryptedMediaUrl = async ( + url: string, + contentType: string, + isAvatar: boolean +): Promise => { if (!url) { return url; } @@ -50,11 +65,13 @@ export const getDecryptedMediaUrl = async (url: string, contentType: string): Pr // if it's not, the hook caller has to fallback to setting the img src as an url to the file instead and load it if (urlToDecryptedBlobMap.has(url)) { // refresh the last access timestamp so we keep the one being currently in use - const existingObjUrl = urlToDecryptedBlobMap.get(url)?.decrypted as string; + const existing = urlToDecryptedBlobMap.get(url); + const existingObjUrl = existing?.decrypted as string; urlToDecryptedBlobMap.set(url, { decrypted: existingObjUrl, lastAccessTimestamp: Date.now(), + forceRetain: existing?.forceRetain || false, }); // typescript does not realize that the has above makes sure the get is not undefined @@ -80,6 +97,7 @@ export const getDecryptedMediaUrl = async (url: string, contentType: string): Pr urlToDecryptedBlobMap.set(url, { decrypted: obj, lastAccessTimestamp: Date.now(), + forceRetain: isAvatar, }); } urlToDecryptingPromise.delete(url); diff --git a/ts/session/utils/RingingManager.ts b/ts/session/utils/RingingManager.ts index f97dd1db3..5357afb3d 100644 --- a/ts/session/utils/RingingManager.ts +++ b/ts/session/utils/RingingManager.ts @@ -20,6 +20,10 @@ function startRinging() { void ringingAudio.play(); } +export function getIsRinging() { + return currentlyRinging; +} + export function setIsRinging(isRinging: boolean) { if (!currentlyRinging && isRinging) { startRinging(); diff --git a/ts/session/utils/User.ts b/ts/session/utils/User.ts index 6132a98df..02bfcd26c 100644 --- a/ts/session/utils/User.ts +++ b/ts/session/utils/User.ts @@ -5,6 +5,7 @@ import { KeyPair } from '../../../libtextsecure/libsignal-protocol'; import { PubKey } from '../types'; import { fromHexToArray, toHex } from './String'; import { getConversationController } from '../conversations'; +import { LokiProfile } from '../../types/Message'; export type HexKeyPair = { pubKey: string; @@ -93,13 +94,7 @@ export function setSignWithRecoveryPhrase(isLinking: boolean) { window.textsecure.storage.user.setSignWithRecoveryPhrase(isLinking); } -export interface OurLokiProfile { - displayName: string; - avatarPointer: string; - profileKey: Uint8Array | null; -} - -export function getOurProfile(): OurLokiProfile | undefined { +export function getOurProfile(): LokiProfile | undefined { try { // Secondary devices have their profile stored // in their primary device's conversation diff --git a/ts/session/utils/calling/CallManager.ts b/ts/session/utils/calling/CallManager.ts index b84c0e776..22517e615 100644 --- a/ts/session/utils/calling/CallManager.ts +++ b/ts/session/utils/calling/CallManager.ts @@ -1,7 +1,6 @@ import _ from 'lodash'; import { MessageUtils, ToastUtils, UserUtils } from '../'; import { getCallMediaPermissionsSettings } from '../../../components/session/settings/SessionSettings'; -import { getConversationById } from '../../../data/data'; import { MessageModelType } from '../../../models/messageType'; import { SignalService } from '../../../protobuf'; import { openConversationWithMessages } from '../../../state/ducks/conversations'; @@ -21,15 +20,18 @@ import { PubKey } from '../../types'; import { v4 as uuidv4 } from 'uuid'; import { PnServer } from '../../../pushnotification'; -import { setIsRinging } from '../RingingManager'; +import { getIsRinging, setIsRinging } from '../RingingManager'; import { getBlackSilenceMediaStream } from './Silence'; import { getMessageQueue } from '../..'; import { MessageSender } from '../../sending'; +import { DURATION } from '../../constants'; // tslint:disable: function-name export type InputItem = { deviceId: string; label: string }; +export const callTimeoutMs = 30000; + /** * This uuid is set only once we accepted a call or started one. */ @@ -88,10 +90,19 @@ export function removeVideoEventsListener(uniqueId: string) { callVideoListeners(); } +type CachedCallMessageType = { + type: SignalService.CallMessage.Type; + sdps: Array; + sdpMLineIndexes: Array; + sdpMids: Array; + uuid: string; + timestamp: number; +}; + /** * This field stores all the details received about a specific call with the same uuid. It is a per pubkey and per call cache. */ -const callCache = new Map>>(); +const callCache = new Map>>(); let peerConnection: RTCPeerConnection | null; let dataChannel: RTCDataChannel | null; @@ -293,6 +304,8 @@ export async function selectAudioInputByDeviceId(audioInputDeviceId: string) { if (sender?.track) { sender.track.enabled = false; } + const silence = getBlackSilenceMediaStream().getAudioTracks()[0]; + sender?.replaceTrack(silence); // do the same changes locally localStream?.getAudioTracks().forEach(t => { t.stop(); @@ -322,10 +335,14 @@ export async function selectAudioInputByDeviceId(audioInputDeviceId: string) { return s.track?.kind === audioTrack.kind; }); window.log.info('replacing audio track'); - + // we actually do not need to toggle the track here, as toggling it here unmuted here locally (so we start to hear ourselves) + // do the same changes locally + localStream?.getAudioTracks().forEach(t => { + t.stop(); + localStream?.removeTrack(t); + }); if (audioSender) { await audioSender.replaceTrack(audioTrack); - // we actually do not need to toggle the track here, as toggling it here unmuted here locally (so we start to hear ourselves) } else { throw new Error('Failed to get sender for selectAudioInputByDeviceId '); } @@ -439,22 +456,38 @@ export async function USER_callRecipient(recipient: string) { return; } await updateConnectedDevices(); + const now = Date.now(); window?.log?.info(`starting call with ${ed25519Str(recipient)}..`); - window.inboxStore?.dispatch(startingCallWith({ pubkey: recipient })); + window.inboxStore?.dispatch( + startingCallWith({ + pubkey: recipient, + }) + ); if (peerConnection) { throw new Error('USER_callRecipient peerConnection is already initialized '); } currentCallUUID = uuidv4(); + const justCreatedCallUUID = currentCallUUID; peerConnection = createOrGetPeerConnection(recipient); // send a pre offer just to wake up the device on the remote side const preOfferMsg = new CallMessage({ - timestamp: Date.now(), + timestamp: now, type: SignalService.CallMessage.Type.PRE_OFFER, uuid: currentCallUUID, }); window.log.info('Sending preOffer message to ', ed25519Str(recipient)); - + const calledConvo = getConversationController().get(recipient); + await calledConvo?.addSingleMessage({ + conversationId: calledConvo.id, + source: UserUtils.getOurPubKeyStrFromCache(), + type: 'outgoing', + sent_at: now, + received_at: now, + expireTimer: 0, + callNotificationType: 'started-call', + unread: 0, + }); // we do it manually as the sendToPubkeyNonDurably rely on having a message saved to the db for MessageSentSuccess // which is not the case for a pre offer message (the message only exists in memory) const rawMessage = await MessageUtils.toRawMessage(PubKey.cast(recipient), preOfferMsg); @@ -464,6 +497,17 @@ export async function USER_callRecipient(recipient: string) { await openMediaDevicesAndAddTracks(); setIsRinging(true); await createOfferAndSendIt(recipient); + + // close and end the call if callTimeoutMs is reached ans still not connected + global.setTimeout(async () => { + if (justCreatedCallUUID === currentCallUUID && getIsRinging()) { + window.log.info( + 'calling timeout reached. hanging up the call we started:', + justCreatedCallUUID + ); + await USER_hangup(recipient); + } + }, callTimeoutMs); } const iceCandidates: Array = new Array(); @@ -762,6 +806,18 @@ export async function USER_acceptIncomingCallRequest(fromSender: string) { await peerConnection.addIceCandidate(candicate); } } + const now = Date.now(); + const callerConvo = getConversationController().get(fromSender); + await callerConvo?.addSingleMessage({ + conversationId: callerConvo.id, + source: UserUtils.getOurPubKeyStrFromCache(), + type: 'incoming', + sent_at: now, + received_at: now, + expireTimer: 0, + callNotificationType: 'answered-a-call', + unread: 0, + }); await buildAnswerAndSendIt(fromSender); } @@ -809,6 +865,7 @@ export async function USER_rejectIncomingCallRequest(fromSender: string) { if (ongoingCallWith && ongoingCallStatus && ongoingCallWith === fromSender) { closeVideoCall(); } + await addMissedCallMessage(fromSender, Date.now()); } async function sendCallMessageAndSync(callmessage: CallMessage, user: string) { @@ -907,6 +964,20 @@ export function isCallRejected(uuid: string) { return rejectedCallUUIDS.has(uuid); } +function getCachedMessageFromCallMessage( + callMessage: SignalService.CallMessage, + envelopeTimestamp: number +) { + return { + type: callMessage.type, + sdps: callMessage.sdps, + sdpMLineIndexes: callMessage.sdpMLineIndexes, + sdpMids: callMessage.sdpMids, + uuid: callMessage.uuid, + timestamp: envelopeTimestamp, + }; +} + export async function handleCallTypeOffer( sender: string, callMessage: SignalService.CallMessage, @@ -920,6 +991,9 @@ export async function handleCallTypeOffer( window.log.info('handling callMessage OFFER with uuid: ', remoteCallUUID); if (!getCallMediaPermissionsSettings()) { + const cachedMsg = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp); + pushCallMessageToCallCache(sender, remoteCallUUID, cachedMsg); + await handleMissedCall(sender, incomingOfferTimestamp, true); return; } @@ -979,8 +1053,9 @@ export async function handleCallTypeOffer( } setIsRinging(true); } + const cachedMessage = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp); - pushCallMessageToCallCache(sender, remoteCallUUID, callMessage); + pushCallMessageToCallCache(sender, remoteCallUUID, cachedMessage); } catch (err) { window.log?.error(`Error handling offer message ${err}`); } @@ -991,7 +1066,7 @@ export async function handleMissedCall( incomingOfferTimestamp: number, isBecauseOfCallPermission: boolean ) { - const incomingCallConversation = await getConversationById(sender); + const incomingCallConversation = getConversationController().get(sender); setIsRinging(false); if (!isBecauseOfCallPermission) { ToastUtils.pushedMissedCall( @@ -1007,26 +1082,30 @@ export async function handleMissedCall( ); } + await addMissedCallMessage(sender, incomingOfferTimestamp); + return; +} + +async function addMissedCallMessage(callerPubkey: string, sentAt: number) { + const incomingCallConversation = getConversationController().get(callerPubkey); + await incomingCallConversation?.addSingleMessage({ - conversationId: incomingCallConversation.id, - source: sender, + conversationId: callerPubkey, + source: callerPubkey, type: 'incoming' as MessageModelType, - sent_at: incomingOfferTimestamp, + sent_at: sentAt, received_at: Date.now(), expireTimer: 0, - isMissedCall: true, + callNotificationType: 'missed-call', unread: 1, }); - incomingCallConversation?.updateLastMessage(); - - return; } function getOwnerOfCallUUID(callUUID: string) { for (const deviceKey of callCache.keys()) { for (const callUUIDEntry of callCache.get(deviceKey) as Map< string, - Array + Array >) { if (callUUIDEntry[0] === callUUID) { return deviceKey; @@ -1036,7 +1115,11 @@ function getOwnerOfCallUUID(callUUID: string) { return null; } -export async function handleCallTypeAnswer(sender: string, callMessage: SignalService.CallMessage) { +export async function handleCallTypeAnswer( + sender: string, + callMessage: SignalService.CallMessage, + envelopeTimestamp: number +) { if (!callMessage.sdps || callMessage.sdps.length === 0) { window.log.warn('cannot handle answered message without signal description proto sdps'); return; @@ -1083,8 +1166,9 @@ export async function handleCallTypeAnswer(sender: string, callMessage: SignalSe } else { window.log.info(`handling callMessage ANSWER from ${callMessageUUID}`); } + const cachedMessage = getCachedMessageFromCallMessage(callMessage, envelopeTimestamp); - pushCallMessageToCallCache(sender, callMessageUUID, callMessage); + pushCallMessageToCallCache(sender, callMessageUUID, cachedMessage); if (!peerConnection) { window.log.info('handleCallTypeAnswer without peer connection. Dropping'); @@ -1114,7 +1198,8 @@ export async function handleCallTypeAnswer(sender: string, callMessage: SignalSe export async function handleCallTypeIceCandidates( sender: string, - callMessage: SignalService.CallMessage + callMessage: SignalService.CallMessage, + envelopeTimestamp: number ) { if (!callMessage.sdps || callMessage.sdps.length === 0) { window.log.warn('cannot handle iceCandicates message without candidates'); @@ -1126,8 +1211,9 @@ export async function handleCallTypeIceCandidates( return; } window.log.info('handling callMessage ICE_CANDIDATES'); + const cachedMessage = getCachedMessageFromCallMessage(callMessage, envelopeTimestamp); - pushCallMessageToCallCache(sender, remoteCallUUID, callMessage); + pushCallMessageToCallCache(sender, remoteCallUUID, cachedMessage); if (currentCallUUID && callMessage.uuid === currentCallUUID) { await addIceCandidateToExistingPeerConnection(callMessage); } @@ -1155,13 +1241,18 @@ async function addIceCandidateToExistingPeerConnection(callMessage: SignalServic } // tslint:disable-next-line: no-async-without-await -export async function handleOtherCallTypes(sender: string, callMessage: SignalService.CallMessage) { +export async function handleOtherCallTypes( + sender: string, + callMessage: SignalService.CallMessage, + envelopeTimestamp: number +) { const remoteCallUUID = callMessage.uuid; if (!remoteCallUUID || remoteCallUUID.length === 0) { window.log.warn('handleOtherCallTypes has no valid uuid'); return; } - pushCallMessageToCallCache(sender, remoteCallUUID, callMessage); + const cachedMessage = getCachedMessageFromCallMessage(callMessage, envelopeTimestamp); + pushCallMessageToCallCache(sender, remoteCallUUID, cachedMessage); } function clearCallCacheFromPubkeyAndUUID(sender: string, callUUID: string) { @@ -1181,7 +1272,7 @@ function createCallCacheForPubkeyAndUUID(sender: string, uuid: string) { function pushCallMessageToCallCache( sender: string, uuid: string, - callMessage: SignalService.CallMessage + callMessage: CachedCallMessageType ) { createCallCacheForPubkeyAndUUID(sender, uuid); callCache @@ -1189,3 +1280,23 @@ function pushCallMessageToCallCache( ?.get(uuid) ?.push(callMessage); } + +/** + * Called when the settings of call media permissions is set to true from the settings page. + * Check for any recent offer and display it to the user if needed. + */ +export function onTurnedOnCallMediaPermissions() { + // this is not ideal as this might take the not latest sender from callCache + callCache.forEach((sender, key) => { + sender.forEach(msgs => { + for (const msg of msgs.reverse()) { + if ( + msg.type === SignalService.CallMessage.Type.OFFER && + Date.now() - msg.timestamp < DURATION.MINUTES * 1 + ) { + window.inboxStore?.dispatch(incomingCall({ pubkey: key })); + } + } + }); + }); +} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 6124189f5..7971b4d44 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -17,8 +17,9 @@ import { QuotedAttachmentType } from '../../components/conversation/Quote'; import { perfEnd, perfStart } from '../../session/utils/Performance'; import { omit } from 'lodash'; -export type PropsForMissedCallNotification = { - isMissedCall: boolean; +export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call'; +export type PropsForCallNotification = { + notificationType: CallNotificationType; messageId: string; receivedAt: number; isUnread: boolean; @@ -30,7 +31,7 @@ export type MessageModelPropsWithoutConvoProps = { propsForTimerNotification?: PropsForExpirationTimer; propsForDataExtractionNotification?: PropsForDataExtractionNotification; propsForGroupNotification?: PropsForGroupUpdate; - propsForMissedCall?: PropsForMissedCallNotification; + propsForCallNotification?: PropsForCallNotification; }; export type MessageModelPropsWithConvoProps = SortedMessageModelProps & { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 7ee0fe576..85e21a34c 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -83,19 +83,6 @@ export const getSelectedConversationIsPublic = createSelector( } ); -const getConversationId = (_whatever: any, id: string) => id; - -export const getConversationById = createSelector( - getConversations, - getConversationId, - ( - state: ConversationsStateType, - convoId: string | undefined - ): ReduxConversationType | undefined => { - return convoId ? state.conversationLookup[convoId] : undefined; - } -); - export const getIsTypingEnabled = createSelector( getConversations, getSelectedConversationKey, @@ -190,7 +177,7 @@ export type MessagePropsType = | 'timer-notification' | 'regular-message' | 'unread-indicator' - | 'missed-call-notification'; + | 'call-notification'; export const getSortedMessagesTypesOfSelectedConversation = createSelector( getSortedMessagesOfSelectedConversation, @@ -257,14 +244,14 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector( }; } - if (msg.propsForMissedCall) { + if (msg.propsForCallNotification) { return { showUnreadIndicator: isFirstUnread, showDateBreak, message: { - messageType: 'missed-call-notification', + messageType: 'call-notification', props: { - ...msg.propsForMissedCall, + ...msg.propsForCallNotification, messageId: msg.propsForMessage.id, }, }, diff --git a/ts/test/types/Conversation_test.ts b/ts/test/types/Conversation_test.ts index fb3d27f6f..884f66e13 100644 --- a/ts/test/types/Conversation_test.ts +++ b/ts/test/types/Conversation_test.ts @@ -1,4 +1,5 @@ import { assert } from 'chai'; +import { LastMessageStatusType } from '../../state/ducks/conversations'; import * as Conversation from '../../types/Conversation'; import { IncomingMessage } from '../../types/Message'; @@ -9,8 +10,8 @@ describe('Conversation', () => { const input = {}; const expected = { lastMessage: '', - lastMessageStatus: null, - timestamp: null, + lastMessageStatus: undefined, + timestamp: undefined, }; const actual = Conversation.createLastMessageUpdate(input); @@ -21,7 +22,7 @@ describe('Conversation', () => { it('should update last message text and timestamp', () => { const input = { currentTimestamp: 555, - lastMessageStatus: 'read', + lastMessageStatus: 'read' as LastMessageStatusType, lastMessage: { type: 'outgoing', conversationId: 'foo', @@ -32,7 +33,7 @@ describe('Conversation', () => { }; const expected = { lastMessage: 'New outgoing message', - lastMessageStatus: 'read', + lastMessageStatus: 'read' as LastMessageStatusType, timestamp: 666, }; @@ -60,7 +61,7 @@ describe('Conversation', () => { }; const expected = { lastMessage: 'Last message before expired', - lastMessageStatus: null, + lastMessageStatus: undefined, timestamp: 555, }; diff --git a/ts/types/Conversation.ts b/ts/types/Conversation.ts index 9f3289e3d..e8de28a70 100644 --- a/ts/types/Conversation.ts +++ b/ts/types/Conversation.ts @@ -1,9 +1,10 @@ +import { LastMessageStatusType } from '../state/ducks/conversations'; import { Message } from './Message'; interface ConversationLastMessageUpdate { lastMessage: string; - lastMessageStatus: string | null; - timestamp: number | null; + lastMessageStatus: LastMessageStatusType; + timestamp: number | undefined; } export const createLastMessageUpdate = ({ @@ -14,14 +15,14 @@ export const createLastMessageUpdate = ({ }: { currentTimestamp?: number; lastMessage?: Message; - lastMessageStatus?: string; + lastMessageStatus?: LastMessageStatusType; lastMessageNotificationText?: string; }): ConversationLastMessageUpdate => { if (!lastMessage) { return { lastMessage: '', - lastMessageStatus: null, - timestamp: null, + lastMessageStatus: undefined, + timestamp: undefined, }; } @@ -35,7 +36,7 @@ export const createLastMessageUpdate = ({ return { lastMessage: lastMessageNotificationText || '', - lastMessageStatus: lastMessageStatus || null, - timestamp: newTimestamp || null, + lastMessageStatus: lastMessageStatus || undefined, + timestamp: newTimestamp || undefined, }; }; diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 82d41b78a..487833518 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -51,6 +51,6 @@ type MessageSchemaVersion5 = Partial< export type LokiProfile = { displayName: string; - avatarPointer: string; + avatarPointer?: string; profileKey: Uint8Array | null; }; diff --git a/ts/util/attachmentsUtil.ts b/ts/util/attachmentsUtil.ts index 5cdb01f4e..fc6f04d12 100644 --- a/ts/util/attachmentsUtil.ts +++ b/ts/util/attachmentsUtil.ts @@ -151,7 +151,7 @@ export const saveAttachmentToDisk = async ({ messageSender: string; conversationId: string; }) => { - const decryptedUrl = await getDecryptedMediaUrl(attachment.url, attachment.contentType); + const decryptedUrl = await getDecryptedMediaUrl(attachment.url, attachment.contentType, false); save({ attachment: { ...attachment, url: decryptedUrl }, document,