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
This commit is contained in:
Audric Ackermann 2021-11-29 17:40:46 +11:00 committed by GitHub
parent 56d58a35e5
commit cf44896a03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 692 additions and 737 deletions

View File

@ -460,5 +460,7 @@
"callMissedCausePermission": "Call missed from '$name$' because you need to enable the 'Voice and video calls' permission in the Privacy Settings.", "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", "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.", "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$"
} }

View File

@ -1,7 +1,6 @@
/* global crypto */ /* global crypto */
const { isFunction } = require('lodash'); const { isFunction } = require('lodash');
const { createLastMessageUpdate } = require('../../../ts/types/Conversation');
const { arrayBufferToBase64 } = require('../crypto'); const { arrayBufferToBase64 } = require('../crypto');
async function computeHash(arraybuffer) { async function computeHash(arraybuffer) {
@ -80,6 +79,5 @@ async function deleteExternalFiles(conversation, options = {}) {
module.exports = { module.exports = {
deleteExternalFiles, deleteExternalFiles,
maybeUpdateAvatar, maybeUpdateAvatar,
createLastMessageUpdate,
arrayBufferToBase64, arrayBufferToBase64,
}; };

View File

@ -46,7 +46,6 @@
"integration-test": "mocha --recursive --exit --timeout 30000 \"./ts/test-integration/**/*.test.js\" \"./ts/test/*.test.js\"", "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;", "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", "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", "sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json" "sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json"
}, },

View File

@ -39,7 +39,7 @@ window.isBehindProxy = () => Boolean(config.proxyUrl);
window.lokiFeatureFlags = { window.lokiFeatureFlags = {
useOnionRequests: true, useOnionRequests: true,
useMessageRequests: true, useMessageRequests: false,
useCallMessage: true, useCallMessage: true,
}; };

View File

@ -13,6 +13,7 @@ body {
margin: 0; margin: 0;
font-family: $session-font-default; font-family: $session-font-default;
font-size: 14px; font-size: 14px;
letter-spacing: 0.3px;
} }
// scrollbars // scrollbars
@ -230,6 +231,8 @@ $loading-height: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
user-select: none; user-select: none;
// force this to black, to stay consistent with the password prompt being in dark mode too.
background-color: black;
.content { .content {
margin-inline-start: auto; margin-inline-start: auto;

View File

@ -276,185 +276,6 @@
font-style: italic; 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
.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 { .module-conversation-header__expiration {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -269,7 +269,6 @@ textarea {
.module-conversation-header__title-flex, .module-conversation-header__title-flex,
.module-conversation-header__title { .module-conversation-header__title {
font-family: $session-font-accent;
font-weight: bold; font-weight: bold;
width: 100%; width: 100%;
display: flex; display: flex;
@ -278,7 +277,6 @@ textarea {
&-text { &-text {
@include session-color-subtle(var(--color-text)); @include session-color-subtle(var(--color-text));
font-family: $session-font-default;
font-weight: 300; font-weight: 300;
font-size: $session-font-sm; font-size: $session-font-sm;
line-height: $session-font-sm; line-height: $session-font-sm;

View File

@ -225,26 +225,6 @@
color: $color-light-45; 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
.module-contact-list-item { .module-contact-list-item {

View File

@ -1,11 +1,15 @@
import React, { useCallback, useState } from 'react'; import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { AvatarPlaceHolder, ClosedGroupAvatar } from './AvatarPlaceHolder';
import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch'; import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch';
import _ from 'underscore'; import _ from 'underscore';
import { useMembersAvatars } from '../hooks/useMembersAvatars'; import {
import { useAvatarPath, useConversationUsername } from '../hooks/useParamSelector'; useAvatarPath,
useConversationUsername,
useIsClosedGroup,
} from '../hooks/useParamSelector';
import { AvatarPlaceHolder } from './AvatarPlaceHolder/AvatarPlaceHolder';
import { ClosedGroupAvatar } from './AvatarPlaceHolder/ClosedGroupAvatar';
import { useDisableDrag } from '../hooks/useDisableDrag';
export enum AvatarSize { export enum AvatarSize {
XS = 28, XS = 28,
@ -19,7 +23,7 @@ export enum AvatarSize {
type Props = { type Props = {
forcedAvatarPath?: string | null; forcedAvatarPath?: string | null;
forcedName?: string; forcedName?: string;
pubkey?: string; pubkey: string;
size: AvatarSize; size: AvatarSize;
base64Data?: string; // if this is not empty, it will be used to render the avatar with base64 encoded data base64Data?: string; // if this is not empty, it will be used to render the avatar with base64 encoded data
onAvatarClick?: () => void; onAvatarClick?: () => void;
@ -28,17 +32,10 @@ type Props = {
const Identicon = (props: Props) => { const Identicon = (props: Props) => {
const { size, forcedName, pubkey } = props; const { size, forcedName, pubkey } = props;
const userName = forcedName || '0'; const displayName = useConversationUsername(pubkey);
const userName = forcedName || displayName || '0';
return ( return <AvatarPlaceHolder diameter={size} name={userName} pubkey={pubkey} />;
<AvatarPlaceHolder
diameter={size}
name={userName}
pubkey={pubkey}
colors={['#5ff8b0', '#26cdb9', '#f3c615', '#fcac5a']}
borderColor={'#00000059'}
/>
);
}; };
const NoImage = ( const NoImage = (
@ -66,10 +63,7 @@ const AvatarImage = (props: {
}) => { }) => {
const { avatarPath, base64Data, name, imageBroken, handleImageError } = props; const { avatarPath, base64Data, name, imageBroken, handleImageError } = props;
const onDragStart = useCallback((e: any) => { const disableDrag = useDisableDrag();
e.preventDefault();
return false;
}, []);
if ((!avatarPath && !base64Data) || imageBroken) { if ((!avatarPath && !base64Data) || imageBroken) {
return null; return null;
@ -79,7 +73,7 @@ const AvatarImage = (props: {
return ( return (
<img <img
onError={handleImageError} onError={handleImageError}
onDragStart={onDragStart} onDragStart={disableDrag}
alt={window.i18n('contactAvatarAlt', [name])} alt={window.i18n('contactAvatarAlt', [name])}
src={dataToDisplay} src={dataToDisplay}
/> />
@ -90,13 +84,13 @@ const AvatarInner = (props: Props) => {
const { base64Data, size, pubkey, forcedAvatarPath, forcedName, dataTestId } = props; const { base64Data, size, pubkey, forcedAvatarPath, forcedName, dataTestId } = props;
const [imageBroken, setImageBroken] = useState(false); const [imageBroken, setImageBroken] = useState(false);
const closedGroupMembers = useMembersAvatars(pubkey); const isClosedGroupAvatar = useIsClosedGroup(pubkey);
const avatarPath = useAvatarPath(pubkey); const avatarPath = useAvatarPath(pubkey);
const name = useConversationUsername(pubkey); const name = useConversationUsername(pubkey);
// contentType is not important // contentType is not important
const { urlToLoad } = useEncryptedFileFetch(forcedAvatarPath || avatarPath || '', ''); const { urlToLoad } = useEncryptedFileFetch(forcedAvatarPath || avatarPath || '', '', true);
const handleImageError = () => { const handleImageError = () => {
window.log.warn( window.log.warn(
'Avatar: Image failed to load; failing over to placeholder', 'Avatar: Image failed to load; failing over to placeholder',
@ -106,7 +100,6 @@ const AvatarInner = (props: Props) => {
setImageBroken(true); setImageBroken(true);
}; };
const isClosedGroupAvatar = Boolean(closedGroupMembers?.length);
const hasImage = (base64Data || urlToLoad) && !imageBroken && !isClosedGroupAvatar; const hasImage = (base64Data || urlToLoad) && !imageBroken && !isClosedGroupAvatar;
const isClickable = !!props.onAvatarClick; const isClickable = !!props.onAvatarClick;

View File

@ -4,13 +4,10 @@ import { getInitials } from '../../util/getInitials';
type Props = { type Props = {
diameter: number; diameter: number;
name: string; name: string;
pubkey?: string; pubkey: string;
colors: Array<string>;
borderColor: string;
}; };
const sha512FromPubkey = async (pubkey: string): Promise<string> => { const sha512FromPubkey = async (pubkey: string): Promise<string> => {
// tslint:disable-next-line: await-promise
const buf = await crypto.subtle.digest('SHA-512', new TextEncoder().encode(pubkey)); const buf = await crypto.subtle.digest('SHA-512', new TextEncoder().encode(pubkey));
// tslint:disable: prefer-template restrict-plus-operands // tslint:disable: prefer-template restrict-plus-operands
@ -19,34 +16,69 @@ const sha512FromPubkey = async (pubkey: string): Promise<string> => {
.join(''); .join('');
}; };
export const AvatarPlaceHolder = (props: Props) => { // do not do this on every avatar, just cache the values so we can reuse them accross the app
const { borderColor, colors, pubkey, diameter, name } = props; // key is the pubkey, value is the hash
const [sha512Seed, setSha512Seed] = useState(undefined as string | undefined); const cachedHashes = new Map<string, number>();
const avatarPlaceholderColors = ['#5ff8b0', '#26cdb9', '#f3c615', '#fcac5a'];
const avatarBorderColor = '#00000059';
function useHashBasedOnPubkey(pubkey: string) {
const [hash, setHash] = useState<number | undefined>(undefined);
const [loading, setIsLoading] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
let isSubscribed = true; const cachedHash = cachedHashes.get(pubkey);
if (cachedHash) {
setHash(cachedHash);
setIsLoading(false);
return;
}
setIsLoading(true);
let isInProgress = true;
if (!pubkey) { if (!pubkey) {
if (isSubscribed) { if (isInProgress) {
setSha512Seed(undefined); setIsLoading(false);
setHash(undefined);
} }
return; return;
} }
void sha512FromPubkey(pubkey).then(sha => { void sha512FromPubkey(pubkey).then(sha => {
if (isSubscribed) { if (isInProgress) {
setSha512Seed(sha); 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 () => { 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 diameterWithoutBorder = diameter - 2;
const viewBox = `0 0 ${diameter} ${diameter}`; const viewBox = `0 0 ${diameter} ${diameter}`;
const r = diameter / 2; const r = diameter / 2;
const rWithoutBorder = diameterWithoutBorder / 2; const rWithoutBorder = diameterWithoutBorder / 2;
if (!sha512Seed) { if (loading || !hash) {
// return grey circle // return grey circle
return ( return (
<svg viewBox={viewBox}> <svg viewBox={viewBox}>
@ -57,7 +89,7 @@ export const AvatarPlaceHolder = (props: Props) => {
r={rWithoutBorder} r={rWithoutBorder}
fill="#d2d2d3" fill="#d2d2d3"
shapeRendering="geometricPrecision" shapeRendering="geometricPrecision"
stroke={borderColor} stroke={avatarBorderColor}
strokeWidth="1" strokeWidth="1"
/> />
</g> </g>
@ -68,12 +100,9 @@ export const AvatarPlaceHolder = (props: Props) => {
const initial = getInitials(name)?.toLocaleUpperCase() || '0'; const initial = getInitials(name)?.toLocaleUpperCase() || '0';
const fontSize = diameter * 0.5; const fontSize = diameter * 0.5;
// Generate the seed simulate the .hashCode as Java const bgColorIndex = hash % avatarPlaceholderColors.length;
const hash = parseInt(sha512Seed.substring(0, 12), 16) || 0;
const bgColorIndex = hash % colors.length; const bgColor = avatarPlaceholderColors[bgColorIndex];
const bgColor = colors[bgColorIndex];
return ( return (
<svg viewBox={viewBox}> <svg viewBox={viewBox}>
@ -84,7 +113,7 @@ export const AvatarPlaceHolder = (props: Props) => {
r={rWithoutBorder} r={rWithoutBorder}
fill={bgColor} fill={bgColor}
shapeRendering="geometricPrecision" shapeRendering="geometricPrecision"
stroke={borderColor} stroke={avatarBorderColor}
strokeWidth="1" strokeWidth="1"
/> />
<text <text

View File

@ -36,8 +36,8 @@ export const ClosedGroupAvatar = (props: Props) => {
return ( return (
<div className="module-avatar__icon-closed"> <div className="module-avatar__icon-closed">
<Avatar size={avatarsDiameter} pubkey={firstMemberId} onAvatarClick={onAvatarClick} /> <Avatar size={avatarsDiameter} pubkey={firstMemberId || ''} onAvatarClick={onAvatarClick} />
<Avatar size={avatarsDiameter} pubkey={secondMemberID} onAvatarClick={onAvatarClick} /> <Avatar size={avatarsDiameter} pubkey={secondMemberID || ''} onAvatarClick={onAvatarClick} />
</div> </div>
); );
}; };

View File

@ -1,2 +0,0 @@
export { AvatarPlaceHolder } from './AvatarPlaceHolder';
export { ClosedGroupAvatar } from './ClosedGroupAvatar';

View File

@ -20,7 +20,7 @@ import {
ReduxConversationType, ReduxConversationType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import _ from 'underscore'; import _ from 'underscore';
import { SessionIcon } from './session/icon'; import { SessionIcon, SessionIconButton } from './session/icon';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { SectionType } from '../state/ducks/section'; import { SectionType } from '../state/ducks/section';
import { getFocusedSection } from '../state/selectors/section'; import { getFocusedSection } from '../state/selectors/section';
@ -83,7 +83,7 @@ const HeaderItem = (props: {
const pinIcon = const pinIcon =
isMessagesSection && isPinned ? ( isMessagesSection && isPinned ? (
<SessionIcon iconType="pin" iconColor={'var(--color-text-subtle)'} iconSize={'small'} /> <SessionIcon iconType="pin" iconColor={'var(--color-text-subtle)'} iconSize="small" />
) : null; ) : null;
const NotificationSettingIcon = () => { const NotificationSettingIcon = () => {
@ -96,11 +96,11 @@ const HeaderItem = (props: {
return null; return null;
case 'disabled': case 'disabled':
return ( return (
<SessionIcon iconType="mute" iconColor={'var(--color-text-subtle)'} iconSize={'small'} /> <SessionIcon iconType="mute" iconColor={'var(--color-text-subtle)'} iconSize="small" />
); );
case 'mentions_only': case 'mentions_only':
return ( return (
<SessionIcon iconType="bell" iconColor={'var(--color-text-subtle)'} iconSize={'small'} /> <SessionIcon iconType="bell" iconColor={'var(--color-text-subtle)'} iconSize="small" />
); );
default: default:
return null; return null;

View File

@ -1,76 +0,0 @@
import React from 'react';
import { RenderTextCallbackType } from '../types/Util';
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
interface Props {
/** The translation string id */
id: string;
components?: Array<FullJSX>;
renderText?: RenderTextCallbackType;
}
export class Intl extends React.Component<Props> {
public static defaultProps: Partial<Props> = {
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<any> = [];
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;
}
}

View File

@ -1,6 +1,6 @@
// tslint:disable:react-a11y-anchors // tslint:disable:react-a11y-anchors
import React, { useCallback, useRef } from 'react'; import React, { useRef } from 'react';
import is from '@sindresorhus/is'; import is from '@sindresorhus/is';
@ -14,6 +14,7 @@ import useUnmount from 'react-use/lib/useUnmount';
import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch'; import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { showLightBox } from '../state/ducks/conversations'; import { showLightBox } from '../state/ducks/conversations';
import { useDisableDrag } from '../hooks/useDisableDrag';
const Colors = { const Colors = {
TEXT_SECONDARY: '#bbb', TEXT_SECONDARY: '#bbb',
@ -204,15 +205,10 @@ export const LightboxObject = ({
renderedRef: React.MutableRefObject<any>; renderedRef: React.MutableRefObject<any>;
onObjectClick: (event: any) => any; onObjectClick: (event: any) => any;
}) => { }) => {
const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType); const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType, false);
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType); const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
const onDragStart = useCallback((e: any) => {
e.preventDefault();
return false;
}, []);
// auto play video on showing a video attachment // auto play video on showing a video attachment
useUnmount(() => { useUnmount(() => {
if (!renderedRef?.current) { if (!renderedRef?.current) {
@ -220,12 +216,13 @@ export const LightboxObject = ({
} }
renderedRef.current.pause.pause(); renderedRef.current.pause.pause();
}); });
const disableDrag = useDisableDrag();
if (isImageTypeSupported) { if (isImageTypeSupported) {
return ( return (
<img <img
style={styles.object as any} style={styles.object as any}
onDragStart={onDragStart} onDragStart={disableDrag}
alt={window.i18n('lightboxImageAlt')} alt={window.i18n('lightboxImageAlt')}
src={urlToLoad} src={urlToLoad}
ref={renderedRef} ref={renderedRef}

View File

@ -36,6 +36,7 @@ import {
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import { callRecipient } from '../../interactions/conversationInteractions'; import { callRecipient } from '../../interactions/conversationInteractions';
import { getHasIncomingCall, getHasOngoingCall } from '../../state/selectors/call'; import { getHasIncomingCall, getHasOngoingCall } from '../../state/selectors/call';
import { useConversationUsername } from '../../hooks/useParamSelector';
export interface TimerOption { export interface TimerOption {
name: string; name: string;
@ -191,7 +192,7 @@ const BackButton = (props: { onGoBack: () => void; showBackButton: boolean }) =>
} }
return ( return (
<SessionIconButton iconType="chevron" iconSize={'large'} iconRotation={90} onClick={onGoBack} /> <SessionIconButton iconType="chevron" iconSize="large" iconRotation={90} onClick={onGoBack} />
); );
}; };
@ -249,22 +250,14 @@ const ConversationHeaderTitle = () => {
const headerTitleProps = useSelector(getConversationHeaderTitleProps); const headerTitleProps = useSelector(getConversationHeaderTitleProps);
const notificationSetting = useSelector(getCurrentNotificationSettingText); const notificationSetting = useSelector(getCurrentNotificationSettingText);
const isRightPanelOn = useSelector(isRightPanelShowing); const isRightPanelOn = useSelector(isRightPanelShowing);
const convoName = useConversationUsername(headerTitleProps?.conversationKey);
const dispatch = useDispatch(); const dispatch = useDispatch();
if (!headerTitleProps) { if (!headerTitleProps) {
return null; return null;
} }
const { const { isGroup, isPublic, members, subscriberCount, isMe, isKickedFromGroup } = headerTitleProps;
conversationKey,
profileName,
isGroup,
isPublic,
members,
subscriberCount,
isMe,
isKickedFromGroup,
name,
} = headerTitleProps;
const { i18n } = window; const { i18n } = window;
@ -294,8 +287,6 @@ const ConversationHeaderTitle = () => {
? `${memberCountText}${notificationSubtitle}` ? `${memberCountText}${notificationSubtitle}`
: `${notificationSubtitle}`; : `${notificationSubtitle}`;
const title = profileName || name || conversationKey;
return ( return (
<div <div
className="module-conversation-header__title" className="module-conversation-header__title"
@ -308,7 +299,7 @@ const ConversationHeaderTitle = () => {
}} }}
role="button" role="button"
> >
<span className="module-contact-name__profile-name">{title}</span> <span className="module-contact-name__profile-name">{convoName}</span>
<StyledSubtitleContainer> <StyledSubtitleContainer>
<ConversationHeaderSubtitle text={fullTextSubtitle} /> <ConversationHeaderSubtitle text={fullTextSubtitle} />
</StyledSubtitleContainer> </StyledSubtitleContainer>

View File

@ -31,7 +31,7 @@ export const DataExtractionNotification = (props: PropsForDataExtractionNotifica
margin={'var(--margins-sm)'} margin={'var(--margins-sm)'}
id={`msg-${messageId}`} id={`msg-${messageId}`}
> >
<SessionIcon iconType="upload" iconSize={'small'} iconRotation={180} /> <SessionIcon iconType="upload" iconSize="small" iconRotation={180} />
<SpacerSM /> <SpacerSM />
<Text text={contentText} subtle={true} /> <Text text={contentText} subtle={true} />
</Flex> </Flex>

View File

@ -64,7 +64,7 @@ export const ExpireTimer = (props: Props) => {
return ( return (
<ExpireTimerBucket> <ExpireTimerBucket>
<SessionIcon iconType={bucket} iconSize={'tiny'} iconColor={expireTimerColor} /> <SessionIcon iconType={bucket} iconSize="tiny" iconColor={expireTimerColor} />
</ExpireTimerBucket> </ExpireTimerBucket>
); );
}; };

View File

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { flatten } from 'lodash';
import { Intl } from '../Intl';
import { import {
PropsForGroupUpdate, PropsForGroupUpdate,
PropsForGroupUpdateAdd, PropsForGroupUpdateAdd,
@ -11,6 +9,7 @@ import {
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import _ from 'underscore'; import _ from 'underscore';
import { ReadableMessage } from './ReadableMessage'; import { ReadableMessage } from './ReadableMessage';
import { NotificationBubble } from './notification-bubble/NotificationBubble';
// This component is used to display group updates in the conversation view. // 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 // 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) { function getPeople(change: TypeWithContacts) {
return _.compact( return change.contacts?.map(c => c.profileName || c.pubkey).join(', ');
flatten(
(change.contacts || []).map((contact, index) => {
const element = (
<span key={`external-${contact.pubkey}`} className="module-group-notification__contact">
{contact.profileName || contact.pubkey}
</span>
);
return [index > 0 ? ', ' : null, element];
})
)
);
} }
function renderChange(change: PropsForGroupUpdateType) { const ChangeItem = (change: PropsForGroupUpdateType): string => {
const people = isTypeWithContact(change) ? getPeople(change) : []; const people = isTypeWithContact(change) ? getPeople(change) : [];
switch (change.type) { switch (change.type) {
case 'name': case 'name':
return `${window.i18n('titleIsNow', [change.newName || ''])}`; return window.i18n('titleIsNow', change.newName || '');
case 'add': case 'add':
if (!change.contacts || !change.contacts.length) { if (!change.contacts || !change.contacts.length) {
throw new Error('Group update add is missing contacts'); throw new Error('Group update add is missing contacts');
} }
const joinKey = change.contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'; const joinKey = change.contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup';
return window.i18n(joinKey, people);
return <Intl id={joinKey} components={[people]} />;
case 'remove': case 'remove':
if (change.isMe) { if (change.isMe) {
return window.i18n('youLeftTheGroup'); return window.i18n('youLeftTheGroup');
@ -63,8 +50,8 @@ function renderChange(change: PropsForGroupUpdateType) {
} }
const leftKey = change.contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; const leftKey = change.contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
return window.i18n(leftKey, people);
return <Intl id={leftKey} components={[people]} />;
case 'kicked': case 'kicked':
if (change.isMe) { if (change.isMe) {
return window.i18n('youGotKickedFromGroup'); return window.i18n('youGotKickedFromGroup');
@ -76,17 +63,20 @@ function renderChange(change: PropsForGroupUpdateType) {
const kickedKey = const kickedKey =
change.contacts.length > 1 ? 'multipleKickedFromTheGroup' : 'kickedFromTheGroup'; change.contacts.length > 1 ? 'multipleKickedFromTheGroup' : 'kickedFromTheGroup';
return window.i18n(kickedKey, people);
return <Intl id={kickedKey} components={[people]} />;
case 'general': case 'general':
return window.i18n('updatedTheGroup'); return window.i18n('updatedTheGroup');
default: default:
window.log.error('Missing case error'); throw new Error('Missing case error');
} }
} };
export const GroupNotification = (props: PropsForGroupUpdate) => { export const GroupNotification = (props: PropsForGroupUpdate) => {
const { changes, messageId, receivedAt, isUnread } = props; const { changes, messageId, receivedAt, isUnread } = props;
const textChange = changes.map(ChangeItem)[0];
return ( return (
<ReadableMessage <ReadableMessage
messageId={messageId} messageId={messageId}
@ -94,13 +84,7 @@ export const GroupNotification = (props: PropsForGroupUpdate) => {
isUnread={isUnread} isUnread={isUnread}
key={`readable-message-${messageId}`} key={`readable-message-${messageId}`}
> >
<div className="module-group-notification" id={`msg-${props.messageId}`}> <NotificationBubble notificationText={textChange} iconType="users" />
{(changes || []).map((change, index) => (
<div key={index} className="module-group-notification__change">
{renderChange(change)}
</div>
))}
</div>
</ReadableMessage> </ReadableMessage>
); );
}; };

View File

@ -19,7 +19,7 @@ export const AudioPlayerWithEncryptedFile = (props: {
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [playbackSpeed, setPlaybackSpeed] = useState(1.0); const [playbackSpeed, setPlaybackSpeed] = useState(1.0);
const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType); const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType, false);
const player = useRef<H5AudioPlayer | null>(null); const player = useRef<H5AudioPlayer | null>(null);
const autoPlaySetting = useSelector(getAudioAutoplay); const autoPlaySetting = useSelector(getAudioAutoplay);
@ -104,10 +104,10 @@ export const AudioPlayerWithEncryptedFile = (props: {
]} ]}
customIcons={{ customIcons={{
play: ( play: (
<SessionIcon iconType="play" iconSize={'small'} iconColor={'var(--color-text-subtle)'} /> <SessionIcon iconType="play" iconSize="small" iconColor={'var(--color-text-subtle)'} />
), ),
pause: ( pause: (
<SessionIcon iconType="pause" iconSize={'small'} iconColor={'var(--color-text-subtle)'} /> <SessionIcon iconType="pause" iconSize="small" iconColor={'var(--color-text-subtle)'} />
), ),
}} }}
/> />

View File

@ -4,6 +4,7 @@ import classNames from 'classnames';
import { Spinner } from '../basic/Spinner'; import { Spinner } from '../basic/Spinner';
import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment'; import { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
import { useDisableDrag } from '../../hooks/useDisableDrag';
type Props = { type Props = {
alt: string; alt: string;
@ -48,17 +49,13 @@ export const Image = (props: Props) => {
width, width,
} = props; } = props;
const onDragStart = useCallback((e: any) => {
e.preventDefault();
return false;
}, []);
const onErrorUrlFilterering = useCallback(() => { const onErrorUrlFilterering = useCallback(() => {
if (url && onError) { if (url && onError) {
onError(); onError();
} }
return; return;
}, [url, onError]); }, [url, onError]);
const disableDrag = useDisableDrag();
const { caption } = attachment || { caption: null }; const { caption } = attachment || { caption: null };
let { pending } = attachment || { pending: true }; let { pending } = attachment || { pending: true };
@ -68,7 +65,7 @@ export const Image = (props: Props) => {
} }
const canClick = onClick && !pending; const canClick = onClick && !pending;
const role = canClick ? 'button' : undefined; 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 // data will be url if loading is finished and '' if not
const srcData = !loading ? urlToLoad : ''; const srcData = !loading ? urlToLoad : '';
@ -118,7 +115,7 @@ export const Image = (props: Props) => {
height: forceSquare ? `${height}px` : '', height: forceSquare ? `${height}px` : '',
}} }}
src={srcData} src={srcData}
onDragStart={onDragStart} onDragStart={disableDrag}
/> />
)} )}
{caption ? ( {caption ? (
@ -126,7 +123,7 @@ export const Image = (props: Props) => {
className="module-image__caption-icon" className="module-image__caption-icon"
src="images/caption-shadow.svg" src="images/caption-shadow.svg"
alt={window.i18n('imageCaptionIconAlt')} alt={window.i18n('imageCaptionIconAlt')}
onDragStart={onDragStart} onDragStart={disableDrag}
/> />
) : null} ) : null}
<div <div

View File

@ -13,7 +13,7 @@ import {
} from '../../state/selectors/conversations'; } from '../../state/selectors/conversations';
import { deleteMessagesById } from '../../interactions/conversations/unsendingInteractions'; import { deleteMessagesById } from '../../interactions/conversations/unsendingInteractions';
const AvatarItem = (props: { pubkey: string | undefined }) => { const AvatarItem = (props: { pubkey: string }) => {
const { pubkey } = props; const { pubkey } = props;
return <Avatar size={AvatarSize.S} pubkey={pubkey} />; return <Avatar size={AvatarSize.S} pubkey={pubkey} />;

View File

@ -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 (
<ReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
>
<StyledFakeMessageBubble>{window.i18n('callMissed', displayName)}</StyledFakeMessageBubble>
</ReadableMessage>
);
};

View File

@ -1,6 +1,4 @@
// tslint:disable:react-this-binding-issue import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as MIME from '../../../ts/types/MIME'; import * as MIME from '../../../ts/types/MIME';
@ -18,6 +16,7 @@ import {
isPublicGroupConversation, isPublicGroupConversation,
} from '../../state/selectors/conversations'; } from '../../state/selectors/conversations';
import { noop } from 'underscore'; import { noop } from 'underscore';
import { useDisableDrag } from '../../hooks/useDisableDrag';
export type QuotePropsWithoutListener = { export type QuotePropsWithoutListener = {
attachment?: QuotedAttachmentType; attachment?: QuotedAttachmentType;
@ -116,15 +115,11 @@ export const QuoteImage = (props: {
icon?: string; icon?: string;
}) => { }) => {
const { url, icon, contentType, handleImageErrorBound } = props; 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 srcData = !loading ? urlToLoad : '';
const onDragStart = useCallback((e: any) => {
e.preventDefault();
return false;
}, []);
const iconElement = icon ? ( const iconElement = icon ? (
<div className="module-quote__icon-container__inner"> <div className="module-quote__icon-container__inner">
<div className="module-quote__icon-container__circle-background"> <div className="module-quote__icon-container__circle-background">
@ -143,7 +138,7 @@ export const QuoteImage = (props: {
<img <img
src={srcData} src={srcData}
alt={window.i18n('quoteThumbnailAlt')} alt={window.i18n('quoteThumbnailAlt')}
onDragStart={onDragStart} onDragStart={disableDrag}
onError={handleImageErrorBound} onError={handleImageErrorBound}
/> />
{iconElement} {iconElement}

View File

@ -131,6 +131,7 @@ export const ReadableMessage = (props: ReadableMessageProps) => {
onChange={haveDoneFirstScroll && isAppFocused ? onVisible : noop} onChange={haveDoneFirstScroll && isAppFocused ? onVisible : noop}
triggerOnce={false} triggerOnce={false}
trackVisibility={true} trackVisibility={true}
key={`inview-msg-${messageId}`}
> >
{props.children} {props.children}
</InView> </InView>

View File

@ -1,41 +1,39 @@
import React from 'react'; import React from 'react';
import { Intl } from '../Intl';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { SessionIcon } from '../session/icon';
import { PropsForExpirationTimer } from '../../state/ducks/conversations'; import { PropsForExpirationTimer } from '../../state/ducks/conversations';
import { ReadableMessage } from './ReadableMessage'; import { ReadableMessage } from './ReadableMessage';
import { NotificationBubble } from './notification-bubble/NotificationBubble';
const TimerNotificationContent = (props: PropsForExpirationTimer) => { export const TimerNotification = (props: PropsForExpirationTimer) => {
const { pubkey, profileName, timespan, type, disabled } = props; const { messageId, receivedAt, isUnread, pubkey, profileName, timespan, type, disabled } = props;
const changeKey = disabled ? 'disabledDisappearingMessages' : 'theyChangedTheTimer';
const contact = ( const contact = profileName || pubkey;
<span key={`external-${pubkey}`} className="module-timer-notification__contact">
{profileName || pubkey}
</span>
);
let textToRender: string | undefined;
switch (type) { switch (type) {
case 'fromOther': case 'fromOther':
return <Intl id={changeKey} components={[contact, timespan]} />; textToRender = disabled
? window.i18n('disabledDisappearingMessages', [contact, timespan])
: window.i18n('theyChangedTheTimer', [contact, timespan]);
break;
case 'fromMe': case 'fromMe':
return disabled textToRender = disabled
? window.i18n('youDisabledDisappearingMessages') ? window.i18n('youDisabledDisappearingMessages')
: window.i18n('youChangedTheTimer', [timespan]); : window.i18n('youChangedTheTimer', [timespan]);
break;
case 'fromSync': case 'fromSync':
return disabled textToRender = disabled
? window.i18n('disappearingMessagesDisabled') ? window.i18n('disappearingMessagesDisabled')
: window.i18n('timerSetOnSync', [timespan]); : window.i18n('timerSetOnSync', [timespan]);
break;
default: default:
throw missingCaseError(type); 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 ( return (
<ReadableMessage <ReadableMessage
messageId={messageId} messageId={messageId}
@ -43,17 +41,11 @@ export const TimerNotification = (props: PropsForExpirationTimer) => {
isUnread={isUnread} isUnread={isUnread}
key={`readable-message-${messageId}`} key={`readable-message-${messageId}`}
> >
<div className="module-timer-notification" id={`msg-${props.messageId}`}> <NotificationBubble
<div className="module-timer-notification__message"> iconType="stopwatch"
<div> iconColor="inherit"
<SessionIcon iconType="stopwatch" iconSize={'small'} iconColor={'#ABABAB'} /> notificationText={textToRender}
</div> />
<div>
<TimerNotificationContent {...props} />
</div>
</div>
</div>
</ReadableMessage> </ReadableMessage>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isImageTypeSupported, isVideoTypeSupported } from '../../../util/GoogleChrome'; import { isImageTypeSupported, isVideoTypeSupported } from '../../../util/GoogleChrome';
@ -6,6 +6,7 @@ import { MediaItemType } from '../../LightboxGallery';
import { useEncryptedFileFetch } from '../../../hooks/useEncryptedFileFetch'; import { useEncryptedFileFetch } from '../../../hooks/useEncryptedFileFetch';
import { showLightBox } from '../../../state/ducks/conversations'; import { showLightBox } from '../../../state/ducks/conversations';
import { LightBoxOptions } from '../../session/conversation/SessionConversation'; import { LightBoxOptions } from '../../session/conversation/SessionConversation';
import { useDisableDrag } from '../../../hooks/useDisableDrag';
type Props = { type Props = {
mediaItem: MediaItemType; mediaItem: MediaItemType;
@ -20,14 +21,11 @@ const MediaGridItemContent = (props: Props) => {
const urlToDecrypt = mediaItem.thumbnailObjectUrl || ''; const urlToDecrypt = mediaItem.thumbnailObjectUrl || '';
const [imageBroken, setImageBroken] = useState(false); 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 // data will be url if loading is finished and '' if not
const srcData = !loading ? urlToLoad : ''; const srcData = !loading ? urlToLoad : '';
const disableDrag = useDisableDrag();
const onImageError = () => { const onImageError = () => {
// tslint:disable-next-line no-console // tslint:disable-next-line no-console
@ -57,7 +55,7 @@ const MediaGridItemContent = (props: Props) => {
className="module-media-grid-item__image" className="module-media-grid-item__image"
src={srcData} src={srcData}
onError={onImageError} onError={onImageError}
onDragStart={onDragStart} onDragStart={disableDrag}
/> />
); );
} else if (contentType && isVideoTypeSupported(contentType)) { } else if (contentType && isVideoTypeSupported(contentType)) {
@ -79,7 +77,7 @@ const MediaGridItemContent = (props: Props) => {
className="module-media-grid-item__image" className="module-media-grid-item__image"
src={srcData} src={srcData}
onError={onImageError} onError={onImageError}
onDragStart={onDragStart} onDragStart={disableDrag}
/> />
<div className="module-media-grid-item__circle-overlay"> <div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" /> <div className="module-media-grid-item__play-overlay" />

View File

@ -110,7 +110,7 @@ export const ClickToTrustSender = (props: { messageId: string }) => {
return ( return (
<StyledTrustSenderUI onClick={openConfirmationModal}> <StyledTrustSenderUI onClick={openConfirmationModal}>
<SessionIcon iconSize={'small'} iconType="gallery" /> <SessionIcon iconSize="small" iconType="gallery" />
<ClickToDownload>{window.i18n('clickToTrustContact')}</ClickToDownload> <ClickToDownload>{window.i18n('clickToTrustContact')}</ClickToDownload>
</StyledTrustSenderUI> </StyledTrustSenderUI>
); );

View File

@ -45,8 +45,22 @@ export const MessageContentWithStatuses = (props: Props) => {
[window.contextMenuShown, props?.messageId, multiSelectMode, props?.isDetailView] [window.contextMenuShown, props?.messageId, multiSelectMode, props?.isDetailView]
); );
const onDoubleClickReplyToMessage = () => { const onDoubleClickReplyToMessage = (e: React.MouseEvent<HTMLDivElement>) => {
void replyToMessage(messageId); 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; const { messageId, onQuoteClick, ctxMenuID, isDetailView } = props;
@ -61,7 +75,7 @@ export const MessageContentWithStatuses = (props: Props) => {
className={classNames('module-message', `module-message--${direction}`)} className={classNames('module-message', `module-message--${direction}`)}
role="button" role="button"
onClick={onClickOnMessageOuterContainer} onClick={onClickOnMessageOuterContainer}
onDoubleClick={onDoubleClickReplyToMessage} onDoubleClickCapture={onDoubleClickReplyToMessage}
style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }} style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }}
> >
<MessageStatus messageId={messageId} isCorrectSide={isIncoming} /> <MessageStatus messageId={messageId} isCorrectSide={isIncoming} />

View File

@ -63,7 +63,7 @@ export const MessagePreview = (props: Props) => {
<div className="module-message__link-preview__icon_container"> <div className="module-message__link-preview__icon_container">
<div className="module-message__link-preview__icon_container__inner"> <div className="module-message__link-preview__icon_container__inner">
<div className="module-message__link-preview__icon-container__circle-background"> <div className="module-message__link-preview__icon-container__circle-background">
<SessionIcon iconType="link" iconSize={'small'} /> <SessionIcon iconType="link" iconSize="small" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,7 +16,7 @@ const MessageStatusSending = () => {
const iconColor = 'var(--color-text)'; const iconColor = 'var(--color-text)';
return ( return (
<MessageStatusSendingContainer> <MessageStatusSendingContainer>
<SessionIcon rotateDuration={2} iconColor={iconColor} iconType="sending" iconSize={'tiny'} /> <SessionIcon rotateDuration={2} iconColor={iconColor} iconType="sending" iconSize="tiny" />
</MessageStatusSendingContainer> </MessageStatusSendingContainer>
); );
}; };
@ -26,7 +26,7 @@ const MessageStatusSent = () => {
return ( return (
<MessageStatusSendingContainer> <MessageStatusSendingContainer>
<SessionIcon iconColor={iconColor} iconType="circleCheck" iconSize={'tiny'} /> <SessionIcon iconColor={iconColor} iconType="circleCheck" iconSize="tiny" />
</MessageStatusSendingContainer> </MessageStatusSendingContainer>
); );
}; };
@ -36,7 +36,7 @@ const MessageStatusRead = () => {
return ( return (
<MessageStatusSendingContainer> <MessageStatusSendingContainer>
<SessionIcon iconColor={iconColor} iconType="doubleCheckCircleFilled" iconSize={'tiny'} /> <SessionIcon iconColor={iconColor} iconType="doubleCheckCircleFilled" iconSize="tiny" />
</MessageStatusSendingContainer> </MessageStatusSendingContainer>
); );
}; };
@ -48,7 +48,7 @@ const MessageStatusError = () => {
return ( return (
<MessageStatusSendingContainer onClick={showDebugLog} title={window.i18n('sendFailed')}> <MessageStatusSendingContainer onClick={showDebugLog} title={window.i18n('sendFailed')}>
<SessionIcon iconColor={'var(--color-destructive'} iconType="error" iconSize={'tiny'} /> <SessionIcon iconColor={'var(--color-destructive'} iconType="error" iconSize="tiny" />
</MessageStatusSendingContainer> </MessageStatusSendingContainer>
); );
}; };

View File

@ -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 (
<ReadableMessage
messageId={messageId}
receivedAt={receivedAt}
isUnread={isUnread}
key={`readable-message-${messageId}`}
>
<NotificationBubble
notificationText={notificationText}
iconType={iconType}
iconColor={iconColor}
/>
</ReadableMessage>
);
};

View File

@ -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 (
<NotificationBubbleFlex>
{iconType && (
<NotificationBubbleIconContainer>
<SessionIcon
iconSize="small"
iconType={iconType}
iconColor={iconColor}
iconPadding="auto 10px"
/>
</NotificationBubbleIconContainer>
)}
<NotificationBubbleText>{notificationText}</NotificationBubbleText>
{iconType && <NotificationBubbleIconContainer />}
</NotificationBubbleFlex>
);
};

View File

@ -79,7 +79,7 @@ export class SessionModal extends React.PureComponent<Props, State> {
<div className={classNames('session-modal__header', headerReverse && 'reverse')}> <div className={classNames('session-modal__header', headerReverse && 'reverse')}>
<div className="session-modal__header__close"> <div className="session-modal__header__close">
{showExitIcon ? ( {showExitIcon ? (
<SessionIconButton iconType="exit" iconSize={'small'} onClick={this.close} /> <SessionIconButton iconType="exit" iconSize="small" onClick={this.close} />
) : null} ) : null}
</div> </div>
<div className="session-modal__header__title">{title}</div> <div className="session-modal__header__title">{title}</div>

View File

@ -56,7 +56,7 @@ export const LeftPaneSectionHeader = (props: Props) => {
{label && <Tab label={label} type={0} isSelected={true} key={label} />} {label && <Tab label={label} type={0} isSelected={true} key={label} />}
{buttonIcon && ( {buttonIcon && (
<SessionButton onClick={buttonClicked} key="compose"> <SessionButton onClick={buttonClicked} key="compose">
<SessionIcon iconType={buttonIcon} iconSize={'small'} iconColor="white" /> <SessionIcon iconType={buttonIcon} iconSize="small" iconColor="white" />
</SessionButton> </SessionButton>
)} )}
</div> </div>
@ -80,6 +80,7 @@ const BannerInner = () => {
buttonType={SessionButtonType.Default} buttonType={SessionButtonType.Default}
text={window.i18n('recoveryPhraseRevealButtonText')} text={window.i18n('recoveryPhraseRevealButtonText')}
onClick={showRecoveryPhraseModal} onClick={showRecoveryPhraseModal}
dataTestId="reveal-recovery-phrase"
/> />
</StyledBannerInner> </StyledBannerInner>
); );

View File

@ -28,15 +28,16 @@ type Props = {
buttonColor: SessionButtonColor; buttonColor: SessionButtonColor;
onClick: any; onClick: any;
children?: ReactNode; children?: ReactNode;
dataTestId?: string;
}; };
export const SessionButton = (props: Props) => { export const SessionButton = (props: Props) => {
const { buttonType, buttonColor, text, disabled } = props; const { buttonType, dataTestId, buttonColor, text, disabled, onClick } = props;
const clickHandler = (e: any) => { const clickHandler = (e: any) => {
if (props.onClick) { if (onClick) {
e.stopPropagation(); e.stopPropagation();
props.onClick(); onClick();
} }
}; };
@ -53,6 +54,7 @@ export const SessionButton = (props: Props) => {
className={classNames('session-button', ...buttonTypes, buttonColor, disabled && 'disabled')} className={classNames('session-button', ...buttonTypes, buttonColor, disabled && 'disabled')}
role="button" role="button"
onClick={onClickFn} onClick={onClickFn}
data-testid={dataTestId}
> >
{props.children || text} {props.children || text}
</div> </div>

View File

@ -156,7 +156,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
return ( return (
<div className="module-left-pane-overlay"> <div className="module-left-pane-overlay">
<div className="exit"> <div className="exit">
<SessionIconButton iconSize={'small'} iconType="exit" onClick={onCloseClick} /> <SessionIconButton iconSize="small" iconType="exit" onClick={onCloseClick} />
</div> </div>
<SpacerMD /> <SpacerMD />

View File

@ -34,7 +34,7 @@ export const SessionDropdown = (props: Props) => {
role="button" role="button"
> >
{label} {label}
<SessionIcon iconType="chevron" iconSize={'small'} iconRotation={chevronOrientation} /> <SessionIcon iconType="chevron" iconSize="small" iconRotation={chevronOrientation} />
</div> </div>
{expanded && ( {expanded && (

View File

@ -36,7 +36,7 @@ export const SessionDropdownItem = (props: Props) => {
role="button" role="button"
onClick={clickHandler} onClick={clickHandler}
> >
{icon ? <SessionIcon iconType={icon} iconSize={'small'} /> : ''} {icon ? <SessionIcon iconType={icon} iconSize="small" /> : ''}
<div className="item-content">{content}</div> <div className="item-content">{content}</div>
</div> </div>
); );

View File

@ -1,9 +1,9 @@
import React from 'react'; import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { SessionIconButton } from './icon'; import { SessionIconButton } from './icon';
interface Props { type Props = {
label?: string; label?: string;
error?: string; error?: string;
type?: string; type?: string;
@ -15,117 +15,100 @@ interface Props {
onEnterPressed?: any; onEnterPressed?: any;
autoFocus?: boolean; autoFocus?: boolean;
ref?: any; ref?: any;
} inputDataTestId?: string;
};
interface State { const LabelItem = (props: { inputValue: string; label?: string }) => {
inputValue: string; return (
forceShow: boolean; <label
} htmlFor="session-input-floating-label"
className={classNames(
props.inputValue !== ''
? 'session-input-with-label-container filled'
: 'session-input-with-label-container'
)}
>
{props.label}
</label>
);
};
export class SessionInput extends React.PureComponent<Props, State> { const ErrorItem = (props: { error: string | undefined }) => {
constructor(props: any) { return (
super(props); <label
htmlFor="session-input-floating-label"
className={classNames('session-input-with-label-container filled error')}
>
{props.error}
</label>
);
};
this.updateInputValue = this.updateInputValue.bind(this); const ShowHideButton = (props: { toggleForceShow: () => void }) => {
this.renderShowHideButton = this.renderShowHideButton.bind(this); return <SessionIconButton iconType="eye" iconSize="medium" onClick={props.toggleForceShow} />;
};
this.state = { export const SessionInput = (props: Props) => {
inputValue: '', const {
forceShow: false, autoFocus,
}; placeholder,
} type,
value,
maxLength,
enableShowHide,
error,
label,
onValueChanged,
inputDataTestId,
} = props;
const [inputValue, setInputValue] = useState('');
const [forceShow, setForceShow] = useState(false);
public render() { const correctType = forceShow ? 'text' : type;
const { autoFocus, placeholder, type, value, maxLength, enableShowHide, error } = this.props;
const { forceShow } = this.state;
const correctType = forceShow ? 'text' : type; const updateInputValue = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const val = e.target.value;
setInputValue(val);
if (onValueChanged) {
onValueChanged(val);
}
};
return ( return (
<div className="session-input-with-label-container"> <div className="session-input-with-label-container">
{error ? this.renderError() : this.renderLabel()} {error ? (
<input <ErrorItem error={props.error} />
id="session-input-floating-label" ) : (
type={correctType} <LabelItem inputValue={inputValue} label={label} />
placeholder={placeholder} )}
value={value} <input
maxLength={maxLength} id="session-input-floating-label"
autoFocus={autoFocus} type={correctType}
onChange={e => { placeholder={placeholder}
this.updateInputValue(e); value={value}
}} maxLength={maxLength}
className={classNames(enableShowHide ? 'session-input-floating-label-show-hide' : '')} autoFocus={autoFocus}
// just incase onChange isn't triggered data-testid={inputDataTestId}
onBlur={e => { onChange={updateInputValue}
this.updateInputValue(e); className={classNames(enableShowHide ? 'session-input-floating-label-show-hide' : '')}
}} // just incase onChange isn't triggered
onKeyPress={event => { onBlur={updateInputValue}
if (event.key === 'Enter' && this.props.onEnterPressed) { onKeyPress={event => {
this.props.onEnterPressed(); if (event.key === 'Enter' && props.onEnterPressed) {
} props.onEnterPressed();
}} }
/>
{enableShowHide && this.renderShowHideButton()}
<hr />
</div>
);
}
private renderLabel() {
const { inputValue } = this.state;
const { label } = this.props;
return (
<label
htmlFor="session-input-floating-label"
className={classNames(
inputValue !== ''
? 'session-input-with-label-container filled'
: 'session-input-with-label-container'
)}
>
{label}
</label>
);
}
private renderError() {
const { error } = this.props;
return (
<label
htmlFor="session-input-floating-label"
className={classNames('session-input-with-label-container filled error')}
>
{error}
</label>
);
}
private renderShowHideButton() {
return (
<SessionIconButton
iconType="eye"
iconSize="medium"
onClick={() => {
this.setState({
forceShow: !this.state.forceShow,
});
}} }}
/> />
);
}
private updateInputValue(e: any) { {enableShowHide && (
e.preventDefault(); <ShowHideButton
this.setState({ toggleForceShow={() => {
inputValue: e.target.value, setForceShow(!forceShow);
}); }}
/>
if (this.props.onValueChanged) { )}
this.props.onValueChanged(e.target.value); <hr />
} </div>
} );
} };

View File

@ -74,6 +74,7 @@ const SessionJoinableRoomAvatar = (props: JoinableRoomProps) => {
size={AvatarSize.XS} size={AvatarSize.XS}
base64Data={props.base64Data} base64Data={props.base64Data}
{...props} {...props}
pubkey=""
onAvatarClick={() => props.onClick(props.completeUrl)} onAvatarClick={() => props.onClick(props.completeUrl)}
/> />
); );

View File

@ -27,7 +27,7 @@ type Props = {
onUnselect?: (selectedMember: ContactType) => void; onUnselect?: (selectedMember: ContactType) => void;
}; };
const AvatarItem = (props: { memberPubkey?: string }) => { const AvatarItem = (props: { memberPubkey: string }) => {
return <Avatar size={AvatarSize.XS} pubkey={props.memberPubkey} />; return <Avatar size={AvatarSize.XS} pubkey={props.memberPubkey} />;
}; };

View File

@ -81,7 +81,7 @@ export const SessionWrapperModal = (props: SessionWrapperModalType) => {
<div className={classNames('session-modal__header', headerReverse && 'reverse')}> <div className={classNames('session-modal__header', headerReverse && 'reverse')}>
<div className="session-modal__header__close"> <div className="session-modal__header__close">
{showExitIcon ? ( {showExitIcon ? (
<SessionIconButton iconType="exit" iconSize={'small'} onClick={props.onClose} /> <SessionIconButton iconType="exit" iconSize="small" onClick={props.onClose} />
) : null} ) : null}
</div> </div>
<div className="session-modal__header__title">{title}</div> <div className="session-modal__header__title">{title}</div>

View File

@ -135,7 +135,7 @@ export const DraggableCallContainer = () => {
autoPlay={true} autoPlay={true}
isVideoMuted={remoteStreamVideoIsMuted} isVideoMuted={remoteStreamVideoIsMuted}
/> />
{remoteStreamVideoIsMuted && ( {remoteStreamVideoIsMuted && ongoingCallPubkey && (
<CenteredAvatarInDraggable> <CenteredAvatarInDraggable>
<Avatar size={AvatarSize.XL} pubkey={ongoingCallPubkey} /> <Avatar size={AvatarSize.XL} pubkey={ongoingCallPubkey} />
</CenteredAvatarInDraggable> </CenteredAvatarInDraggable>

View File

@ -158,7 +158,7 @@ export const InConversationCallContainer = () => {
videoRefRemote.current.muted = true; videoRefRemote.current.muted = true;
} }
if (!ongoingCallWithFocused) { if (!ongoingCallWithFocused || !ongoingCallPubkey) {
return null; return null;
} }

View File

@ -6,6 +6,7 @@ import _ from 'underscore';
import { useConversationUsername } from '../../../hooks/useParamSelector'; import { useConversationUsername } from '../../../hooks/useParamSelector';
import { ed25519Str } from '../../../session/onions/onionPath'; import { ed25519Str } from '../../../session/onions/onionPath';
import { CallManager } from '../../../session/utils'; import { CallManager } from '../../../session/utils';
import { callTimeoutMs } from '../../../session/utils/calling/CallManager';
import { getHasIncomingCall, getHasIncomingCallFrom } from '../../../state/selectors/call'; import { getHasIncomingCall, getHasIncomingCallFrom } from '../../../state/selectors/call';
import { Avatar, AvatarSize } from '../../Avatar'; import { Avatar, AvatarSize } from '../../Avatar';
import { SessionButton, SessionButtonColor } from '../SessionButton'; import { SessionButton, SessionButtonColor } from '../SessionButton';
@ -24,12 +25,10 @@ export const CallWindow = styled.div`
border: var(--session-border); border: var(--session-border);
`; `;
const IncomingCallAvatatContainer = styled.div` const IncomingCallAvatarContainer = styled.div`
padding: 0 0 2rem 0; padding: 0 0 2rem 0;
`; `;
const timeoutMs = 60000;
export const IncomingCallDialog = () => { export const IncomingCallDialog = () => {
const hasIncomingCall = useSelector(getHasIncomingCall); const hasIncomingCall = useSelector(getHasIncomingCall);
const incomingCallFromPubkey = useSelector(getHasIncomingCallFrom); const incomingCallFromPubkey = useSelector(getHasIncomingCallFrom);
@ -42,11 +41,11 @@ export const IncomingCallDialog = () => {
window.log.info( window.log.info(
`call missed with ${ed25519Str( `call missed with ${ed25519Str(
incomingCallFromPubkey 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); await CallManager.USER_rejectIncomingCallRequest(incomingCallFromPubkey);
} }
}, timeoutMs); }, callTimeoutMs);
} }
return () => { return () => {
@ -70,16 +69,16 @@ export const IncomingCallDialog = () => {
} }
}; };
const from = useConversationUsername(incomingCallFromPubkey); const from = useConversationUsername(incomingCallFromPubkey);
if (!hasIncomingCall) { if (!hasIncomingCall || !incomingCallFromPubkey) {
return null; return null;
} }
if (hasIncomingCall) { if (hasIncomingCall) {
return ( return (
<SessionWrapperModal title={window.i18n('incomingCallFrom', from)}> <SessionWrapperModal title={window.i18n('incomingCallFrom', from)}>
<IncomingCallAvatatContainer> <IncomingCallAvatarContainer>
<Avatar size={AvatarSize.XL} pubkey={incomingCallFromPubkey} /> <Avatar size={AvatarSize.XL} pubkey={incomingCallFromPubkey} />
</IncomingCallAvatatContainer> </IncomingCallAvatarContainer>
<div className="session-modal__button-group"> <div className="session-modal__button-group">
<SessionButton <SessionButton
text={window.i18n('decline')} text={window.i18n('decline')}

View File

@ -4,18 +4,18 @@ import { useSelector } from 'react-redux';
import useKey from 'react-use/lib/useKey'; import useKey from 'react-use/lib/useKey';
import { PropsForDataExtractionNotification, QuoteClickOptions } from '../../../models/messageType'; import { PropsForDataExtractionNotification, QuoteClickOptions } from '../../../models/messageType';
import { import {
PropsForCallNotification,
PropsForExpirationTimer, PropsForExpirationTimer,
PropsForGroupInvitation, PropsForGroupInvitation,
PropsForGroupUpdate, PropsForGroupUpdate,
PropsForMissedCallNotification,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { getSortedMessagesTypesOfSelectedConversation } from '../../../state/selectors/conversations'; import { getSortedMessagesTypesOfSelectedConversation } from '../../../state/selectors/conversations';
import { CallNotification } from '../../conversation/notification-bubble/CallNotification';
import { DataExtractionNotification } from '../../conversation/DataExtractionNotification'; import { DataExtractionNotification } from '../../conversation/DataExtractionNotification';
import { GroupInvitation } from '../../conversation/GroupInvitation'; import { GroupInvitation } from '../../conversation/GroupInvitation';
import { GroupNotification } from '../../conversation/GroupNotification'; import { GroupNotification } from '../../conversation/GroupNotification';
import { Message } from '../../conversation/Message'; import { Message } from '../../conversation/Message';
import { MessageDateBreak } from '../../conversation/message/DateBreak'; import { MessageDateBreak } from '../../conversation/message/DateBreak';
import { MissedCallNotification } from '../../conversation/MissedCallNotification';
import { TimerNotification } from '../../conversation/TimerNotification'; import { TimerNotification } from '../../conversation/TimerNotification';
import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; import { SessionLastSeenIndicator } from './SessionLastSeenIndicator';
@ -86,14 +86,10 @@ export const SessionMessagesList = (props: {
return [<TimerNotification key={messageId} {...msgProps} />, dateBreak, unreadIndicator]; return [<TimerNotification key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
} }
if (messageProps.message?.messageType === 'missed-call-notification') { if (messageProps.message?.messageType === 'call-notification') {
const msgProps = messageProps.message.props as PropsForMissedCallNotification; const msgProps = messageProps.message.props as PropsForCallNotification;
return [ return [<CallNotification key={messageId} {...msgProps} />, dateBreak, unreadIndicator];
<MissedCallNotification key={messageId} {...msgProps} />,
dateBreak,
unreadIndicator,
];
} }
if (!messageProps) { if (!messageProps) {

View File

@ -75,7 +75,7 @@ export const SessionQuotedMessageComposition = () => {
margin={'var(--margins-xs)'} margin={'var(--margins-xs)'}
> >
<ReplyingTo>{window.i18n('replyingToMessage')}</ReplyingTo> <ReplyingTo>{window.i18n('replyingToMessage')}</ReplyingTo>
<SessionIconButton iconType="exit" iconSize={'small'} onClick={removeQuotedMessage} /> <SessionIconButton iconType="exit" iconSize="small" onClick={removeQuotedMessage} />
</Flex> </Flex>
<QuotedMessageCompositionReply> <QuotedMessageCompositionReply>
<Flex container={true} justifyContent="space-between" margin={'var(--margins-xs)'}> <Flex container={true} justifyContent="space-between" margin={'var(--margins-xs)'}>

View File

@ -3,6 +3,9 @@ export type SessionIconType =
| 'arrow' | 'arrow'
| 'bell' | 'bell'
| 'brand' | 'brand'
| 'callIncoming'
| 'callMissed'
| 'callOutgoing'
| 'caret' | 'caret'
| 'chatBubble' | 'chatBubble'
| 'check' | 'check'
@ -95,6 +98,24 @@ export const icons = {
viewBox: '0 0 404.085 448.407', viewBox: '0 0 404.085 448.407',
ratio: 1, 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: { caret: {
path: 'M127.5 191.25L255 63.75L0 63.75L127.5 191.25Z', path: 'M127.5 191.25L255 63.75L0 63.75L127.5 191.25Z',
viewBox: '-200 -200 640 640', viewBox: '-200 -200 640 640',

View File

@ -20,6 +20,7 @@ const DisplayNameInput = (props: {
maxLength={MAX_USERNAME_LENGTH} maxLength={MAX_USERNAME_LENGTH}
onValueChanged={props.onDisplayNameChanged} onValueChanged={props.onDisplayNameChanged}
onEnterPressed={props.handlePressEnter} onEnterPressed={props.handlePressEnter}
inputDataTestId="display-name-input"
/> />
); );
}; };
@ -41,6 +42,7 @@ const RecoveryPhraseInput = (props: {
enableShowHide={true} enableShowHide={true}
onValueChanged={props.onSeedChanged} onValueChanged={props.onSeedChanged}
onEnterPressed={props.handlePressEnter} onEnterPressed={props.handlePressEnter}
inputDataTestId="recovery-phrase-input"
/> />
); );
}; };

View File

@ -39,6 +39,7 @@ const RestoreUsingRecoveryPhraseButton = (props: { onRecoveryButtonClicked: () =
buttonType={SessionButtonType.BrandOutline} buttonType={SessionButtonType.BrandOutline}
buttonColor={SessionButtonColor.Green} buttonColor={SessionButtonColor.Green}
text={window.i18n('restoreUsingRecoveryPhrase')} text={window.i18n('restoreUsingRecoveryPhrase')}
dataTestId="restore-using-recovery"
/> />
); );
}; };

View File

@ -2,6 +2,7 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
// tslint:disable-next-line: no-submodule-imports // tslint:disable-next-line: no-submodule-imports
import useUpdate from 'react-use/lib/useUpdate'; import useUpdate from 'react-use/lib/useUpdate';
import { CallManager } from '../../../../session/utils';
import { sessionPassword, updateConfirmModal } from '../../../../state/ducks/modalDialog'; import { sessionPassword, updateConfirmModal } from '../../../../state/ducks/modalDialog';
import { toggleMessageRequests } from '../../../../state/ducks/userConfig'; import { toggleMessageRequests } from '../../../../state/ducks/userConfig';
import { getIsMessageRequestsEnabled } from '../../../../state/selectors/userConfig'; import { getIsMessageRequestsEnabled } from '../../../../state/selectors/userConfig';
@ -23,6 +24,7 @@ const toggleCallMediaPermissions = async (triggerUIUpdate: () => void) => {
onClickOk: async () => { onClickOk: async () => {
await window.toggleCallMediaPermissionsTo(true); await window.toggleCallMediaPermissionsTo(true);
triggerUIUpdate(); triggerUIUpdate();
CallManager.onTurnedOnCallMediaPermissions();
}, },
onClickCancel: async () => { onClickCancel: async () => {
await window.toggleCallMediaPermissionsTo(false); await window.toggleCallMediaPermissionsTo(false);

View File

@ -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;
};

View File

@ -6,7 +6,7 @@ import {
} from '../session/crypto/DecryptedAttachmentsManager'; } from '../session/crypto/DecryptedAttachmentsManager';
import { perfEnd, perfStart } from '../session/utils/Performance'; 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 // tslint:disable-next-line: no-bitwise
const [urlToLoad, setUrlToLoad] = useState(''); const [urlToLoad, setUrlToLoad] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -16,7 +16,7 @@ export const useEncryptedFileFetch = (url: string, contentType: string) => {
async function fetchUrl() { async function fetchUrl() {
perfStart(`getDecryptedMediaUrl-${url}`); perfStart(`getDecryptedMediaUrl-${url}`);
const decryptedUrl = await getDecryptedMediaUrl(url, contentType); const decryptedUrl = await getDecryptedMediaUrl(url, contentType, isAvatar);
perfEnd(`getDecryptedMediaUrl-${url}`, `getDecryptedMediaUrl-${url}`); perfEnd(`getDecryptedMediaUrl-${url}`, `getDecryptedMediaUrl-${url}`);
if (mountedRef.current) { if (mountedRef.current) {

View File

@ -39,3 +39,16 @@ export function useOurConversationUsername() {
export function useIsMe(pubkey?: string) { export function useIsMe(pubkey?: string) {
return pubkey && pubkey === UserUtils.getOurPubKeyStrFromCache(); 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;
});
}

View File

@ -331,7 +331,7 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) {
return; return;
} }
const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG); const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG, true);
if (!decryptedAvatarUrl) { if (!decryptedAvatarUrl) {
window.log.warn('Could not decrypt avatar stored locally..'); window.log.warn('Could not decrypt avatar stored locally..');

View File

@ -50,6 +50,7 @@ import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsMana
import { IMAGE_JPEG } from '../types/MIME'; import { IMAGE_JPEG } from '../types/MIME';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils';
import { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI'; import { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI';
import { createLastMessageUpdate } from '../types/Conversation';
export enum ConversationTypeEnum { export enum ConversationTypeEnum {
GROUP = 'group', GROUP = 'group',
@ -89,7 +90,7 @@ export interface ConversationAttributes {
sessionRestoreSeen?: boolean; sessionRestoreSeen?: boolean;
is_medium_group?: boolean; is_medium_group?: boolean;
type: string; type: string;
avatarPointer?: any; avatarPointer?: string;
avatar?: any; avatar?: any;
/* Avatar hash is currently used for opengroupv2. it's sha256 hash of the base64 avatar data. */ /* Avatar hash is currently used for opengroupv2. it's sha256 hash of the base64 avatar data. */
avatarHash?: string; avatarHash?: string;
@ -131,7 +132,7 @@ export interface ConversationAttributesOptionals {
sessionRestoreSeen?: boolean; sessionRestoreSeen?: boolean;
is_medium_group?: boolean; is_medium_group?: boolean;
type: string; type: string;
avatarPointer?: any; avatarPointer?: string;
avatar?: any; avatar?: any;
avatarHash?: string; avatarHash?: string;
server?: any; server?: any;
@ -900,9 +901,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null; const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null;
const lastMessageStatusModel = lastMessageModel const lastMessageStatusModel = lastMessageModel
? lastMessageModel.getMessagePropStatus() ? lastMessageModel.getMessagePropStatus()
: null; : undefined;
const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate({ const lastMessageUpdate = createLastMessageUpdate({
currentTimestamp: this.get('active_at') || null, currentTimestamp: this.get('active_at'),
lastMessage: lastMessageJSON, lastMessage: lastMessageJSON,
lastMessageStatus: lastMessageStatusModel, lastMessageStatus: lastMessageStatusModel,
lastMessageNotificationText: lastMessageModel ? lastMessageModel.getNotificationText() : null, lastMessageNotificationText: lastMessageModel ? lastMessageModel.getNotificationText() : null,
@ -1057,6 +1058,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
); );
const unreadCount = await this.getUnreadCount(); const unreadCount = await this.getUnreadCount();
this.set({ unreadCount }); this.set({ unreadCount });
this.updateLastMessage();
await this.commit(); await this.commit();
return model; return model;
} }
@ -1487,7 +1490,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const avatarUrl = this.getAvatarPath(); const avatarUrl = this.getAvatarPath();
const noIconUrl = 'images/session/session_icon_32.png'; const noIconUrl = 'images/session/session_icon_32.png';
if (avatarUrl) { if (avatarUrl) {
const decryptedAvatarUrl = await getDecryptedMediaUrl(avatarUrl, IMAGE_JPEG); const decryptedAvatarUrl = await getDecryptedMediaUrl(avatarUrl, IMAGE_JPEG, true);
if (!decryptedAvatarUrl) { if (!decryptedAvatarUrl) {
window.log.warn('Could not decrypt avatar stored locally for getNotificationIcon..'); window.log.warn('Could not decrypt avatar stored locally for getNotificationIcon..');

View File

@ -88,7 +88,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
const propsForGroupInvitation = this.getPropsForGroupInvitation(); const propsForGroupInvitation = this.getPropsForGroupInvitation();
const propsForGroupNotification = this.getPropsForGroupNotification(); const propsForGroupNotification = this.getPropsForGroupNotification();
const propsForTimerNotification = this.getPropsForTimerNotification(); const propsForTimerNotification = this.getPropsForTimerNotification();
const isMissedCall = this.get('isMissedCall'); const callNotificationType = this.get('callNotificationType');
const messageProps: MessageModelPropsWithoutConvoProps = { const messageProps: MessageModelPropsWithoutConvoProps = {
propsForMessage: this.getPropsForMessage(), propsForMessage: this.getPropsForMessage(),
}; };
@ -105,9 +105,9 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
messageProps.propsForTimerNotification = propsForTimerNotification; messageProps.propsForTimerNotification = propsForTimerNotification;
} }
if (isMissedCall) { if (callNotificationType) {
messageProps.propsForMissedCall = { messageProps.propsForCallNotification = {
isMissedCall, notificationType: callNotificationType,
messageId: this.id, messageId: this.id,
receivedAt: this.get('received_at') || Date.now(), receivedAt: this.get('received_at') || Date.now(),
isUnread: this.isUnread(), isUnread: this.isUnread(),
@ -239,6 +239,21 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
getConversationController().getContactProfileNameOrShortenedPubKey(dataExtraction.source) 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'); return this.get('body');
} }
@ -498,7 +513,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return undefined; return undefined;
} }
if (this.isDataExtractionNotification()) { if (this.isDataExtractionNotification() || this.get('callNotificationType')) {
return undefined; return undefined;
} }

View File

@ -1,6 +1,6 @@
import _ from 'underscore'; import _ from 'underscore';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { PropsForMessageWithConvoProps } from '../state/ducks/conversations'; import { CallNotificationType, PropsForMessageWithConvoProps } from '../state/ducks/conversations';
import { AttachmentTypeWithPath } from '../types/Attachment'; import { AttachmentTypeWithPath } from '../types/Attachment';
export type MessageModelType = 'incoming' | 'outgoing'; export type MessageModelType = 'incoming' | 'outgoing';
@ -109,7 +109,7 @@ export interface MessageAttributes {
*/ */
isDeleted?: boolean; isDeleted?: boolean;
isMissedCall?: boolean; callNotificationType?: CallNotificationType;
} }
export interface DataExtractionNotificationMsg { export interface DataExtractionNotificationMsg {
@ -179,7 +179,7 @@ export interface MessageAttributesOptionals {
direction?: any; direction?: any;
messageHash?: string; messageHash?: string;
isDeleted?: boolean; isDeleted?: boolean;
isMissedCall?: boolean; callNotificationType?: CallNotificationType;
} }
/** /**

View File

@ -74,19 +74,19 @@ export async function handleCallMessage(
if (type === SignalService.CallMessage.Type.ANSWER) { if (type === SignalService.CallMessage.Type.ANSWER) {
await removeFromCache(envelope); await removeFromCache(envelope);
await CallManager.handleCallTypeAnswer(sender, callMessage); await CallManager.handleCallTypeAnswer(sender, callMessage, sentTimestamp);
return; return;
} }
if (type === SignalService.CallMessage.Type.ICE_CANDIDATES) { if (type === SignalService.CallMessage.Type.ICE_CANDIDATES) {
await removeFromCache(envelope); await removeFromCache(envelope);
await CallManager.handleCallTypeIceCandidates(sender, callMessage); await CallManager.handleCallTypeIceCandidates(sender, callMessage, sentTimestamp);
return; return;
} }
await removeFromCache(envelope); await removeFromCache(envelope);
// if this another type of call message, just add it to the manager // if this another type of call message, just add it to the manager
await CallManager.handleOtherCallTypes(sender, callMessage); await CallManager.handleOtherCallTypes(sender, callMessage, sentTimestamp);
} }

View File

@ -11,20 +11,29 @@ import * as fse from 'fs-extra';
import { decryptAttachmentBuffer } from '../../types/Attachment'; import { decryptAttachmentBuffer } from '../../types/Attachment';
import { DURATION } from '../constants'; import { DURATION } from '../constants';
// FIXME. const urlToDecryptedBlobMap = new Map<
// add a way to remove the blob when the attachment file path is removed (message removed?) string,
// do not hardcode the password { decrypted: string; lastAccessTimestamp: number; forceRetain: boolean }
const urlToDecryptedBlobMap = new Map<string, { decrypted: string; lastAccessTimestamp: number }>(); >();
const urlToDecryptingPromise = new Map<string, Promise<string>>(); const urlToDecryptingPromise = new Map<string, Promise<string>>();
export const cleanUpOldDecryptedMedias = () => { export const cleanUpOldDecryptedMedias = () => {
const currentTimestamp = Date.now(); const currentTimestamp = Date.now();
let countCleaned = 0; let countCleaned = 0;
let countKept = 0; let countKept = 0;
let keptAsAvatars = 0;
window?.log?.info('Starting cleaning of medias blobs...'); window?.log?.info('Starting cleaning of medias blobs...');
for (const iterator of urlToDecryptedBlobMap) { for (const iterator of urlToDecryptedBlobMap) {
// if the last access is older than one hour, revoke the url and remove it. if (
if (iterator[1].lastAccessTimestamp < currentTimestamp - DURATION.HOURS * 1) { 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); URL.revokeObjectURL(iterator[1].decrypted);
urlToDecryptedBlobMap.delete(iterator[0]); urlToDecryptedBlobMap.delete(iterator[0]);
countCleaned++; countCleaned++;
@ -32,10 +41,16 @@ export const cleanUpOldDecryptedMedias = () => {
countKept++; 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<string> => { export const getDecryptedMediaUrl = async (
url: string,
contentType: string,
isAvatar: boolean
): Promise<string> => {
if (!url) { if (!url) {
return 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 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)) { if (urlToDecryptedBlobMap.has(url)) {
// refresh the last access timestamp so we keep the one being currently in use // 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, { urlToDecryptedBlobMap.set(url, {
decrypted: existingObjUrl, decrypted: existingObjUrl,
lastAccessTimestamp: Date.now(), lastAccessTimestamp: Date.now(),
forceRetain: existing?.forceRetain || false,
}); });
// typescript does not realize that the has above makes sure the get is not undefined // 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, { urlToDecryptedBlobMap.set(url, {
decrypted: obj, decrypted: obj,
lastAccessTimestamp: Date.now(), lastAccessTimestamp: Date.now(),
forceRetain: isAvatar,
}); });
} }
urlToDecryptingPromise.delete(url); urlToDecryptingPromise.delete(url);

View File

@ -20,6 +20,10 @@ function startRinging() {
void ringingAudio.play(); void ringingAudio.play();
} }
export function getIsRinging() {
return currentlyRinging;
}
export function setIsRinging(isRinging: boolean) { export function setIsRinging(isRinging: boolean) {
if (!currentlyRinging && isRinging) { if (!currentlyRinging && isRinging) {
startRinging(); startRinging();

View File

@ -5,6 +5,7 @@ import { KeyPair } from '../../../libtextsecure/libsignal-protocol';
import { PubKey } from '../types'; import { PubKey } from '../types';
import { fromHexToArray, toHex } from './String'; import { fromHexToArray, toHex } from './String';
import { getConversationController } from '../conversations'; import { getConversationController } from '../conversations';
import { LokiProfile } from '../../types/Message';
export type HexKeyPair = { export type HexKeyPair = {
pubKey: string; pubKey: string;
@ -93,13 +94,7 @@ export function setSignWithRecoveryPhrase(isLinking: boolean) {
window.textsecure.storage.user.setSignWithRecoveryPhrase(isLinking); window.textsecure.storage.user.setSignWithRecoveryPhrase(isLinking);
} }
export interface OurLokiProfile { export function getOurProfile(): LokiProfile | undefined {
displayName: string;
avatarPointer: string;
profileKey: Uint8Array | null;
}
export function getOurProfile(): OurLokiProfile | undefined {
try { try {
// Secondary devices have their profile stored // Secondary devices have their profile stored
// in their primary device's conversation // in their primary device's conversation

View File

@ -1,7 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { MessageUtils, ToastUtils, UserUtils } from '../'; import { MessageUtils, ToastUtils, UserUtils } from '../';
import { getCallMediaPermissionsSettings } from '../../../components/session/settings/SessionSettings'; import { getCallMediaPermissionsSettings } from '../../../components/session/settings/SessionSettings';
import { getConversationById } from '../../../data/data';
import { MessageModelType } from '../../../models/messageType'; import { MessageModelType } from '../../../models/messageType';
import { SignalService } from '../../../protobuf'; import { SignalService } from '../../../protobuf';
import { openConversationWithMessages } from '../../../state/ducks/conversations'; import { openConversationWithMessages } from '../../../state/ducks/conversations';
@ -21,15 +20,18 @@ import { PubKey } from '../../types';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { PnServer } from '../../../pushnotification'; import { PnServer } from '../../../pushnotification';
import { setIsRinging } from '../RingingManager'; import { getIsRinging, setIsRinging } from '../RingingManager';
import { getBlackSilenceMediaStream } from './Silence'; import { getBlackSilenceMediaStream } from './Silence';
import { getMessageQueue } from '../..'; import { getMessageQueue } from '../..';
import { MessageSender } from '../../sending'; import { MessageSender } from '../../sending';
import { DURATION } from '../../constants';
// tslint:disable: function-name // tslint:disable: function-name
export type InputItem = { deviceId: string; label: string }; export type InputItem = { deviceId: string; label: string };
export const callTimeoutMs = 30000;
/** /**
* This uuid is set only once we accepted a call or started one. * This uuid is set only once we accepted a call or started one.
*/ */
@ -88,10 +90,19 @@ export function removeVideoEventsListener(uniqueId: string) {
callVideoListeners(); callVideoListeners();
} }
type CachedCallMessageType = {
type: SignalService.CallMessage.Type;
sdps: Array<string>;
sdpMLineIndexes: Array<number>;
sdpMids: Array<string>;
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. * 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<string, Map<string, Array<SignalService.CallMessage>>>(); const callCache = new Map<string, Map<string, Array<CachedCallMessageType>>>();
let peerConnection: RTCPeerConnection | null; let peerConnection: RTCPeerConnection | null;
let dataChannel: RTCDataChannel | null; let dataChannel: RTCDataChannel | null;
@ -293,6 +304,8 @@ export async function selectAudioInputByDeviceId(audioInputDeviceId: string) {
if (sender?.track) { if (sender?.track) {
sender.track.enabled = false; sender.track.enabled = false;
} }
const silence = getBlackSilenceMediaStream().getAudioTracks()[0];
sender?.replaceTrack(silence);
// do the same changes locally // do the same changes locally
localStream?.getAudioTracks().forEach(t => { localStream?.getAudioTracks().forEach(t => {
t.stop(); t.stop();
@ -322,10 +335,14 @@ export async function selectAudioInputByDeviceId(audioInputDeviceId: string) {
return s.track?.kind === audioTrack.kind; return s.track?.kind === audioTrack.kind;
}); });
window.log.info('replacing audio track'); 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) { if (audioSender) {
await audioSender.replaceTrack(audioTrack); 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 { } else {
throw new Error('Failed to get sender for selectAudioInputByDeviceId '); throw new Error('Failed to get sender for selectAudioInputByDeviceId ');
} }
@ -439,22 +456,38 @@ export async function USER_callRecipient(recipient: string) {
return; return;
} }
await updateConnectedDevices(); await updateConnectedDevices();
const now = Date.now();
window?.log?.info(`starting call with ${ed25519Str(recipient)}..`); window?.log?.info(`starting call with ${ed25519Str(recipient)}..`);
window.inboxStore?.dispatch(startingCallWith({ pubkey: recipient })); window.inboxStore?.dispatch(
startingCallWith({
pubkey: recipient,
})
);
if (peerConnection) { if (peerConnection) {
throw new Error('USER_callRecipient peerConnection is already initialized '); throw new Error('USER_callRecipient peerConnection is already initialized ');
} }
currentCallUUID = uuidv4(); currentCallUUID = uuidv4();
const justCreatedCallUUID = currentCallUUID;
peerConnection = createOrGetPeerConnection(recipient); peerConnection = createOrGetPeerConnection(recipient);
// send a pre offer just to wake up the device on the remote side // send a pre offer just to wake up the device on the remote side
const preOfferMsg = new CallMessage({ const preOfferMsg = new CallMessage({
timestamp: Date.now(), timestamp: now,
type: SignalService.CallMessage.Type.PRE_OFFER, type: SignalService.CallMessage.Type.PRE_OFFER,
uuid: currentCallUUID, uuid: currentCallUUID,
}); });
window.log.info('Sending preOffer message to ', ed25519Str(recipient)); 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 // 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) // 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); const rawMessage = await MessageUtils.toRawMessage(PubKey.cast(recipient), preOfferMsg);
@ -464,6 +497,17 @@ export async function USER_callRecipient(recipient: string) {
await openMediaDevicesAndAddTracks(); await openMediaDevicesAndAddTracks();
setIsRinging(true); setIsRinging(true);
await createOfferAndSendIt(recipient); 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<RTCIceCandidate> = new Array(); const iceCandidates: Array<RTCIceCandidate> = new Array();
@ -762,6 +806,18 @@ export async function USER_acceptIncomingCallRequest(fromSender: string) {
await peerConnection.addIceCandidate(candicate); 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); await buildAnswerAndSendIt(fromSender);
} }
@ -809,6 +865,7 @@ export async function USER_rejectIncomingCallRequest(fromSender: string) {
if (ongoingCallWith && ongoingCallStatus && ongoingCallWith === fromSender) { if (ongoingCallWith && ongoingCallStatus && ongoingCallWith === fromSender) {
closeVideoCall(); closeVideoCall();
} }
await addMissedCallMessage(fromSender, Date.now());
} }
async function sendCallMessageAndSync(callmessage: CallMessage, user: string) { async function sendCallMessageAndSync(callmessage: CallMessage, user: string) {
@ -907,6 +964,20 @@ export function isCallRejected(uuid: string) {
return rejectedCallUUIDS.has(uuid); 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( export async function handleCallTypeOffer(
sender: string, sender: string,
callMessage: SignalService.CallMessage, callMessage: SignalService.CallMessage,
@ -920,6 +991,9 @@ export async function handleCallTypeOffer(
window.log.info('handling callMessage OFFER with uuid: ', remoteCallUUID); window.log.info('handling callMessage OFFER with uuid: ', remoteCallUUID);
if (!getCallMediaPermissionsSettings()) { if (!getCallMediaPermissionsSettings()) {
const cachedMsg = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp);
pushCallMessageToCallCache(sender, remoteCallUUID, cachedMsg);
await handleMissedCall(sender, incomingOfferTimestamp, true); await handleMissedCall(sender, incomingOfferTimestamp, true);
return; return;
} }
@ -979,8 +1053,9 @@ export async function handleCallTypeOffer(
} }
setIsRinging(true); setIsRinging(true);
} }
const cachedMessage = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp);
pushCallMessageToCallCache(sender, remoteCallUUID, callMessage); pushCallMessageToCallCache(sender, remoteCallUUID, cachedMessage);
} catch (err) { } catch (err) {
window.log?.error(`Error handling offer message ${err}`); window.log?.error(`Error handling offer message ${err}`);
} }
@ -991,7 +1066,7 @@ export async function handleMissedCall(
incomingOfferTimestamp: number, incomingOfferTimestamp: number,
isBecauseOfCallPermission: boolean isBecauseOfCallPermission: boolean
) { ) {
const incomingCallConversation = await getConversationById(sender); const incomingCallConversation = getConversationController().get(sender);
setIsRinging(false); setIsRinging(false);
if (!isBecauseOfCallPermission) { if (!isBecauseOfCallPermission) {
ToastUtils.pushedMissedCall( 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({ await incomingCallConversation?.addSingleMessage({
conversationId: incomingCallConversation.id, conversationId: callerPubkey,
source: sender, source: callerPubkey,
type: 'incoming' as MessageModelType, type: 'incoming' as MessageModelType,
sent_at: incomingOfferTimestamp, sent_at: sentAt,
received_at: Date.now(), received_at: Date.now(),
expireTimer: 0, expireTimer: 0,
isMissedCall: true, callNotificationType: 'missed-call',
unread: 1, unread: 1,
}); });
incomingCallConversation?.updateLastMessage();
return;
} }
function getOwnerOfCallUUID(callUUID: string) { function getOwnerOfCallUUID(callUUID: string) {
for (const deviceKey of callCache.keys()) { for (const deviceKey of callCache.keys()) {
for (const callUUIDEntry of callCache.get(deviceKey) as Map< for (const callUUIDEntry of callCache.get(deviceKey) as Map<
string, string,
Array<SignalService.CallMessage> Array<CachedCallMessageType>
>) { >) {
if (callUUIDEntry[0] === callUUID) { if (callUUIDEntry[0] === callUUID) {
return deviceKey; return deviceKey;
@ -1036,7 +1115,11 @@ function getOwnerOfCallUUID(callUUID: string) {
return null; 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) { if (!callMessage.sdps || callMessage.sdps.length === 0) {
window.log.warn('cannot handle answered message without signal description proto sdps'); window.log.warn('cannot handle answered message without signal description proto sdps');
return; return;
@ -1083,8 +1166,9 @@ export async function handleCallTypeAnswer(sender: string, callMessage: SignalSe
} else { } else {
window.log.info(`handling callMessage ANSWER from ${callMessageUUID}`); window.log.info(`handling callMessage ANSWER from ${callMessageUUID}`);
} }
const cachedMessage = getCachedMessageFromCallMessage(callMessage, envelopeTimestamp);
pushCallMessageToCallCache(sender, callMessageUUID, callMessage); pushCallMessageToCallCache(sender, callMessageUUID, cachedMessage);
if (!peerConnection) { if (!peerConnection) {
window.log.info('handleCallTypeAnswer without peer connection. Dropping'); window.log.info('handleCallTypeAnswer without peer connection. Dropping');
@ -1114,7 +1198,8 @@ export async function handleCallTypeAnswer(sender: string, callMessage: SignalSe
export async function handleCallTypeIceCandidates( export async function handleCallTypeIceCandidates(
sender: string, sender: string,
callMessage: SignalService.CallMessage callMessage: SignalService.CallMessage,
envelopeTimestamp: number
) { ) {
if (!callMessage.sdps || callMessage.sdps.length === 0) { if (!callMessage.sdps || callMessage.sdps.length === 0) {
window.log.warn('cannot handle iceCandicates message without candidates'); window.log.warn('cannot handle iceCandicates message without candidates');
@ -1126,8 +1211,9 @@ export async function handleCallTypeIceCandidates(
return; return;
} }
window.log.info('handling callMessage ICE_CANDIDATES'); 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) { if (currentCallUUID && callMessage.uuid === currentCallUUID) {
await addIceCandidateToExistingPeerConnection(callMessage); await addIceCandidateToExistingPeerConnection(callMessage);
} }
@ -1155,13 +1241,18 @@ async function addIceCandidateToExistingPeerConnection(callMessage: SignalServic
} }
// tslint:disable-next-line: no-async-without-await // 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; const remoteCallUUID = callMessage.uuid;
if (!remoteCallUUID || remoteCallUUID.length === 0) { if (!remoteCallUUID || remoteCallUUID.length === 0) {
window.log.warn('handleOtherCallTypes has no valid uuid'); window.log.warn('handleOtherCallTypes has no valid uuid');
return; return;
} }
pushCallMessageToCallCache(sender, remoteCallUUID, callMessage); const cachedMessage = getCachedMessageFromCallMessage(callMessage, envelopeTimestamp);
pushCallMessageToCallCache(sender, remoteCallUUID, cachedMessage);
} }
function clearCallCacheFromPubkeyAndUUID(sender: string, callUUID: string) { function clearCallCacheFromPubkeyAndUUID(sender: string, callUUID: string) {
@ -1181,7 +1272,7 @@ function createCallCacheForPubkeyAndUUID(sender: string, uuid: string) {
function pushCallMessageToCallCache( function pushCallMessageToCallCache(
sender: string, sender: string,
uuid: string, uuid: string,
callMessage: SignalService.CallMessage callMessage: CachedCallMessageType
) { ) {
createCallCacheForPubkeyAndUUID(sender, uuid); createCallCacheForPubkeyAndUUID(sender, uuid);
callCache callCache
@ -1189,3 +1280,23 @@ function pushCallMessageToCallCache(
?.get(uuid) ?.get(uuid)
?.push(callMessage); ?.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 }));
}
}
});
});
}

View File

@ -17,8 +17,9 @@ import { QuotedAttachmentType } from '../../components/conversation/Quote';
import { perfEnd, perfStart } from '../../session/utils/Performance'; import { perfEnd, perfStart } from '../../session/utils/Performance';
import { omit } from 'lodash'; import { omit } from 'lodash';
export type PropsForMissedCallNotification = { export type CallNotificationType = 'missed-call' | 'started-call' | 'answered-a-call';
isMissedCall: boolean; export type PropsForCallNotification = {
notificationType: CallNotificationType;
messageId: string; messageId: string;
receivedAt: number; receivedAt: number;
isUnread: boolean; isUnread: boolean;
@ -30,7 +31,7 @@ export type MessageModelPropsWithoutConvoProps = {
propsForTimerNotification?: PropsForExpirationTimer; propsForTimerNotification?: PropsForExpirationTimer;
propsForDataExtractionNotification?: PropsForDataExtractionNotification; propsForDataExtractionNotification?: PropsForDataExtractionNotification;
propsForGroupNotification?: PropsForGroupUpdate; propsForGroupNotification?: PropsForGroupUpdate;
propsForMissedCall?: PropsForMissedCallNotification; propsForCallNotification?: PropsForCallNotification;
}; };
export type MessageModelPropsWithConvoProps = SortedMessageModelProps & { export type MessageModelPropsWithConvoProps = SortedMessageModelProps & {

View File

@ -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( export const getIsTypingEnabled = createSelector(
getConversations, getConversations,
getSelectedConversationKey, getSelectedConversationKey,
@ -190,7 +177,7 @@ export type MessagePropsType =
| 'timer-notification' | 'timer-notification'
| 'regular-message' | 'regular-message'
| 'unread-indicator' | 'unread-indicator'
| 'missed-call-notification'; | 'call-notification';
export const getSortedMessagesTypesOfSelectedConversation = createSelector( export const getSortedMessagesTypesOfSelectedConversation = createSelector(
getSortedMessagesOfSelectedConversation, getSortedMessagesOfSelectedConversation,
@ -257,14 +244,14 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector(
}; };
} }
if (msg.propsForMissedCall) { if (msg.propsForCallNotification) {
return { return {
showUnreadIndicator: isFirstUnread, showUnreadIndicator: isFirstUnread,
showDateBreak, showDateBreak,
message: { message: {
messageType: 'missed-call-notification', messageType: 'call-notification',
props: { props: {
...msg.propsForMissedCall, ...msg.propsForCallNotification,
messageId: msg.propsForMessage.id, messageId: msg.propsForMessage.id,
}, },
}, },

View File

@ -1,4 +1,5 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { LastMessageStatusType } from '../../state/ducks/conversations';
import * as Conversation from '../../types/Conversation'; import * as Conversation from '../../types/Conversation';
import { IncomingMessage } from '../../types/Message'; import { IncomingMessage } from '../../types/Message';
@ -9,8 +10,8 @@ describe('Conversation', () => {
const input = {}; const input = {};
const expected = { const expected = {
lastMessage: '', lastMessage: '',
lastMessageStatus: null, lastMessageStatus: undefined,
timestamp: null, timestamp: undefined,
}; };
const actual = Conversation.createLastMessageUpdate(input); const actual = Conversation.createLastMessageUpdate(input);
@ -21,7 +22,7 @@ describe('Conversation', () => {
it('should update last message text and timestamp', () => { it('should update last message text and timestamp', () => {
const input = { const input = {
currentTimestamp: 555, currentTimestamp: 555,
lastMessageStatus: 'read', lastMessageStatus: 'read' as LastMessageStatusType,
lastMessage: { lastMessage: {
type: 'outgoing', type: 'outgoing',
conversationId: 'foo', conversationId: 'foo',
@ -32,7 +33,7 @@ describe('Conversation', () => {
}; };
const expected = { const expected = {
lastMessage: 'New outgoing message', lastMessage: 'New outgoing message',
lastMessageStatus: 'read', lastMessageStatus: 'read' as LastMessageStatusType,
timestamp: 666, timestamp: 666,
}; };
@ -60,7 +61,7 @@ describe('Conversation', () => {
}; };
const expected = { const expected = {
lastMessage: 'Last message before expired', lastMessage: 'Last message before expired',
lastMessageStatus: null, lastMessageStatus: undefined,
timestamp: 555, timestamp: 555,
}; };

View File

@ -1,9 +1,10 @@
import { LastMessageStatusType } from '../state/ducks/conversations';
import { Message } from './Message'; import { Message } from './Message';
interface ConversationLastMessageUpdate { interface ConversationLastMessageUpdate {
lastMessage: string; lastMessage: string;
lastMessageStatus: string | null; lastMessageStatus: LastMessageStatusType;
timestamp: number | null; timestamp: number | undefined;
} }
export const createLastMessageUpdate = ({ export const createLastMessageUpdate = ({
@ -14,14 +15,14 @@ export const createLastMessageUpdate = ({
}: { }: {
currentTimestamp?: number; currentTimestamp?: number;
lastMessage?: Message; lastMessage?: Message;
lastMessageStatus?: string; lastMessageStatus?: LastMessageStatusType;
lastMessageNotificationText?: string; lastMessageNotificationText?: string;
}): ConversationLastMessageUpdate => { }): ConversationLastMessageUpdate => {
if (!lastMessage) { if (!lastMessage) {
return { return {
lastMessage: '', lastMessage: '',
lastMessageStatus: null, lastMessageStatus: undefined,
timestamp: null, timestamp: undefined,
}; };
} }
@ -35,7 +36,7 @@ export const createLastMessageUpdate = ({
return { return {
lastMessage: lastMessageNotificationText || '', lastMessage: lastMessageNotificationText || '',
lastMessageStatus: lastMessageStatus || null, lastMessageStatus: lastMessageStatus || undefined,
timestamp: newTimestamp || null, timestamp: newTimestamp || undefined,
}; };
}; };

View File

@ -51,6 +51,6 @@ type MessageSchemaVersion5 = Partial<
export type LokiProfile = { export type LokiProfile = {
displayName: string; displayName: string;
avatarPointer: string; avatarPointer?: string;
profileKey: Uint8Array | null; profileKey: Uint8Array | null;
}; };

View File

@ -151,7 +151,7 @@ export const saveAttachmentToDisk = async ({
messageSender: string; messageSender: string;
conversationId: string; conversationId: string;
}) => { }) => {
const decryptedUrl = await getDecryptedMediaUrl(attachment.url, attachment.contentType); const decryptedUrl = await getDecryptedMediaUrl(attachment.url, attachment.contentType, false);
save({ save({
attachment: { ...attachment, url: decryptedUrl }, attachment: { ...attachment, url: decryptedUrl },
document, document,