Merge branch 'clearnet' into feature/blocked-list-in-settings

pull/1297/head
Konstantin Ullrich 5 years ago committed by GitHub
commit 8bc7528e85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ on:
- clearnet
- github-actions
- refactor-ts-react
- clean-en-translation
jobs:
build:

2
.gitignore vendored

@ -41,3 +41,5 @@ tags
proxy.key
proxy.pub
*.tsbuildinfo

@ -83,10 +83,20 @@
"message": "Löschen",
"description": "Edit menu command to remove the selected text"
},
"editProfileModalTitle": {
"message": "Profil",
"description": "Title for the Edit Profile modal"
},
"editMenuSelectAll": {
"message": "Alles auswählen",
"description": "Edit menu comand to select all of the text in selected text box"
},
"newSession": {
"message": "Neue Session"
},
"addContact": {
"message": "Kontakt hinzufügen"
},
"editMenuStartSpeaking": {
"message": "Sprachausgabe starten",
"description": "Edit menu item under 'speech' to start dictation"
@ -147,6 +157,9 @@
"message": "Wird geladen …",
"description": "Message shown on the loading screen before we've loaded any messages"
},
"or": {
"message": "oder"
},
"optimizingApplication": {
"message": "Anwendung wird optimiert …",
"description": "Message shown on the loading screen while we are doing application optimizations"
@ -549,10 +562,90 @@
}
}
},
"copiedPublicKey": {
"message": "In Zwischenablage kopiert",
"description": "A toast message telling the user that the key was copied"
},
"copiedChatId": {
"message": "In Zwischenablage kopiert",
"description": "A toast message telling the user that the key was copied"
},
"sessionResetOngoing": {
"message": "Verschlüsselung neu gestartet",
"description": "your secure session is currently being reset, waiting for the reset acknowledgment."
},
"blockUser": {
"message": "Blockieren"
},
"unblockUser": {
"message": "Freigeben"
},
"members": {
"message": "$count$ mitglied",
"placeholders": {
"count": {
"content": "$1",
"example": "26"
}
}
},
"noContactsForGroup": {
"message": "Sie haben noch keine Kontakte."
},
"getStarted": {
"message": "Loslegen"
},
"createAccount": {
"message": "Konto Erstellen"
},
"signIn": {
"message": "Einloggen"
},
"beginYourSession": {
"message": "Beginnen<br />Sie<br />Ihre<br />Session."
},
"continue": {
"message": "Fortsetzen"
},
"welcomeToYourSession": {
"message": "Willkommen bei Session"
},
"generateSessionID": {
"message": "Session ID erstellen"
},
"yourUniqueSessionID": {
"message": "Das ist Ihre Session ID."
},
"allUsersAreRandomly...": {
"message": "Ihre Session ID ist die eindeutige Adresse, unter der Personen Sie über Session kontaktieren können. Ihre Session ID ist nicht mit Ihrer realen Identität verbunden, völlig anonym und von Natur aus privat."
},
"leaveOpenGroupConfirmation": {
"message": "Möchtest du wirklich diese Gruppe verlassen??",
"description": "Confirmation dialog text that tells the user what will happen if they leave the public channel."
},
"leaveClosedGroupConfirmation": {
"message": "Möchtest du wirklich diese Gruppe verlassen?",
"description": "Confirmation dialog text that tells the user what will happen if they leave the closed group."
},
"copyPublicKey": {
"message": "Kopieren",
"description": "Button action that the user can click to copy their public keys"
},
"copyChatId": {
"message": "Kopieren"
},
"deleteContact": {
"message": "Kontakt löschen",
"description": "Confirmation dialog title that asks the user if they really wish to delete the contact. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"oneNonImageAtATimeToast": {
"message": "Mehrere Anhänge je Nachricht sind ausschließlich bei Bildern erlaubt.",
"description": "An error popup when the user has attempted to add an attachment"
},
"editGroupName": {
"message": "Gruppe bearbeiten",
"description": "Button action that the user can click to edit a group name (closed)"
},
"cannotMixImageAndNonImageAttachments": {
"message": "Du kannst Bilder nicht gemeinsam mit anderen Anhängen in einer Nachricht kombinieren.",
"description": "An error popup when the user has attempted to add an attachment"
@ -739,6 +832,24 @@
"message": "Auf Nachricht antworten",
"description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation"
},
"enterOpenGroupURL": {
"message": "Gruppen-URL öffnen"
},
"newClosedGroup": {
"message": "Neue geschlossene Gruppe"
},
"createClosedGroupNamePrompt": {
"message": "Gruppenname"
},
"createClosedGroupPlaceholder": {
"message": "Geben Sie einen Gruppennamen ein."
},
"addChannelDescription": {
"message": "Geben Sie eine offene Gruppen-URL ein."
},
"next": {
"message": "Weiter"
},
"originalMessageNotFound": {
"message": "Originalnachricht nicht gefunden",
"description": "Shown in quote if reference message was not found as message was initially downloaded and processed"
@ -802,7 +913,7 @@
"description": "Shown on MacOS if running on a read-only volume and we cannot update"
},
"ok": {
"message": "Okay",
"message": "Ok",
"description": ""
},
"cancel": {
@ -907,6 +1018,14 @@
"message": "Soll diese Unterhaltung unwiderruflich gelöscht werden?",
"description": "Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deleteAccount": {
"message": "Alle Daten löschen",
"description": "Text for button in settings view to delete account"
},
"deleteAccountWarningSub": {
"message": "Dadurch werden Ihre Nachrichten, Sessions und Kontakte dauerhaft gelöscht.",
"description": "Warning for account deletion in settings view"
},
"sessionEnded": {
"message": "Verschlüsselung zurückgesetzt",
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
@ -1117,10 +1236,60 @@
"message": "Erfahre mehr über das Verifizieren von Sicherheitsnummern",
"description": "Text that links to a support article on verifying safety numbers"
},
"appearanceSettingsTitle": {
"message": "Darstellung"
},
"privacySettingsTitle": {
"message": "Datenschutz"
},
"notificationsSettingsTitle": {
"message": "Benachrichtigungen"
},
"devicesSettingsTitle": {
"message": "Geräte"
},
"linkPreviewsTitle": {
"message": "Link-Vorschauen senden",
"description": "Option to control creation and send of link previews in setting screen"
},
"linkPreviewDescription": {
"message": "Vorschauen werden unterstützt für Links von Imgur, Instagram, Pinterest, Reddit und YouTube",
"description": "Description shown for the Link Preview option "
},
"expiredWarning": {
"message": "Diese Version von Signal Desktop ist veraltet. Bitte führe eine Aktualisierung auf die aktuellste Version durch, um weiterhin Nachrichten austauschen zu können.",
"description": "Warning notification that this version of the app has expired"
},
"readReceiptSettingDescription": {
"message": "Lesebestätigungen aktivieren",
"description": "Description of the read receipts setting"
},
"readReceiptSettingTitle": {
"message": "Lesebestätigungen",
"description": "Title of the read receipts setting"
},
"typingIndicatorsSettingDescription": {
"message": "sehen und teilen, wann Nachrichten eingetippt werden",
"description": "Description of the typing indicators setting"
},
"typingIndicatorsSettingTitle": {
"message": "Tipp-Indikatoren",
"description": "Title of the typing indicators setting"
},
"inviteContacts": {
"message": "Freunde einladen"
},
"settingsHeader": {
"message": "Einstellungen"
},
"leaveOpenGroup": {
"message": "Gruppe öffnen verlassen",
"description": "Button action that the user can click to leave the group"
},
"leaveClosedGroup": {
"message": "Geschlossene Gruppe verlassen",
"description": "Button action that the user can click to leave the group"
},
"upgrade": {
"message": "Aktualisieren",
"description": "Label text for button to upgrade the app to the latest version"
@ -1513,6 +1682,24 @@
}
}
},
"joinOpenGroup": {
"message": "Offener Gruppe beitreten"
},
"yourSessionID": {
"message": "Ihre Session ID"
},
"createClosedGroup": {
"message": "Neue geschlossene Gruppe"
},
"searchForAKeyPhrase": {
"message": "Nach Unterhaltungen, Kontakten und Nachrichten suchen"
},
"enterSessionID": {
"message": "Session ID eingeben"
},
"usersCanShareTheir...": {
"message": "Benutzer können ihre Session ID freigeben, indem sie in ihren Einstellungen auf \"Session ID freigeben\" tippen oder ihren QR-Code freigeben."
},
"joinedTheGroup": {
"message": "$name$ ist der Gruppe beigetreten",
"description": "Shown in the conversation history when a single person joins the group",

@ -39,10 +39,6 @@
"message": "&Help",
"description": "The label that is used for the Help menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt-<letter> combination."
},
"mainMenuSettings": {
"message": "Preferences…",
"description": "The label that is used for the Preferences menu in the program main menu. This should be consistent with the standard naming for Preferences on the operating system."
},
"appMenuHide": {
"message": "Hide",
"description": "Application menu command to hide the window"
@ -151,10 +147,6 @@
"message": "No Suggestions",
"description": "Shown in the context menu for a misspelled word to indicate that there are no suggestions to replace the misspelled word"
},
"connectingLoad": {
"message": "Connecting To Server",
"description": "Message shown on the as a loading screen while we are connecting to something"
},
"loading": {
"message": "Loading...",
"description": "Message shown on the loading screen before we've loaded any messages"
@ -173,30 +165,10 @@
}
}
},
"archivedConversations": {
"message": "Archived Conversations",
"description": "Shown in place of the search box when showing archived conversation list"
},
"archiveHelperText": {
"message": "These conversations are archived and will only appear in the Inbox if new messages are received.",
"description": "Shown at the top of the archived converations list in the left pane"
},
"archiveConversation": {
"message": "Archive Conversation",
"description": "Shown in menu for conversation, and moves conversation out of main conversation list"
},
"moveConversationToInbox": {
"message": "Move Conversation to Inbox",
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
},
"chooseDirectory": {
"message": "Choose folder",
"description": "Button to allow the user to find a folder on disk"
},
"chooseFile": {
"message": "Choose file",
"description": "Button to allow the user to find a file on disk"
},
"loadDataHeader": {
"message": "Load your data",
"description": "Header shown on the first screen in the data import process"
@ -241,27 +213,6 @@
"message": "Link this device to your phone",
"description": "Button shown at end of successful 'light' import process, so the standard linking process still needs to happen"
},
"selectedLocation": {
"message": "your selected location",
"description": "Message shown as the export location if we didn't capture the target directory"
},
"upgradingDatabase": {
"message": "Upgrading database. This may take some time...",
"description": "Message shown on the loading screen when we're changing database structure on first run of a new version"
},
"loadingMessages": {
"message": "Loading messages. $count$ so far...",
"description": "Message shown on the loading screen when we're catching up on the backlog of messages",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"capsLockOn": {
"message": "Caps lock is on."
},
"me": {
"message": "Me",
"description": "The label for yourself when shown in a group member list"
@ -278,18 +229,6 @@
"message": "You were removed from the group",
"description": "Displayed when a user can't send a message because they have left the group"
},
"scrollDown": {
"message": "Scroll to bottom of conversation",
"description": "Alt text for button to take user down to bottom of conversation, shown when user scrolls up"
},
"messageBelow": {
"message": "New message below",
"description": "Alt text for button to take user down to bottom of conversation with a new message out of screen"
},
"messagesBelow": {
"message": "New messages below",
"description": "Alt text for button to take user down to bottom of conversation with more than one message out of screen"
},
"unreadMessage": {
"message": "1 Unread Message",
"description": "Text for unread message separator, just one message"
@ -434,10 +373,6 @@
"submit": {
"message": "Submit"
},
"acceptNewKey": {
"message": "Accept",
"description": "Label for a button to accept a new safety number"
},
"verify": {
"message": "Mark as verified"
},
@ -471,9 +406,6 @@
"message": "New safety number",
"description": "Header for a key change dialog"
},
"identityChanged": {
"message": "Your safety number with this contact has changed. This could either mean that someone is trying to intercept your communication, or this contact simply reinstalled Session. You may wish to verify the new safety number below."
},
"incomingError": {
"message": "Error handling incoming message"
},
@ -509,18 +441,6 @@
"message": "This Month",
"description": "Section header in the media gallery"
},
"unsupportedAttachment": {
"message": "Unsupported attachment type. Click to save.",
"description": "Displayed for incoming unsupported attachment"
},
"clickToSave": {
"message": "Click to save",
"description": "Hover text for attachment filenames"
},
"unnamedFile": {
"message": "Unnamed File",
"description": "Hover text for attachment filenames"
},
"voiceMessage": {
"message": "Voice Message",
"description": "Name for a voice message attachment"
@ -581,9 +501,6 @@
"unableToLoadAttachment": {
"message": "Unable to load attachment."
},
"connect": {
"message": "Connect"
},
"disconnected": {
"message": "Disconnected",
"description": "Displayed when the desktop client cannot connect to the server."
@ -645,10 +562,6 @@
"message": "Show",
"description": "Command under Window menu, to show the window"
},
"hide": {
"message": "Hide",
"description": "Command in the tray icon menu, to hide the window"
},
"quit": {
"message": "Quit",
"description": "Command in the tray icon menu, to quit the application"
@ -674,10 +587,6 @@
"message": "Conversations",
"description": "Shown to separate the types of search results"
},
"friendsHeader": {
"message": "Friends",
"description": "Shown to separate the types of search results"
},
"contactsHeader": {
"message": "Contacts",
"description": "Shown to separate the types of search results"
@ -687,8 +596,7 @@
"description": "Shown to separate the types of search results"
},
"settingsHeader": {
"message": "Settings",
"description": "Shown to separate the types of search results"
"message": "Settings"
},
"welcomeToSession": {
"message": "Welcome to Session"
@ -774,16 +682,6 @@
"message": "You",
"description": "In Android theme, shown in quote if you or someone else replies to you"
},
"replyingTo": {
"message": "Replying to $name$",
"description": "Shown in iOS theme when you or someone quotes to a message which is not from you",
"placeholders": {
"name": {
"content": "$1",
"example": "John"
}
}
},
"audioPermissionNeeded": {
"message": "To send audio messages, allow Session to access your microphone.",
"description": "Shown if the user attempts to send an audio message without audio permssions turned on"
@ -792,10 +690,6 @@
"message": "Allow Access",
"description": "Button shown in popup asking to enable microphon/video permissions to send audio messages"
},
"showSettings": {
"message": "Show Settings",
"description": "A button shown in dialog requesting the user to turn on audio permissions"
},
"audio": {
"message": "Audio",
"description": "Shown in a quotation of a message containing an audio attachment if no text was originally provided with that attachment"
@ -816,10 +710,6 @@
"message": "Session Desktop failed to update, but there is a new version available. Please go to https://getsession.org/ and install the new version manually, then either contact support or file a bug about this problem.",
"description": "Shown if a general error happened while trying to install update package"
},
"readOnlyVolume": {
"message": "Session Desktop is likely in a macOS quarantine, and will not be able to auto-update. Please try moving Session.app to /Applications with Finder.",
"description": "Shown on MacOS if running on a read-only volume and we cannot update"
},
"ok": {
"message": "OK"
},
@ -832,36 +722,12 @@
"cancel": {
"message": "Cancel"
},
"copy": {
"message": "Copy"
},
"skip": {
"message": "Skip"
},
"close": {
"message": "Close"
},
"continue": {
"message": "Continue"
},
"noThankyou": {
"message": "No, thank you"
},
"noMessagesTitle": {
"message": "You don't have any messages, yet."
},
"noMessagesSubtitle": {
"message": "Would you like to join Session's open group?"
},
"pairNewDevice": {
"message": "Pair New Device"
},
"devicePairingAccepted": {
"message": "Device Linking Accepted"
},
"devicePairingReceived": {
"message": "Device Linking Received"
},
"devicePairingRequestReceivedLimitTitle": {
"message": "Device linking limit reached."
},
@ -880,9 +746,6 @@
"pairNewDevicePrompt": {
"message": "Scan the QR Code on your other device"
},
"pairedDevices": {
"message": "Linked Devices"
},
"noPairedDevices": {
"message": "No linked devices"
},
@ -904,9 +767,6 @@
"showPairingWordsTitle": {
"message": "Linking Secret Words"
},
"secretPrompt": {
"message": "Here is your secret"
},
"confirmUnpairingTitle": {
"message": "Please confirm you want to unlink the following device:"
},
@ -933,25 +793,15 @@
"confirm": {
"message": "Confirm"
},
"failedToSend": {
"message": "Failed to send to some recipients. Check your network connection."
},
"error": {
"message": "Error"
},
"messageDetail": {
"message": "Message Detail"
},
"delete": {
"message": "Delete"
},
"unsend": {
"message": "Unsend"
},
"forwardMessage": {
"message": "Forward",
"description": "Text of Forward Message button"
},
"deletePublicWarning": {
"message": "Are you sure? Clicking 'unsend' will permanently remove this message for everyone in this channel."
},
@ -991,18 +841,6 @@
"message": " Type your message",
"description": "Placeholder text in the message entry field"
},
"sendMessageDisabledSecondary": {
"message": "This pubkey belongs to a secondary device. You should never see this message",
"description": "Placeholder text in the message entry field when it is disabled because a secondary device conversation is visible"
},
"sendMessageDisabled": {
"message": "Waiting for friend request approval",
"description": "Placeholder text in the message entry field when it is disabled while we are waiting for a friend request approval"
},
"sendMessageFriendRequest": {
"message": "Send your first message",
"description": "Placeholder text in the message entry field when it is the first message sent to that contact"
},
"sendMessageLeftGroup": {
"message": "You left this group"
},
@ -1022,10 +860,6 @@
"showSafetyNumber": {
"message": "View Safety Number"
},
"viewAllMedia": {
"message": "View all media",
"description": "This is a menu item for viewing all media (images + video) in a conversation, using the imperative case, as in a command."
},
"verifyHelp": {
"message": "If you wish to verify the security of your end-to-end encryption with $name$, compare the numbers above with the numbers on their device.",
"placeholders": {
@ -1075,11 +909,7 @@
"message": "Permanently delete the messages in this conversation?",
"description": "Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deletePublicChannel": {
"message": "Leave Channel",
"description": "Confirmation dialog title that asks the user if they really wish to delete a public channel. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone."
},
"deletePublicChannelConfirmation": {
"leaveOpenGroupConfirmation": {
"message": "Leave this Open Group?",
"description": "Confirmation dialog text that tells the user what will happen if they leave the public channel."
},
@ -1155,10 +985,6 @@
"message": "Save",
"descripton": "Used as a 'commit changes' button in the Caption Editor for outgoing image attachments"
},
"fileIconAlt": {
"message": "File icon",
"description": "Used in the media gallery documents tab to visually represent a file"
},
"emojiAlt": {
"message": "Emoji image of '$title$'",
"description": "Used in the alt tag of all emoji images",
@ -1169,14 +995,6 @@
}
}
},
"installWelcome": {
"message": "Welcome to Session",
"description": "Welcome title on the install page"
},
"installTagline": {
"message": "Privacy is possible. Signal makes it easy.",
"description": "Tagline displayed under 'installWelcome' string on the install page"
},
"linkYourPhone": {
"message": "Link your phone to Session",
"description": "Shown on the front page when the application first starst, above the QR code"
@ -1197,10 +1015,6 @@
"message": "Link New Device",
"description": "The menu option shown in Signal iOS to add a new linked device"
},
"deviceName": {
"message": "Device name",
"description": "The label in settings panel shown for the user-provided name for this desktop instance"
},
"chooseDeviceName": {
"message": "Choose this device's name",
"description": "The header shown on the 'choose device name' screen in the device linking process"
@ -1220,14 +1034,6 @@
"installTooManyDevices": {
"message": "Sorry, you have too many devices linked already. Try removing some."
},
"settings": {
"message": "Settings",
"description": "Menu item and header for global settings"
},
"theme": {
"message": "Theme",
"description": "Header for theme settings"
},
"permissions": {
"message": "Permissions",
"description": "Header for permissions section of settings"
@ -1237,7 +1043,7 @@
"description": "Header for general options on the settings screen"
},
"linkPreviewsTitle": {
"message": "Link Previews",
"message": "Send Link Previews",
"description": "Option to control creation and send of link previews in setting screen"
},
"linkPreviewDescription": {
@ -1278,26 +1084,10 @@
"message": "This will clear all data in the application, removing all messages and saved account information.",
"description": "Text describing what the clear data button will do."
},
"clearDataButton": {
"message": "Clear Data",
"description": "Button in the settings dialog starting process to delete all data"
},
"deleteAllDataHeader": {
"message": "Delete all data?",
"description": "Header of the full-screen delete data confirmation screen"
},
"deleteAllDataBody": {
"message": "You are about to delete all of this application's saved account information, including all contacts and all messages. You can always register with your seed again, but that will not restore deleted messages.",
"description": "Text describing what exactly will happen if the user clicks the button to delete all data"
},
"deleteAllDataButton": {
"message": "Delete all data",
"description": "Text of the button that deletes all data"
},
"deleteAllDataProgress": {
"message": "Disconnecting and deleting all data",
"description": "Message shown to user when app is disconnected and data deleted"
},
"notifications": {
"message": "Notifications",
"description": "Header for notification settings"
@ -1326,10 +1116,6 @@
"message": "Time to live (how long the recipient will have to collect their messages)",
"description": "Description of the time to live setting"
},
"messageTTLSettingWarning": {
"message": "Warning! Lowering the TTL could result in messages being lost if the recipient doesn't collect them in time!",
"description": "Warning for the time to live setting"
},
"zoomFactorSettingTitle": {
"message": "Zoom Factor",
"description": "Title of the Zoom Factor setting"
@ -1378,14 +1164,6 @@
"message": "Send failed",
"description": "Shown on outgoing message if it fails to send"
},
"showMore": {
"message": "Details",
"description": "Displays the details of a key change"
},
"showLess": {
"message": "Hide details",
"description": "Hides the details of a key change"
},
"learnMore": {
"message": "Learn more about verifying safety numbers",
"description": "Text that links to a support article on verifying safety numbers"
@ -1406,34 +1184,10 @@
"message": "Media message",
"description": "Description of a message that has an attachment and no text, displayed in the conversation list as a preview."
},
"unregisteredUser": {
"message": "Number is not registered",
"description": "Error message displayed when sending to an unregistered user."
},
"sync": {
"message": "Contacts",
"description": "Label for contact and group sync settings"
},
"syncExplanation": {
"message": "Import all Signal groups and contacts from your mobile device.",
"description": "Explanatory text for sync settings"
},
"lastSynced": {
"message": "Last import at",
"description": "Label for date and time of last sync operation"
},
"syncNow": {
"message": "Import now",
"description": "Label for a button that syncs contacts and groups from your phone"
},
"syncing": {
"message": "Importing...",
"description": "Label for a disabled sync button while sync is in progress."
},
"syncFailed": {
"message": "Import failed. Make sure your computer and your phone are connected to the internet.",
"description": "Informational text displayed if a sync operation times out."
},
"timestamp_s": {
"message": "now",
"description": "Brief timestamp for messages sent less than a minute ago. Displayed in the conversation list and message bubble."
@ -1446,50 +1200,6 @@
"message": "1 hour",
"description": "Brief timestamp for messages sent about one hour ago. Displayed in the conversation list and message bubble."
},
"hoursAgoShort": {
"message": "$hours$ hr",
"description": "Even further contracted form of 'X hours ago' which works both for singular and plural, used in the left pane",
"placeholders": {
"hours": {
"content": "$1",
"example": "2"
}
}
},
"hoursAgo": {
"message": "$hours$ hr ago",
"description": "Contracted form of 'X hours ago' which works both for singular and plural",
"placeholders": {
"hours": {
"content": "$1",
"example": "2"
}
}
},
"minutesAgoShort": {
"message": "$minutes$ min",
"description": "Even further contracted form of 'X minutes ago' which works both for singular and plural, used in the left pane",
"placeholders": {
"minutes": {
"content": "$1",
"example": "10"
}
}
},
"minutesAgo": {
"message": "$minutes$ min ago",
"description": "Contracted form of 'X minutes ago' which works both for singular and plural",
"placeholders": {
"minutes": {
"content": "$1",
"example": "10"
}
}
},
"justNow": {
"message": "now",
"description": "Shown if a message is very recent, less than 60 seconds old"
},
"timestampFormat_M": {
"message": "MMM D",
"description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'."
@ -1676,10 +1386,6 @@
}
}
},
"audioNotificationDescription": {
"message": "Play audio notification",
"description": "Description for audio notification setting"
},
"safetyNumberChanged": {
"message": "Safety Number has changed",
"description": "A notification shown in the conversation when a contact reinstalls"
@ -1708,14 +1414,6 @@
}
}
},
"themeLight": {
"message": "Light",
"description": "Label text for light theme (normal)"
},
"themeDark": {
"message": "Dark",
"description": "Label text for dark theme"
},
"themeToggleTitle": {
"message": "Light Mode"
},
@ -1738,10 +1436,6 @@
"message": "Start New Conversation",
"description": "Label underneath number a user enters that is not an existing contact"
},
"newPhoneNumber": {
"message": "Enter a phone number to add a contact.",
"description": "Placeholder for adding a new number to a contact"
},
"invalidNumberError": {
"message": "Invalid public key",
"description": "When a person inputs a public key that is invalid"
@ -1752,6 +1446,9 @@
"unlinked": {
"message": "Unlinked"
},
"successUnlinked": {
"message": "Your device was unlinked successfully"
},
"relink": {
"message": "Relink"
},
@ -1860,50 +1557,6 @@
}
}
},
"friendRequestPending": {
"message": "Friend request",
"description": "Shown in the conversation history when the user sends or recieves a friend request"
},
"friendRequestAccepted": {
"message": "Friend request accepted",
"description": "Shown in the conversation history when the user accepts a friend request"
},
"friendRequestDeclined": {
"message": "Session request declined",
"description": "Shown in the conversation history when the user declines a friend request"
},
"friendRequestExpired": {
"message": "Friend request expired",
"description": "Shown in the conversation history when the users friend request expires"
},
"friendRequestNotificationTitle": {
"message": "Friend request",
"description": "Shown in a notification title when receiving a friend request"
},
"friendRequestNotificationMessage": {
"message": "$name$ sent you a friend request",
"description": "Shown in a notification body when receiving a friend request",
"placeholders": {
"name": {
"content": "$1",
"example": "Bob"
}
}
},
"friendRequestAcceptedNotificationTitle": {
"message": "Friend request accepted",
"description": "Shown in a notification title when friend request was accepted by the other user"
},
"friendRequestAcceptedNotificationMessage": {
"message": "$name$ accepted your friend request",
"description": "Shown in a notification body when friend request was accepted by the other user",
"placeholders": {
"name": {
"content": "$1",
"example": "Bob"
}
}
},
"blockUser": {
"message": "Block User"
},
@ -1945,14 +1598,6 @@
"copyChatId": {
"message": "Copy Chat ID"
},
"updateGroup": {
"message": "Update Group",
"description": "Button action that the user can click to rename the group or add a new member"
},
"leaveGroup": {
"message": "Leave Group",
"description": "Button action that the user can click to leave the group"
},
"leaveOpenGroup": {
"message": "Leave Open Group",
"description": "Button action that the user can click to leave the group"
@ -1965,10 +1610,6 @@
"message": "Leave this Closed Group?",
"description": "Confirmation dialog text that tells the user what will happen if they leave the closed group."
},
"leaveGroupDialogTitle": {
"message": "Are you sure you want to leave this group?",
"description": "Title shown to the user to confirm they want to leave the group"
},
"noContactsForGroup": {
"message": "You don't have any contacts to start a group with."
},
@ -1992,10 +1633,6 @@
"message": "Message text copied",
"description": "A toast message telling the user that the message text was copied"
},
"editProfile": {
"message": "Edit Profile",
"description": "Button action that the user can click to edit their profile"
},
"editGroupNameOrPicture": {
"message": "Edit group name or picture",
"description": "Button action that the user can click to edit a group name (open)"
@ -2004,10 +1641,6 @@
"message": "Edit group name",
"description": "Button action that the user can click to edit a group name (closed)"
},
"createGroupDialogTitle": {
"message": "Creating a Closed Group",
"description": "Title for the dialog box used to create a new private group"
},
"updateGroupDialogTitle": {
"message": "Updating a Closed Group",
"description": "Title for the dialog box used to update an existing private group"
@ -2080,34 +1713,10 @@
"message": "This is <strong>your unique public QR Code.</strong><br/>Other users may scan this in order to begin a conversation with you.",
"description": "Description given to QRCode modal"
},
"showQRCode": {
"message": "Show QR Code",
"description": "Button action that the user can click to view their QR code"
},
"showAddServer": {
"message": "Add Public Server",
"description": "Button action that the user can click to connect to a new public server"
},
"serverUrl": {
"message": "Server URL",
"description": "Placeholder for server URL input"
},
"noServerURL": {
"message": "Please enter a server URL",
"description": "Error message when no server url entered"
},
"addServerDialogTitle": {
"message": "Connect To New Public Server",
"description": "Title for the dialog box used to connect to a new public server"
},
"createPrivateGroup": {
"message": "Create Private Group",
"description": "Button action that the user can click to show a dialog for creating a new private group chat"
},
"seedViewTitle": {
"message": "Please save the seed below in a safe location. They can be used to restore your account if you lose access or migrate to a new device.",
"description": "The title shown when the user views their seeds"
},
"copiedMnemonic": {
"message": "Recovery phrase copied successfully",
"description": "A toast message telling the user that the mnemonic seed was copied"
@ -2123,10 +1732,6 @@
"unlock": {
"message": "Unlock"
},
"resetDatabase": {
"message": "Reset Database",
"description": "A button action that the user can click to reset the database"
},
"password": {
"message": "Password",
"description": "Placeholder for password input"
@ -2203,21 +1808,6 @@
"setPasswordFail": {
"message": "Failed to set password"
},
"removePasswordFail": {
"message": "Failed to remove password"
},
"changePasswordFail": {
"message": "Failed to change password"
},
"setPasswordSuccess": {
"message": "Password set"
},
"removePasswordSuccess": {
"message": "Password removed"
},
"changePasswordSuccess": {
"message": "Password changed"
},
"passwordLengthError": {
"message": "Password must be between 6 and 50 characters long",
"description": "Error string shown to the user when password doesn't meet length criteria"
@ -2243,18 +1833,10 @@
"message": "Invalid Session ID",
"description": "Error string shown when user types an invalid pubkey hex string"
},
"invalidLnsFormat": {
"message": "Invalid LNS Name",
"description": "Error string shown when user types an invalid LNS name"
},
"invalidPubkeyFormat": {
"message": "Invalid Pubkey Format",
"description": "Error string shown when user types an invalid pubkey format"
},
"attemptedConnectionTimeout": {
"message": "Connection to open group timed out",
"description": "Shown in toast when attempted connection to OpenGroup times out"
},
"lnsMappingNotFound": {
"message": "There is no LNS mapping associated with this name",
"description": "Shown in toast if user enters an unknown LNS name"
@ -2266,54 +1848,24 @@
"lnsTooFewNodes": {
"message": "Not enough nodes currently active for LNS lookup"
},
"conversationsTab": {
"message": "Conversations",
"description": "conversation tab title"
},
"friendsTab": {
"message": "Friends",
"description": "friend tab title"
},
"pendingAcceptance": {
"message": "Pending Acceptance",
"description": "Indicates that a friend request is pending"
},
"notFriends": {
"message": "Not Friends",
"description": "Indicates that a conversation is not friends with us"
},
"emptyGroupNameError": {
"message": "Group Name cannot be empty",
"description": "Error message displayed on empty group name"
},
"emptyProfileNameError": {
"message": "Profile name cannot be empty",
"description": "Error message displayed on empty profile name"
},
"nonAdminDeleteMember": {
"message": "Only group admin can remove members!"
},
"editProfileDialogTitle": {
"message": "Editing Profile"
},
"editProfileModalTitle": {
"message": "Profile",
"description": "Title for the Edit Profile modal"
},
"profileName": {
"message": "Profile Name"
},
"groupNamePlaceholder": {
"message": "Group Name"
},
"inviteContacts": {
"message": "Invite Contacts"
},
"manageModerators": {
"message": "Manage Moderators"
},
"addModerators": {
"message": "Add Moderators"
},
@ -2326,11 +1878,11 @@
"groupInvitation": {
"message": "Group Invitation"
},
"addingFriends": {
"message": "Adding friends to"
"addingContacts": {
"message": "Adding contacts to"
},
"noFriendsToAdd": {
"message": "No friends to add"
"noContactsToAdd": {
"message": "No contacts to add"
},
"noMembersInThisGroup": {
"message": "No other members in this group"
@ -2417,9 +1969,6 @@
"linkDevice": {
"message": "Link Device"
},
"restoreSessionID": {
"message": "Restore Session ID"
},
"restoreUsingSeed": {
"message": "Restore From Recovery Phrase"
},
@ -2438,42 +1987,21 @@
"welcomeToYourSession": {
"message": "Welcome to your Session"
},
"completeSignUp": {
"message": "Complete Sign Up"
},
"compose": {
"message": "Compose"
},
"newSession": {
"message": "New Session"
},
"freindRequestsButton": {
"message": "Requests"
},
"searchForAKeyPhrase": {
"message": "Search for a key phrase or contact"
},
"enterRecipient": {
"message": "Enter Recipient"
},
"enterSessionID": {
"message": "Enter Session ID"
},
"pasteSessionIDRecipient": {
"message": "Enter a Session ID"
},
"usersCanShareTheir...": {
"message": "Users can share their Session ID from their account settings, or by sharing their QR code."
},
"searchByIDOrDisplayName": {
"message": "Search by ID # or Display Name"
},
"message": {
"message": "Message"
},
"lists": {
"message": "Lists"
},
"edit": {
"message": "Edit"
},
@ -2483,60 +2011,27 @@
"createGroup": {
"message": "Create Group"
},
"yourPublicKey": {
"message": "Your Session ID"
},
"accept": {
"message": "Accept"
},
"decline": {
"message": "Decline"
},
"appearanceSettingsTitle": {
"message": "Appearance"
},
"appearanceSettingsDescription": {
"message": "Appearance and interface options"
},
"accountSettingsTitle": {
"message": "Account"
},
"accountSettingsDescription": {
"message": "Manage your account"
},
"permissionSettingsTitle": {
"message": "Permissions"
},
"permissionSettingsDescription": {
"message": "Set Session's permissions"
},
"privacySettingsTitle": {
"message": "Privacy"
},
"privacySettingsDescription": {
"message": "Manage your privacy settings"
},
"notificationSettingsTitle": {
"notificationsSettingsTitle": {
"message": "Notifications"
},
"notificationSettingsDescription": {
"message": "Configure notification options"
},
"devicesSettingsTitle": {
"message": "Devices"
},
"devicesSettingsDescription": {
"message": "Manage your linked devices"
},
"mnemonicEmpty": {
"message": "Seed is mandatory"
},
"displayNameEmpty": {
"message": "Display Name Is Mandatory"
},
"youHaveFriendRequestFrom": {
"message": "You have friend requests from..."
},
"members": {
"message": "$count$ members",
"placeholders": {
@ -2546,15 +2041,6 @@
}
}
},
"channels": {
"message": "Channels"
},
"groups": {
"message": "Groups"
},
"addChannel": {
"message": "Join Open Group"
},
"joinOpenGroup": {
"message": "Join Open Group"
},
@ -2576,7 +2062,7 @@
"closedGroupCreatedToastTitle": {
"message": "Group created successfully"
},
"enterChannelURL": {
"enterOpenGroupURL": {
"message": "Enter Open Group URL"
},
"channelUrlPlaceholder": {
@ -2588,27 +2074,18 @@
"joinChannel": {
"message": "Join Open Group"
},
"joinPublicChat": {
"message": "Join open group"
},
"next": {
"message": "Next"
},
"description": {
"message": "Description"
},
"filterReceivedRequests": {
"message": "Filter received requests"
},
"secretWords": {
"message": "Secret words"
},
"pairingDevice": {
"message": "Linking Device"
},
"gotPairingRequest": {
"message": "Linking request received"
},
"devicePairedSuccessfully": {
"message": "Device linked successfully"
},

@ -810,6 +810,7 @@ const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion4,
updateToLokiSchemaVersion5,
updateToLokiSchemaVersion6,
updateToLokiSchemaVersion7,
];
async function updateToLokiSchemaVersion1(currentVersion, instance) {
@ -1027,6 +1028,30 @@ async function updateToLokiSchemaVersion6(currentVersion, instance) {
console.log('updateToLokiSchemaVersion6: success!');
}
async function updateToLokiSchemaVersion7(currentVersion, instance) {
if (currentVersion >= 7) {
return;
}
console.log('updateToLokiSchemaVersion7: starting...');
await instance.run('BEGIN TRANSACTION;');
// Remove multi device data
await instance.run('DELETE FROM pairingAuthorisations;');
await instance.run(
`INSERT INTO loki_schema (
version
) values (
7
);`
);
await instance.run('COMMIT TRANSACTION;');
console.log('updateToLokiSchemaVersion7: success!');
}
async function updateLokiSchema(instance) {
const result = await instance.get(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';"

@ -11,7 +11,6 @@
libloki,
libsession,
libsignal,
StringView,
BlockedNumberController,
libsession,
*/
@ -130,6 +129,52 @@
// of preload.js processing
window.setImmediate = window.nodeSetImmediate;
window.toasts = new Map();
window.pushToast = options => {
// Setting toasts with the same ID can be used to prevent identical
// toasts from appearing at once (stacking).
// If toast already exists, it will be reloaded (updated)
const params = {
title: options.title,
id: options.id || window.generateID(),
description: options.description || '',
type: options.type || '',
icon: options.icon || '',
shouldFade: options.shouldFade,
};
// Give all toasts an ID. User may define.
let currentToast;
const toastID = params.id;
const toast = !!toastID && window.toasts.get(toastID);
if (toast) {
currentToast = window.toasts.get(toastID);
currentToast.update(params);
} else {
// Make new Toast
window.toasts.set(
toastID,
new Whisper.SessionToastView({
el: $('body'),
})
);
currentToast = window.toasts.get(toastID);
currentToast.render();
currentToast.update(params);
}
// Remove some toasts if too many exist
const maxToasts = 6;
while (window.toasts.size > maxToasts) {
const finalToastID = window.toasts.keys().next().value;
window.toasts.get(finalToastID).fadeToast();
}
return toastID;
};
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
const {
mandatoryMessageUpgrade,
@ -152,6 +197,20 @@
window.log.info('background page reloaded');
window.log.info('environment:', window.getEnvironment());
const restartReason = localStorage.getItem('restart-reason');
window.log.info('restartReason:', restartReason);
if (restartReason === 'unlink') {
setTimeout(() => {
localStorage.removeItem('restart-reason');
window.pushToast({
title: window.i18n('successUnlinked'),
type: 'info',
id: '123',
});
}, 2000);
}
let idleDetector;
let initialLoadComplete = false;
@ -301,6 +360,12 @@
storage.put('primaryDevicePubKey', textsecure.storage.user.getNumber());
}
// 4th August 2020 - Force wipe of secondary devices as multi device is being disabled.
if (storage.get('isSecondaryDevice')) {
await window.deleteAccount('unlink');
return;
}
// These make key operations available to IPC handlers created in preload.js
window.Events = {
getThemeSetting: () => 'dark', // storage.get('theme-setting', 'dark')
@ -615,171 +680,6 @@
}
});
// TODO: make sure updating still works
window.doUpdateGroup = async (groupId, groupName, members, avatar) => {
const ourKey = textsecure.storage.user.getNumber();
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
const oldMembers = convo.get('members');
const oldName = convo.getName();
const groupDetails = {
id: groupId,
name: groupName,
members,
active: true,
expireTimer: convo.get('expireTimer'),
avatar,
is_medium_group: false,
};
const recipients = _.union(convo.get('members'), members);
await window.NewReceiver.onGroupReceived(groupDetails);
if (convo.isPublic()) {
const API = await convo.getPublicSendData();
if (avatar) {
// I hate duplicating this...
const readFile = attachment =>
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => {
const data = e.target.result;
resolve({
...attachment,
data,
size: data.byteLength,
});
};
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(attachment.file);
});
const attachment = await readFile({ file: avatar });
// const tempUrl = window.URL.createObjectURL(avatar);
// Get file onto public chat server
const fileObj = await API.serverAPI.putAttachment(attachment.data);
if (fileObj === null) {
// problem
window.warn('File upload failed');
return;
}
// lets not allow ANY URLs, lets force it to be local to public chat server
const url = new URL(fileObj.url);
// write it to the channel
await API.setChannelAvatar(url.pathname);
}
if (await API.setChannelName(groupName)) {
// queue update from server
// and let that set the conversation
API.pollForChannelOnce();
// or we could just directly call
// convo.setGroupName(groupName);
// but gut is saying let the server be the definitive storage of the state
// and trickle down from there
}
return;
}
const nullAvatar = undefined;
if (avatar) {
// would get to download this file on each client in the group
// and reference the local file
}
const options = {};
const isMediumGroup = convo.isMediumGroup();
const updateObj = {
id: groupId,
avatar: nullAvatar,
recipients,
members,
is_medium_group: isMediumGroup,
options,
};
if (oldName !== groupName) {
updateObj.name = groupName;
}
const addedMembers = _.difference(updateObj.members, oldMembers);
if (addedMembers.length > 0) {
updateObj.joined = addedMembers;
}
// Check if anyone got kicked:
const removedMembers = _.difference(oldMembers, updateObj.members);
if (removedMembers.length > 0) {
updateObj.kicked = removedMembers;
}
// Send own sender keys and group secret key
if (isMediumGroup) {
const { chainKey, keyIdx } = await window.MediumGroups.getSenderKeys(
groupId,
ourKey
);
updateObj.senderKey = {
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
};
const groupIdentity = await window.Signal.Data.getIdentityKeyById(
groupId
);
const secretKeyHex = StringView.hexToArrayBuffer(
groupIdentity.secretKey
);
updateObj.secretKey = secretKeyHex;
}
convo.updateGroup(updateObj);
};
window.doCreateGroup = async (groupName, members) => {
const keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringView.arrayBufferToHex(keypair.pubKey);
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
textsecure.storage.user.getNumber();
const allMembers = [primaryDeviceKey, ...members];
const groupDetails = {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: undefined,
};
await window.NewReceiver.onGroupReceived(groupDetails);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.updateGroupAdmins([primaryDeviceKey]);
convo.updateGroup(groupDetails);
textsecure.messaging.sendGroupSyncMessage([convo]);
appView.openConversation(groupId, {});
};
window.confirmationDialog = params => {
const confirmDialog = new Whisper.SessionConfirmView({
el: $('body'),
@ -920,76 +820,9 @@
.toString(36)
.substring(3);
window.toasts = new Map();
window.pushToast = options => {
// Setting toasts with the same ID can be used to prevent identical
// toasts from appearing at once (stacking).
// If toast already exists, it will be reloaded (updated)
const params = {
title: options.title,
id: options.id || window.generateID(),
description: options.description || '',
type: options.type || '',
icon: options.icon || '',
shouldFade: options.shouldFade,
};
// Give all toasts an ID. User may define.
let currentToast;
const toastID = params.id;
const toast = !!toastID && window.toasts.get(toastID);
if (toast) {
currentToast = window.toasts.get(toastID);
currentToast.update(params);
} else {
// Make new Toast
window.toasts.set(
toastID,
new Whisper.SessionToastView({
el: $('body'),
})
);
currentToast = window.toasts.get(toastID);
currentToast.render();
currentToast.update(params);
}
// Remove some toasts if too many exist
const maxToasts = 6;
while (window.toasts.size > maxToasts) {
const finalToastID = window.toasts.keys().next().value;
window.toasts.get(finalToastID).fadeToast();
}
return toastID;
};
// Get memberlist. This function is not accurate >>
// window.getMemberList = window.lokiPublicChatAPI.getListOfMembers();
window.deleteAccount = async () => {
try {
window.log.info('Deleting everything!');
const { Logs } = window.Signal;
await Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
}
window.restart();
};
window.toggleTheme = () => {
const theme = window.Events.getThemeSetting();
const updatedTheme = theme === 'dark' ? 'light' : 'dark';
@ -1486,9 +1319,6 @@
if (messageReceiver) {
await messageReceiver.close();
}
const USERNAME = storage.get('number_id');
const PASSWORD = storage.get('password');
const mySignalingKey = storage.get('signaling_key');
connectCount += 1;
@ -1517,31 +1347,18 @@
);
window.lokiPublicChatAPI = null;
window.feeds = [];
messageReceiver = new textsecure.MessageReceiver(
USERNAME,
PASSWORD,
mySignalingKey,
options
);
messageReceiver = new textsecure.MessageReceiver(mySignalingKey, options);
messageReceiver.addEventListener(
'message',
window.NewReceiver.handleMessageEvent
);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
PASSWORD
);
window.textsecure.messaging = new textsecure.MessageSender();
return;
}
initAPIs();
await initSpecialConversations();
messageReceiver = new textsecure.MessageReceiver(
USERNAME,
PASSWORD,
mySignalingKey,
options
);
messageReceiver = new textsecure.MessageReceiver(mySignalingKey, options);
messageReceiver.addEventListener(
'message',
window.NewReceiver.handleMessageEvent
@ -1560,10 +1377,7 @@
logger: window.log,
});
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
PASSWORD
);
window.textsecure.messaging = new textsecure.MessageSender();
// On startup after upgrading to a new version, request a contact sync
// (but only if we're not the primary device)

@ -59,6 +59,9 @@
window.getInboxCollection = () => inboxCollection;
window.getConversations = () => conversations;
window.getConversationByName = name =>
conversations.find(d => d.get('name') === name);
window.ConversationController = {
get(id) {
if (!this._initialFetchComplete) {

@ -137,6 +137,6 @@ window.onerror = (message, script, line, col, error) => {
window.addEventListener('unhandledrejection', rejectionEvent => {
const error = rejectionEvent.reason;
const errorInfo = error && error.stack ? error.stack : JSON.stringify(error);
window.log.error(`Top-level unhandled promise rejection: ${errorInfo}`);
const errorInfo = error && error.stack ? error.stack : error;
window.log.error('Top-level unhandled promise rejection:', errorInfo);
});

@ -1,4 +1,8 @@
import { MessageModel, MessageAttributes } from './messages';
interface ConversationAttributes {
id: string;
name: string;
members: Array<string>;
left: boolean;
expireTimer: number;
@ -9,12 +13,15 @@ interface ConversationAttributes {
isArchived: boolean;
active_at: number;
timestamp: number; // timestamp of what?
groupAdmins?: Array<string>;
isKickedFromGroup?: boolean;
}
export interface ConversationModel
extends Backbone.Model<ConversationAttributes> {
idForLogging: () => string;
saveChangesToDB: () => Promise<void>;
// Save model changes to the database
commit: () => Promise<void>;
notify: (message: MessageModel) => void;
isSessionResetReceived: () => boolean;
updateExpirationTimer: (
@ -29,6 +36,10 @@ export interface ConversationModel
getRecipients: () => Array<string>;
onReadMessage: (message: MessageModel) => void;
updateTextInputState: () => void;
getName: () => string;
addMessage: (attributes: Partial<MessageAttributes>) => Promise<MessageModel>;
isMediumGroup: () => boolean;
lastMessage: string;
messageCollection: Backbone.Collection<MessageModel>;
}

@ -15,7 +15,6 @@
BlockedNumberController,
lokiPublicChatAPI,
JobQueue,
StringView
*/
/* eslint-disable more/no-then */
@ -253,11 +252,7 @@
this.trigger('change', this);
this.messageCollection.forEach(m => m.trigger('change'));
this.updateTextInputState();
if (this.isPrivate()) {
await textsecure.messaging.sendContactSyncMessage([this]);
} else {
await textsecure.messaging.sendGroupSyncMessage([this]);
}
await textsecure.messaging.sendBlockedListSyncMessage();
},
async unblock() {
if (!this.id || this.isPublic() || this.isRss()) {
@ -270,11 +265,7 @@
this.trigger('change', this);
this.messageCollection.forEach(m => m.trigger('change'));
this.updateTextInputState();
if (this.isPrivate()) {
await textsecure.messaging.sendContactSyncMessage([this]);
} else {
await textsecure.messaging.sendGroupSyncMessage([this]);
}
await textsecure.messaging.sendBlockedListSyncMessage();
},
setMessageSelectionBackdrop() {
const messageSelected = this.selectedMessages.size > 0;
@ -616,6 +607,7 @@
},
isOnline: this.isOnline(),
hasNickname: !!this.getNickname(),
isKickedFromGroup: !!this.get('isKickedFromGroup'),
selectedMessages: this.selectedMessages,
@ -628,6 +620,9 @@
onDeleteContact: () => this.deleteContact(),
onDeleteMessages: () => this.deleteMessages(),
onCloseOverlay: () => this.resetMessageSelection(),
onInviteContacts: () => {
window.Whisper.events.trigger('inviteContacts', this);
},
};
return result;
@ -1346,10 +1341,7 @@
if (this.isMe()) {
return message.sendSyncMessageOnly(chatMessage);
}
const options = {};
options.messageType = message.get('type');
options.isPublic = this.isPublic();
if (this.isPublic()) {
const openGroup = this.toOpenGroup();
@ -1370,7 +1362,6 @@
return null;
}
options.sessionRestoration = sessionRestoration;
const destinationPubkey = new libsession.Types.PubKey(destination);
// Handle Group Invitation Message
if (groupInvitation) {
@ -1747,35 +1738,14 @@
}
},
async saveChangesToDB() {
async commit() {
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
},
async updateGroup(providedGroupUpdate) {
let groupUpdate = providedGroupUpdate;
if (this.isPrivate()) {
throw new Error('Called update group on private conversation');
}
if (groupUpdate === undefined) {
groupUpdate = this.pick(['name', 'avatar', 'members']);
}
const now = Date.now();
const message = this.messageCollection.add({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
group_update: _.pick(groupUpdate, [
'name',
'members',
'avatar',
'admins',
]),
});
async addMessage(messageAttributes) {
const message = this.messageCollection.add(messageAttributes);
const messageId = await window.Signal.Data.saveMessage(
message.attributes,
@ -1784,92 +1754,7 @@
}
);
message.set({ id: messageId });
// TODO: if I added members, it is my responsibility to generate ratchet keys for them
// Difference between `recipients` and `members` is that `recipients` includes the members which were removed in this update
const { id, name, members, avatar, recipients } = groupUpdate;
if (groupUpdate.is_medium_group) {
const { secretKey, senderKeys } = groupUpdate;
const membersBin = members.map(
pkHex => new Uint8Array(StringView.hexToArrayBuffer(pkHex))
);
const adminsBin = this.get('groupAdmins').map(
pkHex => new Uint8Array(StringView.hexToArrayBuffer(pkHex))
);
const createParams = {
timestamp: now,
groupId: id,
identifier: messageId,
groupSecretKey: secretKey,
members: membersBin,
groupName: name,
admins: adminsBin,
senderKeys,
};
const mediumGroupCreateMessage = new libsession.Messages.Outgoing.MediumGroupCreateMessage(
createParams
);
members.forEach(async member => {
const memberPubKey = new libsession.Types.PubKey(member);
libsession
.getMessageQueue()
.sendUsingMultiDevice(memberPubKey, mediumGroupCreateMessage);
});
return;
}
const updateParams = {
// if we do set an identifier here, be sure to not sync the message two times in msg.handleMessageSentSuccess()
identifier: messageId,
timestamp: now,
groupId: id,
name: name || this.getName(),
avatar,
members,
admins: this.get('groupAdmins'),
};
const groupUpdateMessage = new libsession.Messages.Outgoing.ClosedGroupUpdateMessage(
updateParams
);
await this.sendClosedGroupMessage(
groupUpdateMessage,
recipients,
message
);
// send a expireTimer update message to all add members if the expireTimer is set
if (
groupUpdate.joined &&
groupUpdate.joined.length &&
this.get('expireTimer')
) {
const expireUpdate = {
timestamp: Date.now(),
expireTimer: this.get('expireTimer'),
groupId: this.get('id'),
};
const expirationTimerMessage = new libsession.Messages.Outgoing.ExpirationTimerUpdateMessage(
expireUpdate
);
await Promise.all(
groupUpdate.joined.map(async join => {
const device = new libsession.Types.PubKey(join);
await libsession
.getMessageQueue()
.sendUsingMultiDevice(device, expirationTimerMessage)
.catch(log.error);
})
);
}
return message;
},
async sendGroupInfo(recipient) {
@ -1897,9 +1782,15 @@
.getMessageQueue()
.send(recipientPubKey, groupUpdateMessage);
const expireTimer = this.get('expireTimer');
if (!expireTimer) {
return;
}
const expireUpdate = {
timestamp: Date.now(),
expireTimer: this.get('expireTimer'),
expireTimer,
groupId: this.get('id'),
};
@ -1917,22 +1808,18 @@
},
async leaveGroup() {
const now = Date.now();
if (this.get('type') !== 'group') {
log.error('Cannot leave a non-group conversation');
return;
}
if (this.isMediumGroup()) {
// NOTE: we should probably remove sender keys for groupId,
// and its secret key, but it is low priority
// TODO: need to reset everyone's sender keys
window.SwarmPolling.removePubkey(this.id);
}
await window.MediumGroups.leaveMediumGroup(this.id);
} else {
const now = Date.now();
if (this.get('type') === 'group') {
this.set({ left: true });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
this.commit();
const message = this.messageCollection.add({
group_update: { left: 'You' },
@ -1958,54 +1845,16 @@
quitGroup
);
await this.sendClosedGroupMessage(quitGroupMessage, undefined, message);
this.updateTextInputState();
}
},
async sendClosedGroupMessage(message, recipients, dbMessage) {
const {
ClosedGroupMessage,
ClosedGroupChatMessage,
} = libsession.Messages.Outgoing;
if (!(message instanceof ClosedGroupMessage)) {
throw new Error('Invalid closed group message.');
}
const members = this.get('members');
// Sync messages for Chat Messages need to be constructed after confirming send was successful.
if (message instanceof ClosedGroupChatMessage) {
throw new Error(
'ClosedGroupChatMessage should be constructed manually and sent'
await window.MediumGroups.sendClosedGroupMessage(
quitGroupMessage,
members,
message
);
}
const members = recipients || this.get('members');
try {
// Exclude our device from members and send them the message
const ourNumber = textsecure.storage.user.getNumber();
const primary = await libsession.Protocols.MultiDeviceProtocol.getPrimaryDevice(
ourNumber
);
const otherMembers = (members || []).filter(
member => !primary.isEqual(member)
);
// we are the only member in here
if (members.length === 1 && members[0] === primary.key) {
dbMessage.sendSyncMessageOnly(message);
return;
}
const sendPromises = otherMembers.map(member => {
const memberPubKey = libsession.Types.PubKey.cast(member);
return libsession
.getMessageQueue()
.sendUsingMultiDevice(memberPubKey, message);
});
await Promise.all(sendPromises);
} catch (e) {
window.log.error(e);
}
this.updateTextInputState();
},
async markRead(newestUnreadDate, providedOptions) {
@ -2361,16 +2210,20 @@
return;
}
const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer(
profileKey
);
const accessKeyBuffer = await window.Signal.Crypto.deriveAccessKey(
profileKeyBuffer
);
const accessKey = window.Signal.Crypto.arrayBufferToBase64(
accessKeyBuffer
);
this.set({ accessKey });
try {
const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer(
profileKey
);
const accessKeyBuffer = await window.Signal.Crypto.deriveAccessKey(
profileKeyBuffer
);
const accessKey = window.Signal.Crypto.arrayBufferToBase64(
accessKeyBuffer
);
this.set({ accessKey });
} catch (e) {
window.log.warn(`Failed to derive access key for ${this.id}`);
}
},
async upgradeMessages(messages) {
@ -2478,8 +2331,8 @@
let message = i18n('deleteContactConfirmation');
if (this.isPublic()) {
title = i18n('deletePublicChannel');
message = i18n('deletePublicChannelConfirmation');
title = i18n('leaveOpenGroup');
message = i18n('leaveOpenGroupConfirmation');
} else if (this.isClosedGroup()) {
title = i18n('leaveClosedGroup');
message = i18n('leaveClosedGroupConfirmation');

@ -1,4 +1,10 @@
type MessageModelType = 'incoming' | 'outgoing';
type MessageDeliveryStatus =
| 'sending'
| 'sent'
| 'delivered'
| 'read'
| 'error';
export type EndSessionType = 'done' | 'ongoing';
interface MessageAttributes {
@ -34,6 +40,7 @@ interface MessageAttributes {
group: any;
bodyPending: boolean;
timestamp: number;
status: MessageDeliveryStatus;
}
export interface MessageModel extends Backbone.Model<MessageAttributes> {
@ -45,4 +52,5 @@ export interface MessageModel extends Backbone.Model<MessageAttributes> {
markRead: () => void;
merge: (other: MessageModel) => void;
saveErrors: (error: any) => void;
sendSyncMessageOnly: (message: any) => void;
}

@ -264,8 +264,20 @@
/* eslint-enable no-bitwise */
},
getNotificationText() {
const description = this.getDescription();
let description = this.getDescription();
if (description) {
// regex with a 'g' to ignore part groups
const regex = new RegExp(
`@${window.libsession.Types.PubKey.regexForPubkeys}`,
'g'
);
const pubkeysInDesc = description.match(regex);
(pubkeysInDesc || []).forEach(pubkey => {
const displayName = this.getLokiNameForNumber(pubkey.slice(1));
if (displayName && displayName.length) {
description = description.replace(pubkey, `@${displayName}`);
}
});
return description;
}
if (this.get('attachments').length > 0) {

@ -7,6 +7,7 @@ export type IdentityKey = {
firstUse: boolean;
verified: number;
nonblockingApproval: boolean;
secretKey?: string; // found in medium groups
};
export type PreKey = {

@ -30,7 +30,7 @@ export interface LokiPublicChannelAPI {
body?: string;
},
timestamp: number
): Promise<boolean>;
): Promise<number>;
}
declare class LokiAppDotNetServerAPI implements LokiAppDotNetServerInterface {

@ -1710,8 +1710,9 @@ class LokiPublicChannelAPI {
sigString += [...attachmentAnnotations, ...previewAnnotations]
.map(data => data.id || data.image.id)
.sort()
.join();
.join('');
sigString += sigVer;
return dcodeIO.ByteBuffer.wrap(sigString, 'utf8').toArrayBuffer();
}
@ -2355,7 +2356,7 @@ class LokiPublicChannelAPI {
}
// there's no retry on desktop
// this is supposed to be after retries
return false;
return -1;
}
}

@ -218,6 +218,10 @@ class LokiHomeServerInstance extends LokiFileServerInstance {
}
async updateOurDeviceMapping() {
if (!window.lokiFeatureFlags.useMultiDevice) {
return undefined;
}
const isPrimary = !storage.get('isSecondaryDevice');
const authorisations = await window.libsession.Protocols.MultiDeviceProtocol.getPairingAuthorisations(
this.ourKey

@ -87,9 +87,9 @@ class LokiMessageAPI {
data: data64,
};
const promises = _.slice(swarm, 0, numConnections).map(snode =>
_openSendConnection(snode, params)
);
const usedNodes = _.slice(swarm, 0, numConnections);
const promises = usedNodes.map(snode => _openSendConnection(snode, params));
let snode;
try {

@ -238,8 +238,8 @@
let message = i18n('deleteContactConfirmation');
if (groupConvo.isPublic()) {
title = i18n('deletePublicChannel');
message = i18n('deletePublicChannelConfirmation');
title = i18n('leaveOpenGroup');
message = i18n('leaveOpenGroupConfirmation');
} else if (groupConvo.isClosedGroup()) {
title = i18n('leaveClosedGroup');
message = i18n('leaveClosedGroupConfirmation');

@ -1,69 +0,0 @@
/* global Whisper, Signal, Backbone */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
// list of conversations, showing user/group and last message sent
Whisper.ConversationListItemView = Whisper.View.extend({
tagName: 'div',
className() {
return `conversation-list-item contact ${this.model.cid}`;
},
templateName: 'conversation-preview',
initialize() {
this.listenTo(this.model, 'destroy', this.remove);
},
remove() {
if (this.childView) {
this.childView.remove();
this.childView = null;
}
Backbone.View.prototype.remove.call(this);
},
getProps() {
return this.model.getPropsForListItem();
},
render() {
if (this.childView) {
this.childView.remove();
this.childView = null;
}
const props = this.getProps();
this.childView = new Whisper.ReactWrapperView({
className: 'list-item-wrapper',
Component: Signal.Components.ConversationListItem,
props,
});
const update = () => this.childView.update(this.getProps());
this.listenTo(this.model, 'change', update);
this.$el.append(this.childView.el);
return this;
},
});
// list of conversations, showing user/group and last message sent
Whisper.ConversationContactListItemView = Whisper.ConversationListItemView.extend(
{
getProps() {
// We don't want to show a timestamp or a message
const props = this.model.getPropsForListItem();
delete props.lastMessage;
delete props.lastUpdated;
delete props.isSelected;
return props;
},
}
);
})();

@ -1,71 +0,0 @@
/* global Whisper, getInboxCollection, $ */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.ConversationListView = Whisper.ListView.extend({
tagName: 'div',
itemView: Whisper.ConversationListItemView,
getCollection() {
return getInboxCollection();
},
updateLocation(conversation) {
const $el = this.$(`.${conversation.cid}`);
if (!$el || !$el.length) {
window.log.warn(
'updateLocation: did not find element for conversation',
conversation.idForLogging()
);
return;
}
if ($el.length > 1) {
window.log.warn(
'updateLocation: found more than one element for conversation',
conversation.idForLogging()
);
return;
}
const $allConversations = this.$('.conversation-list-item');
const inboxCollection = this.getCollection();
const index = inboxCollection.indexOf(conversation);
const elIndex = $allConversations.index($el);
if (elIndex < 0) {
window.log.warn(
'updateLocation: did not find index for conversation',
conversation.idForLogging()
);
}
if (index === elIndex) {
return;
}
if (index === 0) {
this.$el.prepend($el);
} else if (index === this.collection.length - 1) {
this.$el.append($el);
} else {
const targetConversation = inboxCollection.at(index - 1);
const target = this.$(`.${targetConversation.cid}`);
$el.insertAfter(target);
}
if ($('.selected').length) {
$('.selected')[0].scrollIntoView({
block: 'nearest',
});
}
},
removeItem(conversation) {
const $el = this.$(`.${conversation.cid}`);
if ($el && $el.length > 0) {
$el.remove();
}
},
});
})();

@ -1,177 +0,0 @@
/* global ConversationController, i18n, textsecure, Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const isSearchable = conversation => conversation.isSearchable();
Whisper.NewContactView = Whisper.View.extend({
templateName: 'new-contact',
className: 'conversation-list-item contact',
events: {
click: 'validate',
},
initialize() {
this.listenTo(this.model, 'change', this.render); // auto update
},
render_attributes() {
// Show the appropriate message based on model validity
const message =
this.model && this.model.isValid()
? i18n('startConversation')
: i18n('invalidNumberError');
return {
number: message,
title: this.model.getNumber(),
avatar: this.model.getAvatar(),
};
},
validate() {
if (this.model.isValid()) {
this.$el.addClass('valid');
} else {
this.$el.removeClass('valid');
}
},
});
Whisper.ConversationSearchView = Whisper.View.extend({
className: 'conversation-search',
initialize(options) {
this.$input = options.input;
this.$new_contact = this.$('.new-contact');
this.typeahead = new Whisper.ConversationCollection();
this.collection = new Whisper.ConversationCollection([], {
comparator(m) {
return m.getTitle().toLowerCase();
},
});
this.listenTo(this.collection, 'select', conversation => {
this.resetTypeahead();
this.trigger('open', conversation);
});
// View to display the matched contacts from typeahead
this.typeahead_view = new Whisper.ConversationListView({
collection: this.collection,
});
this.$el.append(this.typeahead_view.el);
this.initNewContact();
this.pending = Promise.resolve();
},
events: {
'click .new-contact': 'createConversation',
},
filterContacts() {
const query = this.$input.val().trim();
if (query.length) {
// Update the contact model
this.new_contact_view.model.set('id', query);
this.new_contact_view.render().$el.hide();
this.new_contact_view.validate();
this.hideHints();
// NOTE: Temporarily allow `then` until we convert the entire file
// to `async` / `await`:
/* eslint-disable more/no-then */
this.pending = this.pending.then(() =>
this.typeahead.search(query).then(() => {
let results = this.typeahead.filter(isSearchable);
const noteToSelf = i18n('noteToSelf');
if (noteToSelf.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
const ourNumber = textsecure.storage.user.getNumber();
const conversation = ConversationController.get(ourNumber);
if (conversation) {
// ensure that we don't have duplicates in our results
results = results.filter(item => item.id !== ourNumber);
results.unshift(conversation);
}
}
this.typeahead_view.collection.reset(results);
// This will allow us to show the last message when searching
this.typeahead_view.collection.forEach(c => c.updateLastMessage());
// Show the new contact view if we already have results
if (this.typeahead_view.collection.length === 0) {
this.new_contact_view.$el.show();
}
})
);
/* eslint-enable more/no-then */
this.trigger('show');
} else {
this.resetTypeahead();
}
},
initNewContact() {
if (this.new_contact_view) {
this.new_contact_view.undelegateEvents();
this.new_contact_view.$el.hide();
}
const model = new Whisper.Conversation({ type: 'private' });
this.new_contact_view = new Whisper.NewContactView({
el: this.$new_contact,
model,
}).render();
},
async createConversation() {
const isValidNumber = this.new_contact_view.model.isValid();
if (!isValidNumber) {
this.$input.focus();
return;
}
const newConversationId = this.new_contact_view.model.id;
const conversation = await ConversationController.getOrCreateAndWait(
newConversationId,
'private'
);
this.trigger('open', conversation);
this.initNewContact();
this.resetTypeahead();
},
reset() {
this.delegateEvents();
this.typeahead_view.delegateEvents();
this.new_contact_view.delegateEvents();
this.resetTypeahead();
},
resetTypeahead() {
this.hideHints();
this.new_contact_view.$el.hide();
this.$input.val('').focus();
this.typeahead_view.collection.reset([]);
this.trigger('hide');
},
showHints() {
if (!this.hintView) {
this.hintView = new Whisper.HintView({
className: 'contact placeholder',
content: i18n('newPhoneNumber'),
}).render();
this.hintView.$el.insertAfter(this.$input);
}
this.hintView.$el.show();
},
hideHints() {
if (this.hintView) {
this.hintView.remove();
this.hintView = null;
}
},
});
})();

@ -172,6 +172,7 @@
isClosable: this.model.isClosable(),
isBlocked: this.model.isBlocked(),
isGroup: !this.model.isPrivate(),
isPrivate: this.model.isPrivate(),
isOnline: this.model.isOnline(),
isArchived: this.model.get('isArchived'),
isPublic: this.model.isPublic(),
@ -528,12 +529,6 @@
}
let placeholder;
switch (type) {
case 'disabled':
placeholder = i18n('sendMessageDisabled');
break;
case 'secondary':
placeholder = i18n('sendMessageDisabledSecondary');
break;
case 'left-group':
placeholder = i18n('sendMessageLeftGroup');
break;
@ -1377,14 +1372,14 @@
}
// If removable from server, we "Unsend" - otherwise "Delete"
const pluralSuffix = multiple ? 's' : '';
const title = i18n(
isPublic
? `unsendMessage${pluralSuffix}`
: `deleteMessage${pluralSuffix}`
);
let title;
if (isPublic) {
title = multiple ? i18n('unsendMessages') : i18n('unsendMessage');
} else {
title = multiple ? i18n('deleteMessages') : i18n('deleteMessage');
}
const okText = i18n(isServerDeletable ? 'unsend' : 'delete');
const okText = isServerDeletable ? i18n('unsend') : i18n('delete');
window.confirmationDialog({
title,

@ -62,7 +62,14 @@
return this;
},
onSubmit(groupName, avatar) {
window.doUpdateGroup(this.groupId, groupName, this.members, avatar);
if (groupName !== this.groupName || avatar !== this.avatarPath) {
window.MediumGroups.initiateGroupUpdate(
this.groupId,
groupName,
this.members,
avatar
);
}
},
close() {
this.remove();
@ -180,7 +187,7 @@
return;
}
window.doUpdateGroup(
window.MediumGroups.initiateGroupUpdate(
this.groupId,
this.groupName,
filteredMemberes,

@ -90,12 +90,21 @@
type: 'success',
});
},
showConfirmationDialog({ title, message, onOk, onCancel }) {
showConfirmationDialog({
title,
message,
messageSub,
onOk,
onCancel,
hideCancel,
}) {
window.confirmationDialog({
title,
message,
resolve: onOk,
reject: onCancel,
hideCancel,
messageSub,
});
},
});

@ -103,7 +103,11 @@
const groupId = this.convo.get('id');
const groupName = this.convo.get('name');
window.doUpdateGroup(groupId, groupName, uniqMembers);
window.MediumGroups.initiateGroupUpdate(
groupId,
groupName,
uniqMembers
);
}
}
},

@ -578,11 +578,6 @@
return;
}
// we need a conversation for sending a message
const secondaryConversation = await ConversationController.getOrCreateAndWait(
secondaryDeviceStr,
'private'
);
const grantSignature = await libloki.crypto.generateSignatureForPairing(
secondaryDeviceStr,
libloki.crypto.PairingType.GRANT

@ -1,7 +1,6 @@
/* global window: false */
/* global callWorker: false */
/* global textsecure: false */
/* global libsignal: false */
/* global WebSocket: false */
/* global Event: false */
/* global dcodeIO: false */
@ -18,14 +17,8 @@ function MessageReceiver(username, password, signalingKey) {
this.count = 0;
this.signalingKey = signalingKey;
this.username = username;
this.password = password;
this.server = WebAPI.connect();
const address = libsignal.SignalProtocolAddress.fromString(username);
this.number = address.getName();
this.deviceId = address.getDeviceId();
this.pending = Promise.resolve();
// only do this once to prevent duplicates

@ -401,17 +401,23 @@ MessageSender.prototype = {
return Promise.resolve();
}
// We only want to sync across closed groups that we haven't left
const sessionGroups = conversations.filter(
c => c.isClosedGroup() && !c.get('left') && !c.isMediumGroup()
const activeGroups = conversations.filter(
c => c.isClosedGroup() && !c.get('left') && !c.get('isKickedFromGroup')
);
if (sessionGroups.length === 0) {
if (activeGroups.length === 0) {
window.console.info('No closed group to sync.');
return Promise.resolve();
}
const mediumGroups = activeGroups.filter(c => c.isMediumGroup());
window.MediumGroups.syncMediumGroups(mediumGroups);
const legacyGroups = activeGroups.filter(c => !c.isMediumGroup());
// We need to sync across 1 group at a time
// This is because we could hit the storage server limit with one group
const syncPromises = sessionGroups
const syncPromises = legacyGroups
.map(c => libloki.api.createGroupSyncMessage(c))
.map(syncMessage =>
libsession.getMessageQueue().sendSyncMessage(syncMessage)
@ -453,6 +459,26 @@ MessageSender.prototype = {
return libsession.getMessageQueue().sendSyncMessage(openGroupsSyncMessage);
},
async sendBlockedListSyncMessage() {
// If we havn't got a primaryDeviceKey then we are in the middle of pairing
// primaryDevicePubKey is set to our own number if we are the master device
const primaryDeviceKey = window.storage.get('primaryDevicePubKey');
if (!primaryDeviceKey) {
return Promise.resolve();
}
const currentlyBlockedNumbers = window.BlockedNumberController.getBlockedNumbers();
// currently we only sync user blocked, not groups
const blockedSyncMessage = new libsession.Messages.Outgoing.BlockedListSyncMessage(
{
timestamp: Date.now(),
numbers: currentlyBlockedNumbers,
groups: [],
}
);
return libsession.getMessageQueue().sendSyncMessage(blockedSyncMessage);
},
syncReadMessages(reads) {
const myDevice = textsecure.storage.user.getDeviceId();
// FIXME currently not in used
@ -509,8 +535,8 @@ MessageSender.prototype = {
window.textsecure = window.textsecure || {};
textsecure.MessageSender = function MessageSenderWrapper(username, password) {
const sender = new MessageSender(username, password);
textsecure.MessageSender = function MessageSenderWrapper() {
const sender = new MessageSender();
this.sendContactSyncMessage = sender.sendContactSyncMessage.bind(sender);
this.sendGroupSyncMessage = sender.sendGroupSyncMessage.bind(sender);
this.sendOpenGroupsSyncMessage = sender.sendOpenGroupsSyncMessage.bind(
@ -521,6 +547,9 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.syncVerification = sender.syncVerification.bind(sender);
this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender);
this.getProxiedSize = sender.getProxiedSize.bind(sender);
this.sendBlockedListSyncMessage = sender.sendBlockedListSyncMessage.bind(
sender
);
};
textsecure.MessageSender.prototype = {

@ -95,8 +95,6 @@ describe('MessageReceiver', () => {
});
window.messageReceiver = new textsecure.MessageReceiver(
'username',
'password',
'signalingKey'
// 'ws://localhost:8080',
// window,

@ -54,7 +54,7 @@ const config = require('./app/config');
// Very important to put before the single instance check, since it is based on the
// userData directory.
const userConfig = require('./app/user_config');
const passwordUtil = require('./app/password_util');
const passwordUtil = require('./ts/util/passwordUtils');
const importMode =
process.argv.some(arg => arg === '--import') || config.get('import');
@ -763,9 +763,8 @@ app.on('ready', async () => {
logger = logging.getLogger();
logger.info('app ready');
logger.info(`starting version ${packageJson.version}`);
if (!locale) {
const appLocale = process.env.NODE_ENV === 'test' ? 'en' : 'en'; // app.getLocale(); // FIXME reenable once we have translated our files
const appLocale = process.env.NODE_ENV === 'test' ? 'en' : app.getLocale();
locale = loadLocale({ appLocale, logger });
}

@ -2,7 +2,7 @@
"name": "session-messenger-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"version": "1.1.2",
"version": "1.2.1",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -48,7 +48,7 @@
"tslint": "tslint --format stylish --project .",
"format": "prettier --list-different --write `git ls-files --modified *.{css,js,json,scss,ts,tsx}` `git ls-files --modified ./**/*.{css,js,json,scss,ts,tsx}`",
"format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"",
"transpile": "tsc",
"transpile": "tsc --incremental",
"clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js",
"pow-metrics": "node metrics_app.js localhost 9000",
"ready": "yarn clean-transpile && yarn grunt && yarn lint-full && yarn test-node && yarn test-electron && yarn lint-deps"

@ -40,7 +40,7 @@ window.CONSTANTS = {
MAX_USERNAME_LENGTH: 20,
};
window.passwordUtil = require('./app/password_util');
window.passwordUtil = require('./ts/util/passwordUtils');
window.Signal.Logs = require('./js/modules/logs');
window.resetDatabase = () => {

@ -164,7 +164,7 @@ window.setPassword = (passPhrase, oldPhrase) =>
ipc.send('set-password', passPhrase, oldPhrase);
});
window.passwordUtil = require('./app/password_util');
window.passwordUtil = require('./ts/util/passwordUtils');
window.libsession = require('./ts/session');
// We never do these in our code, so we'll prevent it everywhere
@ -452,10 +452,11 @@ window.lokiFeatureFlags = {
privateGroupChats: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useOnionRequests: true,
useFileOnionRequests: false,
useFileOnionRequests: true,
enableSenderKeys: false,
onionRequestHops: 3,
debugMessageLogs: process.env.ENABLE_MESSAGE_LOGS,
useMultiDevice: false,
};
// eslint-disable-next-line no-extend-native,func-names
@ -489,8 +490,10 @@ if (config.environment.includes('test-integration')) {
privateGroupChats: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useOnionRequests: false,
useFileOnionRequests: false,
debugMessageLogs: true,
enableSenderKeys: true,
useMultiDevice: false,
};
}
@ -501,3 +504,26 @@ const {
} = require('./ts/util/blockedNumberController');
window.BlockedNumberController = BlockedNumberController;
window.deleteAccount = async reason => {
try {
window.log.info('Deleting everything!');
const { Logs } = window.Signal;
await Logs.deleteAll();
await window.Signal.Data.removeAll();
await window.Signal.Data.close();
await window.Signal.Data.removeDB();
await window.Signal.Data.removeOtherData();
// 'unlink' => toast will be shown on app restart
window.localStorage.setItem('restart-reason', reason);
} catch (error) {
window.log.error(
'Something went wrong deleting all data:',
error && error.stack ? error.stack : error
);
}
window.restart();
};

@ -53,10 +53,11 @@ message MediumGroupContent {
message MediumGroupUpdate {
enum Type {
NEW = 0; // groupPublicKey, name, groupPrivateKey, senderKeys, members, admins
NEW = 0; // groupPublicKey, name, senderKeys, members, admins, groupPrivateKey
INFO = 1; // groupPublicKey, name, senderKeys, members, admins
SENDER_KEY = 2; // groupPublicKey, senderKeys
SENDER_KEY_REQUEST = 3; // groupId
SENDER_KEY_REQUEST = 3; // groupPublicKey
QUIT = 4; // groupPublicKey
}
message SenderKey {

@ -184,11 +184,11 @@
}
.module-conversation-list-item--mentioned-us {
border-left: 4px solid #ffb000 !important;
border-left: 4px solid $session-color-green !important;
}
.at-symbol {
background-color: #ffb000;
background-color: $session-color-green;
color: $color-black;
text-align: center;
@ -198,8 +198,9 @@
padding-right: 3px;
position: absolute;
right: -6px;
top: 12px;
left: 50%;
margin-left: 30px;
top: 2px;
font-weight: 300;
font-size: 11px;

@ -209,7 +209,7 @@ textarea {
border-radius: 2px;
height: 33px;
padding: 0px 18px;
line-height: 33px;
// line-height: 33px;
font-size: $session-font-sm;
}

@ -273,10 +273,6 @@ $session-compose-margin: 20px;
}
}
h4 {
text-transform: uppercase;
}
.white-border {
width: $session-left-pane-width;
position: relative;
@ -339,6 +335,12 @@ $session-compose-margin: 20px;
display: flex;
flex-direction: column;
flex-grow: 1;
.left-pane-contact-bottom-buttons .session-button {
vertical-align: middle;
white-space: normal;
text-align: center;
}
}
.session-left-pane-section-content {
@ -382,6 +384,7 @@ $session-compose-margin: 20px;
flex-grow: 1;
font-size: $session-font-sm;
font-family: $session-font-default;
text-overflow: ellipsis;
&:focus {
outline: none !important;
@ -424,7 +427,7 @@ $session-compose-margin: 20px;
.session-button.square-outline.square.danger {
flex-grow: 1;
height: 50px;
line-height: 50px;
// line-height: 50px;
@at-root .light-theme #{&} {
border: 1px solid $session-shade-15;
@ -609,7 +612,7 @@ $session-compose-margin: 20px;
flex-grow: 1;
border: 1px solid $session-shade-8;
height: 50px;
line-height: 50px;
// line-height: 50px;
}
}
}

@ -242,7 +242,7 @@ body.dark-theme {
}
a {
color: $blue;
color: $session-color-green;
}
.file-input {
@ -410,7 +410,7 @@ body.dark-theme {
}
}
a.link {
color: #2090ea;
color: $session-color-green;
}
.progress {

@ -28,7 +28,12 @@ describe('app/logging', () => {
afterEach(done => {
// we need the unsafe option to recursively remove the directory
tmpDir.removeCallback(done);
try {
tmpDir.removeCallback(done);
} catch (e) {
// eslint-disable-next-line no-console
console.error('removeCallback failed with ', e);
}
});
describe('#isLineAfterDate', () => {

@ -1,6 +1,6 @@
const { assert } = require('chai');
const passwordUtil = require('../../app/password_util');
const passwordUtil = require('../../ts/util/passwordUtils');
describe('Password Util', () => {
describe('hash generation', () => {

@ -3,7 +3,7 @@
'use strict';
describe('ConversationCollection', () => {
textsecure.messaging = new textsecure.MessageSender('');
textsecure.messaging = new textsecure.MessageSender();
before(clearDatabase);
after(clearDatabase);

@ -1,67 +0,0 @@
/* global $, Whisper */
describe('ConversationSearchView', () => {
describe('Searching for left groups', () => {
let convo;
before(() => {
convo = new Whisper.ConversationCollection().add({
id: '1-search-view',
name: 'i left this group',
members: [],
type: 'group',
left: true,
});
return window.Signal.Data.saveConversation(convo.attributes, {
Conversation: Whisper.Conversation,
});
});
describe('with no messages', () => {
let input;
let view;
before(done => {
input = $('<input>');
view = new Whisper.ConversationSearchView({ input }).render();
view.$input.val('left');
view.filterContacts();
view.typeahead_view.collection.on('reset', () => {
done();
});
});
it('should not surface left groups with no messages', () => {
assert.isUndefined(
view.typeahead_view.collection.get(convo.id),
'got left group'
);
});
});
describe('with messages', () => {
let input;
let view;
before(async () => {
input = $('<input>');
view = new Whisper.ConversationSearchView({ input }).render();
convo.set({ id: '2-search-view', left: false });
await window.Signal.Data.saveConversation(convo.attributes, {
Conversation: Whisper.Conversation,
});
view.$input.val('left');
view.filterContacts();
return new Promise(resolve => {
view.typeahead_view.collection.on('reset', resolve);
});
});
it('should surface left groups with messages', () => {
assert.isDefined(
view.typeahead_view.collection.get(convo.id),
'got left group'
);
});
});
});
});

@ -0,0 +1,55 @@
#!/bin/python
# usage : ./tools/unusedLocalizedString.py |grep False
import re
import os
from glob import glob
# get all files matching .js, .ts and .tsx in ./
dir_path = './'
files = [y for x in os.walk(dir_path) for y in glob(os.path.join(x[0], '*.js'))]
files += [y for x in os.walk(dir_path) for y in glob(os.path.join(x[0], '*.ts'))]
files += [y for x in os.walk(dir_path) for y in glob(os.path.join(x[0], '*.tsx'))]
# exclude node_modules and session-file-server directories
filtered_files = [f for f in files if "node_modules" not in f and "session-file-server" not in f]
# search for this pattern in _locales/en/messages.json: it is a defined localized string
patternLocalizedString = re.compile("^ \".*\"\: {")
localizedStringToSearch = 0
localizedStringNotFound = 0
for i, line in enumerate(open('_locales/en/messages.json')):
for match in re.finditer(patternLocalizedString, line):
localizedStringToSearch = localizedStringToSearch + 1
found = match.group()
# extract the key only from the line
foundAline = found[3:-4]
# print 'Found on line %s: \'%s\'' % (i + 1, foundAline)
# generate a new regex to be searched for to find its usage in the code
# currently, it matches
# * i18n('key') with or without line return
# * messages.key (used in some places)
# * and also 'key'. (some false positive might be present here)
searchedLine = "i18n\([\r\n]?\s*'{0}'|messages.{0}|'{0}'".format(foundAline)
found = False
# skip timerOptions string constructed dynamically
if 'timerOption_' in foundAline:
found = True
else:
for file_path in filtered_files:
fileContent = open(file_path, 'r').read()
if len(re.findall(searchedLine,fileContent,re.MULTILINE)) > 0:
found = True
break
if not found:
localizedStringNotFound = localizedStringNotFound + 1
print "i18n for '{0}': found:{1}:".format(foundAline, found)
print "number of localized string found in messages.json:{0}".format(localizedStringToSearch)
print "number of localized string NOT found:{0}".format(localizedStringNotFound)

@ -11,6 +11,14 @@ import { ContactName } from './conversation/ContactName';
import { TypingAnimation } from './conversation/TypingAnimation';
import { Colors, LocalizerType } from '../types/Util';
import {
getBlockMenuItem,
getClearNicknameMenuItem,
getCopyIdMenuItem,
getDeleteContactMenuItem,
getInviteContactMenuItem,
getLeaveGroupMenuItem,
} from '../session/utils/Menu';
export type PropsData = {
id: string;
@ -43,6 +51,7 @@ export type PropsData = {
hasNickname?: boolean;
isSecondary?: boolean;
isGroupInvitation?: boolean;
isKickedFromGroup?: boolean;
};
type PropsHousekeeping = {
@ -56,6 +65,7 @@ type PropsHousekeeping = {
onClearNickname?: () => void;
onCopyPublicKey?: () => void;
onUnblockContact?: () => void;
onInviteContacts?: () => void;
};
type Props = PropsData & PropsHousekeeping;
@ -163,46 +173,73 @@ export class ConversationListItem extends React.PureComponent<Props> {
isRss,
isPublic,
hasNickname,
type,
isKickedFromGroup,
onDeleteContact,
onDeleteMessages,
onBlockContact,
onChangeNickname,
onClearNickname,
onCopyPublicKey,
onUnblockContact,
onInviteContacts,
} = this.props;
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockContact : onBlockContact;
const isPrivate = type === 'direct';
return (
<ContextMenu id={triggerId}>
{!isPublic && !isRss && !isMe ? (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
) : null}
{getBlockMenuItem(
isMe,
isPrivate,
isBlocked,
onBlockContact,
onUnblockContact,
i18n
)}
{/* {!isPublic && !isRss && !isMe ? (
<MenuItem onClick={onChangeNickname}>
{i18n('changeNickname')}
</MenuItem>
) : null} */}
{!isPublic && !isRss && !isMe && hasNickname ? (
<MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
) : null}
{!isPublic && !isRss ? (
<MenuItem onClick={onCopyPublicKey}>{i18n('copyPublicKey')}</MenuItem>
) : null}
{getClearNicknameMenuItem(
isPublic,
isRss,
isMe,
hasNickname,
onClearNickname,
i18n
)}
{getCopyIdMenuItem(
isPublic,
isRss,
type === 'group',
onCopyPublicKey,
i18n
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{!isMe && isClosable ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
</MenuItem>
) : (
<MenuItem onClick={onDeleteContact}>
{i18n('deletePublicChannel')}
</MenuItem>
)
) : null}
{getInviteContactMenuItem(
type === 'group',
isPublic,
onInviteContacts,
i18n
)}
{getDeleteContactMenuItem(
isMe,
isClosable,
type === 'group',
isPublic,
isRss,
onDeleteContact,
i18n
)}
{getLeaveGroupMenuItem(
isKickedFromGroup,
type === 'group',
isPublic,
isRss,
onDeleteContact,
i18n
)}
</ContextMenu>
);
}

@ -122,7 +122,7 @@ export class EditProfileDialog extends React.Component<Props, State> {
{viewDefault || viewQR ? (
<SessionButton
text={window.i18n('copy')}
text={window.i18n('editMenuCopy')}
buttonType={SessionButtonType.BrandOutline}
buttonColor={SessionButtonColor.Green}
onClick={() => {

@ -56,6 +56,9 @@ const styles = {
paddingLeft: 40,
paddingRight: 40,
paddingBottom: 0,
minHeight: 0,
overflow: 'hidden',
minWidth: 0,
} as React.CSSProperties,
objectContainer: {
position: 'relative',

@ -6,7 +6,7 @@ import {
SettingsView,
} from './session/settings/SessionSettings';
import { createMediumSizeGroup } from '../session/medium_group';
import { createLegacyGroup, createMediumGroup } from '../session/medium_group';
export const MainViewController = {
createClosedGroup,
@ -84,9 +84,9 @@ async function createClosedGroup(
const groupMemberIds = groupMembers.map(m => m.id);
if (senderKeys) {
await createMediumSizeGroup(groupName, groupMemberIds);
await createMediumGroup(groupName, groupMemberIds);
} else {
await window.doCreateGroup(groupName, groupMemberIds);
await createLegacyGroup(groupName, groupMemberIds);
}
if (onSuccess) {

@ -72,7 +72,7 @@ export class SearchResults extends React.Component<Props> {
</div>
) : null}
{haveContacts
? this.renderContacts(i18n('friendsHeader'), contacts, true)
? this.renderContacts(i18n('contactsHeader'), contacts, true)
: null}
{haveMessages ? (

@ -2,6 +2,8 @@ import React from 'react';
import { RenderTextCallbackType } from '../../types/Util';
import classNames from 'classnames';
import { MultiDeviceProtocol } from '../../session/protocols';
import { FindMember } from '../../util';
declare global {
interface Window {
@ -19,6 +21,7 @@ interface MentionProps {
interface MentionState {
found: any;
us: boolean;
}
class Mention extends React.Component<MentionProps, MentionState> {
@ -45,8 +48,7 @@ class Mention extends React.Component<MentionProps, MentionState> {
public render() {
if (this.state.found) {
// TODO: We don't have to search the database of message just to know that the message is for us!
const us =
this.state.found.authorPhoneNumber === window.lokiPublicChatAPI.ourKey;
const us = this.state.us;
const className = classNames(
'mention-profile-name',
us && 'mention-profile-name-us'
@ -71,60 +73,18 @@ class Mention extends React.Component<MentionProps, MentionState> {
}
private async tryRenameMention() {
const found = await this.findMember(this.props.text.slice(1));
const bound = this.clearOurInterval.bind(this);
const found = await FindMember.findMember(
this.props.text.slice(1),
this.props.convoId,
bound
);
if (found) {
this.setState({ found });
this.clearOurInterval();
}
}
const us = await MultiDeviceProtocol.isOurDevice(found.authorPhoneNumber);
private async findMember(pubkey: String) {
let groupMembers;
const groupConvos = window.getConversations().models.filter((d: any) => {
return !d.isPrivate();
});
const thisConvo = groupConvos.find((d: any) => {
return d.id === this.props.convoId;
});
if (!thisConvo) {
// If this gets triggered, is is likely because we deleted the conversation
this.setState({ found, us });
this.clearOurInterval();
return;
}
if (thisConvo.isPublic()) {
groupMembers = await window.lokiPublicChatAPI.getListOfMembers();
groupMembers = groupMembers.filter((m: any) => !!m);
} else {
const privateConvos = window
.getConversations()
.models.filter((d: any) => d.isPrivate());
const members = thisConvo.attributes.members;
if (!members) {
return null;
}
const memberConversations = members
.map((m: any) => privateConvos.find((c: any) => c.id === m))
.filter((c: any) => !!c);
groupMembers = memberConversations.map((m: any) => {
const name = m.getLokiProfile()
? m.getLokiProfile().displayName
: m.attributes.displayName;
return {
id: m.id,
authorPhoneNumber: m.id,
authorProfileName: name,
};
});
}
return groupMembers.find(
({ authorPhoneNumber: pn }: any) => pn && pn === pubkey
);
}
}

@ -2,12 +2,7 @@ import React from 'react';
import { Avatar } from '../Avatar';
import { Colors, LocalizerType } from '../../types/Util';
import {
ContextMenu,
ContextMenuTrigger,
MenuItem,
SubMenu,
} from 'react-contextmenu';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import {
SessionIconButton,
@ -20,6 +15,7 @@ import {
SessionButtonColor,
SessionButtonType,
} from '../session/SessionButton';
import * as Menu from '../../session/utils/Menu';
export interface TimerOption {
name: string;
@ -38,6 +34,7 @@ interface Props {
isMe: boolean;
isClosable?: boolean;
isGroup: boolean;
isPrivate: boolean;
isArchived: boolean;
isPublic: boolean;
isRss: boolean;
@ -304,50 +301,59 @@ export class ConversationHeader extends React.Component<Props> {
onUpdateGroupName,
} = this.props;
const isPrivateGroup = isGroup && !isPublic && !isRss;
const copyIdLabel = isGroup ? i18n('copyChatId') : i18n('copyPublicKey');
return (
<ContextMenu id={triggerId}>
{this.renderPublicMenuItems()}
{!isRss ? (
<MenuItem onClick={onCopyPublicKey}>{copyIdLabel}</MenuItem>
) : null}
{Menu.getCopyIdMenuItem(
isPublic,
isRss,
isGroup,
onCopyPublicKey,
i18n
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{amMod && !isKickedFromGroup ? (
<MenuItem onClick={onAddModerators}>{i18n('addModerators')}</MenuItem>
) : null}
{amMod && !isKickedFromGroup ? (
<MenuItem onClick={onRemoveModerators}>
{i18n('removeModerators')}
</MenuItem>
) : null}
{amMod && !isKickedFromGroup ? (
<MenuItem onClick={onUpdateGroupName}>
{i18n('editGroupNameOrPicture')}
</MenuItem>
) : null}
{isPrivateGroup && !isKickedFromGroup ? (
<MenuItem onClick={onLeaveGroup}>{i18n('leaveGroup')}</MenuItem>
) : null}
{Menu.getAddModeratorsMenuItem(
amMod,
isKickedFromGroup,
onAddModerators,
i18n
)}
{Menu.getRemoveModeratorsMenuItem(
amMod,
isKickedFromGroup,
onRemoveModerators,
i18n
)}
{Menu.getUpdateGroupNameMenuItem(
amMod,
isKickedFromGroup,
onUpdateGroupName,
i18n
)}
{Menu.getLeaveGroupMenuItem(
isKickedFromGroup,
isGroup,
isPublic,
isRss,
onLeaveGroup,
i18n
)}
{/* TODO: add delete group */}
{isGroup && isPublic ? (
<MenuItem onClick={onInviteContacts}>
{i18n('inviteContacts')}
</MenuItem>
) : null}
{!isMe && isClosable && !isPrivateGroup ? (
!isPublic ? (
<MenuItem onClick={onDeleteContact}>
{i18n('deleteContact')}
</MenuItem>
) : (
<MenuItem onClick={onDeleteContact}>
{i18n('deletePublicChannel')}
</MenuItem>
)
) : null}
{Menu.getInviteContactMenuItem(
isGroup,
isPublic,
onInviteContacts,
i18n
)}
{Menu.getDeleteContactMenuItem(
isMe,
isClosable,
isGroup,
isPublic,
isRss,
onDeleteContact,
i18n
)}
</ContextMenu>
);
}
@ -433,6 +439,7 @@ export class ConversationHeader extends React.Component<Props> {
isBlocked,
isMe,
isGroup,
isPrivate,
isKickedFromGroup,
isPublic,
isRss,
@ -445,43 +452,45 @@ export class ConversationHeader extends React.Component<Props> {
onUnblockUser,
} = this.props;
if (isPublic || isRss) {
return null;
}
const disappearingTitle = i18n('disappearingMessages') as any;
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
const disappearingMessagesMenuItem = !isKickedFromGroup && !isBlocked && (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
const disappearingMessagesMenuItem = Menu.getDisappearingMenuItem(
isPublic,
isRss,
isKickedFromGroup,
isBlocked,
timerOptions,
onSetDisappearingMessages,
i18n
);
const showMembersMenuItem = isGroup && (
<MenuItem onClick={onShowGroupMembers}>{i18n('showMembers')}</MenuItem>
const showMembersMenuItem = Menu.getShowMemberMenuItem(
isPublic,
isRss,
isGroup,
onShowGroupMembers,
i18n
);
const showSafetyNumberMenuItem = !isGroup && !isMe && (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
</MenuItem>
const showSafetyNumberMenuItem = Menu.getShowSafetyNumberMenuItem(
isPublic,
isRss,
isGroup,
isMe,
onShowSafetyNumber,
i18n
);
const resetSessionMenuItem = !isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
const resetSessionMenuItem = Menu.getResetSessionMenuItem(
isPublic,
isRss,
isGroup,
onResetSession,
i18n
);
const blockHandlerMenuItem = !isMe && !isRss && (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
const blockHandlerMenuItem = Menu.getBlockMenuItem(
isMe,
isPrivate,
isBlocked,
onBlockUser,
onUnblockUser,
i18n
);
return (

@ -3,12 +3,11 @@ import classNames from 'classnames';
import { Contact, MemberList } from './MemberList';
import { SessionModal } from './../session/SessionModal';
import { createLegacyGroup } from '../../session/medium_group';
declare global {
interface Window {
Lodash: any;
doCreateGroup: any;
createMediumSizeGroup: any;
SMALL_GROUP_SIZE_LIMIT: number;
}
}
@ -78,7 +77,7 @@ export class CreateGroupDialog extends React.Component<Props, State> {
return;
}
window.doCreateGroup(this.state.groupName, members);
void createLegacyGroup(this.state.groupName, members);
this.closeDialog();
}

@ -58,7 +58,7 @@ export class InviteContactsDialog extends React.Component<Props, State> {
}
public render() {
const titleText = `${window.i18n('addingFriends')} ${this.props.chatName}`;
const titleText = `${window.i18n('addingContacts')} ${this.props.chatName}`;
const cancelText = window.i18n('cancel');
const okText = window.i18n('ok');
@ -76,7 +76,7 @@ export class InviteContactsDialog extends React.Component<Props, State> {
{hasContacts ? null : (
<>
<div className="spacer-lg" />
<p className="no-friends">{window.i18n('noFriendsToAdd')}</p>
<p className="no-friends">{window.i18n('noContactsToAdd')}</p>
<div className="spacer-lg" />
</>
)}

@ -157,7 +157,7 @@ export class AddModeratorsDialog extends React.Component<Props, State> {
/>
</div>
{hasContacts ? null : (
<p className="no-friends">{i18n('noFriendsToAdd')}</p>
<p className="no-friends">{i18n('noContactsToAdd')}</p>
)}
</div>
<div className="buttons">

@ -65,13 +65,13 @@ export class MediaGallery extends React.Component<Props, State> {
<div className="module-media-gallery">
<div className="module-media-gallery__tab-container">
<Tab
label="Media"
label={window.i18n('media')}
type="media"
isSelected={selectedTab === 'media'}
onSelect={this.handleTabSelect}
/>
<Tab
label="Documents"
label={window.i18n('documents')}
type="documents"
isSelected={selectedTab === 'documents'}
onSelect={this.handleTabSelect}

@ -2,6 +2,9 @@ import React from 'react';
import { SessionIconButton, SessionIconSize, SessionIconType } from './icon';
import { Avatar } from '../Avatar';
import { PropsData as ConversationListItemPropsType } from '../ConversationListItem';
import { MultiDeviceProtocol } from '../../session/protocols';
import { UserUtil } from '../../util';
import { createOrUpdateItem, getItemById } from '../../../js/modules/data';
export enum SectionType {
Profile,

@ -269,7 +269,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
rowHeight={64}
rowRenderer={this.renderRow}
width={width}
autoHeight={true}
autoHeight={false}
/>
)}
</AutoSizer>

@ -192,7 +192,7 @@ export class LeftPaneMessageSection extends React.Component<Props, State> {
rowHeight={64}
rowRenderer={this.renderRow}
width={width}
autoHeight={true}
autoHeight={false}
/>
)}
</AutoSizer>

@ -224,13 +224,13 @@ export class LeftPaneSettingSection extends React.Component<Props, State> {
},
{
id: SessionSettingCategory.Notifications,
title: window.i18n('notificationSettingsTitle'),
title: window.i18n('notificationsSettingsTitle'),
hidden: false,
},
{
id: SessionSettingCategory.Devices,
title: window.i18n('devicesSettingsTitle'),
hidden: isSecondaryDevice,
hidden: !window.lokiFeatureFlags.useMultiDevice || isSecondaryDevice,
},
];
}

@ -557,8 +557,12 @@ export class RegistrationTabs extends React.Component<{}, State> {
SessionButtonType.BrandOutline,
SessionButtonColor.Green
)}
<h4>{or}</h4>
{this.renderLinkDeviceToExistingAccountButton()}
{window.lokiFeatureFlags.useMultiDevice && (
<>
<h4>{or}</h4>
{this.renderLinkDeviceToExistingAccountButton()}
</>
)}
</div>
);
}
@ -583,8 +587,12 @@ export class RegistrationTabs extends React.Component<{}, State> {
return (
<div>
{this.renderContinueYourSessionButton()}
<h4>{or}</h4>
{this.renderLinkDeviceToExistingAccountButton()}
{window.lokiFeatureFlags.useMultiDevice && (
<>
<h4>{or}</h4>
{this.renderLinkDeviceToExistingAccountButton()}
</>
)}
</div>
);
}

@ -134,20 +134,20 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
buttonText = window.i18n('next');
descriptionLong = window.i18n('usersCanShareTheir...');
subtitle = window.i18n('enterSessionID');
placeholder = window.i18n('pasteSessionIDRecipient');
placeholder = window.i18n('enterSessionID');
break;
case 'contact':
title = window.i18n('addContact');
buttonText = window.i18n('next');
descriptionLong = window.i18n('usersCanShareTheir...');
subtitle = window.i18n('enterSessionID');
placeholder = window.i18n('pasteSessionIDRecipient');
placeholder = window.i18n('enterSessionID');
break;
case 'open-group':
title = window.i18n('addChannel');
buttonText = window.i18n('joinChannel');
title = window.i18n('joinOpenGroup');
buttonText = window.i18n('joinOpenGroup');
descriptionLong = window.i18n('addChannelDescription');
subtitle = window.i18n('enterChannelURL');
subtitle = window.i18n('enterOpenGroupURL');
placeholder = window.i18n('channelUrlPlaceholder');
break;
case 'closed-group':
@ -239,13 +239,13 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
<UserSearchDropdown
searchTerm={searchTerm || ''}
updateSearch={updateSearch}
placeholder={window.i18n('searchByIDOrDisplayName')}
placeholder={window.i18n('searchForAKeyPhrase')}
searchResults={searchResults}
/>
)}
{isAddContactView && (
<PillDivider text={window.i18n('yourPublicKey')} />
<PillDivider text={window.i18n('yourSessionID')} />
)}
{isAddContactView && (

@ -1,6 +1,7 @@
import React from 'react';
import { SessionModal } from './SessionModal';
import { SessionButton, SessionButtonColor } from './SessionButton';
import { SessionHtmlRenderer } from './SessionHTMLRenderer';
interface Props {
message: string;
@ -61,7 +62,11 @@ export class SessionConfirm extends React.Component<Props> {
{!showHeader && <div className="spacer-lg" />}
<div className="session-modal__centered">
<span className={messageSubText}>{message}</span>
<SessionHtmlRenderer
tag="span"
className={messageSubText}
html={message}
/>
{messageSub && (
<span className="session-confirm-sub-message subtle">
{messageSub}

@ -5,6 +5,7 @@ interface ReceivedProps {
html: string;
tag?: string;
key?: any;
className?: string;
}
// Needed because of https://github.com/microsoft/tslint-microsoft-contrib/issues/339
@ -14,14 +15,16 @@ export const SessionHtmlRenderer: React.SFC<Props> = ({
tag = 'div',
key,
html,
className,
}) => {
const clean = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_ATTR: ['style', 'script'],
FORBID_ATTR: ['script'],
});
return React.createElement(tag, {
key,
className,
dangerouslySetInnerHTML: { __html: clean },
});
};

@ -2,7 +2,7 @@ import React from 'react';
import { SessionModal } from './SessionModal';
import { SessionButton, SessionButtonColor } from './SessionButton';
import { PasswordUtil } from '../../util/';
export enum PasswordAction {
Set = 'set',
Change = 'change',
@ -17,17 +17,20 @@ interface Props {
interface State {
error: string | null;
currentPasswordEntered: string | null;
currentPasswordConfirmEntered: string | null;
}
export class SessionPasswordModal extends React.Component<Props, State> {
private readonly passwordInput: React.RefObject<HTMLInputElement>;
private readonly passwordInputConfirm: React.RefObject<HTMLInputElement>;
private passportInput: HTMLInputElement | null = null;
constructor(props: any) {
super(props);
this.state = {
error: null,
currentPasswordEntered: null,
currentPasswordConfirmEntered: null,
};
this.showError = this.showError.bind(this);
@ -35,32 +38,28 @@ export class SessionPasswordModal extends React.Component<Props, State> {
this.setPassword = this.setPassword.bind(this);
this.closeDialog = this.closeDialog.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.onPaste = this.onPaste.bind(this);
this.onPasswordInput = this.onPasswordInput.bind(this);
this.onPasswordConfirmInput = this.onPasswordConfirmInput.bind(this);
this.passwordInput = React.createRef();
this.passwordInputConfirm = React.createRef();
this.onPaste = this.onPaste.bind(this);
}
public componentDidMount() {
setTimeout(() => {
if (!this.passwordInput.current) {
return;
}
this.passwordInput.current.focus();
}, 100);
// tslint:disable-next-line: no-unused-expression
this.passportInput && this.passportInput.focus();
}, 1);
}
public render() {
const { action, onOk } = this.props;
const placeholders =
this.props.action === PasswordAction.Change
action === PasswordAction.Change
? [window.i18n('typeInOldPassword'), window.i18n('enterPassword')]
: [window.i18n('enterPassword'), window.i18n('confirmPassword')];
const confirmButtonColor =
this.props.action === PasswordAction.Remove
action === PasswordAction.Remove
? SessionButtonColor.Danger
: SessionButtonColor.Primary;
@ -76,9 +75,11 @@ export class SessionPasswordModal extends React.Component<Props, State> {
<input
type="password"
id="password-modal-input"
ref={this.passwordInput}
ref={input => {
this.passportInput = input;
}}
placeholder={placeholders[0]}
onKeyUp={this.onKeyUp}
onKeyUp={this.onPasswordInput}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onPaste={this.onPaste}
/>
@ -86,9 +87,8 @@ export class SessionPasswordModal extends React.Component<Props, State> {
<input
type="password"
id="password-modal-input-confirm"
ref={this.passwordInputConfirm}
placeholder={placeholders[1]}
onKeyUp={this.onKeyUp}
onKeyUp={this.onPasswordConfirmInput}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onPaste={this.onPaste}
/>
@ -117,7 +117,7 @@ export class SessionPasswordModal extends React.Component<Props, State> {
public async validatePasswordHash(password: string | null) {
// Check if the password matches the hash we have stored
const hash = await window.Signal.Data.getPasswordHash();
if (hash && !window.passwordUtil.matchesHash(password, hash)) {
if (hash && !PasswordUtil.matchesHash(password, hash)) {
return false;
}
@ -139,73 +139,104 @@ export class SessionPasswordModal extends React.Component<Props, State> {
);
}
// tslint:disable-next-line: cyclomatic-complexity
private async setPassword(onSuccess?: any) {
// Only initial input required for PasswordAction.Remove
if (
!this.passwordInput.current ||
(!this.passwordInputConfirm.current &&
this.props.action !== PasswordAction.Remove)
) {
return;
}
const { action } = this.props;
const {
currentPasswordEntered,
currentPasswordConfirmEntered,
} = this.state;
const { Set, Remove, Change } = PasswordAction;
// Trim leading / trailing whitespace for UX
const enteredPassword = String(this.passwordInput.current.value).trim();
const enteredPasswordConfirm =
(this.passwordInputConfirm.current &&
String(this.passwordInputConfirm.current.value).trim()) ||
'';
if (
enteredPassword.length === 0 ||
(enteredPasswordConfirm.length === 0 &&
this.props.action !== PasswordAction.Remove)
) {
return;
}
const enteredPassword = (currentPasswordEntered || '').trim();
const enteredPasswordConfirm = (currentPasswordConfirmEntered || '').trim();
// Check passwords entered
if (
enteredPassword.length === 0 ||
(this.props.action === PasswordAction.Change &&
enteredPasswordConfirm.length === 0)
) {
// if user did not fill the first password field, we can't do anything
const errorFirstInput = PasswordUtil.validatePassword(
enteredPassword,
window.i18n
);
if (errorFirstInput !== null) {
this.setState({
error: window.i18n('noGivenPassword'),
error: errorFirstInput,
});
return;
}
// if action is Set or Change, we need a valid ConfirmPassword
if (action === Set || action === Change) {
const errorSecondInput = PasswordUtil.validatePassword(
enteredPasswordConfirm,
window.i18n
);
if (errorSecondInput !== null) {
this.setState({
error: errorSecondInput,
});
return;
}
}
// Passwords match or remove password successful
const newPassword =
this.props.action === PasswordAction.Remove
? null
: enteredPasswordConfirm;
const oldPassword =
this.props.action === PasswordAction.Set ? null : enteredPassword;
const newPassword = action === Remove ? null : enteredPasswordConfirm;
const oldPassword = action === Set ? null : enteredPassword;
// Check if password match, when setting, changing or removing
const valid =
this.props.action !== PasswordAction.Set
? Boolean(await this.validatePasswordHash(oldPassword))
: enteredPassword === enteredPasswordConfirm;
let valid;
if (action === Set) {
valid = enteredPassword === enteredPasswordConfirm;
} else {
valid = Boolean(await this.validatePasswordHash(oldPassword));
}
if (!valid) {
let str;
switch (action) {
case Set:
str = window.i18n('setPasswordInvalid');
break;
case Change:
str = window.i18n('changePasswordInvalid');
break;
case Remove:
str = window.i18n('removePasswordInvalid');
break;
default:
throw new Error(`Invalid action ${action}`);
}
this.setState({
error: window.i18n(`${this.props.action}PasswordInvalid`),
error: str,
});
return;
}
await window.setPassword(newPassword, oldPassword);
let title;
let description;
switch (action) {
case Set:
title = window.i18n('setPasswordTitle');
description = window.i18n('setPasswordToastDescription');
break;
case Change:
title = window.i18n('changePasswordTitle');
description = window.i18n('changePasswordToastDescription');
break;
case Remove:
title = window.i18n('removePasswordTitle');
description = window.i18n('removePasswordToastDescription');
break;
default:
throw new Error(`Invalid action ${action}`);
}
const toastParams = {
title: window.i18n(`${this.props.action}PasswordTitle`),
description: window.i18n(`${this.props.action}PasswordToastDescription`),
type: this.props.action !== PasswordAction.Remove ? 'success' : 'warning',
icon: this.props.action !== PasswordAction.Remove ? 'lock' : undefined,
title: title,
description: description,
type: action !== Remove ? 'success' : 'warning',
icon: action !== Remove ? 'lock' : undefined,
};
window.pushToast({
@ -244,13 +275,17 @@ export class SessionPasswordModal extends React.Component<Props, State> {
return false;
}
private async onKeyUp(event: any) {
const { onOk } = this.props;
private async onPasswordInput(event: any) {
if (event.key === 'Enter') {
await this.setPassword(onOk);
return this.setPassword(this.props.onOk);
}
this.setState({ currentPasswordEntered: event.target.value });
}
event.preventDefault();
private async onPasswordConfirmInput(event: any) {
if (event.key === 'Enter') {
return this.setPassword(this.props.onOk);
}
this.setState({ currentPasswordConfirmEntered: event.target.value });
}
}

@ -241,6 +241,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
showLinkDeviceButton={!shouldRenderPasswordLock}
category={category}
isSecondaryDevice={isSecondaryDevice}
categoryTitle={window.i18n(`${category}SettingsTitle`)}
/>
<div className="session-settings-view">

@ -12,6 +12,7 @@ interface Props extends SettingsViewProps {
showLinkDeviceButton: boolean | null;
// isSecondaryDevice is used to just disable the linkDeviceButton when we are already a secondary device
isSecondaryDevice: boolean;
categoryTitle: string;
}
export class SettingsHeader extends React.Component<Props, any> {
@ -63,16 +64,8 @@ export class SettingsHeader extends React.Component<Props, any> {
}
public render() {
const { category } = this.props;
const { category, categoryTitle } = this.props;
const { disableLinkDeviceButton } = this.state;
const categoryString = String(category);
const categoryTitlePrefix =
categoryString[0].toUpperCase() + categoryString.substr(1);
// Remove 's' on the end to keep words in singular form
const categoryTitle =
categoryTitlePrefix[categoryTitlePrefix.length - 1] === 's'
? `${categoryTitlePrefix.slice(0, -1)} Settings`
: `${categoryTitlePrefix} Settings`;
const showSearch = false;
const showAddDevice =
category === SessionSettingCategory.Devices &&

@ -4,14 +4,11 @@ import { getEnvelopeId } from './common';
import { removeFromCache, updateCache } from './cache';
import { SignalService } from '../protobuf';
import { toNumber } from 'lodash';
import * as Lodash from 'lodash';
import * as libsession from '../session';
import { handleSessionRequestMessage } from './sessionHandling';
import { handlePairingAuthorisationMessage } from './multidevice';
import {
MediumGroupRequestKeysMessage,
ReceiptMessage,
} from '../session/messages/outgoing';
import { MediumGroupRequestKeysMessage } from '../session/messages/outgoing';
import { MultiDeviceProtocol, SessionProtocol } from '../session/protocols';
import { PubKey } from '../session/types';
@ -20,6 +17,7 @@ import { onError } from './errors';
import ByteBuffer from 'bytebuffer';
import { BlockedNumberController } from '../util/blockedNumberController';
import { decryptWithSenderKey } from '../session/medium_group/ratchet';
import { StringUtils } from '../session/utils';
export async function handleContentMessage(envelope: EnvelopePlus) {
const plaintext = await decrypt(envelope, envelope.content);
@ -188,12 +186,6 @@ async function decryptUnidentifiedSender(
envelope.type = SignalService.Envelope.Type.FALLBACK_MESSAGE;
}
const blocked = await isBlocked(sender.getName());
if (blocked) {
window.log.info('Dropping blocked message after sealed sender decryption');
return null;
}
// Here we take this sender information and attach it back to the envelope
// to make the rest of the app work properly.
@ -298,9 +290,9 @@ async function decrypt(
};
const requestKeysMessage = new MediumGroupRequestKeysMessage(params);
const senderPubKey = new PubKey(senderIdentity);
const sender = new PubKey(senderIdentity);
// tslint:disable-next-line no-floating-promises
libsession.getMessageQueue().send(senderPubKey, requestKeysMessage);
libsession.getMessageQueue().send(sender, requestKeysMessage);
return;
}
@ -336,6 +328,50 @@ async function decrypt(
}
}
function shouldDropBlockedUserMessage(content: SignalService.Content): boolean {
// Even if the user is blocked, we should allow the message if:
// - it is a group message AND
// - the group exists already on the db (to not join a closed group created by a blocked user) AND
// - the group is not blocked AND
// - the message is only control (no body/attachments/quote/groupInvitation/contact/preview)
if (!content?.dataMessage?.group?.id) {
return true;
}
const groupId = StringUtils.decode(content.dataMessage.group.id, 'utf8');
const groupConvo = window.ConversationController.get(groupId);
if (!groupConvo) {
return true;
}
if (groupConvo.isBlocked()) {
return true;
}
// first check that dataMessage is the only field set in the Content
let msgWithoutDataMessage = Lodash.pickBy(
content,
(_, key) => key !== 'dataMessage' && key !== 'toJSON'
);
msgWithoutDataMessage = Lodash.pickBy(msgWithoutDataMessage, Lodash.identity);
const isMessageDataMessageOnly = Lodash.isEmpty(msgWithoutDataMessage);
if (!isMessageDataMessageOnly) {
return true;
}
const data = content.dataMessage;
const isControlDataMessageOnly =
!data.body &&
!data.contact?.length &&
!data.preview?.length &&
!data.attachments?.length &&
!data.groupInvitation &&
!data.quote;
return !isControlDataMessageOnly;
}
export async function innerHandleContentMessage(
envelope: EnvelopePlus,
plaintext: ArrayBuffer
@ -344,6 +380,17 @@ export async function innerHandleContentMessage(
const content = SignalService.Content.decode(new Uint8Array(plaintext));
const blocked = await isBlocked(envelope.source);
if (blocked) {
// We want to allow a blocked user message if that's a control message for a known group and the group is not blocked
if (shouldDropBlockedUserMessage(content)) {
window.log.info('Dropping blocked user message');
return;
} else {
window.log.info('Allowing group-control message only from blocked user');
}
}
const { FALLBACK_MESSAGE } = SignalService.Envelope.Type;
await ConversationController.getOrCreateAndWait(envelope.source, 'private');
@ -451,14 +498,14 @@ async function handleReceiptMessage(
const results = [];
if (type === SignalService.ReceiptMessage.Type.DELIVERY) {
for (const ts of timestamp) {
const promise = onDeliveryReceipt(envelope.source, toNumber(ts));
const promise = onDeliveryReceipt(envelope.source, Lodash.toNumber(ts));
results.push(promise);
}
} else if (type === SignalService.ReceiptMessage.Type.READ) {
for (const ts of timestamp) {
const promise = onReadReceipt(
toNumber(envelope.timestamp),
toNumber(ts),
Lodash.toNumber(envelope.timestamp),
Lodash.toNumber(ts),
envelope.source
);
results.push(promise);
@ -494,8 +541,8 @@ async function handleTypingMessage(
await removeFromCache(envelope);
if (envelope.timestamp && timestamp) {
const envelopeTimestamp = toNumber(envelope.timestamp);
const typingTimestamp = toNumber(timestamp);
const envelopeTimestamp = Lodash.toNumber(envelope.timestamp);
const typingTimestamp = Lodash.toNumber(timestamp);
if (typingTimestamp !== envelopeTimestamp) {
window.log.warn(

@ -395,7 +395,7 @@ async function handleProfileUpdate(
);
// First set profileSharing = true for the conversation we sent to
receiver.set({ profileSharing: true });
await receiver.saveChangesToDB();
await receiver.commit();
// Then we update our own profileKey if it's different from what we have
const ourNumber = window.textsecure.storage.user.getNumber();

@ -4,7 +4,6 @@ import { getMessageQueue } from '../session';
import { PubKey } from '../session/types';
import _ from 'lodash';
import { BlockedNumberController } from '../util/blockedNumberController';
import { RatchetKey } from '../session/messages/outgoing/content/data/mediumgroup/MediumGroupMessage';
function isGroupBlocked(groupId: string) {
return BlockedNumberController.isGroupBlocked(groupId);
@ -40,7 +39,7 @@ export async function preprocessGroupMessage(
'group'
);
if (conversation.isPublic()) {
if (conversation.isPublic() || conversation.isMediumGroup()) {
// window.console.log('No need to preprocess public group chat messages');
return;
}
@ -130,95 +129,3 @@ export async function preprocessGroupMessage(
}
return false;
}
interface GroupInfo {
id: string;
name: string;
members: Array<string>; // Primary keys
is_medium_group: boolean;
active: boolean;
avatar: any;
expireTimer: number;
secretKey: any;
color?: any; // what is this???
blocked?: boolean;
senderKeys: Array<RatchetKey>;
}
export async function onGroupReceived(details: GroupInfo) {
const { ConversationController, libloki, textsecure, Whisper } = window;
const { id } = details;
libloki.api.debug.logGroupSync(
'Got sync group message with group id',
id,
' details:',
details
);
const conversation = await ConversationController.getOrCreateAndWait(
id,
'group'
);
const updates: any = {
name: details.name,
members: details.members,
color: details.color,
type: 'group',
is_medium_group: details.is_medium_group || false,
};
if (details.active) {
const activeAt = conversation.get('active_at');
// The idea is to make any new group show up in the left pane. If
// activeAt is null, then this group has been purposefully hidden.
if (activeAt !== null) {
updates.active_at = activeAt || Date.now();
}
updates.left = false;
} else {
updates.left = true;
}
conversation.set(updates);
// Update the conversation avatar only if new avatar exists and hash differs
const { avatar } = details;
if (avatar && avatar.data) {
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
conversation.attributes,
avatar.data,
{
writeNewAttachmentData: window.Signal.writeNewAttachmentData,
deleteAttachmentData: window.Signal.deleteAttachmentData,
}
);
conversation.set(newAttributes);
}
const isBlocked = details.blocked || false;
if (conversation.isClosedGroup()) {
await BlockedNumberController.setGroupBlocked(conversation.id, isBlocked);
}
conversation.trigger('change', conversation);
conversation.updateTextInputState();
await window.Signal.Data.updateConversation(id, conversation.attributes, {
Conversation: Whisper.Conversation,
});
const { expireTimer } = details;
const isValidExpireTimer = typeof expireTimer === 'number';
if (!isValidExpireTimer) {
return;
}
const source = textsecure.storage.user.getNumber();
const receivedAt = Date.now();
await conversation.updateExpirationTimer(expireTimer, source, receivedAt, {
fromSync: true,
});
}

@ -7,34 +7,53 @@ import { PubKey } from '../session/types';
import _ from 'lodash';
import * as SenderKeyAPI from '../session/medium_group';
import { getChainKey } from '../session/medium_group/ratchet';
import { StringUtils } from '../session/utils';
import { BufferType } from '../session/utils/String';
import { MultiDeviceProtocol } from '../session/protocols';
import { ConversationModel } from '../../js/models/conversations';
import { UserUtil } from '../util';
import { RatchetState } from '../session/medium_group/senderKeys';
const toHex = (d: BufferType) => StringUtils.decode(d, 'hex');
const fromHex = (d: string) => StringUtils.encode(d, 'hex');
async function handleSenderKeyRequest(
envelope: EnvelopePlus,
groupUpdate: any
groupUpdate: SignalService.MediumGroupUpdate
) {
const { StringView, textsecure, log } = window;
const { textsecure, log } = window;
const senderIdentity = envelope.source;
const ourIdentity = await textsecure.storage.user.getNumber();
const { groupId } = groupUpdate;
const { groupPublicKey } = groupUpdate;
const groupId = toHex(groupPublicKey);
log.debug('[sender key] sender key request from:', senderIdentity);
const maybeKey = await getChainKey(groupId, ourIdentity);
if (!maybeKey) {
// Regenerate? This should never happen though
log.error('Could not find own sender key');
await removeFromCache(envelope);
return;
}
// We reuse the same message type for sender keys
const { chainKey, keyIdx } = await SenderKeyAPI.getChainKey(
groupId,
ourIdentity
);
const { chainKey, keyIdx } = maybeKey;
// NOTE: we can't use `shareSenderKeys` here because
// we are not using multidevice
const chainKeyHex = StringView.arrayBufferToHex(chainKey);
const responseParams = {
timestamp: Date.now(),
groupId,
senderKey: {
chainKey: chainKeyHex,
chainKey: new Uint8Array(chainKey),
keyIdx,
pubKey: ourIdentity,
pubKey: new Uint8Array(fromHex(ourIdentity)),
},
};
@ -48,30 +67,100 @@ async function handleSenderKeyRequest(
await removeFromCache(envelope);
}
async function handleSenderKey(envelope: EnvelopePlus, groupUpdate: any) {
async function shareSenderKeys(
groupId: string,
recipientsPrimary: Array<string>,
senderKey: RatchetState
) {
const message = new MediumGroupResponseKeysMessage({
timestamp: Date.now(),
groupId,
senderKey,
});
const recipients = recipientsPrimary.map(pk => PubKey.cast(pk));
await Promise.all(
recipients.map(pk => getMessageQueue().sendUsingMultiDevice(pk, message))
);
}
async function handleSenderKey(
envelope: EnvelopePlus,
groupUpdate: SignalService.MediumGroupUpdate
) {
const { log } = window;
const { groupId, senderKey } = groupUpdate;
const { groupPublicKey, senderKeys } = groupUpdate;
const senderIdentity = envelope.source;
log.debug('[sender key] got a new sender key from:', senderIdentity);
const groupId = toHex(groupPublicKey);
await SenderKeyAPI.saveSenderKeys(
groupId,
PubKey.cast(senderIdentity),
senderKey.chainKey,
senderKey.keyIdx
log.debug(
'[sender key] got a new sender key from:',
senderIdentity,
'group:',
groupId
);
await saveIncomingRatchetKeys(groupId, senderKeys);
await removeFromCache(envelope);
}
async function saveIncomingRatchetKeys(
groupId: string,
ratchetKeys: Array<SignalService.MediumGroupUpdate.ISenderKey>
) {
await Promise.all(
ratchetKeys.map(async senderKey => {
// Note that keyIndex is a number and 0 is considered a valid value:
if (
senderKey.chainKey &&
senderKey.keyIndex !== undefined &&
senderKey.publicKey
) {
const pubKey = toHex(senderKey.publicKey);
const chainKey = toHex(senderKey.chainKey);
const keyIndex = senderKey.keyIndex as number;
window.log.info('Saving sender keys for:', pubKey);
// TODO: check that we are not overriting sender keys when
// we are not expected to do so
await SenderKeyAPI.saveSenderKeys(
groupId,
PubKey.cast(pubKey),
chainKey,
keyIndex
);
} else {
window.log.error('Received invalid sender key');
}
})
);
}
async function checkOwnSenderKeyPresent(
senderKeys: Array<SignalService.MediumGroupUpdate.ISenderKey>
) {
const ownKey = (await UserUtil.getCurrentDevicePubKey()) as string;
const pubkeys = senderKeys
.filter(key => key.publicKey && key.keyIndex !== undefined && key.chainKey)
.map(key => toHex(key.publicKey as Uint8Array));
if (pubkeys.indexOf(ownKey) === -1) {
window.log.error(
'Could not find sender key inside medium group invitation!'
);
// TODO: we should probably create the key ourselves in this case;
}
}
async function handleNewGroup(
envelope: EnvelopePlus,
groupUpdate: SignalService.MediumGroupUpdate
) {
const { Whisper, log } = window;
const senderIdentity = envelope.source;
const { log } = window;
const {
name,
@ -82,107 +171,244 @@ async function handleNewGroup(
senderKeys,
} = groupUpdate;
const groupId = StringUtils.decode(groupPublicKey, 'hex');
const maybeConvo = await window.ConversationController.get(groupId);
const groupId = toHex(groupPublicKey);
const members = membersBinary.map(toHex);
const admins = adminsBinary.map(toHex);
const members = membersBinary.map((pk: Uint8Array) =>
StringUtils.decode(pk, 'hex')
);
const admins = adminsBinary.map((pk: Uint8Array) =>
StringUtils.decode(pk, 'hex')
);
const maybeConvo = await window.ConversationController.get(groupId);
const groupExists = !!maybeConvo;
const convo = groupExists
? maybeConvo
: await window.ConversationController.getOrCreateAndWait(groupId, 'group');
{
// Add group update message
const now = Date.now();
const message = convo.messageCollection.add({
conversationId: convo.id,
type: 'incoming',
sent_at: now,
received_at: now,
group_update: {
name,
members,
},
});
const messageId = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id: messageId });
}
if (groupExists) {
// ***** Updating the group *****
log.info('Received a group update for medium group:', groupId);
// Check that the sender is admin (make sure it words with multidevice)
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
if (maybeConvo.get('isKickedFromGroup')) {
// TODO: indicate that we've been re-invited
// to the group if that is the case
// Enable typing:
maybeConvo.set('isKickedFromGroup', false);
maybeConvo.set('left', false);
maybeConvo.updateTextInputState();
} else {
log.warn(
'Ignoring a medium group message of type NEW: the conversation already exists'
);
await removeFromCache(envelope);
return;
}
}
convo.set('name', name);
convo.set('members', members);
const convo =
maybeConvo ||
(await window.ConversationController.getOrCreateAndWait(groupId, 'group'));
// TODO: check that we are still in the group (when we enable deleting members)
convo.saveChangesToDB();
await SenderKeyAPI.addUpdateMessage(convo, { newName: name }, 'incoming');
// Update other fields. Add a corresponding "update" message to the conversation
} else {
// ***** Creating a new group *****
log.info('Received a new medium group:', groupId);
// TODO: Check that we are even a part of this group?
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', name);
convo.set('groupAdmins', admins);
const secretKeyHex = StringUtils.decode(groupPrivateKey, 'hex');
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Save everyone's ratchet key
await Promise.all(
senderKeys.map(async senderKey => {
// Note that keyIndex is a number and 0 is considered a valid value:
if (
senderKey.chainKey &&
senderKey.keyIndex !== undefined &&
senderKey.publicKey
) {
const pubKey = StringUtils.decode(senderKey.publicKey, 'hex');
const chainKey = StringUtils.decode(senderKey.chainKey, 'hex');
const keyIndex = senderKey.keyIndex as number;
await SenderKeyAPI.saveSenderKeys(
groupId,
PubKey.cast(pubKey),
chainKey,
keyIndex
);
} else {
log.error('Received invalid sender key');
}
})
// ***** Creating a new group *****
log.info('Received a new medium group:', groupId);
// TODO: Check that we are even a part of this group
convo.set('name', name);
convo.set('members', members);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
// We only set group admins on group creation
convo.set('groupAdmins', admins);
convo.commit();
const secretKeyHex = toHex(groupPrivateKey);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Sanity check: we expect to find our own sender key in the list
await checkOwnSenderKeyPresent(senderKeys);
await saveIncomingRatchetKeys(groupId, senderKeys);
window.SwarmPolling.addGroupId(PubKey.cast(groupId));
await removeFromCache(envelope);
}
function sanityCheckMediumGroupUpdate(
primary: PubKey,
diff: SenderKeyAPI.MemberChanges,
groupUpdate: SignalService.MediumGroupUpdate
) {
const joining = diff.joiningMembers || [];
const leaving = diff.leavingMembers || [];
// 1. When there are no member changes, we don't expect any sender keys
if (!joining.length && !leaving.length) {
if (groupUpdate.senderKeys.length) {
window.log.error('Unexpected sender keys in group update');
}
}
// 2. With leaving members, we expect keys for all members
// (ignoring multidevice for now)
if (leaving.length) {
const stillMember = leaving.indexOf(primary.key) === -1;
if (!stillMember) {
// Should not receive any sender keys
if (groupUpdate.senderKeys.length) {
window.log.error('Unexpected sender keys for a leaving member');
}
} else if (groupUpdate.senderKeys.length < groupUpdate.members.length) {
window.log.error('Too few sender keys in group update');
}
}
}
async function handleMediumGroupChange(
envelope: EnvelopePlus,
groupUpdate: SignalService.MediumGroupUpdate
) {
const senderIdentity = envelope.source;
const {
name,
groupPublicKey,
members: membersBinary,
admins: adminsBinary,
senderKeys,
} = groupUpdate;
const { log } = window;
const groupId = toHex(groupPublicKey);
const maybeConvo = await window.ConversationController.get(groupId);
if (!maybeConvo) {
log.warn(
'Ignoring a medium group update message (INFO) for a non-existing group'
);
await removeFromCache(envelope);
// TODO: In practice we probably need to be able to request the group's
// the NEW message if we somehow missed the initial group invitation
return;
}
const convo = maybeConvo as ConversationModel;
// ***** Updating the group *****
const curAdmins = convo.get('groupAdmins') || [];
if (!curAdmins.length) {
log.error('Error: medium group must have at least one admin');
await removeFromCache(envelope);
return;
}
// // Check that the sender is admin (make sure it words with multidevice)
const isAdmin = curAdmins.includes(senderIdentity);
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
await removeFromCache(envelope);
return;
}
// NOTE: right now, we don't expect admins to change
const admins = adminsBinary.map(toHex);
const members = membersBinary.map(toHex);
const diff = SenderKeyAPI.calculateGroupDiff(convo, { name, members });
// Check whether we are still in the group
const primary = await UserUtil.getPrimary();
sanityCheckMediumGroupUpdate(primary, diff, groupUpdate);
await saveIncomingRatchetKeys(groupId, senderKeys);
// Only add update message if we have something to show
if (diff.joiningMembers || diff.leavingMembers || diff.newName) {
await SenderKeyAPI.addUpdateMessage(convo, diff, 'incoming');
}
convo.set('name', name);
convo.set('members', members);
window.SwarmPolling.addGroupId(PubKey.cast(groupId));
const areWeKicked = members.indexOf(primary.key) === -1;
if (areWeKicked) {
convo.set('isKickedFromGroup', true);
// Disable typing:
convo.updateTextInputState();
}
await convo.commit();
await removeFromCache(envelope);
}
async function handleQuit(
envelope: EnvelopePlus,
groupUpdate: SignalService.MediumGroupUpdate
) {
const quitter = envelope.source;
const groupId = toHex(groupUpdate.groupPublicKey);
const quitterPrimary = await MultiDeviceProtocol.getPrimaryDevice(quitter);
const maybeConvo = await window.ConversationController.get(groupId);
if (!maybeConvo) {
window.log.warn('Received QUIT for a non-existing medium group');
await removeFromCache(envelope);
return;
}
const convo = maybeConvo;
// 1. Remove primary device from members:
const members = convo.get('members');
const membersUpdated = _.without(members, quitterPrimary.key);
convo.set({ members: membersUpdated });
convo.commit();
// 2. Show message (device left the group);
await SenderKeyAPI.addUpdateMessage(
convo,
{ leavingMembers: [quitterPrimary.key] },
'incoming'
);
const ourNumber = (await UserUtil.getCurrentDevicePubKey()) as string;
const primary = await UserUtil.getPrimary();
if (quitterPrimary.key === primary.key) {
convo.set('isKickedFromGroup', true);
// Disable typing:
convo.updateTextInputState();
await convo.commit();
await removeFromCache(envelope);
return;
}
// 3. update your own sender key
const senderKey = await SenderKeyAPI.createSenderKeyForGroup(
groupId,
PubKey.cast(ourNumber)
);
// Send keys in the background
// tslint:disable-next-line no-floating-promises
shareSenderKeys(groupId, membersUpdated, senderKey);
await removeFromCache(envelope);
}
@ -199,5 +425,11 @@ export async function handleMediumGroupUpdate(
await handleSenderKey(envelope, groupUpdate);
} else if (type === Type.NEW) {
await handleNewGroup(envelope, groupUpdate);
} else if (type === Type.INFO) {
await handleMediumGroupChange(envelope, groupUpdate);
} else if (type === Type.QUIT) {
await handleQuit(envelope, groupUpdate);
} else {
window.log.error('Unknown group update type: ', type);
}
}

@ -87,6 +87,14 @@ export async function handlePairingAuthorisationMessage(
pairingAuthorisation: SignalService.IPairingAuthorisationMessage,
dataMessage: SignalService.IDataMessage | undefined | null
): Promise<void> {
if (!window.lokiFeatureFlags.useMultiDevice) {
window.log.info(
`Received a pairing authorisation message from ${envelope.source} while multi device is disabled.`
);
await removeFromCache(envelope);
return;
}
const { secondaryDevicePubKey, grantSignature } = pairingAuthorisation;
const isGrant =
grantSignature &&

@ -388,7 +388,8 @@ async function handleRegularMessage(
const now = new Date().getTime();
if (dataMessage.group) {
// Medium grups might have `group` set even if with group chat messages...
if (dataMessage.group && !conversation.isMediumGroup()) {
// This is not necessarily a group update message, it could also be a regular group message
const groupUpdate = await handleGroups(
conversation,
@ -557,7 +558,7 @@ export async function handleMessageJob(
// call it after we have an id for this message, because the jobs refer back
// to their source message.
await queueAttachmentDownloads(message);
await conversation.saveChangesToDB();
await conversation.commit();
conversation.trigger('newmessage', message);

@ -4,7 +4,6 @@ import { handleEndSession } from './sessionHandling';
import { EnvelopePlus } from './types';
import { downloadAttachment } from './attachments';
import { handleMediumGroupUpdate } from './mediumGroups';
import { onGroupReceived } from './groups';
import { addToCache, getAllFromCache, removeFromCache } from './cache';
import { processMessage } from '../session/snode_api/swarmPolling';
@ -14,12 +13,11 @@ import { onError } from './errors';
import {
handleContentMessage,
innerHandleContentMessage,
isBlocked,
onDeliveryReceipt,
} from './contentMessage';
import _ from 'lodash';
export { processMessage, onDeliveryReceipt, onGroupReceived };
export { processMessage, onDeliveryReceipt };
import {
handleDataMessage,
@ -31,6 +29,7 @@ import { getEnvelopeId } from './common';
import { StringUtils } from '../session/utils';
import { SignalService } from '../protobuf';
import { BlockedNumberController } from '../util/blockedNumberController';
import { MultiDeviceProtocol } from '../session/protocols';
// TODO: check if some of these exports no longer needed
export {
@ -310,9 +309,7 @@ export async function handleUnencryptedMessage({ message: outerMessage }: any) {
await updateProfile(conversation, profile, profileKey);
}
const primaryDevice = window.storage.get('primaryDevicePubKey');
const isOurDevice =
source && (source === ourNumber || source === primaryDevice);
const isOurDevice = await MultiDeviceProtocol.isOurDevice(source);
const isPublicChatMessage =
group && group.id && !!group.id.match(/^publicChat:/);

@ -14,9 +14,11 @@ import {
} from './dataMessage';
import { updateProfile } from './receiver';
import { handleContacts } from './multidevice';
import { onGroupReceived } from './groups';
import { updateOrCreateGroupFromSync } from '../session/medium_group';
import { MultiDeviceProtocol } from '../session/protocols';
import { DataMessage } from '../session/messages/outgoing';
import { BlockedNumberController } from '../util';
import { StringUtils } from '../session/utils';
export async function handleSyncMessage(
envelope: EnvelopePlus,
@ -36,6 +38,14 @@ export async function handleSyncMessage(
);
}
// remove empty fields (generated by ts even if they should be null)
if (syncMessage.openGroups && !syncMessage.openGroups.length) {
syncMessage.openGroups = null;
}
if (syncMessage.read && !syncMessage.read.length) {
syncMessage.read = null;
}
if (syncMessage.sent) {
const sentMessage = syncMessage.sent;
const message = sentMessage.message as SignalService.IDataMessage;
@ -54,7 +64,7 @@ export async function handleSyncMessage(
} else if (syncMessage.contacts) {
return handleContacts(envelope, syncMessage.contacts);
} else if (syncMessage.groups) {
await handleGroups(envelope, syncMessage.groups);
await handleGroupsSync(envelope, syncMessage.groups);
} else if (syncMessage.openGroups) {
await handleOpenGroups(envelope, syncMessage.openGroups);
} else if (syncMessage.blocked) {
@ -166,16 +176,42 @@ async function handleBlocked(
blocked: SignalService.SyncMessage.IBlocked
) {
window.log.info('Setting these numbers as blocked:', blocked.numbers);
window.textsecure.storage.put('blocked', blocked.numbers);
const groupIds = _.map(blocked.groupIds, (groupId: any) =>
groupId.toBinary()
);
window.log.info(
'Setting these groups as blocked:',
groupIds.map((groupId: any) => `group(${groupId})`)
);
window.textsecure.storage.put('blocked-groups', groupIds);
// blocked.numbers contains numbers
if (blocked.numbers) {
const currentlyBlockedNumbers = BlockedNumberController.getBlockedNumbers();
const toRemoveFromBlocked = _.difference(
currentlyBlockedNumbers,
blocked.numbers
);
const toAddToBlocked = _.difference(
blocked.numbers,
currentlyBlockedNumbers
);
async function markConvoBlocked(block: boolean, n: string) {
const conv = await window.ConversationController.get(n);
if (conv) {
if (conv.isPrivate()) {
await BlockedNumberController.setBlocked(n, block);
} else {
window.console.warn('Ignoring block/unblock for group:', n);
}
conv.trigger('change', conv);
} else {
window.console.warn(
'Did not find corresponding conversation to block',
n
);
}
}
await Promise.all(toAddToBlocked.map(async n => markConvoBlocked(true, n)));
await Promise.all(
toRemoveFromBlocked.map(async n => markConvoBlocked(false, n))
);
}
await removeFromCache(envelope);
}
@ -330,7 +366,7 @@ async function handleConfiguration(
await removeFromCache(envelope);
}
async function handleGroups(
async function handleGroupsSync(
envelope: EnvelopePlus,
groups: SignalService.SyncMessage.IGroups
) {
@ -344,9 +380,11 @@ async function handleGroups(
while (groupDetails !== undefined) {
groupDetails.id = groupDetails.id.toBinary();
const promise = onGroupReceived(groupDetails).catch((e: any) => {
window.log.error('error processing group', e);
});
const promise = updateOrCreateGroupFromSync(groupDetails).catch(
(e: any) => {
window.log.error('error processing group', e);
}
);
promises.push(promise);
groupDetails = groupBuffer.next();
@ -355,4 +393,6 @@ async function handleGroups(
// Note: we do not return here because we don't want to block the next message on
// this attachment download and a lot of processing of that attachment.
void Promise.all(promises);
await removeFromCache(envelope);
}

@ -1,13 +1,18 @@
import { NumberUtils } from './utils';
import { DAYS, HOURS, MINUTES } from './utils/Number';
// tslint:disable: binary-expression-operand-order
// Default TTL
/**
* FIXME The -1 hours is a hack to make the PN not trigger a Notification for those control message.
* Apple devices must show a Notification if a PN is received, and for those
* control message, there is nothing to display (yet).
*/
export const TTL_DEFAULT = {
PAIRING_REQUEST: NumberUtils.timeAsMs(2, 'minutes'),
DEVICE_UNPAIRING: NumberUtils.timeAsMs(4, 'days'),
SESSION_REQUEST: NumberUtils.timeAsMs(4, 'days'),
SESSION_ESTABLISHED: NumberUtils.timeAsMs(2, 'days'),
END_SESSION_MESSAGE: NumberUtils.timeAsMs(4, 'days'),
TYPING_MESSAGE: NumberUtils.timeAsMs(1, 'minute'),
ONLINE_BROADCAST: NumberUtils.timeAsMs(1, 'minute'),
REGULAR_MESSAGE: NumberUtils.timeAsMs(2, 'days'),
PAIRING_REQUEST: 2 * MINUTES,
DEVICE_UNPAIRING: 4 * DAYS - 1 * HOURS,
SESSION_REQUEST: 4 * DAYS - 1 * HOURS,
SESSION_ESTABLISHED: 2 * DAYS - 1 * HOURS,
END_SESSION_MESSAGE: 4 * DAYS - 1 * HOURS,
TYPING_MESSAGE: 1 * MINUTES,
ONLINE_BROADCAST: 1 * MINUTES,
REGULAR_MESSAGE: 2 * DAYS,
};

@ -1,5 +1,4 @@
import { PubKey } from '../types';
import { onGroupReceived } from '../../receiver/receiver';
import { StringUtils } from '../utils';
import * as Data from '../../../js/modules/data';
import _ from 'lodash';
@ -12,6 +11,24 @@ import {
} from './senderKeys';
import { getChainKey } from './ratchet';
import { MultiDeviceProtocol } from '../protocols';
import { BufferType } from '../utils/String';
import { UserUtil } from '../../util';
import { MediumGroupQuitMessage } from '../messages/outgoing/content/data/mediumgroup/MediumGroupQuitMessage';
import {
ClosedGroupChatMessage,
ClosedGroupMessage,
ClosedGroupUpdateMessage,
ExpirationTimerUpdateMessage,
MediumGroupCreateMessage,
MediumGroupMessage,
Message,
} from '../messages/outgoing';
import { MessageModel, MessageModelType } from '../../../js/models/messages';
import { getMessageQueue } from '../../session';
import { ConversationModel } from '../../../js/models/conversations';
import { MediumGroupUpdateMessage } from '../messages/outgoing/content/data/mediumgroup/MediumGroupUpdateMessage';
import uuid from 'uuid';
import { BlockedNumberController } from '../../util/blockedNumberController';
export {
createSenderKeyForGroup,
@ -20,6 +37,9 @@ export {
getChainKey,
};
const toHex = (d: BufferType) => StringUtils.decode(d, 'hex');
const fromHex = (d: string) => StringUtils.encode(d, 'hex');
async function createSenderKeysForMembers(
groupId: string,
members: Array<string>
@ -39,23 +59,19 @@ async function createSenderKeysForMembers(
);
}
export async function createMediumSizeGroup(
export async function createMediumGroup(
groupName: string,
members: Array<string>
) {
const { ConversationController, libsignal } = window;
// ***** 1. Create group parameters *****
// Create Group Identity
const identityKeys = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringUtils.decode(identityKeys.pubKey, 'hex');
const groupId = toHex(identityKeys.pubKey);
const groupSecretKeyHex = StringUtils.decode(identityKeys.privKey, 'hex');
const primary = window.storage.get('primaryDevicePubKey');
const allMembers = [primary, ...members];
const senderKeys = await createSenderKeysForMembers(groupId, allMembers);
const groupSecretKeyHex = toHex(identityKeys.privKey);
// TODO: make this strongly typed!
await Data.createOrUpdateIdentityKey({
@ -63,32 +79,836 @@ export async function createMediumSizeGroup(
secretKey: groupSecretKeyHex,
});
const primary: string = window.storage.get('primaryDevicePubKey');
const allMembers = [primary, ...members];
const newKeys = await createSenderKeysForMembers(groupId, allMembers);
const senderKeysContainer: SenderKeysContainer = {
newKeys,
existingKeys: [],
};
// ***** 2. Send group update message *****
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
const admins = [primary];
const groupDetails = {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
admins,
active: true,
expireTimer: 0,
avatar: '',
secretKey: new Uint8Array(identityKeys.privKey),
senderKeys,
senderKeysContainer,
is_medium_group: true,
};
await onGroupReceived(groupDetails);
const groupDiff: GroupDiff = {
newName: groupName,
joiningMembers: allMembers,
};
const dbMessage = await addUpdateMessage(convo, groupDiff, 'outgoing');
await sendGroupUpdate(convo, groupDiff, groupDetails, dbMessage.id);
// ***** 3. Add update message to the conversation *****
await updateOrCreateGroup(groupDetails);
convo.updateGroupAdmins(admins);
window.owsDesktopApp.appView.openConversation(groupId, {});
// Subscribe to this group id
window.SwarmPolling.addGroupId(new PubKey(groupId));
}
// Legacy groups don't belong here, but we will probably remove them anyway
export async function createLegacyGroup(
groupName: string,
members: Array<string>
) {
const { ConversationController, libsignal } = window;
const keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = toHex(keypair.pubKey);
const primary = await UserUtil.getPrimary();
const allMembers = [primary.key, ...members];
const groupDetails = {
id: groupId,
name: groupName,
members: allMembers,
active: true,
expireTimer: 0,
is_medium_group: false,
admins: [primary.key],
};
await updateOrCreateGroup(groupDetails);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.updateGroupAdmins([primary]);
convo.updateGroupAdmins([primary.key]);
convo.updateGroup(groupDetails);
const diff: GroupDiff = {
newName: groupName,
joiningMembers: allMembers,
};
const dbMessage = await addUpdateMessage(convo, diff, 'outgoing');
await sendGroupUpdate(convo, diff, groupDetails, dbMessage.id);
window.textsecure.messaging.sendGroupSyncMessage([convo]);
window.owsDesktopApp.appView.openConversation(groupId, {});
}
// Subscribe to this group id
window.SwarmPolling.addGroupId(new PubKey(groupId));
export async function leaveMediumGroup(groupId: string) {
const { ConversationController } = window;
// NOTE: we should probably remove sender keys for groupId,
// and its secret key, but it is low priority
// TODO: need to reset everyone's sender keys
window.SwarmPolling.removePubkey(groupId);
const maybeConvo = await ConversationController.get(groupId);
if (!maybeConvo) {
window.log.error('Cannot leave non-existing group');
return;
}
const convo: ConversationModel = maybeConvo;
const now = Date.now();
convo.set({ left: true });
await convo.commit();
const dbMessage = await convo.addMessage({
group_update: { left: 'You' },
conversationId: groupId,
type: 'outgoing',
sent_at: now,
received_at: now,
});
const updateParams = {
timestamp: Date.now(),
identifier: dbMessage.id,
groupId,
};
const message = new MediumGroupQuitMessage(updateParams);
await sendToMembers(groupId, message, dbMessage);
}
// Just a container to store two named list of keys
interface SenderKeysContainer {
newKeys: Array<RatchetState>;
existingKeys: Array<RatchetState>;
}
// Load all known keys for all members (or only for select devices if specified)
async function getExistingSenderKeysForGroup(
groupId: string,
devices: Array<PubKey>
): Promise<Array<RatchetState>> {
const maybeKeys = await Promise.all(
devices.map(async device => {
const maybeKey = await getChainKey(groupId, device.key);
if (!maybeKey) {
return null;
} else {
const { chainKey, keyIdx } = maybeKey;
const pubKeyBin = new Uint8Array(fromHex(device.key));
return {
chainKey: new Uint8Array(chainKey),
keyIdx,
pubKey: pubKeyBin,
};
}
})
);
return maybeKeys.filter(d => d !== null).map(d => d as RatchetState);
}
// Create all sender keys based on the changes in
// the group's composition
async function getOrCreateSenderKeysForUpdate(
groupId: string,
members: Array<string>,
changes: MemberChanges
): Promise<SenderKeysContainer> {
// 1. Create sender keys for every joining member
const joining = changes.joiningMembers || [];
const leaving = changes.leavingMembers || [];
let newKeys = await createSenderKeysForMembers(groupId, joining);
// 2. Get ratchet states for existing members
const existingMembers = _.difference(members, joining);
// get all devices for members
const allDevices = _.flatten(
await Promise.all(
existingMembers.map(m => MultiDeviceProtocol.getAllDevices(m))
)
);
let existingKeys: Array<RatchetState> = [];
if (leaving.length > 0) {
// If we have leaving members, we have to re-generate ratchet
// keys for existing members
const otherKeys = await Promise.all(
allDevices.map(async device => {
return createSenderKeyForGroup(groupId, PubKey.cast(device));
})
);
newKeys = _.union(newKeys, otherKeys);
} else {
// We can reuse existing keys
existingKeys = await getExistingSenderKeysForGroup(groupId, allDevices);
}
return { existingKeys, newKeys };
}
async function getGroupSecretKey(groupId: string): Promise<Uint8Array> {
const groupIdentity = await Data.getIdentityKeyById(groupId);
if (!groupIdentity) {
throw new Error(`Could not load secret key for group ${groupId}`);
}
const secretKey = groupIdentity.secretKey;
if (!secretKey) {
throw new Error(
`Secret key not found in identity key record for group ${groupId}`
);
}
return new Uint8Array(fromHex(secretKey));
}
async function syncMediumGroup(group: ConversationModel) {
const ourPrimary = await UserUtil.getPrimary();
const groupId = group.get('id');
const members = group.get('members');
const secretKey = await getGroupSecretKey(groupId);
const allDevices = _.flatten(
await Promise.all(members.map(m => MultiDeviceProtocol.getAllDevices(m)))
);
const secondaryKeys = await MultiDeviceProtocol.getSecondaryDevices(
ourPrimary
);
const existingKeys = await getExistingSenderKeysForGroup(groupId, allDevices);
const senderKeysForSecondary = await Promise.all(
secondaryKeys.map(key => createSenderKeyForGroup(groupId, key))
);
const senderKeysContainer: SenderKeysContainer = {
existingKeys,
newKeys: senderKeysForSecondary,
};
const groupUpdate: GroupInfo = {
id: group.get('id'),
name: group.get('name'),
members: group.get('members'),
is_medium_group: true,
admins: group.get('groupAdmins'),
senderKeysContainer,
secretKey,
};
// Note: we send this to our primary device which will in effect will send to
// our other devices, actually ignoring the current device
await sendGroupUpdateForMedium(
{ joiningMembers: [ourPrimary.key] },
groupUpdate
);
}
// Secondary devices are not expected to already have the group, so
// we send messages of type NEW
export async function syncMediumGroups(groups: Array<ConversationModel>) {
await Promise.all(groups.map(syncMediumGroup));
}
export async function initiateGroupUpdate(
groupId: string,
groupName: string,
members: Array<string>,
avatar: any
) {
const { ConversationController } = window;
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
const isMediumGroup = convo.isMediumGroup();
const groupDetails = {
id: groupId,
name: groupName,
members,
active: true,
expireTimer: convo.get('expireTimer'),
avatar,
is_medium_group: isMediumGroup,
};
const diff = calculateGroupDiff(convo, groupDetails);
await updateOrCreateGroup(groupDetails);
if (convo.isPublic()) {
await updatePublicGroup(convo, groupName, avatar);
return;
}
if (avatar) {
// would get to download this file on each client in the group
// and reference the local file
}
const updateObj: GroupInfo = {
id: groupId,
name: groupName,
members,
is_medium_group: isMediumGroup,
admins: convo.get('groupAdmins'),
};
if (isMediumGroup) {
// Send sender keys and group secret key
updateObj.senderKeysContainer = await getOrCreateSenderKeysForUpdate(
groupId,
members,
diff
);
const secretKey = await getGroupSecretKey(groupId);
updateObj.secretKey = secretKey;
}
const dbMessage = await addUpdateMessage(convo, diff, 'outgoing');
await sendGroupUpdate(convo, diff, updateObj, dbMessage.id);
}
// NOTE: Old-style groups and open groups don't really belong here
async function updatePublicGroup(convo: any, groupName: string, avatar: any) {
const API = await convo.getPublicSendData();
if (avatar) {
// I hate duplicating this...
const readFile = async (attachment: any) =>
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e: any) => {
const data = e.target.result;
resolve({
...attachment,
data,
size: data.byteLength,
});
};
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(attachment.file);
});
const avatarAttachment: any = await readFile({ file: avatar });
// const tempUrl = window.URL.createObjectURL(avatar);
// Get file onto public chat server
const fileObj = await API.serverAPI.putAttachment(avatarAttachment.data);
if (fileObj === null) {
// problem
window.log.warn('File upload failed');
return;
}
// lets not allow ANY URLs, lets force it to be local to public chat server
const url = new URL(fileObj.url);
// write it to the channel
await API.setChannelAvatar(url.pathname);
}
if (await API.setChannelName(groupName)) {
// queue update from server
// and let that set the conversation
API.pollForChannelOnce();
// or we could just directly call
// convo.setGroupName(groupName);
// but gut is saying let the server be the definitive storage of the state
// and trickle down from there
}
}
async function sendToMembers(
groupId: string,
message: MediumGroupMessage,
dbMessage: MessageModel
) {
const { ConversationController } = window;
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
);
const members = convo.get('members') || [];
try {
// Exclude our device from members and send them the message
const primary = await UserUtil.getPrimary();
const otherMembers = members.filter(
(member: string) => !primary.isEqual(member)
);
// we are the only member in here
if (members.length === 1 && members[0] === primary.key) {
dbMessage.sendSyncMessageOnly(message);
return;
}
const sendPromises = otherMembers.map(async (member: string) => {
const memberPubKey = PubKey.cast(member);
return getMessageQueue().sendUsingMultiDevice(memberPubKey, message);
});
await Promise.all(sendPromises);
} catch (e) {
window.log.error(e);
}
}
interface GroupInfo {
id: string;
name: string;
members: Array<string>; // Primary keys
is_medium_group: boolean;
active?: boolean;
expireTimer?: number;
avatar?: any;
color?: any; // what is this???
blocked?: boolean;
admins?: Array<string>;
secretKey?: Uint8Array;
senderKeysContainer?: SenderKeysContainer;
}
interface UpdatableGroupState {
name: string;
members: Array<string>;
}
export interface GroupDiff extends MemberChanges {
newName?: string;
}
export interface MemberChanges {
joiningMembers?: Array<string>;
leavingMembers?: Array<string>;
}
export async function addUpdateMessage(
convo: ConversationModel,
diff: GroupDiff,
type: MessageModelType
): Promise<MessageModel> {
const groupUpdate: any = {};
if (diff.newName) {
groupUpdate.name = diff.newName;
}
if (diff.joiningMembers) {
groupUpdate.joined = diff.joiningMembers;
}
if (diff.leavingMembers) {
groupUpdate.left = diff.leavingMembers;
}
const now = Date.now();
const message = await convo.addMessage({
conversationId: convo.get('id'),
type,
sent_at: now,
received_at: now,
group_update: groupUpdate,
});
return message;
}
export function calculateGroupDiff(
convo: ConversationModel,
update: UpdatableGroupState
): GroupDiff {
const groupDiff: GroupDiff = {};
if (convo.get('name') !== update.name) {
groupDiff.newName = update.name;
}
const oldMembers = convo.get('members');
const addedMembers = _.difference(update.members, oldMembers);
if (addedMembers.length > 0) {
groupDiff.joiningMembers = addedMembers;
}
// Check if anyone got kicked:
const removedMembers = _.difference(oldMembers, update.members);
if (removedMembers.length > 0) {
groupDiff.leavingMembers = removedMembers;
}
return groupDiff;
}
async function sendGroupUpdateForExistingMembers(
leavingMembers: Array<string>,
remainingMembers: Array<string>,
groupUpdate: GroupInfo,
messageId?: string
) {
const { id: groupId, members, name: groupName } = groupUpdate;
const membersBin = members.map(
(pkHex: string) => new Uint8Array(fromHex(pkHex))
);
const admins = groupUpdate.admins || [];
const adminsBin = admins.map(
(pkHex: string) => new Uint8Array(fromHex(pkHex))
);
// Existing members only receive new sender keys
const senderKeys = groupUpdate.senderKeysContainer
? groupUpdate.senderKeysContainer.newKeys
: [];
const params = {
timestamp: Date.now(),
identifier: messageId || uuid(),
groupId,
members: membersBin,
groupName,
admins: adminsBin,
senderKeys,
};
const message = new MediumGroupUpdateMessage(params);
remainingMembers.forEach(async member => {
const memberPubKey = new PubKey(member);
await getMessageQueue().sendUsingMultiDevice(memberPubKey, message);
});
// Remove sender keys from the params to send to leaving memebers
params.senderKeys = [];
const strippedMessage = new MediumGroupUpdateMessage(params);
leavingMembers.forEach(async member => {
const memberPubKey = new PubKey(member);
await getMessageQueue().sendUsingMultiDevice(memberPubKey, strippedMessage);
});
}
async function sendGroupUpdateForJoiningMembers(
recipients: Array<string>,
groupUpdate: GroupInfo,
messageId?: string
) {
const { id: groupId, name, members } = groupUpdate;
const now = Date.now();
const { secretKey, senderKeysContainer } = groupUpdate;
if (!secretKey) {
window.log.error('Group secret key not specified, aborting...');
return;
}
let senderKeys: Array<RatchetState> = [];
if (!senderKeysContainer) {
window.log.warn('Sender keys for joining members not found');
} else {
// Joining members should receive all known sender keys
senderKeys = _.union(
senderKeysContainer.existingKeys,
senderKeysContainer.newKeys
);
}
const membersBin = members.map(
(pkHex: string) => new Uint8Array(fromHex(pkHex))
);
const admins = groupUpdate.admins || [];
const adminsBin = admins.map(
(pkHex: string) => new Uint8Array(fromHex(pkHex))
);
const createParams = {
timestamp: now,
groupId,
identifier: messageId || uuid(),
groupSecretKey: secretKey,
members: membersBin,
groupName: name,
admins: adminsBin,
senderKeys,
};
const mediumGroupCreateMessage = new MediumGroupCreateMessage(createParams);
recipients.forEach(async member => {
const memberPubKey = new PubKey(member);
await getMessageQueue().sendUsingMultiDevice(
memberPubKey,
mediumGroupCreateMessage
);
});
}
async function sendGroupUpdateForMedium(
diff: MemberChanges,
groupUpdate: GroupInfo,
messageId?: string
) {
const joining = diff.joiningMembers || [];
const leaving = diff.leavingMembers || [];
// 1. create group for all joining members (send timeout timer if necessary)
if (joining.length) {
await sendGroupUpdateForJoiningMembers(joining, groupUpdate, messageId);
}
// 2. send group update to all other members
const others = _.difference(groupUpdate.members, joining);
if (others.length) {
await sendGroupUpdateForExistingMembers(
leaving,
others,
groupUpdate,
messageId
);
}
}
async function sendGroupUpdateForLegacy(
convo: ConversationModel,
diff: MemberChanges,
groupUpdate: GroupInfo,
messageId: string
) {
const { id: groupId, name, members, avatar } = groupUpdate;
const now = Date.now();
const updateParams = {
// if we do set an identifier here, be sure to not sync the message two times in msg.handleMessageSentSuccess()
identifier: messageId,
timestamp: now,
groupId,
name,
avatar,
members,
admins: convo.get('groupAdmins'),
};
const groupUpdateMessage = new ClosedGroupUpdateMessage(updateParams);
const recipients = _.union(diff.leavingMembers, groupUpdate.members);
await sendClosedGroupMessage(groupUpdateMessage, recipients);
// Send current timer update to every joining member
if (
diff.joiningMembers &&
diff.joiningMembers.length &&
convo.get('expireTimer')
) {
const expireUpdate = {
timestamp: Date.now(),
expireTimer: convo.get('expireTimer'),
groupId,
};
const expirationTimerMessage = new ExpirationTimerUpdateMessage(
expireUpdate
);
await Promise.all(
diff.joiningMembers.map(async member => {
const device = new PubKey(member);
await getMessageQueue()
.sendUsingMultiDevice(device, expirationTimerMessage)
.catch(window.log.error);
})
);
}
}
async function sendGroupUpdate(
convo: ConversationModel,
diff: MemberChanges,
groupUpdate: GroupInfo,
messageId: string
) {
if (groupUpdate.is_medium_group) {
await sendGroupUpdateForMedium(diff, groupUpdate, messageId);
} else {
await sendGroupUpdateForLegacy(convo, diff, groupUpdate, messageId);
}
}
export async function updateOrCreateGroupFromSync(details: GroupInfo) {
await updateOrCreateGroup(details);
}
// update conversation model
async function updateOrCreateGroup(details: GroupInfo) {
const { ConversationController, libloki, storage, textsecure } = window;
const { id } = details;
libloki.api.debug.logGroupSync(
'Got sync group message with group id',
id,
' details:',
details
);
const conversation = await ConversationController.getOrCreateAndWait(
id,
'group'
);
// TODO: check that we don't downgrade an existing group to a 'medium group'
const updates: any = {
name: details.name,
members: details.members,
color: details.color,
type: 'group',
is_medium_group: details.is_medium_group || false,
};
if (details.active) {
const activeAt = conversation.get('active_at');
// The idea is to make any new group show up in the left pane. If
// activeAt is null, then this group has been purposefully hidden.
if (activeAt !== null) {
updates.active_at = activeAt || Date.now();
}
updates.left = false;
} else {
updates.left = true;
}
conversation.set(updates);
// Update the conversation avatar only if new avatar exists and hash differs
const { avatar } = details;
if (avatar && avatar.data) {
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
conversation.attributes,
avatar.data,
{
writeNewAttachmentData: window.Signal.writeNewAttachmentData,
deleteAttachmentData: window.Signal.deleteAttachmentData,
}
);
conversation.set(newAttributes);
}
const isBlocked = details.blocked || false;
if (conversation.isClosedGroup()) {
await BlockedNumberController.setGroupBlocked(conversation.id, isBlocked);
}
conversation.trigger('change', conversation);
conversation.updateTextInputState();
await conversation.commit();
const { expireTimer } = details;
const isValidExpireTimer = typeof expireTimer === 'number';
if (!isValidExpireTimer) {
return;
}
const source = textsecure.storage.user.getNumber();
const receivedAt = Date.now();
await conversation.updateExpirationTimer(expireTimer, source, receivedAt, {
fromSync: true,
});
}
export async function sendClosedGroupMessage(
message: ClosedGroupMessage,
recipients: Array<string>
) {
// Sync messages for Chat Messages need to be constructed after confirming send was successful.
if (message instanceof ClosedGroupChatMessage) {
throw new Error(
'ClosedGroupChatMessage should be constructed manually and sent'
);
}
try {
// Exclude our device from members and send them the message
const primary = await UserUtil.getPrimary();
const otherMembers = (recipients || []).filter(
member => !primary.isEqual(member)
);
// NOTE(maxim): this is an edge case that we won't need
// to handle once we've reworked how the queue works
// we are the only member in here
// if (recipients.length === 1 && recipients[0] === primary.key) {
// dbMessage.sendSyncMessageOnly(message);
// return;
// }
const sendPromises = otherMembers.map(async member => {
const memberPubKey = PubKey.cast(member);
return getMessageQueue().sendUsingMultiDevice(memberPubKey, message);
});
await Promise.all(sendPromises);
} catch (e) {
window.log.error(e);
}
}

@ -30,14 +30,16 @@ interface Ratchet {
messageKeys: any;
}
async function loadChainKey(groupId: string, senderIdentity: string) {
// TODO: change the signature to return "NO KEY" instead of throwing
async function loadChainKey(
groupId: string,
senderIdentity: string
): Promise<Ratchet | null> {
const senderKeyEntry = await Data.getSenderKeys(groupId, senderIdentity);
if (!senderKeyEntry) {
// TODO: we should try to request the key from the sender in this case
throw Error(
`Sender key not found for group ${groupId} sender ${senderIdentity}`
);
return null;
}
const { chainKeyHex, idx: keyIdx, messageKeys } = senderKeyEntry.ratchet;
@ -53,10 +55,18 @@ async function loadChainKey(groupId: string, senderIdentity: string) {
return { chainKey, keyIdx, messageKeys };
}
export async function getChainKey(groupId: string, senderIdentity: string) {
const { chainKey, keyIdx } = await loadChainKey(groupId, senderIdentity);
export async function getChainKey(
groupId: string,
senderIdentity: string
): Promise<{ chainKey: Uint8Array; keyIdx: number } | null> {
const maybeKey = await loadChainKey(groupId, senderIdentity);
return { chainKey, keyIdx };
if (!maybeKey) {
return null;
} else {
const { chainKey, keyIdx } = maybeKey;
return { chainKey, keyIdx };
}
}
export async function encryptWithSenderKey(
@ -251,6 +261,10 @@ async function decryptWithSenderKeyInner(
) {
const messageKey = await advanceRatchet(groupId, senderIdentity, curKeyIdx);
if (!messageKey) {
return null;
}
// TODO: this might fail, handle this
const plaintext = await window.libloki.crypto.DecryptGCM(
messageKey,

@ -9,12 +9,4 @@ export abstract class ContentMessage extends Message {
public abstract ttl(): number;
protected abstract contentProto(): SignalService.Content;
/**
* If the message is not a message with a specific TTL,
* this value can be used in all child classes
*/
protected getDefaultTTL(): number {
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
}

@ -25,14 +25,8 @@ export class SessionRequestMessage extends ContentMessage {
this.preKeyBundle = params.preKeyBundle;
}
public static defaultTTL(): number {
// Gets the default TTL for Session Request, as
// public static readonly ttl: number <-- cannot be assigned a non-literal
return Constants.TTL_DEFAULT.SESSION_REQUEST;
}
public ttl(): number {
return SessionRequestMessage.defaultTTL();
return Constants.TTL_DEFAULT.SESSION_REQUEST;
}
protected getPreKeyBundleMessage(): SignalService.PreKeyBundleMessage {

@ -3,6 +3,7 @@ import { SignalService } from '../../../../../protobuf';
import { MessageParams } from '../../Message';
import { LokiProfile } from '../../../../../types/Message';
import ByteBuffer from 'bytebuffer';
import { Constants } from '../../../..';
export interface AttachmentPointer {
id?: number;
@ -75,7 +76,7 @@ export class ChatMessage extends DataMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -4,6 +4,7 @@ import { MessageParams } from '../../Message';
import { StringUtils } from '../../../../utils';
import { DataMessage } from './DataMessage';
import { PubKey } from '../../../../types';
import { Constants } from '../../../..';
interface ExpirationTimerUpdateMessageParams extends MessageParams {
groupId?: string | PubKey;
@ -26,7 +27,7 @@ export class ExpirationTimerUpdateMessage extends DataMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -1,6 +1,7 @@
import { DataMessage } from './DataMessage';
import { SignalService } from '../../../../../protobuf';
import { MessageParams } from '../../Message';
import { Constants } from '../../../..';
interface GroupInvitationMessageParams extends MessageParams {
serverAddress: string;
@ -21,7 +22,7 @@ export class GroupInvitationMessage extends DataMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -2,6 +2,7 @@ import { SignalService } from '../../../../../../protobuf';
import { ChatMessage } from '../ChatMessage';
import { ClosedGroupMessage } from './ClosedGroupMessage';
import { PubKey } from '../../../../../types';
import { Constants } from '../../../../..';
interface ClosedGroupChatMessageParams {
identifier?: string;
@ -22,7 +23,7 @@ export class ClosedGroupChatMessage extends ClosedGroupMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -3,6 +3,7 @@ import { SignalService } from '../../../../../../protobuf';
import { MessageParams } from '../../../Message';
import { PubKey } from '../../../../../types';
import { StringUtils } from '../../../../../utils';
import { Constants } from '../../../../..';
export interface ClosedGroupMessageParams extends MessageParams {
groupId: string | PubKey;
@ -20,7 +21,7 @@ export abstract class ClosedGroupMessage extends DataMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -13,7 +13,7 @@ export interface ClosedGroupUpdateMessageParams
avatar?: AttachmentPointer;
}
export abstract class ClosedGroupUpdateMessage extends ClosedGroupMessage {
export class ClosedGroupUpdateMessage extends ClosedGroupMessage {
private readonly name: string;
private readonly members?: Array<string>;
private readonly admins?: Array<string>;

@ -2,23 +2,23 @@ import { SignalService } from '../../../../../../protobuf';
import {
MediumGroupMessage,
MediumGroupMessageParams,
RatchetKey,
} from './MediumGroupMessage';
import { RatchetState } from '../../../../../medium_group/senderKeys';
interface MediumGroupCreateParams extends MediumGroupMessageParams {
groupSecretKey: Uint8Array;
members: Array<Uint8Array>;
admins: Array<Uint8Array>;
groupName: string;
senderKeys: Array<RatchetKey>;
senderKeys: Array<RatchetState>;
}
export abstract class MediumGroupCreateMessage extends MediumGroupMessage {
export class MediumGroupCreateMessage extends MediumGroupMessage {
public readonly groupSecretKey: Uint8Array;
public readonly members: Array<Uint8Array>;
public readonly admins: Array<Uint8Array>;
public readonly groupName: string;
public readonly senderKeys: Array<RatchetKey>;
public readonly senderKeys: Array<RatchetState>;
constructor({
timestamp,

@ -3,12 +3,7 @@ import { SignalService } from '../../../../../../protobuf';
import { MessageParams } from '../../../Message';
import { PubKey } from '../../../../../types';
import { StringUtils } from '../../../../../utils';
export interface RatchetKey {
chainKey: Uint8Array;
keyIdx: number;
pubKey: Uint8Array;
}
import { Constants } from '../../../../..';
export interface MediumGroupMessageParams extends MessageParams {
groupId: string | PubKey;
@ -26,7 +21,7 @@ export abstract class MediumGroupMessage extends DataMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public dataProto(): SignalService.DataMessage {

@ -0,0 +1,12 @@
import { SignalService } from '../../../../../../protobuf';
import { MediumGroupMessage } from '.';
export class MediumGroupQuitMessage extends MediumGroupMessage {
protected mediumGroupContext(): SignalService.MediumGroupUpdate {
const mediumGroupContext = super.mediumGroupContext();
mediumGroupContext.type = SignalService.MediumGroupUpdate.Type.QUIT;
return mediumGroupContext;
}
}

@ -1,13 +1,14 @@
import { SignalService } from '../../../../../../protobuf';
import { MediumGroupMessage, MediumGroupMessageParams, RatchetKey } from '.';
import { MediumGroupMessage, MediumGroupMessageParams } from '.';
import { RatchetState } from '../../../../../medium_group/senderKeys';
export interface MediumGroupResponseKeysParams
extends MediumGroupMessageParams {
senderKey: RatchetKey;
senderKey: RatchetState;
}
export class MediumGroupResponseKeysMessage extends MediumGroupMessage {
public readonly senderKey: RatchetKey;
public readonly senderKey: RatchetState;
constructor({
timestamp,

@ -0,0 +1,56 @@
import { SignalService } from '../../../../../../protobuf';
import {
MediumGroupMessage,
MediumGroupMessageParams,
} from './MediumGroupMessage';
import { RatchetState } from '../../../../../medium_group/senderKeys';
interface MediumGroupUpdateParams extends MediumGroupMessageParams {
members: Array<Uint8Array>;
admins: Array<Uint8Array>;
groupName: string;
senderKeys: Array<RatchetState>; // sender keys for new members only
}
export class MediumGroupUpdateMessage extends MediumGroupMessage {
public readonly members: Array<Uint8Array>;
public readonly admins: Array<Uint8Array>;
public readonly groupName: string;
public readonly senderKeys: Array<RatchetState>;
constructor({
timestamp,
identifier,
groupId,
members,
admins,
groupName,
senderKeys,
}: MediumGroupUpdateParams) {
super({ timestamp, identifier, groupId });
this.members = members;
this.admins = admins;
this.groupName = groupName;
this.senderKeys = senderKeys;
}
protected mediumGroupContext(): SignalService.MediumGroupUpdate {
const mediumGroupContext = super.mediumGroupContext();
const senderKeys = this.senderKeys.map(sk => {
return {
chainKey: sk.chainKey,
keyIndex: sk.keyIdx,
publicKey: sk.pubKey,
};
});
mediumGroupContext.type = SignalService.MediumGroupUpdate.Type.INFO;
mediumGroupContext.members = this.members;
mediumGroupContext.admins = this.admins;
mediumGroupContext.name = this.groupName;
mediumGroupContext.senderKeys = senderKeys;
return mediumGroupContext;
}
}

@ -1,6 +1,7 @@
import { ContentMessage } from '../ContentMessage';
import { SignalService } from '../../../../../protobuf';
import { MessageParams } from '../../Message';
import { Constants } from '../../../..';
interface ReceiptMessageParams extends MessageParams {
timestamps: Array<number>;
@ -14,7 +15,7 @@ export abstract class ReceiptMessage extends ContentMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
public abstract getReceiptType(): SignalService.ReceiptMessage.Type;

@ -0,0 +1,43 @@
import { SyncMessage } from './SyncMessage';
import { SignalService } from '../../../../../protobuf';
import { MessageParams } from '../../Message';
import { StringUtils } from '../../../../utils';
interface BlockedListSyncMessageParams extends MessageParams {
groups: Array<string>;
numbers: Array<string>;
}
export abstract class BlockedListSyncMessage extends SyncMessage {
public readonly groups: Array<Uint8Array>;
public readonly numbers: Array<string>;
constructor(params: BlockedListSyncMessageParams) {
super({ timestamp: params.timestamp, identifier: params.identifier });
this.groups = params.groups.map(g => {
if (typeof g !== 'string') {
throw new TypeError(
`invalid group id (expected string) found:${typeof g}`
);
}
return new Uint8Array(StringUtils.encode(g, 'utf8'));
});
if (params.numbers.length && typeof params.numbers[0] !== 'string') {
throw new TypeError(
`invalid number (expected string) found:${typeof params.numbers[0]}`
);
}
this.numbers = params.numbers;
}
protected syncProto(): SignalService.SyncMessage {
const syncMessage = super.syncProto();
// currently we do not handle the closed group blocked
syncMessage.blocked = new SignalService.SyncMessage.Blocked({
numbers: this.numbers,
groupIds: this.groups,
});
return syncMessage;
}
}

@ -1,6 +1,7 @@
import { SyncMessage } from './SyncMessage';
import { SignalService } from '../../../../../protobuf';
import { MessageParams } from '../../Message';
import { Constants } from '../../../..';
interface RequestSyncMessageParams extends MessageParams {
requestType: SignalService.SyncMessage.Request.Type;
@ -15,7 +16,7 @@ export abstract class RequestSyncMessage extends SyncMessage {
}
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
protected contentProto(): SignalService.Content {

@ -1,10 +1,11 @@
import { ContentMessage } from '../ContentMessage';
import { SignalService } from '../../../../../protobuf';
import * as crypto from 'crypto';
import { Constants } from '../../../..';
export abstract class SyncMessage extends ContentMessage {
public ttl(): number {
return this.getDefaultTTL();
return Constants.TTL_DEFAULT.REGULAR_MESSAGE;
}
protected contentProto(): SignalService.Content {

@ -6,3 +6,4 @@ export * from './SyncMessage';
export * from './SentSyncMessage';
export * from './SyncReadMessage';
export * from './VerifiedSyncMessage';
export * from './BlockedListSyncMessage';

@ -28,6 +28,11 @@ export class MultiDeviceProtocol {
public static async fetchPairingAuthorisationsIfNeeded(
device: PubKey
): Promise<void> {
// Disable fetching if we don't want to use multi device
if (!window.lokiFeatureFlags.useMultiDevice) {
return;
}
// This return here stops an infinite loop when we get all our other devices
const ourKey = await UserUtil.getCurrentDevicePubKey();
if (!ourKey || device.key === ourKey) {

@ -3,6 +3,7 @@ import { createOrUpdateItem, getItemById } from '../../../js/modules/data';
import { MessageSender } from '../sending';
import { MessageUtils } from '../utils';
import { PubKey } from '../types';
import { Constants } from '..';
interface StringToNumberMap {
[key: string]: number;
@ -81,7 +82,7 @@ export class SessionProtocol {
const now = Date.now();
const sentTimestamps = Object.entries(this.sentSessionsTimestamp);
const promises = sentTimestamps.map(async ([device, sent]) => {
const expireTime = sent + SessionRequestMessage.defaultTTL();
const expireTime = sent + Constants.TTL_DEFAULT.SESSION_REQUEST;
// Check if we need to send a session request
if (now < expireTime) {
return;

@ -61,10 +61,18 @@ export class MessageQueue implements MessageQueueInterface {
// This is absolutely yucky ... we need to make it not use Promise<boolean>
try {
const result = await MessageSender.sendToOpenGroup(message);
if (result) {
this.events.emit('success', message);
} else {
// sendToOpenGroup returns -1 if failed or an id if succeeded
if (result < 0) {
this.events.emit('fail', message, error);
} else {
const messageEventData = {
pubKey: message.group.groupId,
timestamp: message.timestamp,
serverId: result,
};
this.events.emit('success', message);
window.Whisper.events.trigger('publicMessageSent', messageEventData);
}
} catch (e) {
console.warn(
@ -177,11 +185,11 @@ export class MessageQueue implements MessageQueueInterface {
private async process(
device: PubKey,
message?: ContentMessage
message: ContentMessage
): Promise<void> {
// Don't send to ourselves
const currentDevice = await UserUtil.getCurrentDevicePubKey();
if (!message || (currentDevice && device.isEqual(currentDevice))) {
if (currentDevice && device.isEqual(currentDevice)) {
return;
}

@ -98,7 +98,7 @@ function wrapEnvelope(envelope: SignalService.Envelope): Uint8Array {
*/
export async function sendToOpenGroup(
message: OpenGroupMessage
): Promise<boolean> {
): Promise<number> {
/*
Note: Retrying wasn't added to this but it can be added in the future if needed.
The only problem is that `channelAPI.sendMessage` returns true/false and doesn't throw any error so we can never be sure why sending failed.
@ -112,10 +112,10 @@ export async function sendToOpenGroup(
);
if (!channelAPI) {
return false;
return -1;
}
// Don't think returning true/false on `sendMessage` is a good way
// Returns -1 on fail or an id > 0 on success
return channelAPI.sendMessage(
{
quote,
@ -125,18 +125,4 @@ export async function sendToOpenGroup(
},
timestamp
);
// TODO: The below should be handled in whichever class calls this
/*
const res = await sendToOpenGroup(message);
if (!res) {
throw new textsecure.PublicChatError('Failed to send public chat message');
}
const messageEventData = {
pubKey,
timestamp: messageTimeStamp,
};
messageEventData.serverId = res;
window.Whisper.events.trigger('publicMessageSent', messageEventData);
*/
}

@ -5,10 +5,12 @@ import fetch from 'node-fetch';
import { PubKey } from '../types';
import { snodeRpc } from './lokiRpc';
import { SnodeResponse } from './onions';
import { sendOnionRequestLsrpcDest, SnodeResponse } from './onions';
import { sleepFor } from '../../../js/modules/loki_primitives';
export { sendOnionRequestLsrpcDest };
import {
getRandomSnodeAddress,
markNodeUnreachable,
@ -152,7 +154,7 @@ interface SendParams {
}
// get snodes for pubkey from random snode. Uses an existing snode
export async function getSnodesForPubkey(
export async function requestSnodesForPubkey(
pubKey: string
): Promise<Array<Snode>> {
const { log } = window;
@ -170,7 +172,7 @@ export async function getSnodesForPubkey(
if (!result) {
log.warn(
`LokiSnodeAPI::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value`,
`LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value`,
result
);
return [];
@ -189,7 +191,7 @@ export async function getSnodesForPubkey(
if (!json.snodes) {
// we hit this when snode gives 500s
log.warn(
`LokiSnodeAPI::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value for snodes`,
`LokiSnodeAPI::requestSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value for snodes`,
result
);
return [];
@ -204,7 +206,11 @@ export async function getSnodesForPubkey(
return [];
}
} catch (e) {
log.error('LokiSnodeAPI::_getSnodesForPubkey - error', e.code, e.message);
log.error(
'LokiSnodeAPI::requestSnodesForPubkey - error',
e.code,
e.message
);
if (snode) {
markNodeUnreachable(snode);

@ -4,9 +4,9 @@ import {
} from '../../../js/modules/loki_primitives';
import {
getSnodesForPubkey,
getSnodesFromSeedUrl,
getVersion,
requestSnodesForPubkey,
} from './serviceNodeAPI';
import * as Data from '../../../js/modules/data';
@ -117,6 +117,8 @@ export function markNodeUnreachable(snode: Snode): void {
export async function getRandomSnodeAddress(): Promise<Snode> {
// resolve random snode
if (randomSnodePool.length === 0) {
// TODO: ensure that we only call this once at a time
// Should not this be saved to the database?
await refreshRandomPool([]);
if (randomSnodePool.length === 0) {
@ -331,11 +333,9 @@ async function internalUpdateSnodesFor(pubkey: string, edkeys: Array<string>) {
}
export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
let maybeNodes = nodesForPubkey.get(pubkey);
const maybeNodes = nodesForPubkey.get(pubkey);
let nodes: Array<string>;
maybeNodes = [];
// NOTE: important that maybeNodes is not [] here
if (maybeNodes === undefined) {
// First time access, try the database:
@ -352,7 +352,7 @@ export async function getSnodesFor(pubkey: string): Promise<Array<Snode>> {
if (goodNodes.length < MIN_NODES) {
// Request new node list from the network
const freshNodes = await getSnodesForPubkey(pubkey);
const freshNodes = _.shuffle(await requestSnodesForPubkey(pubkey));
const edkeys = freshNodes.map((n: Snode) => n.pubkey_ed25519);
// tslint:disable-next-line no-floating-promises

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save