diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 08dec5ee7..078b8f400 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2951,13 +2951,46 @@ "message": "Emoji", "description": "Label for emoji button" }, + + "ErrorModal--title": { + "message": "Something went wrong!", + "description": "Title of pop-up dialog when user-initiated task has gone wrong" + }, + "ErrorModal--description": { + "message": "Please try again or contact support.", + "description": "Description text in pop-up dialog when user-initiated task has gone wrong" + }, + "ErrorModal--buttonText": { + "message": "Okay", + "description": "Button to dismiss pop-up dialog when user-initiated task has gone wrong" + }, + "GroupV2--admin": { "message": "Admin", "description": "Shown next to the set of administrators in a group" }, - "GroupV2--timerConflict": { - "message": "Failed to update disappearing message timer. Please try again later.", - "description": "Shown if the user runs into a group update conflict attempting to update a GroupV2 message timer" + "updating": { + "message": "Updating...", + "description": "Shown along with a spinner when an update operation takes longer than one second" + }, + + "GroupV2--create--you": { + "message": "You created the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--create--other": { + "message": "$memberName$ created the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--create--unknown": { + "message": "The group was created.", + "description": "Shown in timeline or conversation preview when v2 group changes" }, "GroupV2--title--change--other": { "message": "$memberName$ changed the group name to \"$newTitle$\".", @@ -3253,7 +3286,7 @@ "description": "Shown in timeline or conversation preview when v2 group changes" }, "GroupV2--member-add--you--unknown": { - "message": "A member added you to the group.", + "message": "You were added to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "GroupV2--member-remove--other--other": { @@ -3271,7 +3304,7 @@ } }, "GroupV2--member-remove--other--self": { - "message": "$memberName$ left.", + "message": "$memberName$ left the group.", "description": "Shown in timeline or conversation preview when v2 group changes", "placeholders": { "memberName": { @@ -3311,11 +3344,11 @@ } }, "GroupV2--member-remove--you--you": { - "message": "You left.", + "message": "You left the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "GroupV2--member-remove--you--unknown": { - "message": "A member removed you.", + "message": "You were removed from the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, @@ -3437,7 +3470,7 @@ } }, "GroupV2--pending-add--one--other--unknown": { - "message": "A member invited 1 person to the group.", + "message": "One person was invited to the group.", "description": "Shown in timeline or conversation preview when v2 group changes", "placeholders": { "inviteeName": { @@ -3457,7 +3490,7 @@ } }, "GroupV2--pending-add--one--you--unknown": { - "message": "A member invited you to the group.", + "message": "You were invited to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "GroupV2--pending-add--many--other": { @@ -3485,7 +3518,7 @@ } }, "GroupV2--pending-add--many--unknown": { - "message": "A member invited $count$ people to the group.", + "message": "$count$ people were invited to the group.", "description": "Shown in timeline or conversation preview when v2 group changes", "placeholders": { "count": { @@ -3515,6 +3548,10 @@ } } }, + "GroupV2--pending-remove--decline--from-you": { + "message": "You declined the invitation to the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, "GroupV2--pending-remove--decline--unknown": { "message": "1 person declined their invitation to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" @@ -3539,6 +3576,26 @@ } } }, + "GroupV2--pending-remove--revoke-own--to-you": { + "message": "$inviterName$ revoked their invitation to you.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviterName": { + "content": "$1", + "example": "Bob" + } + } + }, + "GroupV2--pending-remove--revoke-own--unknown": { + "message": "$inviterName$ revoked their invitation to 1 person.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "inviterName": { + "content": "$1", + "example": "Bob" + } + } + }, "GroupV2--pending-remove--revoke--one--unknown": { "message": "An admin revoked an invitation to the group for 1 person.", "description": "Shown in timeline or conversation preview when v2 group changes", diff --git a/js/modules/signal.js b/js/modules/signal.js index cf485cabf..578e50c80 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -28,6 +28,7 @@ const { AttachmentList, } = require('../../ts/components/conversation/AttachmentList'); const { CaptionEditor } = require('../../ts/components/CaptionEditor'); +const { ConfirmationModal } = require('../../ts/components/ConfirmationModal'); const { ContactDetail, } = require('../../ts/components/conversation/ContactDetail'); @@ -36,6 +37,7 @@ const { ConversationHeader, } = require('../../ts/components/conversation/ConversationHeader'); const { Emojify } = require('../../ts/components/conversation/Emojify'); +const { ErrorModal } = require('../../ts/components/ErrorModal'); const { Lightbox } = require('../../ts/components/Lightbox'); const { LightboxGallery } = require('../../ts/components/LightboxGallery'); const { @@ -45,6 +47,7 @@ const { MessageDetail, } = require('../../ts/components/conversation/MessageDetail'); const { Quote } = require('../../ts/components/conversation/Quote'); +const { ProgressModal } = require('../../ts/components/ProgressModal'); const { SafetyNumberChangeDialog, } = require('../../ts/components/SafetyNumberChangeDialog'); @@ -289,16 +292,19 @@ exports.setup = (options = {}) => { const Components = { AttachmentList, CaptionEditor, + ConfirmationModal, ContactDetail, ContactListItem, ConversationHeader, Emojify, + ErrorModal, getCallingNotificationText, Lightbox, LightboxGallery, MediaGallery, MessageDetail, Quote, + ProgressModal, SafetyNumberChangeDialog, StagedLinkPreview, Types: { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 8880589a0..4510dc605 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5293,6 +5293,18 @@ button.module-image__border-overlay:focus { } } +.module-spinner__circle--on-progress-dialog { + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-80; + } +} +.module-spinner__arc--on-progress-dialog { + background-color: $ultramarine-ui-light; +} + // Module: Highlighted Message Body .module-message-body__highlight { @@ -6659,7 +6671,6 @@ button.module-image__border-overlay:focus { border-top: 1px solid $color-gray-05; display: flex; justify-content: flex-end; - margin-bottom: -18px; margin-left: -16px; margin-right: -16px; margin-top: -14px; @@ -7808,10 +7819,11 @@ button.module-image__border-overlay:focus { &__content { @include font-body-1; - margin-bottom: 22px; } &__buttons { + margin-top: 22px; + display: flex; flex-direction: row; justify-content: flex-end; @@ -9276,6 +9288,60 @@ button.module-image__border-overlay:focus { margin-right: auto; } +// Module: Progress Dialog + +.module-progress-dialog { + width: 138px; + padding: 18px; + border-radius: 8px; + @include popper-shadow(); + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-80; + color: $color-gray-05; + } +} + +.module-progress-dialog__spinner { + padding: 10px; +} + +.module-progress-dialog__text { + @include font-body-2; +} + +.module-progress-dialog__overlay { + background: $color-black-alpha-40; + position: fixed; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + z-index: 5; +} + +// Module: Error Modal + +.module-error-modal__button-container { + margin-top: 10px; + display: flex; + flex-direction: row; + justify-content: flex-end; +} + /* Third-party module: react-tooltip-lite */ .react-tooltip-lite { diff --git a/ts/background.ts b/ts/background.ts index 720e733bc..43cbba723 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2546,6 +2546,9 @@ type WhatIsThis = typeof window.WhatIsThis; if (message.groupV2) { const { id } = message.groupV2; const conversationId = window.ConversationController.ensureGroup(id, { + // Note: We don't set active_at, because we don't want the group to show until + // we have information about it beyond these initial details. + // see maybeUpdateGroup(). groupVersion: 2, masterKey: message.groupV2.masterKey, secretParams: message.groupV2.secretParams, diff --git a/ts/components/ErrorModal.stories.tsx b/ts/components/ErrorModal.stories.tsx new file mode 100644 index 000000000..4ab9f1242 --- /dev/null +++ b/ts/components/ErrorModal.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; + +import { PropsType, ErrorModal } from './ErrorModal'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + title: text('title', overrideProps.title || ''), + description: text('description', overrideProps.description || ''), + buttonText: text('buttonText', overrideProps.buttonText || ''), + i18n, + onClose: action('onClick'), +}); + +storiesOf('Components/ErrorModal', module).add('Normal', () => { + return ; +}); + +storiesOf('Components/ErrorModal', module).add('Custom Strings', () => { + return ( + + ); +}); diff --git a/ts/components/ErrorModal.tsx b/ts/components/ErrorModal.tsx new file mode 100644 index 000000000..65bc30dd7 --- /dev/null +++ b/ts/components/ErrorModal.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { LocalizerType } from '../types/Util'; +import { ConfirmationModal } from './ConfirmationModal'; + +export type PropsType = { + buttonText: string; + description: string; + title: string; + + onClose: () => void; + i18n: LocalizerType; +}; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const ErrorModal = (props: PropsType): JSX.Element => { + const { buttonText, description, i18n, onClose, title } = props; + + return ( + +
+ {description || i18n('ErrorModal--description')} +
+
+ +
+
+ ); +}; diff --git a/ts/components/ProgressDialog.stories.tsx b/ts/components/ProgressDialog.stories.tsx new file mode 100644 index 000000000..b17b84fd5 --- /dev/null +++ b/ts/components/ProgressDialog.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { storiesOf } from '@storybook/react'; +import { ProgressDialog, PropsType } from './ProgressDialog'; +import { setup as setupI18n } from '../../js/modules/i18n'; + +import enMessages from '../../_locales/en/messages.json'; + +const story = storiesOf('Components/ProgressDialog', module); + +const i18n = setupI18n('en', enMessages); + +const createProps = (): PropsType => ({ + i18n, +}); + +story.add('Normal', () => { + const props = createProps(); + + return ; +}); diff --git a/ts/components/ProgressDialog.tsx b/ts/components/ProgressDialog.tsx new file mode 100644 index 000000000..3d001815d --- /dev/null +++ b/ts/components/ProgressDialog.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { LocalizerType } from '../types/Util'; +import { Spinner } from './Spinner'; + +export type PropsType = { + readonly i18n: LocalizerType; +}; + +export const ProgressDialog = React.memo(({ i18n }: PropsType) => { + return ( +
+
+ +
+
{i18n('updating')}
+
+ ); +}); diff --git a/ts/components/ProgressModal.stories.tsx b/ts/components/ProgressModal.stories.tsx new file mode 100644 index 000000000..f4254755b --- /dev/null +++ b/ts/components/ProgressModal.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; + +import { ProgressModal } from './ProgressModal'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +storiesOf('Components/ProgressModal', module).add('Normal', () => { + return ; +}); diff --git a/ts/components/ProgressModal.tsx b/ts/components/ProgressModal.tsx new file mode 100644 index 000000000..884264c63 --- /dev/null +++ b/ts/components/ProgressModal.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { ProgressDialog } from './ProgressDialog'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + readonly i18n: LocalizerType; +}; + +export const ProgressModal = React.memo(({ i18n }: PropsType) => { + const [root, setRoot] = React.useState(null); + + // Note: We explicitly don't register for user interaction here, since this dialog + // cannot be dismissed. + + React.useEffect(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + setRoot(div); + + return () => { + document.body.removeChild(div); + setRoot(null); + }; + }, []); + + return root + ? createPortal( +
+ +
, + root + ) + : null; +}); diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index 787f8334c..4cdbfa1ae 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -8,6 +8,7 @@ export const SpinnerDirections = [ 'outgoing', 'incoming', 'on-background', + 'on-progress-dialog', ] as const; export type SpinnerDirection = typeof SpinnerDirections[number]; diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx index 351fd8436..0263c09cf 100644 --- a/ts/components/conversation/GroupV2Change.stories.tsx +++ b/ts/components/conversation/GroupV2Change.stories.tsx @@ -81,6 +81,35 @@ storiesOf('Components/Conversation/GroupV2Change', module) ); }) + .add('Create', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'create', + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'create', + }, + ], + })} + {renderChange({ + details: [ + { + type: 'create', + }, + ], + })} + + ); + }) .add('Title', () => { return ( <> @@ -784,6 +813,28 @@ storiesOf('Components/Conversation/GroupV2Change', module) }, ], })} + + {renderChange({ + from: CONTACT_B, + details: [ + { + type: 'pending-remove-one', + conversationId: OUR_ID, + inviter: CONTACT_B, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-one', + conversationId: CONTACT_B, + inviter: CONTACT_A, + }, + ], + })} + {renderChange({ from: CONTACT_C, details: [ diff --git a/ts/components/conversation/StagedLinkPreview.stories.tsx b/ts/components/conversation/StagedLinkPreview.stories.tsx index b7470b55b..16902d093 100644 --- a/ts/components/conversation/StagedLinkPreview.stories.tsx +++ b/ts/components/conversation/StagedLinkPreview.stories.tsx @@ -65,10 +65,6 @@ story.add('No Image', () => { return ; }); -story.add('No Image', () => { - return ; -}); - story.add('Image', () => { const props = createProps({ image: createAttachment({ @@ -102,18 +98,6 @@ story.add('No Image, Long Title With Description', () => { return ; }); -story.add('Image, Long Title With Description', () => { - const props = createProps({ - title: LONG_TITLE, - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: 'image/jpeg' as MIMEType, - }), - }); - - return ; -}); - story.add('No Image, Long Title Without Description', () => { const props = createProps({ title: LONG_TITLE, @@ -123,7 +107,7 @@ story.add('No Image, Long Title Without Description', () => { return ; }); -story.add('Image, Long Title With Description', () => { +story.add('Image, Long Title Without Description', () => { const props = createProps({ title: LONG_TITLE, image: createAttachment({ diff --git a/ts/groupChange.ts b/ts/groupChange.ts index 7802dcba1..ec0109594 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -52,6 +52,17 @@ export function renderChangeDetail( } = options; const fromYou = Boolean(from && from === ourConversationId); + if (detail.type === 'create') { + if (fromYou) { + return renderString('GroupV2--create--you', i18n); + } + if (from) { + return renderString('GroupV2--create--other', i18n, { + memberName: renderContact(from), + }); + } + return renderString('GroupV2--create--unknown', i18n); + } if (detail.type === 'title') { const { newTitle } = detail; @@ -406,10 +417,12 @@ export function renderChangeDetail( } else if (detail.type === 'pending-remove-one') { const { inviter, conversationId } = detail; const weAreInviter = Boolean(inviter && inviter === ourConversationId); + const weAreInvited = conversationId === ourConversationId; const sentByInvited = Boolean(from && from === conversationId); + const sentByInviter = Boolean(from && inviter && from === inviter); if (weAreInviter) { - if (inviter && sentByInvited) { + if (sentByInvited) { return renderString('GroupV2--pending-remove--decline--you', i18n, [ renderContact(conversationId), ]); @@ -438,6 +451,9 @@ export function renderChangeDetail( ); } if (sentByInvited) { + if (fromYou) { + return renderString('GroupV2--pending-remove--decline--from-you', i18n); + } if (inviter) { return renderString('GroupV2--pending-remove--decline--other', i18n, [ renderContact(inviter), @@ -445,6 +461,20 @@ export function renderChangeDetail( } return renderString('GroupV2--pending-remove--decline--unknown', i18n); } + if (inviter && sentByInviter) { + if (weAreInvited) { + return renderString( + 'GroupV2--pending-remove--revoke-own--to-you', + i18n, + [renderContact(inviter)] + ); + } + return renderString( + 'GroupV2--pending-remove--revoke-own--unknown', + i18n, + [renderContact(inviter)] + ); + } if (inviter) { if (fromYou) { return renderString( diff --git a/ts/groups.ts b/ts/groups.ts index 8ba526d86..93a0ee303 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -20,6 +20,7 @@ import { MessageAttributesType, } from './model-types.d'; import { + createProfileKeyCredentialPresentation, decryptGroupBlob, decryptProfileKey, decryptProfileKeyCredentialPresentation, @@ -28,9 +29,11 @@ import { deriveGroupPublicParams, deriveGroupSecretParams, encryptGroupBlob, + encryptUuid, getAuthCredentialPresentation, getClientZkAuthOperations, getClientZkGroupCipher, + getClientZkProfileOperations, } from './util/zkgroup'; import { arrayBufferToBase64, @@ -51,6 +54,9 @@ import { GroupCredentialsType } from './textsecure/WebAPI'; import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; import { ConversationModel } from './models/conversations'; +export type GroupV2AccessCreateChangeType = { + type: 'create'; +}; export type GroupV2AccessAttributesChangeType = { type: 'access-attributes'; newPrivilege: number; @@ -112,6 +118,7 @@ export type GroupV2PendingRemoveManyChangeType = { }; export type GroupV2ChangeDetailType = + | GroupV2AccessCreateChangeType | GroupV2TitleChangeType | GroupV2AvatarChangeType | GroupV2AccessAttributesChangeType @@ -156,7 +163,7 @@ export const MASTER_KEY_LENGTH = 32; const TEMPORAL_AUTH_REJECTED_CODE = 401; const GROUP_ACCESS_DENIED_CODE = 403; -// Group Changes +// Group Modifications export function buildDisappearingMessagesTimerChange({ expireTimer, @@ -189,6 +196,91 @@ export function buildDisappearingMessagesTimerChange({ return actions; } +export function buildDeletePendingMemberChange({ + uuid, + group, +}: { + uuid: string; + group: ConversationAttributesType; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error( + 'buildDeletePendingMemberChange: group was missing secretParams!' + ); + } + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + + const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeletePendingMemberAction(); + deletePendingMember.deletedUserId = uuidCipherTextBuffer; + + actions.version = (group.revision || 0) + 1; + actions.deletePendingMembers = [deletePendingMember]; + + return actions; +} + +export function buildDeleteMemberChange({ + uuid, + group, +}: { + uuid: string; + group: ConversationAttributesType; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error('buildDeleteMemberChange: group was missing secretParams!'); + } + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + + const deleteMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberAction(); + deleteMember.deletedUserId = uuidCipherTextBuffer; + + actions.version = (group.revision || 0) + 1; + actions.deleteMembers = [deleteMember]; + + return actions; +} + +export function buildPromoteMemberChange({ + group, + profileKeyCredentialBase64, + serverPublicParamsBase64, +}: { + group: ConversationAttributesType; + profileKeyCredentialBase64: string; + serverPublicParamsBase64: string; +}): GroupChangeClass.Actions { + const actions = new window.textsecure.protobuf.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error( + 'buildDisappearingMessagesTimerChange: group was missing secretParams!' + ); + } + const clientZkProfileCipher = getClientZkProfileOperations( + serverPublicParamsBase64 + ); + + const presentation = createProfileKeyCredentialPresentation( + clientZkProfileCipher, + profileKeyCredentialBase64, + group.secretParams + ); + + const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromotePendingMemberAction(); + promotePendingMember.presentation = presentation; + + actions.version = (group.revision || 0) + 1; + actions.promotePendingMembers = [promotePendingMember]; + + return actions; +} + export async function uploadGroupChange({ actions, group, @@ -309,11 +401,7 @@ export async function maybeUpdateGroup({ // Ensure we have the credentials we need before attempting GroupsV2 operations await maybeFetchNewCredentials(); - const { - newAttributes, - groupChangeMessages, - members, - } = await getGroupUpdates({ + const updates = await getGroupUpdates({ group: conversation.attributes, serverPublicParamsBase64: window.getServerPublicParams(), newRevision, @@ -321,48 +409,7 @@ export async function maybeUpdateGroup({ dropInitialJoinMessage, }); - conversation.set(newAttributes); - - // Ensure that all generated messages are ordered properly. - // Before the provided timestamp so update messages appear before the - // initiating message, or after now(). - let syntheticTimestamp = receivedAt - ? receivedAt - (groupChangeMessages.length + 1) - : Date.now(); - // Save all synthetic messages describing group changes - const changeMessagesToSave = groupChangeMessages.map(changeMessage => { - // We do this to preserve the order of the timeline - syntheticTimestamp += 1; - - return { - ...changeMessage, - conversationId: conversation.id, - received_at: syntheticTimestamp, - sent_at: sentAt, - }; - }); - - if (changeMessagesToSave.length > 0) { - await window.Signal.Data.saveMessages(changeMessagesToSave, { - forceSave: true, - }); - changeMessagesToSave.forEach(changeMessage => { - const model = new window.Whisper.Message(changeMessage); - window.MessageController.register(model.id, model); - conversation.trigger('newmessage', model); - }); - } - - // Capture profile key for each member in the group, if we don't have it yet - members.forEach(member => { - const contact = window.ConversationController.get(member.uuid); - - if (member.profileKey && contact && !contact.get('profileKey')) { - contact.setProfileKey(member.profileKey); - } - }); - - await conversation.updateLastMessage(); + await updateGroup({ conversation, receivedAt, sentAt, updates }); } catch (error) { window.log.error( `maybeUpdateGroup/${logId}: Failed to update group:`, @@ -372,6 +419,79 @@ export async function maybeUpdateGroup({ } } +async function updateGroup({ + conversation, + receivedAt, + sentAt, + updates, +}: { + conversation: ConversationModel; + receivedAt?: number; + sentAt?: number; + updates: UpdatesResultType; +}): Promise { + const { newAttributes, groupChangeMessages, members } = updates; + + const startingRevision = conversation.get('revision'); + const endingRevision = newAttributes.revision; + + const isInitialDataFetch = + !isNumber(startingRevision) && isNumber(endingRevision); + + // Ensure that all generated messages are ordered properly. + // Before the provided timestamp so update messages appear before the + // initiating message, or after now(). + let syntheticTimestamp = receivedAt + ? receivedAt - (groupChangeMessages.length + 1) + : Date.now(); + + conversation.set({ + ...newAttributes, + // We force this conversation into the left pane if this is the first time we've + // fetched data about it, and we were able to fetch its name. Nobody likes to see + // Unknown Group in the left pane. + active_at: + isInitialDataFetch && newAttributes.name + ? syntheticTimestamp + : newAttributes.active_at, + }); + + // Save all synthetic messages describing group changes + const changeMessagesToSave = groupChangeMessages.map(changeMessage => { + // We do this to preserve the order of the timeline + syntheticTimestamp += 1; + + return { + ...changeMessage, + conversationId: conversation.id, + received_at: syntheticTimestamp, + sent_at: sentAt, + }; + }); + + if (changeMessagesToSave.length > 0) { + await window.Signal.Data.saveMessages(changeMessagesToSave, { + forceSave: true, + }); + changeMessagesToSave.forEach(changeMessage => { + const model = new window.Whisper.Message(changeMessage); + window.MessageController.register(model.id, model); + conversation.trigger('newmessage', model); + }); + } + + // Capture profile key for each member in the group, if we don't have it yet + members.forEach(member => { + const contact = window.ConversationController.get(member.uuid); + + if (member.profileKey && contact && !contact.get('profileKey')) { + contact.setProfileKey(member.profileKey); + } + }); + + // No need for convo.updateLastMessage(), 'newmessage' handler does that +} + function idForLogging(group: ConversationAttributesType) { return `groupv2(${group.groupId})`; } @@ -396,12 +516,16 @@ async function getGroupUpdates({ const currentRevision = group.revision; const isFirstFetch = !isNumber(group.revision); + const isInitialCreationMessage = isFirstFetch && newRevision === 0; + const isOneVersionUp = + isNumber(currentRevision) && + isNumber(newRevision) && + newRevision === currentRevision + 1; + if ( groupChangeBase64 && - ((isFirstFetch && newRevision === 0) || - (isNumber(newRevision) && - isNumber(currentRevision) && - newRevision === currentRevision + 1)) + isNumber(newRevision) && + (isInitialCreationMessage || isOneVersionUp) ) { window.log.info(`getGroupUpdates/${logId}: Processing just one change`); const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64); @@ -700,9 +824,12 @@ async function integrateGroupChanges({ for (let j = 0; j < jmax; j += 1) { const changeState = groupChanges[j]; - const { groupChange } = changeState; + const { groupChange, groupState } = changeState; - if (!groupChange) { + if (!groupChange || !groupState) { + window.log.warn( + 'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.' + ); // eslint-disable-next-line no-continue continue; } @@ -717,6 +844,7 @@ async function integrateGroupChanges({ group: attributes, newRevision, groupChange, + groupState, }); attributes = newAttributes; @@ -768,15 +896,19 @@ async function integrateGroupChanges({ async function integrateGroupChange({ group, groupChange, + groupState, newRevision, }: { group: ConversationAttributesType; groupChange: GroupChangeClass; + groupState?: GroupClass; newRevision: number; }): Promise { const logId = idForLogging(group); if (!group.secretParams) { - throw new Error('integrateGroupChange: Group was missing secretParams!'); + throw new Error( + `integrateGroupChange/${logId}: Group was missing secretParams!` + ); } const groupChangeActions = window.textsecure.protobuf.GroupChange.Actions.decode( @@ -804,9 +936,48 @@ async function integrateGroupChange({ ); const sourceConversationId = sourceConversation.id; + const isFirstFetch = !isNumber(group.revision); + const isMoreThanOneVersionUp = + groupChangeActions.version && + isNumber(group.revision) && + groupChangeActions.version > group.revision + 1; + + if (groupState && (isFirstFetch || isMoreThanOneVersionUp)) { + window.log.info( + `integrateGroupChange/${logId}: Applying full group state, from version ${group.revision} to ${groupState.version}` + ); + + const decryptedGroupState = decryptGroupState( + groupState, + group.secretParams, + logId + ); + + const newAttributes = await applyGroupState({ + group, + groupState: decryptedGroupState, + sourceConversationId: isFirstFetch ? sourceConversationId : undefined, + }); + + return { + newAttributes, + groupChangeMessages: extractDiffs({ + old: group, + current: newAttributes, + sourceConversationId: isFirstFetch ? sourceConversationId : undefined, + }), + members: getMembers(decryptedGroupState), + }; + } + + window.log.info( + `integrateGroupChange/${logId}: Applying group change actions, from version ${group.revision} to ${groupChangeActions.version}` + ); + const { newAttributes, newProfileKeys } = await applyGroupChange({ group, actions: decryptedChangeActions, + sourceConversationId, }); const groupChangeMessages = extractDiffs({ old: group, @@ -861,7 +1032,10 @@ export async function getCurrentGroupState({ logId ); - const newAttributes = await applyGroupState(group, decryptedGroupState); + const newAttributes = await applyGroupState({ + group, + groupState: decryptedGroupState, + }); return { newAttributes, @@ -888,7 +1062,10 @@ function extractDiffs({ const logId = idForLogging(old); const details: Array = []; const ourConversationId = window.ConversationController.getOurConversationId(); + let areWeInGroup = false; + let areWeInvitedToGroup = false; + let whoInvitedUsUserId = null; if ( current.accessControl && @@ -988,6 +1165,11 @@ function extractDiffs({ const { conversationId } = currentPendingMember; const oldPendingMember = oldPendingMemberLookup[conversationId]; + if (ourConversationId && conversationId === ourConversationId) { + areWeInvitedToGroup = true; + whoInvitedUsUserId = currentPendingMember.addedByUserId; + } + if (!oldPendingMember) { lastPendingConversationId = conversationId; count += 1; @@ -1049,16 +1231,50 @@ function extractDiffs({ const sourceUuid = conversation ? conversation.get('uuid') : undefined; const firstUpdate = !isNumber(old.revision); - const firstEventSourceId = sourceConversationId || ourConversationId; + // Here we hardcode initial messages if this is our first time processing data this + // group. Ideally we can collapse it down to just one of: 'you were added', + // 'you were invited', or 'you created.' if (firstUpdate && dropInitialJoinMessage) { message = undefined; + } else if ( + firstUpdate && + ourConversationId && + sourceConversationId && + sourceConversationId === ourConversationId + ) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + from: sourceConversationId, + details: [ + { + type: 'create', + }, + ], + }, + }; + } else if (firstUpdate && ourConversationId && areWeInvitedToGroup) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + from: whoInvitedUsUserId || sourceConversationId, + details: [ + { + type: 'pending-add-one', + conversationId: ourConversationId, + }, + ], + }, + }; } else if (firstUpdate && ourConversationId && areWeInGroup) { message = { ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: firstEventSourceId, + from: sourceConversationId, details: [ { type: 'member-add', @@ -1067,6 +1283,19 @@ function extractDiffs({ ], }, }; + } else if (firstUpdate) { + message = { + ...generateBasicMessage(), + type: 'group-v2-change', + groupV2Change: { + from: sourceConversationId, + details: [ + { + type: 'create', + }, + ], + }, + }; } else if (details.length > 0) { message = { ...generateBasicMessage(), @@ -1132,18 +1361,22 @@ type GroupChangeResultType = { }; async function applyGroupChange({ - group, actions, + group, + sourceConversationId, }: { - sourceConversationId?: string; - group: ConversationAttributesType; actions: GroupChangeClass.Actions; + group: ConversationAttributesType; + sourceConversationId: string; }): Promise { const logId = idForLogging(group); + const ourConversationId = window.ConversationController.getOurConversationId(); + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; + const version = actions.version || 0; - const result = { + const result: ConversationAttributesType = { ...group, }; const newProfileKeys: Array = []; @@ -1198,6 +1431,15 @@ async function applyGroupChange({ delete pendingMembers[conversation.id]; } + // Capture who added us + if ( + ourConversationId && + sourceConversationId && + conversation.id === ourConversationId + ) { + result.addedBy = sourceConversationId; + } + if (added.profileKey) { newProfileKeys.push({ profileKey: added.profileKey, @@ -1438,7 +1680,6 @@ async function applyGroupChange({ }; } - const ourConversationId = window.ConversationController.getOurConversationId(); if (ourConversationId) { result.left = !members[ourConversationId]; } @@ -1524,14 +1765,19 @@ async function applyNewAvatar( } /* eslint-enable no-param-reassign */ -async function applyGroupState( - group: ConversationAttributesType, - groupState: GroupClass -): Promise { +async function applyGroupState({ + group, + groupState, + sourceConversationId, +}: { + group: ConversationAttributesType; + groupState: GroupClass; + sourceConversationId?: string; +}): Promise { const logId = idForLogging(group); const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const version = groupState.version || 0; - const result = { + const result: ConversationAttributesType = { ...group, }; @@ -1589,6 +1835,16 @@ async function applyGroupState( if (ourConversationId && conversation.id === ourConversationId) { result.left = false; + + // Capture who added us if we were previously not in group + if ( + sourceConversationId && + (result.membersV2 || []).every( + item => item.conversationId !== ourConversationId + ) + ) { + result.addedBy = sourceConversationId; + } } if ( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 6a1aeb17f..78cb02615 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -139,7 +139,7 @@ export type ConversationAttributesTypeType = 'private' | 'group'; export type ConversationAttributesType = { accessKey: string | null; - addedBy: string; + addedBy?: string; capabilities: { uuid: string }; color?: ColorType; discoveredUnregisteredAt: number; @@ -155,8 +155,8 @@ export type ConversationAttributesType = { muteExpiresAt: number; pinIndex?: number; profileAvatar: WhatIsThis; - profileKeyCredential: unknown | null; - profileKeyVersion: string; + profileKeyCredential: string | null; + profileKeyVersion: string | null; quotedMessageId: string; sealedSender: unknown; sentMessageCount: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 6531045c3..7e5a889c4 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -7,7 +7,7 @@ import { ConversationAttributesType, VerificationOptions, } from '../model-types.d'; -import { CallbackResultType } from '../textsecure/SendMessage'; +import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage'; import { ConversationType, ConversationTypeType, @@ -25,6 +25,7 @@ import { stringFromBytes, verifyAccessKey, } from '../Crypto'; +import { GroupChangeClass } from '../textsecure.d'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -107,11 +108,6 @@ export class ConversationModel extends window.Backbone.Model< messageCollection?: MessageModelCollectionType; - // backbone ensures this exists in initialize() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - messageRequestEnum: typeof window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; - ourNumber?: string; ourUuid?: string; @@ -192,8 +188,6 @@ export class ConversationModel extends window.Backbone.Model< this.ourNumber = window.textsecure.storage.user.getNumber(); this.ourUuid = window.textsecure.storage.user.getUuid(); this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; - this.messageRequestEnum = - window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; // This may be overridden by window.ConversationController.getOrCreate, and signify // our first save to the database. Or first fetch from the database. @@ -258,16 +252,334 @@ export class ConversationModel extends window.Backbone.Model< } isGroupV1(): boolean { - const groupID = this.get('groupId'); - if (!groupID) { + const groupId = this.get('groupId'); + if (!groupId) { return false; } - return fromEncodedBinaryToArrayBuffer(groupID).byteLength === 16; + return fromEncodedBinaryToArrayBuffer(groupId).byteLength === 16; } isGroupV2(): boolean { - return (this.get('groupVersion') || 0) === 2; + const groupId = this.get('groupId'); + if (!groupId) { + return false; + } + + const groupVersion = this.get('groupVersion') || 0; + + return groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === 32; + } + + isMemberPending(conversationId: string): boolean { + if (!this.isGroupV2()) { + throw new Error( + `isPendingMember: Called for non-GroupV2 conversation ${this.idForLogging()}` + ); + } + const pendingMembersV2 = this.get('pendingMembersV2'); + + if (!pendingMembersV2 || !pendingMembersV2.length) { + return false; + } + + return window._.any( + pendingMembersV2, + item => item.conversationId === conversationId + ); + } + + isMember(conversationId: string): boolean { + if (!this.isGroupV2()) { + throw new Error( + `isMember: Called for non-GroupV2 conversation ${this.idForLogging()}` + ); + } + const membersV2 = this.get('membersV2'); + + if (!membersV2 || !membersV2.length) { + return false; + } + + return window._.any( + membersV2, + item => item.conversationId === conversationId + ); + } + + async updateExpirationTimerInGroupV2( + seconds?: number + ): Promise { + const idLog = this.idForLogging(); + const current = this.get('expireTimer'); + const bothFalsey = Boolean(current) === false && Boolean(seconds) === false; + + if (current === seconds || bothFalsey) { + window.log.warn( + `updateExpirationTimerInGroupV2/${idLog}: Requested timer ${seconds} is unchanged from existing ${current}.` + ); + return undefined; + } + + return window.Signal.Groups.buildDisappearingMessagesTimerChange({ + expireTimer: seconds || 0, + group: this.attributes, + }); + } + + async promotePendingMember( + conversationId: string + ): Promise { + const idLog = this.idForLogging(); + + // This user's pending state may have changed in the time between the user's + // button press and when we get here. It's especially important to check here + // in conflict/retry cases. + if (!this.isMemberPending(conversationId)) { + window.log.warn( + `promotePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + ); + return undefined; + } + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + throw new Error( + `promotePendingMember/${idLog}: No conversation found for conversation ${conversationId}` + ); + } + + // We need the user's profileKeyCredential, which requires a roundtrip with the + // server, and most definitely their profileKey. A getProfiles() call will + // ensure that we have as much as we can get with the data we have. + let profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); + if (!profileKeyCredentialBase64) { + await pendingMember.getProfiles(); + + profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); + if (!profileKeyCredentialBase64) { + throw new Error( + `promotePendingMember/${idLog}: No profileKeyCredential for conversation ${pendingMember.idForLogging()}` + ); + } + } + + return window.Signal.Groups.buildPromoteMemberChange({ + group: this.attributes, + profileKeyCredentialBase64, + serverPublicParamsBase64: window.getServerPublicParams(), + }); + } + + async removePendingMember( + conversationId: string + ): Promise { + const idLog = this.idForLogging(); + + // This user's pending state may have changed in the time between the user's + // button press and when we get here. It's especially important to check here + // in conflict/retry cases. + if (!this.isMemberPending(conversationId)) { + window.log.warn( + `removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + ); + return undefined; + } + + const pendingMember = window.ConversationController.get(conversationId); + if (!pendingMember) { + throw new Error( + `removePendingMember/${idLog}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = pendingMember.get('uuid'); + if (!uuid) { + throw new Error( + `removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` + ); + } + + return window.Signal.Groups.buildDeletePendingMemberChange({ + group: this.attributes, + uuid, + }); + } + + async removeMember( + conversationId: string + ): Promise { + const idLog = this.idForLogging(); + + // This user's pending state may have changed in the time between the user's + // button press and when we get here. It's especially important to check here + // in conflict/retry cases. + if (!this.isMember(conversationId)) { + window.log.warn( + `removeMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` + ); + return undefined; + } + + const member = window.ConversationController.get(conversationId); + if (!member) { + throw new Error( + `removeMember/${idLog}: No conversation found for conversation ${conversationId}` + ); + } + + const uuid = member.get('uuid'); + if (!uuid) { + throw new Error( + `removeMember/${idLog}: Missing uuid for conversation ${member.idForLogging()}` + ); + } + + return window.Signal.Groups.buildDeleteMemberChange({ + group: this.attributes, + uuid, + }); + } + + async modifyGroupV2({ + name, + createGroupChange, + }: { + name: string; + createGroupChange: () => Promise; + }): Promise { + const idLog = `${name}/${this.idForLogging()}`; + + if (!this.isGroupV2()) { + throw new Error( + `modifyGroupV2/${idLog}: Called for non-GroupV2 conversation` + ); + } + + const ONE_MINUTE = 1000 * 60; + const startTime = Date.now(); + const timeoutTime = startTime + ONE_MINUTE; + + const MAX_ATTEMPTS = 5; + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`); + try { + // eslint-disable-next-line no-await-in-loop + await window.waitForEmptyEventQueue(); + + window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`); + + // eslint-disable-next-line no-await-in-loop + await this.queueJob(async () => { + window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`); + + const actions = await createGroupChange(); + if (!actions) { + window.log.warn( + `modifyGroupV2/${idLog}: No change actions. Returning early.` + ); + return; + } + + // The new revision has to be exactly one more than the current revision + // or it won't upload properly, and it won't apply in maybeUpdateGroup + const currentRevision = this.get('revision'); + const newRevision = actions.version; + + if ((currentRevision || 0) + 1 !== newRevision) { + throw new Error( + `modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.` + ); + } + + // Upload. If we don't have permission, the server will return an error here. + const groupChange = await window.Signal.Groups.uploadGroupChange({ + actions, + group: this.attributes, + serverPublicParamsBase64: window.getServerPublicParams(), + }); + + const groupChangeBuffer = groupChange.toArrayBuffer(); + const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer); + + // Apply change locally, just like we would with an incoming change. This will + // change conversation state and add change notifications to the timeline. + await window.Signal.Groups.maybeUpdateGroup({ + conversation: this, + groupChangeBase64, + newRevision, + }); + + // Send message to notify group members (including pending members) of change + const profileKey = this.get('profileSharing') + ? window.storage.get('profileKey') + : undefined; + + const sendOptions = this.getSendOptions(); + const timestamp = Date.now(); + + const promise = this.wrapSend( + window.textsecure.messaging.sendMessageToGroup( + { + groupV2: this.getGroupV2Info({ + groupChange: groupChangeBuffer, + includePendingMembers: true, + }), + timestamp, + profileKey, + }, + sendOptions + ) + ); + + // We don't save this message; we just use it to ensure that a sync message is + // sent to our linked devices. + const m = new window.Whisper.Message(({ + conversationId: this.id, + type: 'not-to-save', + sent_at: timestamp, + received_at: timestamp, + // TODO: DESKTOP-722 + // this type does not fully implement the interface it is expected to + } as unknown) as MessageAttributesType); + + // This is to ensure that the functions in send() and sendSyncMessage() + // don't save anything to the database. + m.doNotSave = true; + + await m.send(promise); + }); + + // If we've gotten here with no error, we exit! + window.log.info( + `modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!` + ); + break; + } catch (error) { + if (error.code === 409 && Date.now() <= timeoutTime) { + window.log.info( + `modifyGroupV2/${idLog}: Conflict while updating. Trying again...` + ); + + // eslint-disable-next-line no-await-in-loop + await this.fetchLatestGroupV2Data(); + } else if (error.code === 409) { + window.log.error( + `modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.` + ); + // We don't wait here because we're breaking out of the loop immediately. + this.fetchLatestGroupV2Data(); + throw error; + } else { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `modifyGroupV2/${idLog}: Error updating: ${errorString}` + ); + throw error; + } + } + } } isEverUnregistered(): boolean { @@ -522,7 +834,11 @@ export class ConversationModel extends window.Backbone.Model< window.Signal.Data.updateConversation(this.attributes); } - getGroupV2Info(groupChange?: ArrayBuffer): WhatIsThis { + getGroupV2Info( + options: { groupChange?: ArrayBuffer; includePendingMembers?: boolean } = {} + ): GroupV2InfoType | undefined { + const { groupChange, includePendingMembers } = options; + if (this.isPrivate() || !this.isGroupV2()) { return undefined; } @@ -533,7 +849,9 @@ export class ConversationModel extends window.Backbone.Model< ), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion revision: this.get('revision')!, - members: this.getRecipients(), + members: this.getRecipients({ + includePendingMembers, + }), groupChange, }; } @@ -854,7 +1172,11 @@ export class ConversationModel extends window.Backbone.Model< * This function is called when a message request is accepted in order to * handle sending read receipts and download any pending attachments. */ - async handleReadAndDownloadAttachments(): Promise { + async handleReadAndDownloadAttachments( + options: { isLocalAction?: boolean } = {} + ): Promise { + const { isLocalAction } = options; + let messages: MessageModelCollectionType | undefined; do { const first = messages ? messages.first() : undefined; @@ -887,8 +1209,12 @@ export class ConversationModel extends window.Backbone.Model< timestamp: m.get('sent_at'), hasErrors: m.hasErrors(), })); - // eslint-disable-next-line no-await-in-loop - await this.sendReadReceiptsFor(receiptSpecs); + + if (isLocalAction) { + // eslint-disable-next-line no-await-in-loop + await this.sendReadReceiptsFor(receiptSpecs); + } + // eslint-disable-next-line no-await-in-loop await Promise.all(readMessages.map(m => m.queueAttachmentDownloads())); } while (messages.length > 0); @@ -898,57 +1224,129 @@ export class ConversationModel extends window.Backbone.Model< response: number, { fromSync = false, viaStorageServiceSync = false } = {} ): Promise { + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + const isLocalAction = !fromSync && !viaStorageServiceSync; + const ourConversationId = window.ConversationController.getOurConversationId(); + // Apply message request response locally this.set({ messageRequestResponseType: response, }); window.Signal.Data.updateConversation(this.attributes); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (response === this.messageRequestEnum!.ACCEPT) { + if (response === messageRequestEnum.ACCEPT) { this.unblock({ viaStorageServiceSync }); this.enableProfileSharing({ viaStorageServiceSync }); - if (!fromSync) { - this.sendProfileKeyUpdate(); - // Locally accepted - await this.handleReadAndDownloadAttachments(); + await this.handleReadAndDownloadAttachments({ isLocalAction }); + + if (isLocalAction) { + if (this.isGroupV1() || this.isPrivate()) { + this.sendProfileKeyUpdate(); + } else if ( + ourConversationId && + this.isGroupV2() && + this.isMemberPending(ourConversationId) + ) { + await this.modifyGroupV2({ + name: 'promotePendingMember', + createGroupChange: () => + this.promotePendingMember(ourConversationId), + }); + } else if ( + ourConversationId && + this.isGroupV2() && + this.isMember(ourConversationId) + ) { + window.log.info( + 'applyMessageRequestResponse/accept: Already a member of v2 group' + ); + } else { + window.log.error( + 'applyMessageRequestResponse/accept: Neither member nor pending member of v2 group' + ); + } } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - } else if (response === this.messageRequestEnum!.BLOCK) { + } else if (response === messageRequestEnum.BLOCK) { // Block locally, other devices should block upon receiving the sync message this.block({ viaStorageServiceSync }); this.disableProfileSharing({ viaStorageServiceSync }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - } else if (response === this.messageRequestEnum!.DELETE) { + + if (isLocalAction) { + if (this.isGroupV1() || this.isPrivate()) { + await this.leaveGroup(); + } else if (this.isGroupV2()) { + await this.leaveGroupV2(); + } + } + } else if (response === messageRequestEnum.DELETE) { + this.disableProfileSharing({ viaStorageServiceSync }); + // Delete messages locally, other devices should delete upon receiving // the sync message - this.destroyMessages(); - this.disableProfileSharing({ viaStorageServiceSync }); + await this.destroyMessages(); this.updateLastMessage(); - if (!fromSync) { + + if (isLocalAction) { this.trigger('unload', 'deleted from message request'); + + if (this.isGroupV1() || this.isPrivate()) { + await this.leaveGroup(); + } else if (this.isGroupV2()) { + await this.leaveGroupV2(); + } } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - } else if (response === this.messageRequestEnum!.BLOCK_AND_DELETE) { - // Delete messages locally, other devices should delete upon receiving - // the sync message - this.destroyMessages(); - this.disableProfileSharing({ viaStorageServiceSync }); - this.updateLastMessage(); + } else if (response === messageRequestEnum.BLOCK_AND_DELETE) { // Block locally, other devices should block upon receiving the sync message this.block({ viaStorageServiceSync }); - // Leave group if this was a local action - if (!fromSync) { - // TODO: DESKTOP-721 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.leaveGroup(); + this.disableProfileSharing({ viaStorageServiceSync }); + + // Delete messages locally, other devices should delete upon receiving + // the sync message + await this.destroyMessages(); + this.updateLastMessage(); + + if (isLocalAction) { this.trigger('unload', 'blocked and deleted from message request'); + + if (this.isGroupV1() || this.isPrivate()) { + await this.leaveGroup(); + } else if (this.isGroupV2()) { + await this.leaveGroupV2(); + } } } } + async leaveGroupV2(): Promise { + const ourConversationId = window.ConversationController.getOurConversationId(); + + if ( + ourConversationId && + this.isGroupV2() && + this.isMemberPending(ourConversationId) + ) { + await this.modifyGroupV2({ + name: 'delete', + createGroupChange: () => this.removePendingMember(ourConversationId), + }); + } else if ( + ourConversationId && + this.isGroupV2() && + this.isMember(ourConversationId) + ) { + await this.modifyGroupV2({ + name: 'delete', + createGroupChange: () => this.removeMember(ourConversationId), + }); + } else { + window.log.error( + 'leaveGroupV2: We were neither a member nor a pending member of the group' + ); + } + } + async syncMessageRequestResponse(response: number): Promise { // Let this run, no await this.applyMessageRequestResponse(response); @@ -1302,10 +1700,9 @@ export class ConversationModel extends window.Backbone.Model< return true; } - if ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.getMessageRequestResponseType() === this.messageRequestEnum!.ACCEPT - ) { + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) { return true; } @@ -1611,15 +2008,24 @@ export class ConversationModel extends window.Backbone.Model< return this.jobQueue.add(taskWithTimeout); } - getMembers(): Array { + getMembers( + options: { includePendingMembers?: boolean } = {} + ): Array { if (this.isPrivate()) { return [this]; } if (this.get('membersV2')) { + const { includePendingMembers } = options; + const members: Array<{ conversationId: string }> = includePendingMembers + ? [ + ...(this.get('membersV2') || []), + ...(this.get('pendingMembersV2') || []), + ] + : this.get('membersV2') || []; + return window._.compact( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('membersV2')!.map(member => { + members.map(member => { const c = window.ConversationController.get(member.conversationId); // In groups we won't sent to contacts we believe are unregistered @@ -1660,8 +2066,12 @@ export class ConversationModel extends window.Backbone.Model< return members.map(member => member.id); } - getRecipients(): Array { - const members = this.getMembers(); + getRecipients( + options: { includePendingMembers?: boolean } = {} + ): Array { + const { includePendingMembers } = options; + + const members = this.getMembers({ includePendingMembers }); // Eliminate our return window._.compact( @@ -1913,6 +2323,10 @@ export class ConversationModel extends window.Backbone.Model< ); })(); + // This is to ensure that the functions in send() and sendSyncMessage() don't save + // anything to the database. + message.doNotSave = true; + return message.send(this.wrapSend(promise)); }).catch(error => { window.log.error( @@ -2040,6 +2454,10 @@ export class ConversationModel extends window.Backbone.Model< ); })(); + // This is to ensure that the functions in send() and sendSyncMessage() don't save + // anything to the database. + message.doNotSave = true; + return message.send(this.wrapSend(promise)); }).catch(error => { window.log.error('Error sending reaction', reaction, target, error); @@ -2436,7 +2854,9 @@ export class ConversationModel extends window.Backbone.Model< return false; } - return Boolean(conv.get('name')) || conv.get('profileSharing'); + return Boolean( + conv.isMe() || conv.get('name') || conv.get('profileSharing') + ); } async updateLastMessage(): Promise { @@ -2500,74 +2920,6 @@ export class ConversationModel extends window.Backbone.Model< } } - async updateExpirationTimerInGroupV2(seconds?: number): Promise { - // Make change on the server - const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange({ - expireTimer: seconds || 0, - group: this.attributes, - }); - let signedGroupChange; - try { - signedGroupChange = await window.Signal.Groups.uploadGroupChange({ - actions, - group: this.attributes, - serverPublicParamsBase64: window.getServerPublicParams(), - }); - } catch (error) { - // Get latest GroupV2 data, since we ran into trouble updating it - this.fetchLatestGroupV2Data(); - throw error; - } - - // Update local conversation - this.set({ - expireTimer: seconds || 0, - revision: actions.version, - }); - window.Signal.Data.updateConversation(this.attributes); - - // Create local notification - const timestamp = Date.now(); - const id = window.getGuid(); - const message = window.MessageController.register( - id, - new window.Whisper.Message(({ - id, - conversationId: this.id, - sent_at: timestamp, - received_at: timestamp, - flags: - window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - expirationTimerUpdate: { - expireTimer: seconds, - sourceUuid: this.ourUuid, - }, - // TODO: DESKTOP-722 - } as unknown) as typeof window.Whisper.MessageAttributesType) - ); - await window.Signal.Data.saveMessage(message.attributes, { - Message: window.Whisper.Message, - forceSave: true, - }); - this.trigger('newmessage', message); - - // Send message to all group members - const profileKey = this.get('profileSharing') - ? window.storage.get('profileKey') - : undefined; - const sendOptions = this.getSendOptions(); - const promise = window.textsecure.messaging.sendMessageToGroup( - { - groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()), - timestamp, - profileKey, - }, - sendOptions - ); - - message.send(promise); - } - async updateExpirationTimer( providedExpireTimer: number | undefined, providedSource: unknown, @@ -2580,7 +2932,11 @@ export class ConversationModel extends window.Backbone.Model< 'updateExpirationTimer: GroupV2 timers are not updated this way' ); } - await this.updateExpirationTimerInGroupV2(providedExpireTimer); + await this.modifyGroupV2({ + name: 'updateExpirationTimer', + createGroupChange: () => + this.updateExpirationTimerInGroupV2(providedExpireTimer), + }); return false; } @@ -3018,11 +3374,15 @@ export class ConversationModel extends window.Backbone.Model< const profileKeyVersionHex = c.get('profileKeyVersion')!; const existingProfileKeyCredential = c.get('profileKeyCredential'); - const weHaveVersion = Boolean(profileKey && uuid && profileKeyVersionHex); let profileKeyCredentialRequestHex; let profileCredentialRequestContext; - if (weHaveVersion && !existingProfileKeyCredential) { + if ( + profileKey && + uuid && + profileKeyVersionHex && + !existingProfileKeyCredential + ) { window.log.info('Generating request...'); ({ requestHex: profileKeyCredentialRequestHex, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 0c2bd5bfe..23a10778b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -115,6 +115,10 @@ export class MessageModel extends window.Backbone.Model { CURRENT_PROTOCOL_VERSION?: number; + // Set when sending some sync messages, so we get the functionality of + // send(), without zombie messages going into the database. + doNotSave?: boolean; + INITIAL_PROTOCOL_VERSION?: number; OUR_NUMBER?: string; @@ -1715,7 +1719,7 @@ export class MessageModel extends window.Backbone.Model { this.set({ errors }); - if (!skipSave) { + if (!skipSave && !this.doNotSave) { await window.Signal.Data.saveMessage(this.attributes, { Message: window.Whisper.Message, }); @@ -2130,9 +2134,11 @@ export class MessageModel extends window.Backbone.Model { unidentifiedDeliveries: result.unidentifiedDeliveries, }); - await window.Signal.Data.saveMessage(this.attributes, { - Message: window.Whisper.Message, - }); + if (!this.doNotSave) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } this.trigger('sent', this); this.sendSyncMessage(); @@ -2315,9 +2321,16 @@ export class MessageModel extends window.Backbone.Model { synced: true, dataMessage: null, }); - return window.Signal.Data.saveMessage(this.attributes, { + + // Return early, skip the save + if (this.doNotSave) { + return result; + } + + await window.Signal.Data.saveMessage(this.attributes, { Message: window.Whisper.Message, - }).then(() => result); + }); + return result; }); }; diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 0756bd043..46ff5e075 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -237,18 +237,21 @@ function applyMessageRequestState( record: MessageRequestCapableRecord, conversation: ConversationModel ): void { + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + if (record.blocked) { - conversation.applyMessageRequestResponse( - conversation.messageRequestEnum.BLOCK, - { fromSync: true, viaStorageServiceSync: true } - ); + conversation.applyMessageRequestResponse(messageRequestEnum.BLOCK, { + fromSync: true, + viaStorageServiceSync: true, + }); } else if (record.whitelisted) { // unblocking is also handled by this function which is why the next // condition is part of the else-if and not separate - conversation.applyMessageRequestResponse( - conversation.messageRequestEnum.ACCEPT, - { fromSync: true, viaStorageServiceSync: true } - ); + conversation.applyMessageRequestResponse(messageRequestEnum.ACCEPT, { + fromSync: true, + viaStorageServiceSync: true, + }); } else if (!record.blocked) { // if the condition above failed the state could still be blocked=false // in which case we should unblock the conversation @@ -408,8 +411,9 @@ export async function mergeGroupV2Record( const now = Date.now(); const conversationId = window.ConversationController.ensureGroup(groupId, { - // We want this conversation to show in the left pane when we first learn about it - active_at: now, + // Note: We don't set active_at, because we don't want the group to show until + // we have information about it beyond these initial details. + // see maybeUpdateGroup(). timestamp: now, // Basic GroupV2 data groupVersion: 2, diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 168f989a2..3db78d509 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -318,6 +318,7 @@ export declare class GroupChangeClass { data: ArrayBuffer | ByteBufferClass, encoding?: string ) => GroupChangeClass; + toArrayBuffer: () => ArrayBuffer; actions?: ProtoBinaryType; serverSignature?: ProtoBinaryType; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index af7435e48..b4772e7a6 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -90,7 +90,7 @@ type QuoteAttachmentType = { attachmentPointer?: AttachmentPointerClass; }; -type GroupV2InfoType = { +export type GroupV2InfoType = { groupChange?: ArrayBuffer; masterKey: ArrayBuffer; revision: number; diff --git a/ts/util/zkgroup.ts b/ts/util/zkgroup.ts index 46bd3c98d..5247c73b1 100644 --- a/ts/util/zkgroup.ts +++ b/ts/util/zkgroup.ts @@ -9,6 +9,7 @@ import { GroupSecretParams, ProfileKey, ProfileKeyCiphertext, + ProfileKeyCredential, ProfileKeyCredentialPresentation, ProfileKeyCredentialRequestContext, ProfileKeyCredentialResponse, @@ -220,6 +221,29 @@ export function getAuthCredentialPresentation( return compatArrayToArrayBuffer(presentation.serialize()); } +export function createProfileKeyCredentialPresentation( + clientZkProfileCipher: ClientZkProfileOperations, + profileKeyCredentialBase64: string, + groupSecretParamsBase64: string +): ArrayBuffer { + const profileKeyCredentialArray = base64ToCompatArray( + profileKeyCredentialBase64 + ); + const profileKeyCredential = new ProfileKeyCredential( + profileKeyCredentialArray + ); + const secretParams = new GroupSecretParams( + base64ToCompatArray(groupSecretParamsBase64) + ); + + const presentation = clientZkProfileCipher.createProfileKeyCredentialPresentation( + secretParams, + profileKeyCredential + ); + + return compatArrayToArrayBuffer(presentation.serialize()); +} + export function getClientZkAuthOperations( serverPublicParamsBase64: string ): ClientZkAuthOperations { diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 527479186..d6d31e1c3 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1,4 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ + +// Note: because this file is pulled in directly from background.html, we can't use any +// imports here aside from types. That means everything will have to be references via +// globals right on window. + interface GetLinkPreviewResult { title: string; url: string; @@ -249,10 +254,6 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ template: window.i18n('maximumAttachments'), }); -Whisper.TimerConflictToast = Whisper.ToastView.extend({ - template: window.i18n('GroupV2--timerConflict'), -}); - Whisper.ConversationLoadingScreen = Whisper.View.extend({ templateName: 'conversation-loading-screen', className: 'conversation-loading-screen', @@ -592,26 +593,51 @@ Whisper.ConversationView = Whisper.View.extend({ clearQuotedMessage: () => this.setQuoteMessage(null), micCellEl, attachmentListEl, - onAccept: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.ACCEPT - ), - onBlock: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.BLOCK - ), - onUnblock: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.ACCEPT - ), - onDelete: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.DELETE - ), - onBlockAndDelete: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.BLOCK_AND_DELETE - ), + onAccept: () => { + this.longRunningTaskWrapper({ + name: 'onAccept', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + }); + }, + onBlock: () => { + this.longRunningTaskWrapper({ + name: 'onBlock', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK + ), + }); + }, + onUnblock: () => { + this.longRunningTaskWrapper({ + name: 'onUnblock', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + }); + }, + onDelete: () => { + this.longRunningTaskWrapper({ + name: 'onDelete', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.DELETE + ), + }); + }, + onBlockAndDelete: () => { + this.longRunningTaskWrapper({ + name: 'onBlockAndDelete', + task: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK_AND_DELETE + ), + }); + }, }; this.compositionAreaView = new Whisper.ReactWrapperView({ @@ -626,6 +652,74 @@ Whisper.ConversationView = Whisper.View.extend({ this.$('.composition-area-placeholder').append(this.compositionAreaView.el); }, + async longRunningTaskWrapper({ + name, + task, + }: { + name: string; + task: () => Promise; + }): Promise { + const idLog = `${name}/${this.model.idForLogging()}`; + const ONE_SECOND = 1000; + + let progressView: any | undefined; + let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => { + window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`); + + // Note: this component uses a portal to render itself into the top-level DOM. No + // need to attach it to the DOM here. + progressView = new Whisper.ReactWrapperView({ + className: 'progress-modal-wrapper', + Component: window.Signal.Components.ProgressModal, + }); + }, ONE_SECOND); + + // Note: any task we put here needs to have its own safety valve; this function will + // show a spinner until it's done + try { + window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`); + await task(); + window.log.info( + `longRunningTaskWrapper/${idLog}: Task completed successfully` + ); + + if (progressTimeout) { + clearTimeout(progressTimeout); + progressTimeout = undefined; + } + if (progressView) { + progressView.remove(); + progressView = undefined; + } + } catch (error) { + window.log.error( + `longRunningTaskWrapper/${idLog}: Error!`, + error && error.stack ? error.stack : error + ); + + if (progressTimeout) { + clearTimeout(progressTimeout); + progressTimeout = undefined; + } + if (progressView) { + progressView.remove(); + progressView = undefined; + } + + window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`); + + // Note: this component uses a portal to render itself into the top-level DOM. No + // need to attach it to the DOM here. + const errorView = new Whisper.ReactWrapperView({ + className: 'error-modal-wrapper', + Component: window.Signal.Components.ErrorModal, + props: { + onClose: () => errorView.remove(), + }, + }); + } + }, + setupTimeline() { const { id } = this.model; @@ -2619,17 +2713,12 @@ Whisper.ConversationView = Whisper.View.extend({ }, async setDisappearingMessages(seconds: any) { - try { - if (seconds > 0) { - await this.model.updateExpirationTimer(seconds); - } else { - await this.model.updateExpirationTimer(null); - } - } catch (error) { - if (error.code === 409) { - this.showToast(Whisper.TimerConflictToast); - } - } + const valueToSet = seconds > 0 ? seconds : null; + + await this.longRunningTaskWrapper({ + name: 'updateExpirationTimer', + task: async () => this.model.updateExpirationTimer(valueToSet), + }); }, setMuteNotifications(ms: number) { diff --git a/ts/window.d.ts b/ts/window.d.ts index a9b1be25b..39a25f49d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -16,8 +16,10 @@ import { import { ContactRecordIdentityState, TextSecureType } from './textsecure.d'; import { WebAPIConnectType } from './textsecure/WebAPI'; import { CallingClass } from './services/calling'; +import * as Groups from './groups'; import * as Crypto from './Crypto'; import * as RemoteConfig from './RemoteConfig'; +import * as zkgroup from './util/zkgroup'; import { LocalizerType, BodyRangesType } from './types/Util'; import { CallHistoryDetailsType } from './types/Calling'; import { ColorType } from './types/Colors'; @@ -33,6 +35,8 @@ import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { combineNames } from './util'; import { BatcherType } from './util/batcher'; +import { ErrorModal } from './components/ErrorModal'; +import { ProgressModal } from './components/ProgressModal'; export { Long } from 'long'; @@ -166,16 +170,7 @@ declare global { }; Crypto: typeof Crypto; Data: typeof Data; - Groups: { - maybeUpdateGroup: (options: unknown) => Promise; - waitThenMaybeUpdateGroup: (options: unknown) => Promise; - uploadGroupChange: ( - options: unknown - ) => Promise<{ toArrayBuffer: () => ArrayBuffer }>; - buildDisappearingMessagesTimerChange: ( - options: unknown - ) => { version: number }; - }; + Groups: typeof Groups; Metadata: { SecretSessionCipher: typeof SecretSessionCipherClass; createCertificateValidator: ( @@ -383,23 +378,7 @@ declare global { del: unknown, bool: boolean ) => void; - zkgroup: { - generateProfileKeyCredentialRequest: ( - clientZkProfileCipher: unknown, - uuid: string, - profileKey: unknown - ) => { requestHex: string; context: unknown }; - getClientZkProfileOperations: (params: unknown) => unknown; - handleProfileKeyCredential: ( - clientZkProfileCipher: unknown, - profileCredentialRequestContext: unknown, - credential: unknown - ) => unknown; - deriveProfileKeyVersion: ( - profileKey: unknown, - uuid: string - ) => string; - }; + zkgroup: typeof zkgroup; combineNames: typeof combineNames; migrateColor: (color: string) => ColorType; createBatcher: (options: WhatIsThis) => WhatIsThis; @@ -429,20 +408,23 @@ declare global { renderChange: (change: unknown, things: unknown) => Array; }; Components: { - StagedLinkPreview: any; - Quote: any; - ContactDetail: any; - MessageDetail: any; - Lightbox: any; - MediaGallery: any; - CaptionEditor: any; - ConversationHeader: any; AttachmentList: any; + CaptionEditor: any; + ContactDetail: any; + ConversationHeader: any; + ErrorModal: typeof ErrorModal; + Lightbox: any; + LightboxGallery: any; + MediaGallery: any; + MessageDetail: any; + ProgressModal: typeof ProgressModal; + Quote: any; + StagedLinkPreview: any; + getCallingNotificationText: ( callHistoryDetails: unknown, i18n: unknown ) => string; - LightboxGallery: any; }; OS: { isLinux: () => boolean;