Various UI fixes (#2070)

* cleanup unused convo json fields in db

* display a toast if the user is not approved yet on call OFFER received

* enable CBR for calls

* do not update active_at on configMessage if !!active_at

* remove mkdirp dependency

* disable call button if focused convo is blocked

* quote: do not include the full body in quote, but just the first 100

* click on the edit profile qr code padding

* Allow longer input for opengroup join overlay

Fixes #2068

* Fix overlay feature for start new session button

* make ringing depend on redux CALL status

* turn ON read-receipt by default
pull/2071/head
Audric Ackermann 3 years ago committed by GitHub
parent 273d866b98
commit 48e7a0e25f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,6 +1,4 @@
const importOnce = require('node-sass-import-once');
const rimraf = require('rimraf');
const mkdirp = require('mkdirp');
const sass = require('node-sass');
/* eslint-disable more/no-then, no-console */
@ -39,7 +37,6 @@ module.exports = grunt => {
'js/curve/curve25519_wrapper.js',
'node_modules/libsodium/dist/modules/libsodium.js',
'node_modules/libsodium-wrappers/dist/modules/libsodium-wrappers.js',
'libtextsecure/libsignal-protocol.js',
'js/util_worker_tasks.js',
];
@ -169,11 +166,6 @@ module.exports = grunt => {
updateLocalConfig({ commitHash: hash });
});
grunt.registerTask('clean-release', () => {
rimraf.sync('release');
mkdirp.sync('release');
});
grunt.registerTask('dev', ['default', 'watch']);
grunt.registerTask('date', ['gitinfo', 'getExpireTime']);
grunt.registerTask('default', [

@ -156,6 +156,7 @@
"spellCheckDirty": "You must restart Session to apply your new settings",
"notifications": "Notifications",
"readReceiptSettingDescription": "See and share when messages have been read (enables read receipts in all sessions).",
"readReceiptDialogDescription": "Read Receipts are now turned ON by default. Click \"Cancel\" to turn them down.",
"readReceiptSettingTitle": "Read Receipts",
"typingIndicatorsSettingDescription": "See and share when messages are being typed (applies to all sessions).",
"typingIndicatorsSettingTitle": "Typing Indicators",
@ -458,6 +459,7 @@
"noAudioOutputFound": "No audio output found",
"callMediaPermissionsTitle": "Voice and video calls",
"callMissedCausePermission": "Call missed from '$name$' because you need to enable the 'Voice and video calls' permission in the Privacy Settings.",
"callMissedNotApproved": "Call missed from '$name$' as you haven't approved this conversation yet. Send a message to him first.",
"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",

@ -6,7 +6,6 @@ const fs = require('fs');
const electron = require('electron');
const bunyan = require('bunyan');
const mkdirp = require('mkdirp');
const _ = require('lodash');
const readFirstLine = require('firstline');
const readLastLines = require('read-last-lines').read;
@ -31,7 +30,7 @@ function initialize() {
const basePath = app.getPath('userData');
const logPath = path.join(basePath, 'logs');
mkdirp.sync(logPath);
fs.mkdirSync(logPath, { recursive: true });
return cleanupLogs(logPath).then(() => {
if (logger) {
@ -63,7 +62,7 @@ function initialize() {
});
ipc.on('fetch-log', event => {
mkdirp.sync(logPath);
fs.mkdirSync(logPath, { recursive: true });
fetch(logPath).then(
data => {
@ -125,7 +124,7 @@ async function cleanupLogs(logPath) {
// delete and re-create the log directory
await deleteAllLogs(logPath);
mkdirp.sync(logPath);
fs.mkdirSync(logPath, { recursive: true });
}
}
@ -221,7 +220,7 @@ function fetch(logPath) {
// Check that the file exists locally
if (!fs.existsSync(logPath)) {
console._log('Log folder not found while fetching its content. Quick! Creating it.');
mkdirp.sync(logPath);
fs.mkdirSync(logPath, { recursive: true });
}
const files = fs.readdirSync(logPath);
const paths = files.map(file => path.join(logPath, file));

@ -1,12 +1,11 @@
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const { app } = require('electron').remote;
const userDataPath = app.getPath('userData');
const PATH = path.join(userDataPath, 'profileImages');
mkdirp.sync(PATH);
fs.mkdirSync(PATH, { recursive: true });
const hasImage = pubKey => fs.existsSync(getImagePath(pubKey));

@ -1,5 +1,5 @@
const path = require('path');
const mkdirp = require('mkdirp');
const fs = require('fs');
const rimraf = require('rimraf');
const SQL = require('better-sqlite3');
const { app, dialog, clipboard } = require('electron');
@ -72,6 +72,7 @@ module.exports = {
getNextExpiringMessage,
getMessagesByConversation,
getFirstUnreadMessageIdInConversation,
hasConversationOutgoingMessage,
getUnprocessedCount,
getAllUnprocessed,
@ -1240,7 +1241,11 @@ function updateToLokiSchemaVersion17(currentVersion, db) {
UPDATE ${CONVERSATIONS_TABLE} SET
json = json_set(json, '$.isApproved', 1)
`);
// remove the moderators field. As it was only used for opengroups a long time ago and whatever is there is probably unused
db.exec(`
UPDATE ${CONVERSATIONS_TABLE} SET
json = json_remove(json, '$.moderators', '$.dataMessage', '$.accessKey', '$.profileSharing', '$.sessionRestoreSeen')
`);
writeLokiSchemaVersion(targetVersion, db);
})();
console.log(`updateToLokiSchemaVersion${targetVersion}: success!`);
@ -1311,8 +1316,7 @@ let databaseFilePath;
function _initializePaths(configDir) {
const dbDir = path.join(configDir, 'sql');
mkdirp.sync(dbDir);
fs.mkdirSync(dbDir, { recursive: true });
databaseFilePath = path.join(dbDir, 'db.sqlite');
}
@ -1357,7 +1361,9 @@ function initialize({ configDir, key, messages, passwordAttempt }) {
// Clear any already deleted db entries on each app start.
vacuumDatabase(db);
const msgCount = getMessageCount();
console.warn('total message count: ', msgCount);
const convoCount = getConversationCount();
console.info('total message count: ', msgCount);
console.info('total conversation count: ', convoCount);
} catch (error) {
if (passwordAttempt) {
throw error;
@ -2171,6 +2177,27 @@ function getMessagesByConversation(
return map(rows, row => jsonToObject(row.json));
}
function hasConversationOutgoingMessage(conversationId) {
const row = globalInstance
.prepare(
`
SELECT count(*) FROM ${MESSAGES_TABLE} WHERE
conversationId = $conversationId AND
type IS 'outgoing'
`
)
.get({
conversationId,
});
if (!row) {
throw new Error('hasConversationOutgoingMessage: Unable to get coun');
}
console.warn('hasConversationOutgoingMessage', row);
return Boolean(row['count(*)']);
}
function getFirstUnreadMessageIdInConversation(conversationId) {
const rows = globalInstance
.prepare(

@ -6,7 +6,6 @@
const { ipcRenderer } = require('electron');
const _ = require('lodash');
const debuglogs = require('./modules/debuglogs');
const Privacy = require('./modules/privacy');
const ipc = ipcRenderer;
@ -100,7 +99,6 @@ function fetch() {
});
}
const publish = debuglogs.upload;
const development = window.getEnvironment() !== 'production';
// A modern logging interface for the browser
@ -127,7 +125,6 @@ window.log = {
debug: _.partial(logAtLevel, 'debug', 'DEBUG'),
trace: _.partial(logAtLevel, 'trace', 'TRACE'),
fetch,
publish,
};
window.onerror = (message, script, line, col, error) => {

@ -1,53 +0,0 @@
/* eslint-env node */
/* global window */
const FormData = require('form-data');
const insecureNodeFetch = require('node-fetch');
const BASE_URL = 'https://debuglogs.org';
const VERSION = window.getVersion();
const USER_AGENT = `Session ${VERSION}`;
// upload :: String -> Promise URL
exports.upload = async content => {
window.log.warn('insecureNodeFetch => upload debugLogs');
const signedForm = await insecureNodeFetch(BASE_URL, {
headers: {
'user-agent': USER_AGENT,
},
});
const json = await signedForm.json();
if (!signedForm.ok || !json) {
throw new Error('Failed to retrieve token');
}
const { fields, url } = json;
const form = new FormData();
// The API expects `key` to be the first field:
form.append('key', fields.key);
Object.entries(fields)
.filter(([key]) => key !== 'key')
.forEach(([key, value]) => {
form.append(key, value);
});
const contentBuffer = Buffer.from(content, 'utf8');
const contentType = 'text/plain';
form.append('Content-Type', contentType);
form.append('file', contentBuffer, {
contentType,
filename: `session-desktop-debug-log-${VERSION}.txt`,
});
const result = await insecureNodeFetch(url, {
method: 'POST',
body: form,
});
const { status } = result;
if (status !== 204) {
throw new Error(`Failed to upload to S3, got status ${status}`);
}
return `${BASE_URL}/${fields.key}`;
};

@ -88,7 +88,6 @@
"lodash": "4.17.11",
"long": "^4.0.0",
"mic-recorder-to-mp3": "^2.2.2",
"mkdirp": "0.5.1",
"moment": "2.21.0",
"mustache": "2.3.0",
"nan": "2.14.2",
@ -153,7 +152,6 @@
"@types/libsodium-wrappers": "^0.7.8",
"@types/linkify-it": "2.0.3",
"@types/lodash": "4.14.106",
"@types/mkdirp": "0.5.2",
"@types/mocha": "5.0.0",
"@types/node-fetch": "^2.5.7",
"@types/pify": "3.0.2",

@ -226,8 +226,8 @@
justify-content: center;
position: absolute;
right: -3px;
height: 26px;
width: 26px;
height: 30px;
width: 30px;
border-radius: 50%;
background-color: $session-color-white;
transition: $session-transition-duration;

@ -149,12 +149,6 @@ $session-compose-margin: 20px;
&__list {
height: -webkit-fill-available;
&-popup {
width: -webkit-fill-available;
height: -webkit-fill-available;
position: absolute;
}
}
&-overlay {

@ -13,6 +13,7 @@ import {
getConversationHeaderProps,
getConversationHeaderTitleProps,
getCurrentNotificationSettingText,
getIsSelectedBlocked,
getIsSelectedNoteToSelf,
getIsSelectedPrivate,
getSelectedConversationIsPublic,
@ -198,6 +199,7 @@ const BackButton = (props: { onGoBack: () => void; showBackButton: boolean }) =>
const CallButton = () => {
const isPrivate = useSelector(getIsSelectedPrivate);
const isBlocked = useSelector(getIsSelectedBlocked);
const isMe = useSelector(getIsSelectedNoteToSelf);
const selectedConvoKey = useSelector(getSelectedConversationKey);
@ -205,7 +207,7 @@ const CallButton = () => {
const hasOngoingCall = useSelector(getHasOngoingCall);
const canCall = !(hasIncomingCall || hasOngoingCall);
if (!isPrivate || isMe || !selectedConvoKey) {
if (!isPrivate || isMe || !selectedConvoKey || isBlocked) {
return null;
}

@ -150,15 +150,14 @@ export class EditProfileDialog extends React.Component<{}, State> {
name="name"
onChange={this.onFileSelected}
/>
<div className="qr-view-button">
<SessionIconButton
iconType="qr"
iconSize={'small'}
iconColor={'rgb(0, 0, 0)'}
onClick={() => {
this.setState(state => ({ ...state, mode: 'qr' }));
}}
/>
<div
className="qr-view-button"
onClick={() => {
this.setState(state => ({ ...state, mode: 'qr' }));
}}
role="button"
>
<SessionIconButton iconType="qr" iconSize="small" iconColor={'rgb(0, 0, 0)'} />
</div>
</div>
</div>

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { Dispatch, useEffect, useState } from 'react';
import { SessionIconButton } from './icon';
import { Avatar, AvatarSize } from '../Avatar';
import { SessionToastContainer } from './SessionToastContainer';
@ -6,6 +6,7 @@ import { getConversationController } from '../../session/conversations';
import { syncConfigurationIfNeeded } from '../../session/utils/syncUtils';
import {
createOrUpdateItem,
generateAttachmentKeyIfEmpty,
getAllOpenGroupV1Conversations,
getItemById,
@ -36,7 +37,11 @@ import { forceRefreshRandomSnodePool } from '../../session/snode_api/snodePool';
import { getSwarmPollingInstance } from '../../session/snode_api';
import { DURATION } from '../../session/constants';
import { conversationChanged, conversationRemoved } from '../../state/ducks/conversations';
import { editProfileModal, onionPathModal } from '../../state/ducks/modalDialog';
import {
editProfileModal,
onionPathModal,
updateConfirmModal,
} from '../../state/ducks/modalDialog';
import { uploadOurAvatar } from '../../interactions/conversationInteractions';
import { ModalContainer } from '../dialog/ModalContainer';
import { debounce } from 'underscore';
@ -49,7 +54,29 @@ import { switchHtmlToDarkTheme, switchHtmlToLightTheme } from '../../state/ducks
import { DraggableCallContainer } from './calling/DraggableCallContainer';
import { IncomingCallDialog } from './calling/IncomingCallDialog';
import { CallInFullScreenContainer } from './calling/CallInFullScreenContainer';
import { SessionButtonColor } from './SessionButton';
import { settingsReadReceipt } from './settings/section/CategoryPrivacy';
async function showTurnOnReadAck(dispatch: Dispatch<any>) {
const singleShotSettingId = 'read-receipt-turn-on-asked';
const item = (await getItemById(singleShotSettingId))?.value || false;
if (!item) {
await createOrUpdateItem({ id: singleShotSettingId, value: true });
// set it to true by default, user will be asked to willingfully turn it off
window.setSettingValue(settingsReadReceipt, true);
dispatch(
updateConfirmModal({
title: window.i18n('readReceiptSettingTitle'),
messageSub: window.i18n('readReceiptDialogDescription'),
okTheme: SessionButtonColor.Green,
onClickCancel: () => {
window.setSettingValue(settingsReadReceipt, false);
},
})
);
}
}
const Section = (props: { type: SectionType }) => {
const ourNumber = useSelector(getOurNumber);
const unreadMessageCount = useSelector(getUnreadMessageCount);
@ -227,7 +254,7 @@ const triggerAvatarReUploadIfNeeded = async () => {
/**
* This function is called only once: on app startup with a logged in user
*/
const doAppStartUp = () => {
const doAppStartUp = (dispatch: Dispatch<any>) => {
// init the messageQueue. In the constructor, we add all not send messages
// this call does nothing except calling the constructor, which will continue sending message in the pipeline
void getMessageQueue().processAllPending();
@ -246,6 +273,8 @@ const doAppStartUp = () => {
void loadDefaultRooms();
void showTurnOnReadAck(dispatch);
debounce(triggerAvatarReUploadIfNeeded, 200);
};
@ -267,10 +296,11 @@ export const ActionsPanel = () => {
const [startCleanUpMedia, setStartCleanUpMedia] = useState(false);
const ourPrimaryConversation = useSelector(getOurPrimaryConversation);
const dispatch = useDispatch();
// this maxi useEffect is called only once: when the component is mounted.
// For the action panel, it means this is called only one per app start/with a user loggedin
useEffect(() => {
void doAppStartUp();
void doAppStartUp(dispatch);
}, []);
// wait for cleanUpMediasInterval and then start cleaning up medias

@ -53,7 +53,7 @@ const ContactListItemSection = () => {
export const LeftPaneContactSection = () => {
return (
<div className="left-pane-contact-section">
<LeftPaneSectionHeader label={window.i18n('contactsHeader')} />
<LeftPaneSectionHeader />
<div className="left-pane-contact-content">
<ContactListItemSection />
</div>

@ -139,22 +139,12 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
}
}
public renderHeader(): JSX.Element {
return (
<LeftPaneSectionHeader
label={window.i18n('messagesHeader')}
buttonIcon="plus"
buttonClicked={this.handleNewSessionButtonClick}
/>
);
}
public render(): JSX.Element {
const { overlay } = this.state;
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
<LeftPaneSectionHeader buttonClicked={this.handleNewSessionButtonClick} />
{overlay ? this.renderClosableOverlay() : this.renderConversations()}
</div>
);

@ -1,6 +1,4 @@
import React from 'react';
import classNames from 'classnames';
import { SessionIcon, SessionIconType } from './icon';
import styled from 'styled-components';
import { SessionButton, SessionButtonType } from './SessionButton';
import { useDispatch, useSelector } from 'react-redux';
@ -11,52 +9,42 @@ import { Flex } from '../basic/Flex';
import { getFocusedSection } from '../../state/selectors/section';
import { SectionType } from '../../state/ducks/section';
import { UserUtils } from '../../session/utils';
import { SessionIcon } from './icon';
const Tab = ({
isSelected,
label,
onSelect,
type,
}: {
isSelected: boolean;
label: string;
onSelect?: (event: number) => void;
type: number;
}) => {
const handleClick = onSelect
? () => {
onSelect(type);
}
: undefined;
return (
<h1
className={classNames('module-left-pane__title', isSelected ? 'active' : null)}
onClick={handleClick}
role="button"
>
{label}
</h1>
);
};
type Props = {
label?: string;
buttonIcon?: SessionIconType;
buttonClicked?: any;
};
const SectionTitle = styled.h1`
padding: 0 var(--margins-sm);
flex-grow: 1;
color: var(--color-text);
`;
export const LeftPaneSectionHeader = (props: Props) => {
const { label, buttonIcon, buttonClicked } = props;
export const LeftPaneSectionHeader = (props: { buttonClicked?: any }) => {
const showRecoveryPhrasePrompt = useSelector(getShowRecoveryPhrasePrompt);
const focusedSection = useSelector(getFocusedSection);
let label: string | undefined;
const isMessageSection = focusedSection === SectionType.Message;
switch (focusedSection) {
case SectionType.Contact:
label = window.i18n('contactsHeader');
break;
case SectionType.Settings:
label = window.i18n('settingsHeader');
break;
case SectionType.Message:
label = window.i18n('messagesHeader');
break;
default:
}
return (
<Flex flexDirection="column">
<div className="module-left-pane__header">
{label && <Tab label={label} type={0} isSelected={true} key={label} />}
{buttonIcon && (
<SessionButton onClick={buttonClicked} key="compose">
<SessionIcon iconType={buttonIcon} iconSize="small" iconColor="white" />
<SectionTitle>{label}</SectionTitle>
{isMessageSection && (
<SessionButton onClick={props.buttonClicked}>
<SessionIcon iconType="plus" iconSize="small" iconColor="white" />
</SessionButton>
)}
</div>

@ -113,7 +113,7 @@ const LeftPaneBottomButtons = () => {
export const LeftPaneSettingSection = () => {
return (
<div className="left-pane-setting-section">
<LeftPaneSectionHeader label={window.i18n('settingsHeader')} />
<LeftPaneSectionHeader />
<div className="left-pane-setting-content">
<LeftPaneSettingsCategories />
<LeftPaneBottomButtons />

@ -26,9 +26,9 @@ const StyledMessageRequestBannerHeader = styled.span`
font-weight: bold;
font-size: 15px;
color: var(--color-text-subtle);
padding-left: var(--margin-xs);
padding-left: var(--margins-xs);
margin-inline-start: 12px;
margin-top: var(--margin-sm);
margin-top: var(--margins-sm);
line-height: 18px;
overflow-x: hidden;
overflow-y: hidden;
@ -37,7 +37,7 @@ const StyledMessageRequestBannerHeader = styled.span`
`;
const StyledCircleIcon = styled.div`
padding-left: var(--margin-xs);
padding-left: var(--margins-xs);
`;
const StyledUnreadCounter = styled.div`

@ -13,6 +13,7 @@ import { useSelector } from 'react-redux';
import { getConversationRequests } from '../../state/selectors/conversations';
import { MemoConversationListItemWithDetails } from '../ConversationListItem';
import styled from 'styled-components';
// tslint:disable: use-simple-attributes
export enum SessionClosableOverlayType {
Message = 'message',
@ -178,7 +179,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
placeholder={placeholder}
value={groupName}
isGroup={true}
maxLength={100}
maxLength={isOpenGroupView ? 300 : 100}
onChange={this.onGroupNameChanged}
onPressEnter={() => onButtonClick(groupName, selectedMembers)}
/>

@ -6,7 +6,7 @@ const SessionToastContainerPrivate = () => {
return (
<WrappedToastContainer
position="bottom-right"
autoClose={3000}
autoClose={5000}
hideProgressBar={true}
newestOnTop={true}
closeOnClick={true}

@ -16,7 +16,6 @@ import {
getPinConversationMenuItem,
getRemoveModeratorsMenuItem,
getShowUserDetailsMenuItem,
getStartCallMenuItem,
getUpdateGroupNameMenuItem,
} from './Menu';
import _ from 'lodash';
@ -62,7 +61,6 @@ const ConversationHeaderMenu = (props: PropsConversationHeaderMenu) => {
return (
<Menu id={triggerId} animation={animation.fade}>
{getStartCallMenuItem(conversationId)}
{getDisappearingMenuItem(isPublic, isKickedFromGroup, left, isBlocked, conversationId)}
{getNotificationForConvoMenuItem({
isKickedFromGroup,

@ -1,6 +1,5 @@
import React from 'react';
import { getHasIncomingCall, getHasOngoingCall } from '../../../state/selectors/call';
import { getNumberOfPinnedConversations } from '../../../state/selectors/conversations';
import { getFocusedSection } from '../../../state/selectors/section';
import { Item, Submenu } from 'react-contexify';
@ -18,7 +17,6 @@ import { SectionType } from '../../../state/ducks/section';
import { getConversationController } from '../../../session/conversations';
import {
blockConvoById,
callRecipient,
clearNickNameByConvoId,
copyPublicKeyByConvoId,
deleteAllMessagesByConvoIdWithConfirmation,
@ -347,32 +345,6 @@ export function getMarkAllReadMenuItem(conversationId: string): JSX.Element | nu
);
}
export function getStartCallMenuItem(conversationId: string): JSX.Element | null {
if (window?.lokiFeatureFlags.useCallMessage) {
const convoOut = getConversationController().get(conversationId);
// we don't support calling groups
const hasIncomingCall = useSelector(getHasIncomingCall);
const hasOngoingCall = useSelector(getHasOngoingCall);
const canCall = !(hasIncomingCall || hasOngoingCall);
if (!convoOut?.isPrivate() || convoOut.isMe()) {
return null;
}
return (
<Item
onClick={() => {
void callRecipient(conversationId, canCall);
}}
>
{window.i18n('menuCall')}
</Item>
);
}
return null;
}
export function getDisappearingMenuItem(
isPublic: boolean | undefined,
isKickedFromGroup: boolean | undefined,

@ -10,7 +10,7 @@ import { PasswordAction } from '../../../dialog/SessionPasswordDialog';
import { SessionButtonColor } from '../../SessionButton';
import { SessionSettingButtonItem, SessionToggleWithDescription } from '../SessionSettingListItem';
const settingsReadReceipt = 'read-receipt-setting';
export const settingsReadReceipt = 'read-receipt-setting';
const settingsTypingIndicator = 'typing-indicators-setting';
const settingsAutoUpdate = 'auto-update';

@ -120,6 +120,7 @@ const channelsToMake = {
getNextExpiringMessage,
getMessagesByConversation,
getFirstUnreadMessageIdInConversation,
hasConversationOutgoingMessage,
getSeenMessagesByHashList,
getLastHashBySnode,
@ -763,6 +764,9 @@ export async function getFirstUnreadMessageIdInConversation(
return channels.getFirstUnreadMessageIdInConversation(conversationId);
}
export async function hasConversationOutgoingMessage(conversationId: string): Promise<boolean> {
return channels.hasConversationOutgoingMessage(conversationId);
}
export async function getLastHashBySnode(convoId: string, snode: string): Promise<string> {
return channels.getLastHashBySnode(convoId, snode);
}

@ -82,12 +82,10 @@ export interface ConversationAttributes {
active_at: number;
lastJoinedTimestamp: number; // ClosedGroup: last time we were added to this group
groupAdmins?: Array<string>;
moderators?: Array<string>; // TODO to merge to groupAdmins with a migration on the db
isKickedFromGroup?: boolean;
avatarPath?: string;
isMe?: boolean;
subscriberCount?: number;
sessionRestoreSeen?: boolean;
is_medium_group?: boolean;
type: string;
avatarPointer?: string;
@ -124,12 +122,10 @@ export interface ConversationAttributesOptionals {
timestamp?: number; // timestamp of what?
lastJoinedTimestamp?: number;
groupAdmins?: Array<string>;
moderators?: Array<string>;
isKickedFromGroup?: boolean;
avatarPath?: string;
isMe?: boolean;
subscriberCount?: number;
sessionRestoreSeen?: boolean;
is_medium_group?: boolean;
type: string;
avatarPointer?: string;
@ -164,11 +160,9 @@ export const fillConvoAttributesWithDefaults = (
lastMessageStatus: null,
lastJoinedTimestamp: new Date('1970-01-01Z00:00:00:000').getTime(),
groupAdmins: [],
moderators: [],
isKickedFromGroup: false,
isMe: false,
subscriberCount: 0,
sessionRestoreSeen: false,
is_medium_group: false,
lastMessage: null,
expireTimer: 0,
@ -280,6 +274,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public isMediumGroup() {
return this.get('is_medium_group');
}
/**
* Returns true if this conversation is active
* i.e. the conversation is visibie on the left pane. (Either we or another user created this convo).
@ -290,99 +285,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return Boolean(this.get('active_at'));
}
public async bumpTyping() {
// We don't send typing messages if the setting is disabled
// or we blocked that user
if (
this.isPublic() ||
this.isMediumGroup() ||
!this.isActive() ||
!window.storage.get('typing-indicators-setting') ||
this.isBlocked()
) {
return;
}
if (!this.typingRefreshTimer) {
const isTyping = true;
this.setTypingRefreshTimer();
this.sendTypingMessage(isTyping);
}
this.setTypingPauseTimer();
}
public setTypingRefreshTimer() {
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
}
this.typingRefreshTimer = global.setTimeout(this.onTypingRefreshTimeout.bind(this), 10 * 1000);
}
public onTypingRefreshTimeout() {
const isTyping = true;
this.sendTypingMessage(isTyping);
// This timer will continue to reset itself until the pause timer stops it
this.setTypingRefreshTimer();
}
public setTypingPauseTimer() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
}
this.typingPauseTimer = global.setTimeout(this.onTypingPauseTimeout.bind(this), 10 * 1000);
}
public onTypingPauseTimeout() {
const isTyping = false;
this.sendTypingMessage(isTyping);
this.clearTypingTimers();
}
public clearTypingTimers() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
this.typingPauseTimer = null;
}
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
this.typingRefreshTimer = null;
}
}
public sendTypingMessage(isTyping: boolean) {
if (!this.isPrivate()) {
return;
}
const recipientId = this.id;
if (!recipientId) {
throw new Error('Need to provide either recipientId');
}
const primaryDevicePubkey = window.storage.get('primaryDevicePubKey');
if (recipientId && primaryDevicePubkey === recipientId) {
// note to self
return;
}
const typingParams = {
timestamp: Date.now(),
isTyping,
typingTimestamp: Date.now(),
};
const typingMessage = new TypingMessage(typingParams);
// send the message to a single recipient if this is a session chat
const device = new PubKey(recipientId);
getMessageQueue()
.sendToPubKey(device, typingMessage)
.catch(window?.log?.error);
}
public async cleanup() {
const { deleteAttachmentData } = window.Signal.Migrations;
await window.Signal.Types.Conversation.deleteExternalFiles(this.attributes, {
@ -409,12 +311,10 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
// removeMessage();
}
public getGroupAdmins() {
public getGroupAdmins(): Array<string> {
const groupAdmins = this.get('groupAdmins');
if (groupAdmins?.length) {
return groupAdmins;
}
return this.get('moderators');
return groupAdmins && groupAdmins?.length > 0 ? groupAdmins : [];
}
// tslint:disable-next-line: cyclomatic-complexity
@ -558,9 +458,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const newAdmins = _.uniq(_.sortBy(groupAdmins));
if (_.isEqual(existingAdmins, newAdmins)) {
// window?.log?.info(
// 'Skipping updates of groupAdmins/moderators. No change detected.'
// );
return;
}
this.set({ groupAdmins });
@ -694,7 +591,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return {
author: quotedMessage.getSource(),
id: `${quotedMessage.get('sent_at')}` || '',
text: body,
// no need to quote the full message length.
text: body?.slice(0, 100),
attachments: quotedAttachments,
timestamp: quotedMessage.get('sent_at') || 0,
convoId: this.id,
@ -1626,7 +1524,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
await this.commit();
}
} else {
// tslint:disable-next-line: no-dynamic-delete
this.typingTimer = null;
if (wasTyping) {
// User was previously typing, and is no longer. State change!
@ -1635,7 +1532,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
}
public async clearContactTypingTimer(_sender: string) {
private async clearContactTypingTimer(_sender: string) {
if (!!this.typingTimer) {
global.clearTimeout(this.typingTimer);
this.typingTimer = null;
@ -1654,6 +1551,112 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return typeof expireTimer === 'number' && expireTimer > 0;
}
private shouldDoTyping() {
// for typing to happen, this must be a private unblocked active convo, and the settings to be on
if (
!this.isActive() ||
!window.storage.get('typing-indicators-setting') ||
this.isBlocked() ||
!this.isPrivate()
) {
return false;
}
const msgRequestsEnabled =
window.lokiFeatureFlags.useMessageRequests &&
window.inboxStore?.getState().userConfig.messageRequests;
// if msg requests are unused, we have to send typing (this is already a private active unblocked convo)
if (!msgRequestsEnabled) {
return true;
}
// with message requests in use, we just need to check for isApproved
return Boolean(this.get('isApproved'));
}
private async bumpTyping() {
if (!this.shouldDoTyping()) {
return;
}
if (!this.typingRefreshTimer) {
const isTyping = true;
this.setTypingRefreshTimer();
this.sendTypingMessage(isTyping);
}
this.setTypingPauseTimer();
}
private setTypingRefreshTimer() {
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
}
this.typingRefreshTimer = global.setTimeout(this.onTypingRefreshTimeout.bind(this), 10 * 1000);
}
private onTypingRefreshTimeout() {
const isTyping = true;
this.sendTypingMessage(isTyping);
// This timer will continue to reset itself until the pause timer stops it
this.setTypingRefreshTimer();
}
private setTypingPauseTimer() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
}
this.typingPauseTimer = global.setTimeout(this.onTypingPauseTimeout.bind(this), 10 * 1000);
}
private onTypingPauseTimeout() {
const isTyping = false;
this.sendTypingMessage(isTyping);
this.clearTypingTimers();
}
private clearTypingTimers() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
this.typingPauseTimer = null;
}
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
this.typingRefreshTimer = null;
}
}
private sendTypingMessage(isTyping: boolean) {
if (!this.isPrivate()) {
return;
}
const recipientId = this.id;
if (!recipientId) {
throw new Error('Need to provide either recipientId');
}
if (this.isMe()) {
// note to self
return;
}
const typingParams = {
timestamp: Date.now(),
isTyping,
typingTimestamp: Date.now(),
};
const typingMessage = new TypingMessage(typingParams);
// send the message to a single recipient if this is a session chat
const device = new PubKey(recipientId);
getMessageQueue()
.sendToPubKey(device, typingMessage)
.catch(window?.log?.error);
}
}
export class ConversationCollection extends Backbone.Collection<ConversationModel> {

@ -1117,18 +1117,6 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
await this.commit();
}
public async markMessageSyncOnly(dataMessage: DataMessage) {
this.set({
// These are the same as a normal send()
dataMessage,
sent_to: [UserUtils.getOurPubKeyStrFromCache()],
sent: true,
expirationStartTimestamp: Date.now(),
});
await this.commit();
}
public async saveErrors(providedErrors: any) {
let errors = providedErrors;

@ -49,7 +49,7 @@ export interface MessageAttributes {
*/
timestamp?: number;
status?: MessageDeliveryStatus;
dataMessage: any;
// dataMessage: any;
sent_to: any;
sent: boolean;

@ -199,10 +199,12 @@ export class OpenGroupManagerV2 {
await saveV2OpenGroupRoom(room);
// mark active so it's not in the contacts list but in the conversation list
// mark isApproved as this is a public chat
conversation.set({
active_at: Date.now(),
name: room.roomName,
avatarPath: room.roomName,
isApproved: true,
});
await conversation.commit();

@ -123,7 +123,7 @@ const handleContactReceived = async (
envelope: EnvelopePlus
) => {
try {
if (!contactReceived.publicKey) {
if (!contactReceived.publicKey?.length) {
return;
}
const contactConvo = await getConversationController().getOrCreateAndWait(
@ -134,8 +134,11 @@ const handleContactReceived = async (
displayName: contactReceived.name,
profilePictre: contactReceived.profilePicture,
};
// updateProfile will do a commit for us
contactConvo.set('active_at', _.toNumber(envelope.timestamp));
const existingActiveAt = contactConvo.get('active_at');
if (!existingActiveAt || existingActiveAt === 0) {
contactConvo.set('active_at', _.toNumber(envelope.timestamp));
}
if (
window.lokiFeatureFlags.useMessageRequests &&

@ -17,7 +17,7 @@ function startRinging() {
ringingAudio.loop = true;
ringingAudio.volume = 0.6;
}
void ringingAudio.play();
void ringingAudio.play().catch(window.log.info);
}
export function getIsRinging() {

@ -24,7 +24,8 @@ export function pushToastInfo(
id: string,
title: string,
description?: string,
onToastClick?: () => void
onToastClick?: () => void,
delay?: number
) {
toast.info(
<SessionToast
@ -33,7 +34,7 @@ export function pushToastInfo(
type={SessionToastType.Info}
onToastClick={onToastClick}
/>,
{ toastId: id, updateId: id }
{ toastId: id, updateId: id, delay }
);
}
@ -166,6 +167,14 @@ export function pushedMissedCallCauseOfPermission(conversationName: string) {
);
}
export function pushedMissedCallNotApproved(displayName: string) {
pushToastInfo(
'missedCall',
window.i18n('callMissedTitle'),
window.i18n('callMissedNotApproved', [displayName])
);
}
export function pushVideoCallPermissionNeeded() {
pushToastInfo(
'videoCallPermissionNeeded',

@ -20,11 +20,12 @@ import { PubKey } from '../../types';
import { v4 as uuidv4 } from 'uuid';
import { PnServer } from '../../../pushnotification';
import { getIsRinging, setIsRinging } from '../RingingManager';
import { getIsRinging } from '../RingingManager';
import { getBlackSilenceMediaStream } from './Silence';
import { getMessageQueue } from '../..';
import { MessageSender } from '../../sending';
import { DURATION } from '../../constants';
import { hasConversationOutgoingMessage } from '../../../data/data';
// tslint:disable: function-name
@ -386,10 +387,20 @@ async function createOfferAndSendIt(recipient: string) {
}
if (offer && offer.sdp) {
const lines = offer.sdp.split(/\r?\n/);
const lineWithFtmpIndex = lines.findIndex(f => f.startsWith('a=fmtp:111'));
const partBeforeComma = lines[lineWithFtmpIndex].split(';');
lines[lineWithFtmpIndex] = `${partBeforeComma[0]};cbr=1`;
let overridenSdps = lines.join('\n');
overridenSdps = overridenSdps.replace(
new RegExp('.+urn:ietf:params:rtp-hdrext:ssrc-audio-level.*\\r?\\n'),
''
);
const offerMessage = new CallMessage({
timestamp: Date.now(),
type: SignalService.CallMessage.Type.OFFER,
sdps: [offer.sdp],
sdps: [overridenSdps],
uuid: currentCallUUID,
});
@ -497,7 +508,6 @@ export async function USER_callRecipient(recipient: string) {
void PnServer.notifyPnServer(wrappedEnvelope, recipient);
await openMediaDevicesAndAddTracks();
setIsRinging(true);
await createOfferAndSendIt(recipient);
// close and end the call if callTimeoutMs is reached ans still not connected
@ -583,7 +593,6 @@ function handleConnectionStateChanged(pubkey: string) {
if (peerConnection?.signalingState === 'closed' || peerConnection?.connectionState === 'failed') {
closeVideoCall();
} else if (peerConnection?.connectionState === 'connected') {
setIsRinging(false);
const firstAudioInput = audioInputsList?.[0].deviceId || undefined;
if (firstAudioInput) {
void selectAudioInputByDeviceId(firstAudioInput);
@ -603,7 +612,6 @@ function handleConnectionStateChanged(pubkey: string) {
function closeVideoCall() {
window.log.info('closingVideoCall ');
currentCallStartTimestamp = undefined;
setIsRinging(false);
if (peerConnection) {
peerConnection.ontrack = null;
peerConnection.onicecandidate = null;
@ -687,7 +695,6 @@ function onDataChannelReceivedMessage(ev: MessageEvent<string>) {
}
function onDataChannelOnOpen() {
window.log.info('onDataChannelOnOpen: sending video status');
setIsRinging(false);
sendVideoStatusViaDataChannel();
}
@ -747,7 +754,6 @@ function createOrGetPeerConnection(withPubkey: string) {
export async function USER_acceptIncomingCallRequest(fromSender: string) {
window.log.info('USER_acceptIncomingCallRequest');
setIsRinging(false);
if (currentCallUUID) {
window.log.warn(
'Looks like we are already in a call as in USER_acceptIncomingCallRequest is not undefined'
@ -828,7 +834,6 @@ export async function USER_acceptIncomingCallRequest(fromSender: string) {
}
export async function rejectCallAlreadyAnotherCall(fromSender: string, forcedUUID: string) {
setIsRinging(false);
window.log.info(`rejectCallAlreadyAnotherCall ${ed25519Str(fromSender)}: ${forcedUUID}`);
rejectedCallUUIDS.add(forcedUUID);
const rejectCallMessage = new CallMessage({
@ -843,7 +848,6 @@ export async function rejectCallAlreadyAnotherCall(fromSender: string, forcedUUI
}
export async function USER_rejectIncomingCallRequest(fromSender: string) {
setIsRinging(false);
// close the popup call
window.inboxStore?.dispatch(endCall());
const lastOfferMessage = findLastMessageTypeFromSender(
@ -943,8 +947,6 @@ export async function handleCallTypeEndCall(sender: string, aboutCallUUID?: stri
(ongoingCallStatus === 'incoming' || ongoingCallStatus === 'connecting')
) {
// remote user hangup an offer he sent but we did not accept it yet
setIsRinging(false);
window.inboxStore?.dispatch(endCall());
}
}
@ -993,6 +995,18 @@ function getCachedMessageFromCallMessage(
};
}
async function isUserApprovedOrWeSentAMessage(user: string) {
const isApproved = getConversationController()
.get(user)
?.isApproved();
if (isApproved) {
return true;
}
return hasConversationOutgoingMessage(user);
}
export async function handleCallTypeOffer(
sender: string,
callMessage: SignalService.CallMessage,
@ -1009,7 +1023,16 @@ export async function handleCallTypeOffer(
const cachedMsg = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp);
pushCallMessageToCallCache(sender, remoteCallUUID, cachedMsg);
await handleMissedCall(sender, incomingOfferTimestamp, true);
await handleMissedCall(sender, incomingOfferTimestamp, 'permissions');
return;
}
const shouldDisplayOffer = await isUserApprovedOrWeSentAMessage(sender);
if (!shouldDisplayOffer) {
const cachedMsg = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp);
pushCallMessageToCallCache(sender, remoteCallUUID, cachedMsg);
await handleMissedCall(sender, incomingOfferTimestamp, 'not-approved');
return;
}
@ -1022,7 +1045,7 @@ export async function handleCallTypeOffer(
return;
}
// add a message in the convo with this user about the missed call.
await handleMissedCall(sender, incomingOfferTimestamp, false);
await handleMissedCall(sender, incomingOfferTimestamp, 'another-call-ongoing');
// Here, we are in a call, and we got an offer from someone we are in a call with, and not one of his other devices.
// Just hangup automatically the call on the calling side.
@ -1066,7 +1089,6 @@ export async function handleCallTypeOffer(
} else if (callerConvo) {
await callerConvo.notifyIncomingCall();
}
setIsRinging(true);
}
const cachedMessage = getCachedMessageFromCallMessage(callMessage, incomingOfferTimestamp);
@ -1079,22 +1101,26 @@ export async function handleCallTypeOffer(
export async function handleMissedCall(
sender: string,
incomingOfferTimestamp: number,
isBecauseOfCallPermission: boolean
reason: 'not-approved' | 'permissions' | 'another-call-ongoing'
) {
const incomingCallConversation = getConversationController().get(sender);
setIsRinging(false);
if (!isBecauseOfCallPermission) {
ToastUtils.pushedMissedCall(
incomingCallConversation?.getNickname() ||
incomingCallConversation?.getProfileName() ||
'Unknown'
);
} else {
ToastUtils.pushedMissedCallCauseOfPermission(
incomingCallConversation?.getNickname() ||
incomingCallConversation?.getProfileName() ||
'Unknown'
);
const displayname =
incomingCallConversation?.getNickname() ||
incomingCallConversation?.getProfileName() ||
'Unknown';
switch (reason) {
case 'permissions':
ToastUtils.pushedMissedCallCauseOfPermission(displayname);
break;
case 'another-call-ongoing':
ToastUtils.pushedMissedCall(displayname);
break;
case 'not-approved':
ToastUtils.pushedMissedCallNotApproved(displayname);
break;
default:
}
await addMissedCallMessage(sender, incomingOfferTimestamp);

@ -1,4 +1,5 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { setIsRinging } from '../../session/utils/RingingManager';
export type CallStatusEnum = 'offering' | 'incoming' | 'connecting' | 'ongoing' | undefined;
@ -31,11 +32,13 @@ const callSlice = createSlice({
}
state.ongoingWith = callerPubkey;
state.ongoingCallStatus = 'incoming';
setIsRinging(true);
return state;
},
endCall(state: CallStateType) {
state.ongoingCallStatus = undefined;
state.ongoingWith = undefined;
setIsRinging(false);
return state;
},
@ -50,6 +53,7 @@ const callSlice = createSlice({
}
state.ongoingCallStatus = 'connecting';
state.callIsInFullScreen = false;
setIsRinging(false);
return state;
},
callConnected(state: CallStateType, action: PayloadAction<{ pubkey: string }>) {
@ -66,6 +70,7 @@ const callSlice = createSlice({
);
return state;
}
setIsRinging(false);
state.ongoingCallStatus = 'ongoing';
state.callIsInFullScreen = false;
@ -80,6 +85,7 @@ const callSlice = createSlice({
window.log.warn('cannot start a call with an ongoing call already: ongoingCallStatus');
return state;
}
setIsRinging(true);
const callerPubkey = action.payload.pubkey;
state.ongoingWith = callerPubkey;

@ -7,7 +7,6 @@ export enum SectionType {
Profile,
Message,
Contact,
Channel,
Settings,
Moon,
PathIndicator,

@ -13,7 +13,7 @@ export interface UserConfigState {
export const initialUserConfigState = {
audioAutoplay: false,
showRecoveryPhrasePrompt: true,
messageRequests: true,
messageRequests: false,
};
const userConfigSlice = createSlice({

@ -539,6 +539,13 @@ export const getIsSelectedPrivate = createSelector(
}
);
export const getIsSelectedBlocked = createSelector(
getConversationHeaderProps,
(headerProps): boolean => {
return headerProps?.isBlocked || false;
}
);
export const getIsSelectedNoteToSelf = createSelector(
getConversationHeaderProps,
(headerProps): boolean => {

@ -94,6 +94,7 @@ export type LocalizerKeys =
| 'pinConversation'
| 'lightboxImageAlt'
| 'linkDevice'
| 'callMissedNotApproved'
| 'goToOurSurvey'
| 'invalidPubkeyFormat'
| 'disappearingMessagesDisabled'
@ -208,6 +209,7 @@ export type LocalizerKeys =
| 'timerOption_0_seconds_abbreviated'
| 'timerOption_5_minutes_abbreviated'
| 'enterOptionalPassword'
| 'userRemovedFromModerators'
| 'goToReleaseNotes'
| 'unpinConversation'
| 'viewMenuResetZoom'
@ -339,7 +341,7 @@ export type LocalizerKeys =
| 'youDisabledDisappearingMessages'
| 'updateGroupDialogTitle'
| 'surveyTitle'
| 'userRemovedFromModerators'
| 'readReceiptDialogDescription'
| 'timerOption_5_seconds'
| 'failedToRemoveFromModerator'
| 'conversationsHeader'

@ -148,10 +148,13 @@ async function createAccount(identityKeyPair: any) {
await window.textsecure.storage.put('identityKey', identityKeyPair);
await window.textsecure.storage.put('password', password);
await window.textsecure.storage.put('read-receipt-setting', false);
// enable read-receipt by default
await window.textsecure.storage.put('read-receipt-setting', true);
await window.textsecure.storage.put('read-receipt-turn-on-asked', true); // this can be removed once enough people upgraded 8/12/2021
// Enable typing indicators by default
await window.textsecure.storage.put('typing-indicators-setting', Boolean(true));
await window.textsecure.storage.put('typing-indicators-setting', true);
await window.textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1);
}

@ -1096,13 +1096,6 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
"@types/mkdirp@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.5.2.tgz#503aacfe5cc2703d5484326b1b27efa67a339c1f"
integrity sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg==
dependencies:
"@types/node" "*"
"@types/mocha@5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.0.0.tgz#a3014921991066193f6c8e47290d4d598dfd19e6"

Loading…
Cancel
Save