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.",
"callMediaPermissionsDescription": "Allows access to accept voice and video calls from other users",
"callMediaPermissionsDialogContent": "The current implementation of voice/video calls will expose your IP address to the Oxen Foundation servers and the calling/called user.",
"menuCall": "Call"
"menuCall": "Call",
"startedACall": "You called $name$",
"answeredACall": "Call with $name$"
}

View File

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

View File

@ -46,7 +46,6 @@
"integration-test": "mocha --recursive --exit --timeout 30000 \"./ts/test-integration/**/*.test.js\" \"./ts/test/*.test.js\"",
"clean-transpile": "rimraf 'ts/**/*.js' 'ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;",
"ready": "yarn clean-transpile; yarn grunt && yarn lint-full && yarn test",
"build:webpack:sql-worker": "cross-env NODE_ENV=production webpack -c webpack-sql-worker.config.ts",
"sedtoAppImage": "sed -i 's/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/\"target\": \"AppImage\"/g' package.json",
"sedtoDeb": "sed -i 's/\"target\": \"AppImage\"/\"target\": \\[\"deb\", \"rpm\", \"freebsd\"\\]/g' package.json"
},

View File

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

View File

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

View File

@ -276,185 +276,6 @@
font-style: italic;
}
.module-message__typing-container {
height: 16px;
padding-bottom: 20px;
display: flex;
flex-direction: row;
align-items: center;
}
// Module: Contact Detail
.module-contact-detail {
text-align: center;
max-width: 300px;
margin-inline-start: auto;
margin-inline-end: auto;
}
.module-contact-detail__avatar {
margin-bottom: 4px;
}
.module-contact-detail__contact-name {
font-size: 20px;
font-weight: bold;
}
.module-contact-detail__contact-method {
font-size: 14px;
margin-top: 10px;
}
.module-contact-detail__send-message {
cursor: pointer;
border-radius: 4px;
background-color: $blue;
display: inline-block;
padding: 6px;
margin-top: 20px;
color: $color-white;
flex-direction: column;
align-items: center;
button {
@include button-reset;
}
}
.module-contact-detail__send-message__inner {
display: flex;
align-items: center;
}
.module-contact-detail__additional-contact {
text-align: left;
border-top: 1px solid $color-light-10;
margin-top: 15px;
padding-top: 8px;
}
.module-contact-detail__additional-contact__type {
color: $color-light-45;
font-size: 12px;
margin-bottom: 3px;
}
// Module: Group Notification
.module-group-notification {
margin-top: 14px;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.3px;
color: $color-gray-60;
text-align: center;
}
.module-group-notification__change,
.module-timer-notification__message {
background: var(--color-fake-chat-bubble-background);
color: var(--color-text);
width: 90%;
max-width: 700px;
margin: 10px auto;
padding: 5px 0px;
border-radius: 4px;
word-break: break-word;
}
.module-group-notification__contact {
font-family: $session-font-default;
font-weight: bold;
}
// Module: Timer Notification
.module-timer-notification {
margin-top: 20px;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.3px;
color: $color-gray-60;
text-align: center;
}
.module-timer-notification__contact {
font-family: $session-font-default;
font-weight: bold;
padding-inline-end: $session-margin-xs;
}
.module-timer-notification__icon-container {
margin-inline-start: auto;
margin-inline-end: auto;
display: inline-flex;
flex-direction: row;
align-items: center;
margin-bottom: 4px;
}
.module-timer-notification__icon {
height: 20px;
width: 20px;
display: inline-block;
@include color-svg('../images/timer.svg', $color-gray-60);
}
.module-timer-notification__icon--disabled {
@include color-svg('../images/timer-disabled.svg', $color-gray-60);
}
.module-timer-notification__icon-label {
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
margin-inline-start: 6px;
text-transform: uppercase;
// Didn't seem centered otherwise
margin-top: 1px;
}
.module-timer-notification__message {
display: grid;
grid-template-columns: 40px auto 40px;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.3px;
& > div {
align-self: center;
justify-self: center;
align-items: center;
justify-content: center;
}
.module-contact-name__profile-name {
text-align: center;
}
.module-message__author__profile-name {
margin-inline-end: $session-margin-xs;
}
}
.module-notification--with-click-handler {
cursor: pointer;
}
.module-notification__icon {
height: 24px;
width: 24px;
margin-inline-start: auto;
margin-inline-end: auto;
}
// Module: Contact List Item
.module-contact-list-item {
@ -549,10 +370,6 @@
}
}
.module-conversation-header__title__profile-name {
font-style: italic;
}
.module-conversation-header__expiration {
display: flex;
flex-direction: row;

View File

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

View File

@ -225,26 +225,6 @@
color: $color-light-45;
}
// Module: Group Notification
.module-group-notification {
color: $color-dark-30;
}
// Module: Timer Notification
.module-timer-notification {
color: $color-dark-30;
}
.module-timer-notification__icon {
@include color-svg('../images/timer.svg', $color-dark-30);
}
.module-timer-notification__icon--disabled {
@include color-svg('../images/timer-disabled.svg', $color-dark-30);
}
// Module: Contact List Item
.module-contact-list-item {

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import {
ReduxConversationType,
} from '../state/ducks/conversations';
import _ from 'underscore';
import { SessionIcon } from './session/icon';
import { SessionIcon, SessionIconButton } from './session/icon';
import { useDispatch, useSelector } from 'react-redux';
import { SectionType } from '../state/ducks/section';
import { getFocusedSection } from '../state/selectors/section';
@ -83,7 +83,7 @@ const HeaderItem = (props: {
const pinIcon =
isMessagesSection && isPinned ? (
<SessionIcon iconType="pin" iconColor={'var(--color-text-subtle)'} iconSize={'small'} />
<SessionIcon iconType="pin" iconColor={'var(--color-text-subtle)'} iconSize="small" />
) : null;
const NotificationSettingIcon = () => {
@ -96,11 +96,11 @@ const HeaderItem = (props: {
return null;
case 'disabled':
return (
<SessionIcon iconType="mute" iconColor={'var(--color-text-subtle)'} iconSize={'small'} />
<SessionIcon iconType="mute" iconColor={'var(--color-text-subtle)'} iconSize="small" />
);
case 'mentions_only':
return (
<SessionIcon iconType="bell" iconColor={'var(--color-text-subtle)'} iconSize={'small'} />
<SessionIcon iconType="bell" iconColor={'var(--color-text-subtle)'} iconSize="small" />
);
default:
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
import React, { useCallback, useRef } from 'react';
import React, { useRef } from 'react';
import is from '@sindresorhus/is';
@ -14,6 +14,7 @@ import useUnmount from 'react-use/lib/useUnmount';
import { useEncryptedFileFetch } from '../hooks/useEncryptedFileFetch';
import { useDispatch } from 'react-redux';
import { showLightBox } from '../state/ducks/conversations';
import { useDisableDrag } from '../hooks/useDisableDrag';
const Colors = {
TEXT_SECONDARY: '#bbb',
@ -204,15 +205,10 @@ export const LightboxObject = ({
renderedRef: React.MutableRefObject<any>;
onObjectClick: (event: any) => any;
}) => {
const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType);
const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType, false);
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
const onDragStart = useCallback((e: any) => {
e.preventDefault();
return false;
}, []);
// auto play video on showing a video attachment
useUnmount(() => {
if (!renderedRef?.current) {
@ -220,12 +216,13 @@ export const LightboxObject = ({
}
renderedRef.current.pause.pause();
});
const disableDrag = useDisableDrag();
if (isImageTypeSupported) {
return (
<img
style={styles.object as any}
onDragStart={onDragStart}
onDragStart={disableDrag}
alt={window.i18n('lightboxImageAlt')}
src={urlToLoad}
ref={renderedRef}

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export const AudioPlayerWithEncryptedFile = (props: {
}) => {
const dispatch = useDispatch();
const [playbackSpeed, setPlaybackSpeed] = useState(1.0);
const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType);
const { urlToLoad } = useEncryptedFileFetch(props.src, props.contentType, false);
const player = useRef<H5AudioPlayer | null>(null);
const autoPlaySetting = useSelector(getAudioAutoplay);
@ -104,10 +104,10 @@ export const AudioPlayerWithEncryptedFile = (props: {
]}
customIcons={{
play: (
<SessionIcon iconType="play" iconSize={'small'} iconColor={'var(--color-text-subtle)'} />
<SessionIcon iconType="play" iconSize="small" iconColor={'var(--color-text-subtle)'} />
),
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 { AttachmentType, AttachmentTypeWithPath } from '../../types/Attachment';
import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch';
import { useDisableDrag } from '../../hooks/useDisableDrag';
type Props = {
alt: string;
@ -48,17 +49,13 @@ export const Image = (props: Props) => {
width,
} = props;
const onDragStart = useCallback((e: any) => {
e.preventDefault();
return false;
}, []);
const onErrorUrlFilterering = useCallback(() => {
if (url && onError) {
onError();
}
return;
}, [url, onError]);
const disableDrag = useDisableDrag();
const { caption } = attachment || { caption: null };
let { pending } = attachment || { pending: true };
@ -68,7 +65,7 @@ export const Image = (props: Props) => {
}
const canClick = onClick && !pending;
const role = canClick ? 'button' : undefined;
const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType);
const { loading, urlToLoad } = useEncryptedFileFetch(url || '', attachment.contentType, false);
// data will be url if loading is finished and '' if not
const srcData = !loading ? urlToLoad : '';
@ -118,7 +115,7 @@ export const Image = (props: Props) => {
height: forceSquare ? `${height}px` : '',
}}
src={srcData}
onDragStart={onDragStart}
onDragStart={disableDrag}
/>
)}
{caption ? (
@ -126,7 +123,7 @@ export const Image = (props: Props) => {
className="module-image__caption-icon"
src="images/caption-shadow.svg"
alt={window.i18n('imageCaptionIconAlt')}
onDragStart={onDragStart}
onDragStart={disableDrag}
/>
) : null}
<div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,8 +45,22 @@ export const MessageContentWithStatuses = (props: Props) => {
[window.contextMenuShown, props?.messageId, multiSelectMode, props?.isDetailView]
);
const onDoubleClickReplyToMessage = () => {
void replyToMessage(messageId);
const onDoubleClickReplyToMessage = (e: React.MouseEvent<HTMLDivElement>) => {
const currentSelection = window.getSelection();
const currentSelectionString = currentSelection?.toString() || undefined;
// if multiple word are selected, consider that this double click was actually NOT used to reply to
// but to select
if (
!currentSelectionString ||
currentSelectionString.length === 0 ||
!currentSelectionString.includes(' ')
) {
void replyToMessage(messageId);
currentSelection?.empty();
e.preventDefault();
return;
}
};
const { messageId, onQuoteClick, ctxMenuID, isDetailView } = props;
@ -61,7 +75,7 @@ export const MessageContentWithStatuses = (props: Props) => {
className={classNames('module-message', `module-message--${direction}`)}
role="button"
onClick={onClickOnMessageOuterContainer}
onDoubleClick={onDoubleClickReplyToMessage}
onDoubleClickCapture={onDoubleClickReplyToMessage}
style={{ width: hasAttachments && isTrustedForAttachmentDownload ? 'min-content' : 'auto' }}
>
<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__inner">
<div className="module-message__link-preview__icon-container__circle-background">
<SessionIcon iconType="link" iconSize={'small'} />
<SessionIcon iconType="link" iconSize="small" />
</div>
</div>
</div>

View File

@ -16,7 +16,7 @@ const MessageStatusSending = () => {
const iconColor = 'var(--color-text)';
return (
<MessageStatusSendingContainer>
<SessionIcon rotateDuration={2} iconColor={iconColor} iconType="sending" iconSize={'tiny'} />
<SessionIcon rotateDuration={2} iconColor={iconColor} iconType="sending" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
@ -26,7 +26,7 @@ const MessageStatusSent = () => {
return (
<MessageStatusSendingContainer>
<SessionIcon iconColor={iconColor} iconType="circleCheck" iconSize={'tiny'} />
<SessionIcon iconColor={iconColor} iconType="circleCheck" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
@ -36,7 +36,7 @@ const MessageStatusRead = () => {
return (
<MessageStatusSendingContainer>
<SessionIcon iconColor={iconColor} iconType="doubleCheckCircleFilled" iconSize={'tiny'} />
<SessionIcon iconColor={iconColor} iconType="doubleCheckCircleFilled" iconSize="tiny" />
</MessageStatusSendingContainer>
);
};
@ -48,7 +48,7 @@ const MessageStatusError = () => {
return (
<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>
);
};

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="session-modal__header__close">
{showExitIcon ? (
<SessionIconButton iconType="exit" iconSize={'small'} onClick={this.close} />
<SessionIconButton iconType="exit" iconSize="small" onClick={this.close} />
) : null}
</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} />}
{buttonIcon && (
<SessionButton onClick={buttonClicked} key="compose">
<SessionIcon iconType={buttonIcon} iconSize={'small'} iconColor="white" />
<SessionIcon iconType={buttonIcon} iconSize="small" iconColor="white" />
</SessionButton>
)}
</div>
@ -80,6 +80,7 @@ const BannerInner = () => {
buttonType={SessionButtonType.Default}
text={window.i18n('recoveryPhraseRevealButtonText')}
onClick={showRecoveryPhraseModal}
dataTestId="reveal-recovery-phrase"
/>
</StyledBannerInner>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ type Props = {
onUnselect?: (selectedMember: ContactType) => void;
};
const AvatarItem = (props: { memberPubkey?: string }) => {
const AvatarItem = (props: { memberPubkey: string }) => {
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="session-modal__header__close">
{showExitIcon ? (
<SessionIconButton iconType="exit" iconSize={'small'} onClick={props.onClose} />
<SessionIconButton iconType="exit" iconSize="small" onClick={props.onClose} />
) : null}
</div>
<div className="session-modal__header__title">{title}</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,9 @@ export type SessionIconType =
| 'arrow'
| 'bell'
| 'brand'
| 'callIncoming'
| 'callMissed'
| 'callOutgoing'
| 'caret'
| 'chatBubble'
| 'check'
@ -95,6 +98,24 @@ export const icons = {
viewBox: '0 0 404.085 448.407',
ratio: 1,
},
callIncoming: {
path:
'M14.414 7l3.293-3.293a1 1 0 00-1.414-1.414L13 5.586V4a1 1 0 10-2 0v4.003a.996.996 0 00.617.921A.997.997 0 0012 9h4a1 1 0 100-2h-1.586zM2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z',
viewBox: '0 0 20 20',
ratio: 1,
},
callOutgoing: {
path:
'M17.924 2.617a.997.997 0 00-.215-.322l-.004-.004A.997.997 0 0017 2h-4a1 1 0 100 2h1.586l-3.293 3.293a1 1 0 001.414 1.414L16 5.414V7a1 1 0 102 0V3a.997.997 0 00-.076-.383zM2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z',
viewBox: '0 0 20 20',
ratio: 1,
},
callMissed: {
path:
'M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3zM16.707 3.293a1 1 0 010 1.414L15.414 6l1.293 1.293a1 1 0 01-1.414 1.414L14 7.414l-1.293 1.293a1 1 0 11-1.414-1.414L12.586 6l-1.293-1.293a1 1 0 011.414-1.414L14 4.586l1.293-1.293a1 1 0 011.414 0z',
viewBox: '0 0 20 20',
ratio: 1,
},
caret: {
path: 'M127.5 191.25L255 63.75L0 63.75L127.5 191.25Z',
viewBox: '-200 -200 640 640',

View File

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

View File

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

View File

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

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

View File

@ -39,3 +39,16 @@ export function useOurConversationUsername() {
export function useIsMe(pubkey?: string) {
return pubkey && pubkey === UserUtils.getOurPubKeyStrFromCache();
}
export function useIsClosedGroup(convoId?: string) {
return useSelector((state: StateType) => {
if (!convoId) {
return false;
}
const convo = state.conversations.conversationLookup[convoId];
if (!convo) {
return false;
}
return (convo.isGroup && !convo.isPublic) || false;
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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