Merge branch 'clearnet' into fileonion

pull/1143/head
Ryan Tharp 5 years ago committed by GitHub
commit a22dbbc37e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -30,3 +30,4 @@ ts/**/*.js
# Libloki specific files
libloki/test/components.js
libloki/modules/mnemonic.js
session-file-server/**

@ -23,6 +23,9 @@ jobs:
- name: Checkout git repo
uses: actions/checkout@v2
- name: Pull git submodules
run: git submodule update --init
- name: Install node
uses: actions/setup-node@v1
with:

@ -7,6 +7,7 @@ on:
- development
- clearnet
- github-actions
- message-sending-refactor
jobs:
build:
@ -24,6 +25,15 @@ jobs:
- name: Checkout git repo
uses: actions/checkout@v2
- name: Pull git submodules
run: git submodule update --init
- name: Install file server dependency
run: |
cd session-file-server
yarn install;
cd -
- name: Install node
uses: actions/setup-node@v1
with:

@ -20,6 +20,9 @@ jobs:
- name: Checkout git repo
uses: actions/checkout@v2
- name: Pull git submodules
run: git submodule update --init
- name: Install node
uses: actions/setup-node@v1
with:

3
.gitignore vendored

@ -37,3 +37,6 @@ ts/protobuf/*.d.ts
# Ctags
tags
proxy.key
proxy.pub

3
.gitmodules vendored

@ -0,0 +1,3 @@
[submodule "session-file-server"]
path = session-file-server
url = https://github.com/loki-project/session-file-server/

@ -54,3 +54,4 @@ stylesheets/_intlTelInput.scss
# Coverage
coverage/**
.nyc_output/**
session-file-server/**

@ -94,9 +94,9 @@ There are a few scripts which you can use:
```
yarn start - Start development
yarn start-multi - Start second instance of development
MULTI=1 yarn start - Start second instance of development
yarn start-prod - Start production but in development mode
yarn start-prod-multi - Start another instance of production
MULTI=1 yarn start-prod - Start another instance of production
```
For more than 2 clients, you may run the above command with `NODE_APP_INSTANCE` set before them.

@ -202,22 +202,14 @@ module.exports = grunt => {
},
'test-release': {
osx: {
archive: `mac/${
packageJson.productName
}.app/Contents/Resources/app.asar`,
appUpdateYML: `mac/${
packageJson.productName
}.app/Contents/Resources/app-update.yml`,
exe: `mac/${packageJson.productName}.app/Contents/MacOS/${
packageJson.productName
}`,
archive: `mac/${packageJson.productName}.app/Contents/Resources/app.asar`,
appUpdateYML: `mac/${packageJson.productName}.app/Contents/Resources/app-update.yml`,
exe: `mac/${packageJson.productName}.app/Contents/MacOS/${packageJson.productName}`,
},
mas: {
archive: 'mas/Signal.app/Contents/Resources/app.asar',
appUpdateYML: 'mac/Signal.app/Contents/Resources/app-update.yml',
exe: `mas/${packageJson.productName}.app/Contents/MacOS/${
packageJson.productName
}`,
exe: `mas/${packageJson.productName}.app/Contents/MacOS/${packageJson.productName}`,
},
linux: {
archive: 'linux-unpacked/resources/app.asar',

File diff suppressed because it is too large Load Diff

@ -19,8 +19,7 @@ const removeImage = pubKey => {
};
const removeImagesNotInArray = pubKeyArray => {
fs
.readdirSync(PATH)
fs.readdirSync(PATH)
// Get all files that end with png
.filter(file => file.includes('.png'))
// Strip the extension

@ -1540,6 +1540,8 @@ async function getGrantAuthorisationsForPrimaryPubKey(primaryDevicePubKey) {
async function createOrUpdatePairingAuthorisation(data) {
const { primaryDevicePubKey, secondaryDevicePubKey, grantSignature } = data;
// remove any existing authorisation for this pubkey (we allow only one secondary device for now)
await removePairingAuthorisationForPrimaryPubKey(primaryDevicePubKey);
await db.run(
`INSERT OR REPLACE INTO ${PAIRING_AUTHORISATIONS_TABLE} (
@ -1562,6 +1564,15 @@ async function createOrUpdatePairingAuthorisation(data) {
);
}
async function removePairingAuthorisationForPrimaryPubKey(pubKey) {
await db.run(
`DELETE FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE primaryDevicePubKey = $primaryDevicePubKey;`,
{
$primaryDevicePubKey: pubKey,
}
);
}
async function removePairingAuthorisationForSecondaryPubKey(pubKey) {
await db.run(
`DELETE FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey;`,

@ -22,8 +22,7 @@
"openDevTools": false,
"buildExpiration": 0,
"commitHash": "",
"certificateAuthority":
"-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n",
"certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n",
"import": false,
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx",
"defaultPublicChatServer": "https://chat.getsession.org",

@ -18,18 +18,16 @@ describe('Add friends', function() {
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: common.TEST_MNEMONIC2,
displayName: common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStub2(app2Props),
common.startAndStubN(app2Props, 2),
]);
});
@ -116,5 +114,25 @@ describe('Add friends', function() {
ConversationPage.acceptedFriendRequestMessage,
5000
);
// app trigger the friend request logic first
const aliceLogs = await app.client.getRenderProcessLogs();
const bobLogs = await app2.client.getRenderProcessLogs();
await common.logsContains(
aliceLogs,
`Sending undefined:friend-request message to ${common.TEST_PUBKEY2}`
);
await common.logsContains(
bobLogs,
`Received a NORMAL_FRIEND_REQUEST from source: ${common.TEST_PUBKEY1}, primarySource: ${common.TEST_PUBKEY1},`
);
await common.logsContains(
bobLogs,
`Sending incoming-friend-request-accept:onlineBroadcast message to ${common.TEST_PUBKEY1}`
);
await common.logsContains(
aliceLogs,
`Sending outgoing-friend-request-accepted:onlineBroadcast message to ${common.TEST_PUBKEY2}`
);
});
});

@ -25,11 +25,10 @@ describe('Closed groups', function() {
});
it('closedGroup: can create a closed group with a friend and send/receive a message', async () => {
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.createClosedGroupButton).click();
const useSenderKeys = false;
// create group and add new friend
await common.addFriendToNewClosedGroup(app, app2);
await common.addFriendToNewClosedGroup([app, app2], useSenderKeys);
// send a message from app and validate it is received on app2
const textMessage = common.generateSendMessageText();

@ -20,6 +20,13 @@ chai.should();
chai.use(chaiAsPromised);
chai.config.includeStack = true;
// From https://github.com/chaijs/chai/issues/200
chai.use((_chai, _) => {
_chai.Assertion.addMethod('withMessage', msg => {
_.flag(this, 'message', msg);
});
});
const STUB_SNODE_SERVER_PORT = 3000;
const ENABLE_LOG = false;
@ -29,19 +36,19 @@ module.exports = {
'faxed mechanic mocked agony unrest loincloth pencil eccentric boyfriend oasis speedy ribbon faxed',
TEST_PUBKEY1:
'0552b85a43fb992f6bdb122a5a379505a0b99a16f0628ab8840249e2a60e12a413',
TEST_DISPLAY_NAME1: 'integration_tester_1',
TEST_DISPLAY_NAME1: 'tester_Alice',
TEST_MNEMONIC2:
'guide inbound jerseys bays nouns basin sulking awkward stockpile ostrich ascend pylons ascend',
TEST_PUBKEY2:
'054e1ca8681082dbd9aad1cf6fc89a32254e15cba50c75b5a73ac10a0b96bcbd2a',
TEST_DISPLAY_NAME2: 'integration_tester_2',
TEST_DISPLAY_NAME2: 'tester_Bob',
TEST_MNEMONIC3:
'alpine lukewarm oncoming blender kiwi fuel lobster upkeep vogue simplest gasp fully simplest',
TEST_PUBKEY3:
'05f8662b6e83da5a31007cc3ded44c601f191e07999acb6db2314a896048d9036c',
TEST_DISPLAY_NAME3: 'integration_tester_3',
TEST_DISPLAY_NAME3: 'tester_Charlie',
/* ************** OPEN GROUPS ****************** */
VALID_GROUP_URL: 'https://chat.getsession.org',
@ -184,20 +191,11 @@ module.exports = {
async startAndStub({
mnemonic,
displayName,
stubSnode = false,
stubOpenGroups = false,
env = 'test-integration-session',
}) {
const app = await this.startAndAssureCleanedApp(env);
if (stubSnode) {
await this.startStubSnodeServer();
this.stubSnodeCalls(app);
}
if (stubOpenGroups) {
this.stubOpenGroupsCalls(app);
}
await this.startStubSnodeServer();
if (mnemonic && displayName) {
await this.restoreFromMnemonic(app, mnemonic, displayName);
@ -242,23 +240,8 @@ module.exports = {
);
},
async startAppsAsFriends() {
const app1Props = {
mnemonic: this.TEST_MNEMONIC1,
displayName: this.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: this.TEST_MNEMONIC2,
displayName: this.TEST_DISPLAY_NAME2,
stubSnode: true,
};
const [app1, app2] = await Promise.all([
this.startAndStub(app1Props),
this.startAndStubN(app2Props, 2),
]);
async makeFriends(app1, client2) {
const [app2, pubkey2] = client2;
/** add each other as friends */
const textMessage = this.generateSendMessageText();
@ -266,11 +249,7 @@ module.exports = {
await app1.client.element(ConversationPage.contactsButtonSection).click();
await app1.client.element(ConversationPage.addContactButton).click();
await this.setValueWrapper(
app1,
ConversationPage.sessionIDInput,
this.TEST_PUBKEY2
);
await this.setValueWrapper(app1, ConversationPage.sessionIDInput, pubkey2);
await app1.client.element(ConversationPage.nextButton).click();
await app1.client.waitForExist(
ConversationPage.sendFriendRequestTextarea,
@ -317,33 +296,75 @@ module.exports = {
ConversationPage.acceptedFriendRequestMessage,
5000
);
},
async startAppsAsFriends() {
const app1Props = {
mnemonic: this.TEST_MNEMONIC1,
displayName: this.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: this.TEST_MNEMONIC2,
displayName: this.TEST_DISPLAY_NAME2,
stubSnode: true,
};
const [app1, app2] = await Promise.all([
this.startAndStub(app1Props),
this.startAndStubN(app2Props, 2),
]);
await this.makeFriends(app1, [app2, this.TEST_PUBKEY2]);
return [app1, app2];
},
async addFriendToNewClosedGroup(app, app2) {
async addFriendToNewClosedGroup(members, useSenderKeys) {
const [app, ...others] = members;
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.createClosedGroupButton).click();
await this.setValueWrapper(
app,
ConversationPage.closedGroupNameTextarea,
this.VALID_CLOSED_GROUP_NAME1
);
await app.client
.element(ConversationPage.closedGroupNameTextarea)
.getValue()
.should.eventually.equal(this.VALID_CLOSED_GROUP_NAME1);
await app.client
.element(ConversationPage.createClosedGroupMemberItem)
.isVisible().should.eventually.be.true;
// This assumes that app does not have any other friends
for (let i = 0; i < others.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
await app.client
.element(ConversationPage.createClosedGroupMemberItem(i))
.isVisible().should.eventually.be.true;
// eslint-disable-next-line no-await-in-loop
await app.client
.element(ConversationPage.createClosedGroupMemberItem(i))
.click();
}
// select the first friend as a member of the groups being created
await app.client
.element(ConversationPage.createClosedGroupMemberItem)
.click();
await app.client
.element(ConversationPage.createClosedGroupMemberItemSelected)
.isVisible().should.eventually.be.true;
if (useSenderKeys) {
// Select Sender Keys
await app.client
.element(ConversationPage.createClosedGroupSealedSenderToggle)
.click();
}
// trigger the creation of the group
await app.client
.element(ConversationPage.validateCreationClosedGroupButton)
@ -356,8 +377,9 @@ module.exports = {
await app.client.isExisting(
ConversationPage.headerTitleGroupName(this.VALID_CLOSED_GROUP_NAME1)
).should.eventually.be.true;
await app.client.element(ConversationPage.headerTitleMembers(2)).isVisible()
.should.eventually.be.true;
await app.client
.element(ConversationPage.headerTitleMembers(members.length))
.isVisible().should.eventually.be.true;
// validate overlay is closed
await app.client
@ -376,28 +398,32 @@ module.exports = {
)
).should.eventually.be.true;
// next check app2 has been invited and has the group in its conversations
await app2.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
),
6000
await Promise.all(
others.map(async otherApp => {
// next check that other members have been invited and have the group in their conversations
await otherApp.client.waitForExist(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
),
6000
);
// open the closed group conversation on otherApp
await otherApp.client
.element(ConversationPage.conversationButtonSection)
.click();
await this.timeout(500);
await otherApp.client
.element(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
)
)
.click();
})
);
// open the closed group conversation on app2
await app2.client
.element(ConversationPage.conversationButtonSection)
.click();
await this.timeout(500);
await app2.client
.element(
ConversationPage.rowOpenGroupConversationName(
this.VALID_CLOSED_GROUP_NAME1
)
)
.click();
},
async linkApp2ToApp(app1, app2) {
async linkApp2ToApp(app1, app2, app1Pubkey) {
// app needs to be logged in as user1 and app2 needs to be logged out
// start the pairing dialog for the first app
await app1.client.element(SettingsPage.settingsButtonSection).click();
@ -422,20 +448,19 @@ module.exports = {
await this.setValueWrapper(
app2,
RegistrationPage.textareaLinkDevicePubkey,
this.TEST_PUBKEY1
app1Pubkey
);
await app2.client.element(RegistrationPage.linkDeviceTriggerButton).click();
await app1.client.waitForExist(RegistrationPage.toastWrapper, 7000);
let secretWordsapp1 = await app1.client
.element(RegistrationPage.secretToastDescription)
await app1.client.waitForExist(SettingsPage.secretWordsTextInDialog, 7000);
const secretWordsapp1 = await app1.client
.element(SettingsPage.secretWordsTextInDialog)
.getText();
secretWordsapp1 = secretWordsapp1.split(': ')[1];
await app2.client.waitForExist(RegistrationPage.toastWrapper, 6000);
await app2.client
.element(RegistrationPage.secretToastDescription)
.getText()
.should.eventually.be.equal(secretWordsapp1);
await app1.client.element(ConversationPage.allowPairingButton).click();
await app1.client.element(ConversationPage.okButton).click();
// validate device paired in settings list with correct secrets
@ -458,7 +483,7 @@ module.exports = {
// validate primary pubkey of app2 is the same that in app1
await app2.webContents
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(this.TEST_PUBKEY1);
.should.eventually.be.equal(app1Pubkey);
},
async triggerUnlinkApp2FromApp(app1, app2) {
@ -466,9 +491,9 @@ module.exports = {
await app2.client.isExisting(RegistrationPage.conversationListContainer)
.should.eventually.be.true;
await app1.client.element(ConversationPage.settingsButtonSection).click();
await app1.client.element(SettingsPage.settingsButtonSection).click();
await app1.client
.element(ConversationPage.settingsRowWithText('Devices'))
.element(SettingsPage.settingsRowWithText('Devices'))
.click();
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
.should.eventually.be.true;
@ -478,7 +503,7 @@ module.exports = {
await app1.client.waitForExist(
ConversationPage.noPairedDeviceMessage,
2000
5000
);
await app1.client.element(ConversationPage.linkDeviceButton).isEnabled()
.should.eventually.be.true;
@ -533,23 +558,6 @@ module.exports = {
generateSendMessageText: () =>
`Test message from integration tests ${Date.now()}`,
stubOpenGroupsCalls: app1 => {
app1.webContents.executeJavaScript(
'window.LokiAppDotNetServerAPI = window.StubAppDotNetAPI;'
);
},
stubSnodeCalls(app1) {
app1.webContents.executeJavaScript(
'window.LokiMessageAPI = window.StubMessageAPI;'
);
},
logsContainsString: async (app1, str) => {
const logs = JSON.stringify(await app1.client.getRenderProcessLogs());
return logs.includes(str);
},
async startStubSnodeServer() {
if (!this.stubSnode) {
this.messages = {};
@ -557,42 +565,98 @@ module.exports = {
const { query } = url.parse(request.url, true);
const { pubkey, data, timestamp } = query;
if (pubkey) {
if (request.method === 'POST') {
if (ENABLE_LOG) {
console.warn('POST', [data, timestamp]);
}
if (!pubkey) {
console.warn('NO PUBKEY');
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end();
return;
}
let ori = this.messages[pubkey];
if (!this.messages[pubkey]) {
ori = [];
}
if (request.method === 'POST') {
if (ENABLE_LOG) {
console.warn(
'POST',
pubkey.substr(2, 3),
data.substr(4, 10),
timestamp
);
}
this.messages[pubkey] = [...ori, { data, timestamp }];
let ori = this.messages[pubkey];
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end();
} else {
const retrievedMessages = { messages: this.messages[pubkey] };
if (ENABLE_LOG) {
console.warn('GET', pubkey, retrievedMessages);
}
if (this.messages[pubkey]) {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write(JSON.stringify(retrievedMessages));
this.messages[pubkey] = [];
}
response.end();
if (!this.messages[pubkey]) {
ori = [];
}
this.messages[pubkey] = [...ori, { data, timestamp }];
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end();
} else {
const retrievedMessages = { messages: this.messages[pubkey] || [] };
if (ENABLE_LOG) {
const messages = retrievedMessages.messages.map(m =>
m.data.substr(4, 10)
);
console.warn('GET', pubkey.substr(2, 3), messages);
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write(JSON.stringify(retrievedMessages));
response.end();
}
response.end();
});
this.startLocalFileServer();
this.stubSnode.listen(STUB_SNODE_SERVER_PORT);
} else {
this.messages = {};
}
},
async startLocalFileServer() {
if (!this.fileServer) {
// be sure to run `git submodule update --init && cd session-file-server && yarn install; cd -`
// eslint-disable-next-line global-require
this.fileServer = require('../session-file-server/app');
}
},
async joinOpenGroup(app, openGroupUrl, name) {
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await this.setValueWrapper(
app,
ConversationPage.openGroupInputUrl,
openGroupUrl
);
await app.client
.element(ConversationPage.openGroupInputUrl)
.getValue()
.should.eventually.equal(openGroupUrl);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
// validate session loader is shown
await app.client.isExisting(ConversationPage.sessionLoader).should
.eventually.be.true;
// account for slow home internet connection delays...
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupSuccess,
60 * 1000
);
// validate overlay is closed
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.false;
// validate open chat has been added
await app.client.isExisting(
ConversationPage.rowOpenGroupConversationName(name)
).should.eventually.be.true;
},
async stopStubSnodeServer() {
if (this.stubSnode) {
this.stubSnode.close();
@ -600,6 +664,32 @@ module.exports = {
}
},
/**
* Search for a string in logs
* @param {*} app the render logs to search in
* @param {*} str the string to search (not regex)
* Note: getRenderProcessLogs() clears the app logs each calls.
*/
async logsContains(renderLogs, str, count = undefined) {
const foundLines = renderLogs.filter(log => log.message.includes(str));
// eslint-disable-next-line no-unused-expressions
chai.expect(
foundLines.length > 0,
`'${str}' not found in logs but was expected`
).to.be.true;
if (count) {
// eslint-disable-next-line no-unused-expressions
chai
.expect(
foundLines.length,
`'${str}' found but not the correct number of times`
)
.to.be.equal(count);
}
},
// async killStubSnodeServer() {
// return new Promise(resolve => {
// exec(

@ -13,6 +13,8 @@ require('./link_device_test');
require('./closed_group_test');
require('./message_functions_test');
require('./settings_test');
require('./message_sync_test');
require('./sender_keys_test');
before(async () => {
// start the app once before all tests to get the platform-dependent

@ -18,12 +18,9 @@ describe('Link Device', function() {
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
stubSnode: true,
};
const app2Props = {};
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
@ -37,12 +34,37 @@ describe('Link Device', function() {
});
it('linkDevice: link two desktop devices', async () => {
await common.linkApp2ToApp(app, app2);
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
});
it('linkDevice: unlink two devices', async () => {
await common.linkApp2ToApp(app, app2);
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
await common.timeout(1000);
await common.triggerUnlinkApp2FromApp(app, app2);
});
it('linkDevice:sync no groups, closed group, nor open groups', async () => {
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
await common.timeout(10000);
// get logs at this stage (getRenderProcessLogs() clears the app logs)
const secondaryRenderLogs = await app2.client.getRenderProcessLogs();
// pairing request message sent from secondary to primary pubkey
await common.logsContains(
secondaryRenderLogs,
`Sending pairing-request:pairing-request message to ${common.TEST_PUBKEY1}`
);
const primaryRenderLogs = await app.client.getRenderProcessLogs();
// primary grant pairing request
await common.logsContains(
primaryRenderLogs,
'Sending pairing-request:pairing-request message to OUR SECONDARY PUBKEY'
);
// no friends, no closed groups, no open groups. we should see those message sync in the log
await common.logsContains(primaryRenderLogs, 'No closed group to sync.', 1);
await common.logsContains(primaryRenderLogs, 'No open groups to sync', 1);
await common.logsContains(primaryRenderLogs, 'No contacts to sync.', 1);
});
});

@ -4,7 +4,7 @@
/* eslint-disable import/no-extraneous-dependencies */
const path = require('path');
const { after, before, describe, it } = require('mocha');
const { afterEach, beforeEach, describe, it } = require('mocha');
const common = require('./common');
const ConversationPage = require('./page-objects/conversation.page');
@ -14,25 +14,22 @@ describe('Message Functions', function() {
this.timeout(60000);
this.slow(15000);
before(async () => {
beforeEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
[app, app2] = await common.startAppsAsFriends();
});
after(async () => {
afterEach(async () => {
await common.stopApp(app);
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('can send attachment', async () => {
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.createClosedGroupButton).click();
// create group and add new friend
await common.addFriendToNewClosedGroup(app, app2);
await common.addFriendToNewClosedGroup([app, app2], false);
// send attachment from app1 to closed group
const fileLocation = path.join(__dirname, 'test_attachment');
@ -53,6 +50,8 @@ describe('Message Functions', function() {
});
it('can delete message', async () => {
// create group and add new friend
await common.addFriendToNewClosedGroup([app, app2], false);
const messageText = 'delete_me';
await common.sendMessage(app, messageText);
@ -71,7 +70,7 @@ describe('Message Functions', function() {
.click();
await app.client.element(ConversationPage.deleteMessageCtxButton).click();
// delete messaage from modal
// delete message from modal
await app.client.waitForExist(
ConversationPage.deleteMessageModalButton,
5000

@ -1,49 +1,142 @@
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const { afterEach, beforeEach, describe, it } = require('mocha');
const { after, before, describe, it } = require('mocha');
const common = require('./common');
describe('Message Syncing', function() {
let app;
let app2;
let Alice1;
let Bob1;
let Alice2;
this.timeout(60000);
this.slow(15000);
beforeEach(async () => {
// this test suite builds a complex usecase over several tests,
// so you need to run all of those tests together (running only one might fail)
before(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: common.TEST_MNEMONIC2,
displayName: common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStubN(app2Props, 2),
]);
const alice2Props = {};
[Alice1, Bob1] = await common.startAppsAsFriends(); // Alice and Bob are friends
await common.addFriendToNewClosedGroup([Alice1, Bob1], false);
await common.joinOpenGroup(
Alice1,
common.VALID_GROUP_URL,
common.VALID_GROUP_NAME
);
Alice2 = await common.startAndStubN(alice2Props, 4); // Alice secondary, just start the app for now. no linking
});
afterEach(async () => {
after(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('message syncing between linked devices', async () => {
await common.linkApp2ToApp(app, app2);
});
it('message syncing with 1 friend, 1 closed group, 1 open group', async () => {
// Alice1 has:
// * no linked device
// * Bob is a friend
// * one open group
// * one closed group with Bob inside
// Bob1 has:
// * no linked device
// * Alice as a friend
// * one open group with Alice
// Linking Alice2 to Alice1
// alice2 should trigger auto FR with bob1 as it's one of her friend
// and alice2 should trigger a SESSION_REQUEST with bob1 as he is in a closed group with her
await common.linkApp2ToApp(Alice1, Alice2, common.TEST_PUBKEY1);
await common.timeout(25000);
// validate pubkey of app2 is the set
const alice2Pubkey = await Alice2.webContents.executeJavaScript(
'window.textsecure.storage.user.getNumber()'
);
alice2Pubkey.should.have.lengthOf(66);
const alice1Logs = await Alice1.client.getRenderProcessLogs();
const bob1Logs = await Bob1.client.getRenderProcessLogs();
const alice2Logs = await Alice2.client.getRenderProcessLogs();
// validate primary alice
await common.logsContains(
alice1Logs,
'Sending closed-group-sync-send:outgoing message to OUR SECONDARY PUBKEY',
1
);
await common.logsContains(
alice1Logs,
'Sending open-group-sync-send:outgoing message to OUR SECONDARY PUBKEY',
1
);
await common.logsContains(
alice1Logs,
'Sending contact-sync-send:outgoing message to OUR SECONDARY PUBKEY',
1
);
// validate secondary alice
// what is expected is
// alice2 receives group sync, contact sync and open group sync
// alice2 triggers session request with closed group members and autoFR with contact sync received
// once autoFR is auto-accepted, alice2 trigger contact sync
await common.logsContains(
alice2Logs,
'Got sync group message with group id',
1
);
await common.logsContains(
alice2Logs,
'Received GROUP_SYNC with open groups: [chat.getsession.org]',
1
);
await common.logsContains(
alice2Logs,
`Sending auto-friend-request:friend-request message to ${common.TEST_PUBKEY2}`,
1
);
await common.logsContains(
alice2Logs,
`Sending session-request:friend-request message to ${common.TEST_PUBKEY2}`,
1
);
await common.logsContains(
alice2Logs,
`Sending contact-sync-send:outgoing message to OUR_PRIMARY_PUBKEY`,
1
);
it('unlink two devices', async () => {
await common.linkApp2ToApp(app, app2);
await common.timeout(1000);
await common.triggerUnlinkApp2FromApp(app, app2);
// validate primary bob
// what is expected is
// bob1 receives session request from alice2
// bob1 accept auto fr by sending a bg message
// once autoFR is auto-accepted, alice2 trigger contact sync
await common.logsContains(
bob1Logs,
`Received SESSION_REQUEST from source: ${alice2Pubkey}`,
1
);
await common.logsContains(
bob1Logs,
`Received AUTO_FRIEND_REQUEST from source: ${alice2Pubkey}`,
1
);
await common.logsContains(
bob1Logs,
`Sending auto-friend-accept:onlineBroadcast message to ${alice2Pubkey}`,
1
);
// be sure only one autoFR accept was sent (even if multi device, we need to reply to that specific device only)
await common.logsContains(
bob1Logs,
`Sending auto-friend-accept:onlineBroadcast message to`,
1
);
});
});

@ -7,7 +7,7 @@ const ConversationPage = require('./page-objects/conversation.page');
describe('Open groups', function() {
let app;
this.timeout(30000);
this.timeout(40000);
this.slow(15000);
beforeEach(async () => {
@ -15,7 +15,6 @@ describe('Open groups', function() {
const login = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubOpenGroups: true,
};
app = await common.startAndStub(login);
});
@ -24,46 +23,25 @@ describe('Open groups', function() {
await common.killallElectron();
});
// reduce code duplication to get the initial join
async function joinOpenGroup(url, name) {
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(app, ConversationPage.openGroupInputUrl, url);
await app.client
.element(ConversationPage.openGroupInputUrl)
.getValue()
.should.eventually.equal(url);
await app.client.element(ConversationPage.joinOpenGroupButton).click();
// validate session loader is shown
await app.client.isExisting(ConversationPage.sessionLoader).should
.eventually.be.true;
// account for slow home internet connection delays...
await app.client.waitForExist(
ConversationPage.sessionToastJoinOpenGroupSuccess,
60 * 1000
);
// validate overlay is closed
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
.eventually.be.false;
// validate open chat has been added
await app.client.isExisting(
ConversationPage.rowOpenGroupConversationName(name)
).should.eventually.be.true;
}
it('openGroup: works with valid open group url', async () => {
await joinOpenGroup(common.VALID_GROUP_URL, common.VALID_GROUP_NAME);
await common.joinOpenGroup(
app,
common.VALID_GROUP_URL,
common.VALID_GROUP_NAME
);
});
it('openGroup: cannot join two times the same open group', async () => {
await joinOpenGroup(common.VALID_GROUP_URL2, common.VALID_GROUP_NAME2);
await common.joinOpenGroup(
app,
common.VALID_GROUP_URL2,
common.VALID_GROUP_NAME2
);
// adding a second time the same open group
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(
@ -88,7 +66,9 @@ describe('Open groups', function() {
it('openGroup: can send message to open group', async () => {
// join dev-chat group
await app.client.element(ConversationPage.globeButtonSection).click();
await app.client
.element(ConversationPage.conversationButtonSection)
.click();
await app.client.element(ConversationPage.joinOpenGroupButton).click();
await common.setValueWrapper(

@ -14,6 +14,8 @@ module.exports = {
inputWithId: id => `//input[contains(@id, '${id}')]`,
textAreaWithPlaceholder: placeholder =>
`//textarea[contains(@placeholder, "${placeholder}")]`,
textAreaWithClass: classname =>
`//textarea[contains(@class, "${classname}")]`,
byId: id => `//*[@id="${id}"]`,
divWithClass: classname => `//div[contains(@class, "${classname}")]`,
divWithClassAndText: (classname, text) =>

@ -4,7 +4,7 @@ module.exports = {
// conversation view
sessionLoader: commonPage.divWithClass('session-loader'),
leftPaneOverlay: commonPage.divWithClass('module-left-pane-overlay'),
sendMessageTextarea: commonPage.textAreaWithPlaceholder('Type your message'),
sendMessageTextarea: commonPage.textAreaWithClass('send-message'),
sendFriendRequestTextarea: commonPage.textAreaWithPlaceholder(
'Send your first message'
),
@ -40,8 +40,6 @@ module.exports = {
'//*[contains(@class, "session-modal")]//div[contains(string(), "Delete") and contains(@class, "session-button")]',
// channels
globeButtonSection:
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "globe")]]',
joinOpenGroupButton: commonPage.divRoleButtonWithText('Join Open Group'),
openGroupInputUrl: commonPage.textAreaWithPlaceholder('chat.getsession.org'),
sessionToastJoinOpenGroupSuccess: commonPage.toastWithText(
@ -63,7 +61,11 @@ module.exports = {
closedGroupNameTextarea: commonPage.textAreaWithPlaceholder(
'Enter a group name'
),
createClosedGroupMemberItem: commonPage.divWithClass('session-member-item'),
createClosedGroupMemberItem: idx =>
commonPage.divWithClass(`session-member-item-${idx}`),
createClosedGroupSealedSenderToggle: commonPage.divWithClass(
'session-toggle'
),
createClosedGroupMemberItemSelected: commonPage.divWithClass(
'session-member-item selected'
),

@ -17,4 +17,7 @@ module.exports = {
// Confirm is a boolean. Selects confirmation input
passwordSetModalInput: _confirm =>
`//input[@id = 'password-modal-input${_confirm ? '-confirm' : ''}']`,
secretWordsTextInDialog:
'//div[@class="device-pairing-dialog__secret-words"]/div[@class="subtle"]',
};

@ -5,6 +5,7 @@
const { afterEach, beforeEach, describe, it } = require('mocha');
const common = require('./common');
const SettingsPage = require('./page-objects/settings.page');
const RegistrationPage = require('./page-objects/registration.page');
const ConversationPage = require('./page-objects/conversation.page');
@ -104,7 +105,6 @@ describe('Window Test and Login', function() {
const login = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubOpenGroups: true,
};
app = await common.startAndStub(login);
@ -117,7 +117,7 @@ describe('Window Test and Login', function() {
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
.should.eventually.be.equal(common.TEST_PUBKEY1);
// delete account
await app.client.element(ConversationPage.settingsButtonSection).click();
await app.client.element(SettingsPage.settingsButtonSection).click();
await app.client.element(ConversationPage.deleteAccountButton).click();
await app.client.isExisting(ConversationPage.descriptionDeleteAccount)
.should.eventually.be.true;

@ -0,0 +1,153 @@
/* eslint-disable func-names */
/* eslint-disable import/no-extraneous-dependencies */
const { afterEach, beforeEach, describe, it } = require('mocha');
const common = require('./common');
const ConversationPage = require('./page-objects/conversation.page');
async function generateAndSendMessage(app) {
// send a message from app and validate it is received on app2
const textMessage = common.generateSendMessageText();
await app.client
.element(ConversationPage.sendMessageTextarea)
.setValue(textMessage);
await app.client
.element(ConversationPage.sendMessageTextarea)
.getValue()
.should.eventually.equal(textMessage);
// send the message
await app.client.keys('Enter');
// validate that the message has been added to the message list view
await app.client.waitForExist(
ConversationPage.existingSendMessageText(textMessage),
2000
);
return textMessage;
}
async function makeFriendsPlusMessage(app, [app2, pubkey]) {
await common.makeFriends(app, [app2, pubkey]);
// Send something back so that `app` can see our name
const text = await generateAndSendMessage(app2);
await app.client.waitForExist(
ConversationPage.existingReceivedMessageText(text),
8000
);
// Click away so we can call this function again
await app.client.element(ConversationPage.conversationButtonSection).click();
}
async function testTwoMembers() {
const [app, app2] = await common.startAppsAsFriends();
const useSenderKeys = true;
// create group and add new friend
await common.addFriendToNewClosedGroup([app, app2], useSenderKeys);
const text1 = await generateAndSendMessage(app);
// validate that the message has been added to the message list view
await app2.client.waitForExist(
ConversationPage.existingReceivedMessageText(text1),
5000
);
// Send a message back:
const text2 = await generateAndSendMessage(app2);
// TODO: fix this. We can send messages back manually, not sure
// why this test fails
await app.client.waitForExist(
ConversationPage.existingReceivedMessageText(text2),
10000
);
}
async function testThreeMembers() {
// 1. Make three clients A, B, C
const app1Props = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
const app2Props = {
mnemonic: common.TEST_MNEMONIC2,
displayName: common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
const app3Props = {
mnemonic: common.TEST_MNEMONIC3,
displayName: common.TEST_DISPLAY_NAME3,
stubSnode: true,
};
const [app1, app2, app3] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStubN(app2Props, 2),
common.startAndStubN(app3Props, 3),
]);
// 2. Make A friends with B and C (B and C are not friends)
await makeFriendsPlusMessage(app1, [app2, common.TEST_PUBKEY2]);
await makeFriendsPlusMessage(app1, [app3, common.TEST_PUBKEY3]);
const useSenderKeys = true;
// 3. Add all three to the group
await common.addFriendToNewClosedGroup([app1, app2, app3], useSenderKeys);
// 4. Test that all members can see the message from app1
const text1 = await generateAndSendMessage(app1);
await app2.client.waitForExist(
ConversationPage.existingReceivedMessageText(text1),
5000
);
await app3.client.waitForExist(
ConversationPage.existingReceivedMessageText(text1),
5000
);
// TODO: test that B and C can send messages to the group
// const text2 = await generateAndSendMessage(app3);
// await app2.client.waitForExist(
// ConversationPage.existingReceivedMessageText(text2),
// 5000
// );
}
describe('senderkeys', function() {
let app;
this.timeout(600000);
this.slow(40000);
beforeEach(async () => {
await common.killallElectron();
await common.stopStubSnodeServer();
});
afterEach(async () => {
await common.stopApp(app);
await common.killallElectron();
await common.stopStubSnodeServer();
});
it('Two member group', testTwoMembers);
it('Three member group: test session requests', testThreeMembers);
});

@ -27,7 +27,6 @@ describe('Settings', function() {
const appProps = {
mnemonic: common.TEST_MNEMONIC1,
displayName: common.TEST_DISPLAY_NAME1,
stubSnode: true,
};
app = await common.startAndStub(appProps);

@ -0,0 +1,10 @@
/* global log */
class StubLokiSnodeAPI {
// eslint-disable-next-line class-methods-use-this
async refreshSwarmNodesForPubKey(pubKey) {
log.info('refreshSwarmNodesForPubkey: ', pubKey);
}
}
module.exports = StubLokiSnodeAPI;

@ -1,4 +1,4 @@
/* global clearTimeout, dcodeIO, Buffer, TextDecoder, process */
/* global clearTimeout, dcodeIO, Buffer, TextDecoder, process, log */
const nodeFetch = require('node-fetch');
class StubMessageAPI {
@ -26,6 +26,35 @@ class StubMessageAPI {
);
}
async pollForGroupId(groupId, onMessages) {
const get = {
method: 'GET',
};
const res = await nodeFetch(
`${this.baseUrl}/messages?pubkey=${groupId}`,
get
);
try {
const json = await res.json();
const modifiedMessages = json.messages.map(m => {
// eslint-disable-next-line no-param-reassign
m.conversationId = groupId;
return m;
});
onMessages(modifiedMessages || []);
} catch (e) {
log.error('invalid json for GROUP', e);
onMessages([]);
}
setTimeout(() => {
this.pollForGroupId(groupId, onMessages);
}, 1000);
}
async startLongPolling(numConnections, stopPolling, callback) {
const ourPubkey = this.ourKey;
@ -36,10 +65,15 @@ class StubMessageAPI {
`${this.baseUrl}/messages?pubkey=${ourPubkey}`,
get
);
const json = await res.json();
// console.warn('STUBBED polling messages ', json.messages);
callback(json.messages || []);
try {
const json = await res.json();
callback(json.messages || []);
} catch (e) {
log.error('invalid json: ', e);
callback([]);
}
// console.warn('STUBBED polling messages ', json.messages);
}
}

@ -0,0 +1,9 @@
/* eslint-disable class-methods-use-this */
class StubSnodeAPI {
async refreshSwarmNodesForPubKey() {
return [];
}
}
module.exports = StubSnodeAPI;

@ -394,9 +394,7 @@
idleDetector = new IdleDetector();
let isMigrationWithIndexComplete = false;
window.log.info(
`Starting background data migration. Target version: ${
Message.CURRENT_SCHEMA_VERSION
}`
`Starting background data migration. Target version: ${Message.CURRENT_SCHEMA_VERSION}`
);
idleDetector.on('idle', async () => {
const NUM_MESSAGES_PER_BATCH = 1;
@ -638,23 +636,21 @@
window.doUpdateGroup = async (groupId, groupName, members, avatar) => {
const ourKey = textsecure.storage.user.getNumber();
const ev = new Event('message');
ev.confirm = () => {};
ev.data = {
source: ourKey,
timestamp: Date.now(),
message: {
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.UPDATE,
name: groupName,
members,
avatar: null, // TODO
},
const ev = {
groupDetails: {
id: groupId,
name: groupName,
members,
active: true,
expireTimer: 0,
avatar: '',
is_medium_group: false,
},
confirm: () => {},
};
await onGroupReceived(ev);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
'group'
@ -719,15 +715,42 @@
const recipients = _.union(convo.get('members'), members);
await onMessageReceived(ev);
convo.updateGroup({
groupId,
groupName,
const isMediumGroup = convo.isMediumGroup();
const updateObj = {
id: groupId,
name: groupName,
avatar: nullAvatar,
recipients,
members,
is_medium_group: isMediumGroup,
options,
});
};
// Send own sender keys and group secret key
if (isMediumGroup) {
const { chainKey, keyIdx } = await window.SenderKeyAPI.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.createMediumSizeGroup = async (groupName, members) => {
@ -746,66 +769,73 @@
identityKeys.privKey
);
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const groupUpdate = new textsecure.protobuf.MediumGroupUpdate();
const primary = window.storage.get('primaryDevicePubKey');
groupUpdate.groupId = groupId;
groupUpdate.groupSecretKey = groupSecretKeyHex;
groupUpdate.senderKey = senderKey;
groupUpdate.members = [ourIdentity, ...members];
groupUpdate.groupName = groupName;
proto.mediumGroupUpdate = groupUpdate;
const allMembers = [primary, ...members];
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKeyHex,
});
const convo = await window.ConversationController.getOrCreateAndWait(
const ev = {
groupDetails: {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: '',
secretKey: identityKeys.privKey,
senderKey,
is_medium_group: true,
},
confirm: () => {},
};
await onGroupReceived(ev);
const convo = await ConversationController.getOrCreateAndWait(
groupId,
Message.GROUP
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.updateGroupAdmins([primary]);
convo.updateGroup(ev.groupDetails);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
appView.openConversation(groupId, {});
// Subscribe to this group id
messageReceiver.pollForAdditionalId(groupId);
// TODO: include ourselves so that our lined devices work as well!
await textsecure.messaging.updateMediumGroup(members, proto);
};
window.doCreateGroup = async (groupName, members) => {
const keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const groupId = StringView.arrayBufferToHex(keypair.pubKey);
const ev = new Event('group');
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
textsecure.storage.user.getNumber();
const allMembers = [primaryDeviceKey, ...members];
ev.groupDetails = {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: '',
const ev = {
groupDetails: {
id: groupId,
name: groupName,
members: allMembers,
recipients: allMembers,
active: true,
expireTimer: 0,
avatar: '',
},
confirm: () => {},
};
ev.confirm = () => {};
await onGroupReceived(ev);
const convo = await ConversationController.getOrCreateAndWait(
@ -822,6 +852,7 @@
window.friends.friendRequestStatusEnum.friends
);
textsecure.messaging.sendGroupSyncMessage([convo]);
appView.openConversation(groupId, {});
};
@ -957,10 +988,6 @@
window.setSettingValue('link-preview-setting', false);
}
// Render onboarding message from LeftPaneMessageSection
// unless user turns it off during their session
window.setSettingValue('render-message-onboarding', true);
// Generates useful random ID for various purposes
window.generateID = () =>
Math.random()
@ -1013,20 +1040,6 @@
return toastID;
};
window.getFriendsFromContacts = contacts => {
// To call from TypeScript, input / output are both
// of type Array<ConversationType>
let friendList = contacts;
if (friendList !== undefined) {
friendList = friendList.filter(
friend =>
(friend.type === 'direct' && !friend.isMe) ||
(friend.type === 'group' && !friend.isPublic && !friend.isRss)
);
}
return friendList;
};
// Get memberlist. This function is not accurate >>
// window.getMemberList = window.lokiPublicChatAPI.getListOfMembers();
@ -1430,9 +1443,11 @@
// TODO: we should ensure the message was sent and retry automatically if not
await libloki.api.sendUnpairingMessageToSecondary(pubKey);
// Remove all traces of the device
ConversationController.deleteContact(pubKey);
Whisper.events.trigger('refreshLinkedDeviceList');
callback();
setTimeout(() => {
ConversationController.deleteContact(pubKey);
Whisper.events.trigger('refreshLinkedDeviceList');
callback();
}, 1000);
});
}
@ -1630,7 +1645,10 @@
});
if (Whisper.Import.isComplete()) {
const { wrap, sendOptions } = ConversationController.prepareForSend(
const {
wrap,
sendOptions,
} = ConversationController.prepareForSend(
textsecure.storage.user.getNumber(),
{ syncMessage: true }
);
@ -1759,7 +1777,6 @@
const details = ev.contactDetails;
const id = details.number;
libloki.api.debug.logContactSync(
'Got sync contact message with',
id,
@ -1814,12 +1831,21 @@
await conversation.setSecondaryStatus(true, ourPrimaryKey);
}
if (conversation.isFriendRequestStatusNoneOrExpired()) {
libloki.api.sendAutoFriendRequestMessage(conversation.id);
} else {
// Accept any pending friend requests if there are any
conversation.onAcceptFriendRequest({ blockSync: true });
}
const otherDevices = await libloki.storage.getPairedDevicesFor(id);
const devices = [id, ...otherDevices];
const deviceConversations = await Promise.all(
devices.map(d =>
ConversationController.getOrCreateAndWait(d, 'private')
)
);
deviceConversations.forEach(device => {
if (device.isFriendRequestStatusNoneOrExpired()) {
libloki.api.sendAutoFriendRequestMessage(device.id);
} else {
// Accept any pending friend requests if there are any
device.onAcceptFriendRequest({ blockSync: true });
}
});
if (details.profileKey) {
const profileKey = window.Signal.Crypto.arrayBufferToBase64(
@ -1917,6 +1943,7 @@
members: details.members,
color: details.color,
type: 'group',
is_medium_group: details.is_medium_group || false,
};
if (details.active) {
@ -2201,9 +2228,7 @@
)
).catch(error => {
window.log.error(
`Failed to send delivery receipt to ${data.source} for message ${
data.timestamp
}:`,
`Failed to send delivery receipt to ${data.source} for message ${data.timestamp}:`,
error && error.stack ? error.stack : error
);
});

@ -4,6 +4,7 @@
log,
i18n,
Backbone,
libloki,
ConversationController,
MessageController,
storage,
@ -13,7 +14,8 @@
clipboard,
BlockedNumberController,
lokiPublicChatAPI,
JobQueue
JobQueue,
StringView
*/
/* eslint-disable more/no-then */
@ -234,6 +236,9 @@
isBlocked() {
return BlockedNumberController.isBlocked(this.id);
},
isMediumGroup() {
return this.get('is_medium_group');
},
block() {
BlockedNumberController.block(this.id);
this.trigger('change');
@ -245,22 +250,29 @@
this.messageCollection.forEach(m => m.trigger('change'));
},
async acceptFriendRequest() {
// Friend request message conmfirmations (Accept / Decline) are always
// sent to the primary device conversation
const messages = await window.Signal.Data.getMessagesByConversation(
this.id,
this.getPrimaryDevicePubKey(),
{
limit: 1,
limit: 5,
MessageCollection: Whisper.MessageCollection,
type: 'friend-request',
}
);
const lastMessageModel = messages.at(0);
if (lastMessageModel) {
lastMessageModel.acceptFriendRequest();
let lastMessage = null;
messages.forEach(m => {
m.acceptFriendRequest();
lastMessage = m;
});
if (lastMessage) {
await this.markRead();
window.Whisper.events.trigger(
'showConversation',
this.id,
lastMessageModel.id
lastMessage.id
);
}
},
@ -549,6 +561,7 @@
MessageCollection: Whisper.MessageCollection,
}
);
if (typeof status === 'string') {
// eslint-disable-next-line no-param-reassign
status = [status];
@ -584,7 +597,6 @@
const result = {
id: this.id,
isArchived: this.get('isArchived'),
activeAt: this.get('active_at'),
avatarPath: this.getAvatarPath(),
@ -970,25 +982,47 @@
Conversation: Whisper.Conversation,
});
},
async respondToAllFriendRequests(options) {
async updateAllFriendRequestsMessages(options) {
const { response, status, direction = null } = options;
// Ignore if no response supplied
if (!response) {
return;
}
const primaryConversation = ConversationController.get(
// Accept FRs from all the user's devices
const allDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
this.getPrimaryDevicePubKey()
);
// Should never happen
if (!primaryConversation) {
if (!allDevices.length) {
return;
}
const pending = await primaryConversation.getFriendRequests(
direction,
status
const allConversationsWithUser = allDevices.map(d =>
ConversationController.get(d)
);
// Search through each conversation (device) for friend request messages
const pendingRequestPromises = allConversationsWithUser.map(
async conversation => {
const request = (
await conversation.getFriendRequests(direction, status)
)[0];
return { conversation, request };
}
);
let pendingRequests = await Promise.all(pendingRequestPromises);
// Filter out all undefined requests
pendingRequests = pendingRequests.filter(p => Boolean(p.request));
// We set all friend request messages from all devices
// from a user here to accepted where possible
await Promise.all(
pending.map(async request => {
pendingRequests.map(async friendRequest => {
const { conversation, request } = friendRequest;
if (request.hasErrors()) {
return;
}
@ -997,12 +1031,12 @@
await window.Signal.Data.saveMessage(request.attributes, {
Message: Whisper.Message,
});
primaryConversation.trigger('updateMessage', request);
conversation.trigger('updateMessage', request);
})
);
},
async respondToAllPendingFriendRequests(options) {
return this.respondToAllFriendRequests({
async updateAllPendingFriendRequestsMessages(options) {
return this.updateAllFriendRequestsMessages({
...options,
status: 'pending',
});
@ -1017,7 +1051,7 @@
// We have declined an incoming friend request
async onDeclineFriendRequest() {
this.setFriendRequestStatus(FriendRequestStatusEnum.none);
await this.respondToAllPendingFriendRequests({
await this.updateAllPendingFriendRequestsMessages({
response: 'declined',
direction: 'incoming',
});
@ -1031,13 +1065,16 @@
},
// We have accepted an incoming friend request
async onAcceptFriendRequest(options = {}) {
if (this.get('type') !== Message.PRIVATE) {
return;
}
if (this.unlockTimer) {
clearTimeout(this.unlockTimer);
}
if (this.hasReceivedFriendRequest()) {
this.setFriendRequestStatus(FriendRequestStatusEnum.friends, options);
await this.respondToAllFriendRequests({
await this.updateAllFriendRequestsMessages({
response: 'accepted',
direction: 'incoming',
status: ['pending', 'expired'],
@ -1047,6 +1084,12 @@
window.textsecure.OutgoingMessage.DebugMessageType
.INCOMING_FR_ACCEPTED
);
} else if (this.isFriendRequestStatusNoneOrExpired()) {
// send AFR if we haven't sent a message before
const autoFrMessage = textsecure.OutgoingMessage.buildAutoFriendRequestMessage(
this.id
);
await autoFrMessage.sendToNumber(this.id, false);
}
},
// Our outgoing friend request has been accepted
@ -1059,7 +1102,7 @@
}
if (this.hasSentFriendRequest()) {
this.setFriendRequestStatus(FriendRequestStatusEnum.friends);
await this.respondToAllFriendRequests({
await this.updateAllFriendRequestsMessages({
response: 'accepted',
status: ['pending', 'expired'],
});
@ -1091,7 +1134,7 @@
}
// Change any pending outgoing friend requests to expired
await this.respondToAllPendingFriendRequests({
await this.updateAllPendingFriendRequestsMessages({
response: 'expired',
direction: 'outgoing',
});
@ -1104,7 +1147,7 @@
await Promise.all([
this.setFriendRequestStatus(FriendRequestStatusEnum.friends),
// Accept all outgoing FR
this.respondToAllPendingFriendRequests({
this.updateAllPendingFriendRequestsMessages({
direction: 'outgoing',
response: 'accepted',
}),
@ -1658,6 +1701,7 @@
const model = this.addSingleMessage(attributes);
const message = MessageController.register(model.id, model);
await window.Signal.Data.saveMessage(message.attributes, {
forceSave: true,
Message: Whisper.Message,
@ -1753,7 +1797,7 @@
let dest = destination;
let numbers = groupNumbers;
if (this.get('is_medium_group')) {
if (this.isMediumGroup()) {
dest = this.id;
numbers = [destination];
options.isMediumGroup = true;
@ -2243,6 +2287,12 @@
}
},
async saveChangesToDB() {
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
},
async updateGroup(providedGroupUpdate) {
let groupUpdate = providedGroupUpdate;
@ -2261,15 +2311,44 @@
group_update: groupUpdate,
});
const id = await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
});
message.set({ id });
const messageId = await window.Signal.Data.saveMessage(
message.attributes,
{
Message: Whisper.Message,
}
);
message.set({ id: messageId });
const options = this.getSendOptions();
if (groupUpdate.is_medium_group) {
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const mgUpdate = new textsecure.protobuf.MediumGroupUpdate();
const { id, name, secretKey, senderKey, members } = groupUpdate;
mgUpdate.type = textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP;
mgUpdate.groupId = id;
mgUpdate.groupSecretKey = secretKey;
mgUpdate.senderKey = new textsecure.protobuf.SenderKey(senderKey);
mgUpdate.members = members.map(pkHex =>
StringView.hexToArrayBuffer(pkHex)
);
mgUpdate.groupName = name;
mgUpdate.admins = this.get('groupAdmins');
proto.mediumGroupUpdate = mgUpdate;
message.send(
this.wrapSend(textsecure.messaging.updateMediumGroup(members, proto))
);
return;
}
message.send(
this.wrapSend(
textsecure.messaging.updateGroup(
textsecure.messaging.sendGroupUpdate(
this.id,
this.get('name'),
this.get('avatar'),
@ -2285,7 +2364,7 @@
sendGroupInfo(recipients) {
if (this.isClosedGroup()) {
const options = this.getSendOptions();
textsecure.messaging.updateGroup(
textsecure.messaging.sendGroupUpdate(
this.id,
this.get('name'),
this.get('avatar'),
@ -2299,9 +2378,19 @@
async leaveGroup() {
const now = Date.now();
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.lokiMessageAPI.stopPollingForGroup(this.id);
}
if (this.get('type') === 'group') {
const groupNumbers = this.getRecipients();
this.set({ left: true });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
@ -2439,7 +2528,6 @@
},
// LOKI PROFILES
async setNickname(nickname) {
const trimmed = nickname && nickname.trim();
if (this.get('nickname') === trimmed) {

@ -418,33 +418,79 @@
if (this.get('friendStatus') !== 'pending') {
return;
}
const conversation = await this.getSourceDeviceConversation();
// If we somehow received an old friend request (e.g. after having restored
// from seed, we won't be able to accept it, we should initiate our own
// friend request to reset the session:
if (conversation.get('sessionRestoreSeen')) {
conversation.sendMessage('', null, null, null, null, {
sessionRestoration: true,
});
return;
const devicePubKey = this.get('conversationId');
const otherDevices = await libloki.storage.getPairedDevicesFor(
devicePubKey
);
const allDevices = [devicePubKey, ...otherDevices];
// Set profile name to primary conversation
let profileName;
const allConversationsWithUser = allDevices
.map(d => ConversationController.get(d))
.filter(c => Boolean(c));
allConversationsWithUser.forEach(conversation => {
// If we somehow received an old friend request (e.g. after having restored
// from seed, we won't be able to accept it, we should initiate our own
// friend request to reset the session:
if (conversation.get('sessionRestoreSeen')) {
conversation.sendMessage('', null, null, null, null, {
sessionRestoration: true,
});
return;
}
profileName = conversation.getProfileName() || profileName;
conversation.onAcceptFriendRequest();
});
// If you don't have a profile name for this device, and profileName is set,
// add profileName to conversation.
const primaryDevicePubKey =
(await window.Signal.Data.getPrimaryDeviceFor(devicePubKey)) ||
devicePubKey;
const primaryConversation = allConversationsWithUser.find(
c => c.id === primaryDevicePubKey
);
if (!primaryConversation.getProfileName() && profileName) {
await primaryConversation.setNickname(profileName);
}
this.set({ friendStatus: 'accepted' });
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
conversation.onAcceptFriendRequest();
this.set({ friendStatus: 'accepted' });
// Update redux store
window.Signal.Data.updateConversation(
primaryConversation.id,
primaryConversation.attributes,
{ Conversation: Whisper.Conversation }
);
},
async declineFriendRequest() {
if (this.get('friendStatus') !== 'pending') {
return;
}
const conversation = this.getConversation();
this.set({ friendStatus: 'declined' });
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
conversation.onDeclineFriendRequest();
const devicePubKey = this.attributes.conversationId;
const otherDevices = await libloki.storage.getPairedDevicesFor(
devicePubKey
);
const allDevices = [devicePubKey, ...otherDevices];
const allConversationsWithUser = allDevices
.map(d => ConversationController.get(d))
.filter(c => Boolean(c));
allConversationsWithUser.forEach(conversation => {
conversation.onDeclineFriendRequest();
});
},
getPropsForFriendRequest() {
const friendStatus = this.get('friendStatus') || 'pending';
@ -1444,7 +1490,8 @@
if (!this.isFriendRequest()) {
const c = this.getConversation();
// Don't bother sending sync messages to public chats
if (c && !c.isPublic()) {
// or groups with sender keys
if (c && !c.isPublic() && !c.isMediumGroup()) {
this.sendSyncMessage();
}
}
@ -1574,7 +1621,9 @@
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
{
syncMessage: true,
}
);
this.syncPromise = this.syncPromise || Promise.resolve();
@ -1941,7 +1990,7 @@
const { primaryDevicePubKey } = authorisation;
// ensure the primary device is a friend
const c = window.ConversationController.get(primaryDevicePubKey);
if (!c || !await c.isFriendWithAnyDevice()) {
if (!c || !(await c.isFriendWithAnyDevice())) {
return false;
}
await libloki.storage.savePairingAuthorisation(authorisation);
@ -2049,6 +2098,7 @@
return false;
},
async handleSessionRequest(source, confirm) {
window.console.log(`Received SESSION_REQUEST from source: ${source}`);
window.libloki.api.sendSessionEstablishedMessage(source);
confirm();
},
@ -2200,11 +2250,10 @@
return null;
}
}
const conversation = conversationPrimary;
return conversation.queueJob(async () => {
return conversationPrimary.queueJob(async () => {
window.log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversationPrimary.idForLogging()}`
);
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const type = message.get('type');
@ -2217,8 +2266,9 @@
try {
const now = new Date().getTime();
let attributes = {
...conversation.attributes,
...conversationPrimary.attributes,
};
if (dataMessage.group) {
let groupUpdate = null;
attributes = {
@ -2234,18 +2284,18 @@
};
groupUpdate =
conversation.changedAttributes(
conversationPrimary.changedAttributes(
_.pick(dataMessage.group, 'name', 'avatar')
) || {};
const addedMembers = _.difference(
attributes.members,
conversation.get('members')
conversationPrimary.get('members')
);
if (addedMembers.length > 0) {
groupUpdate.joined = addedMembers;
}
if (conversation.get('left')) {
if (conversationPrimary.get('left')) {
// TODO: Maybe we shouldn't assume this message adds us:
// we could maybe still get this message by mistake
window.log.warn('re-added to a left group');
@ -2259,7 +2309,7 @@
// Check if anyone got kicked:
const removedMembers = _.difference(
conversation.get('members'),
conversationPrimary.get('members'),
attributes.members
);
@ -2281,7 +2331,7 @@
groupUpdate = { left: source };
}
attributes.members = _.without(
conversation.get('members'),
conversationPrimary.get('members'),
source
);
}
@ -2314,7 +2364,7 @@
attachments: dataMessage.attachments,
body: dataMessage.body,
contact: dataMessage.contact,
conversationId: conversation.id,
conversationId: conversationPrimary.id,
decrypted_at: now,
errors: [],
flags: dataMessage.flags,
@ -2328,7 +2378,7 @@
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
conversation,
conversationPrimary,
message
);
receipts.forEach(receipt =>
@ -2341,10 +2391,10 @@
);
}
attributes.active_at = now;
conversation.set(attributes);
conversationPrimary.set(attributes);
// Re-enable typing if re-joined the group
conversation.updateTextInputState();
conversationPrimary.updateTextInputState();
if (message.isExpirationTimerUpdate()) {
message.set({
@ -2353,7 +2403,7 @@
expireTimer: dataMessage.expireTimer,
},
});
conversation.set({ expireTimer: dataMessage.expireTimer });
conversationPrimary.set({ expireTimer: dataMessage.expireTimer });
} else if (dataMessage.expireTimer) {
message.set({ expireTimer: dataMessage.expireTimer });
}
@ -2365,7 +2415,7 @@
message.isExpirationTimerUpdate() || expireTimer;
if (shouldLogExpireTimerChange) {
window.log.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
id: conversationPrimary.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
@ -2373,8 +2423,11 @@
if (!message.isEndSession()) {
if (dataMessage.expireTimer) {
if (dataMessage.expireTimer !== conversation.get('expireTimer')) {
conversation.updateExpirationTimer(
if (
dataMessage.expireTimer !==
conversationPrimary.get('expireTimer')
) {
conversationPrimary.updateExpirationTimer(
dataMessage.expireTimer,
source,
message.get('received_at'),
@ -2384,18 +2437,18 @@
);
}
} else if (
conversation.get('expireTimer') &&
conversationPrimary.get('expireTimer') &&
// We only turn off timers if it's not a group update
!message.isGroupUpdate()
) {
conversation.updateExpirationTimer(
conversationPrimary.updateExpirationTimer(
null,
source,
message.get('received_at')
);
}
} else {
const endSessionType = conversation.isSessionResetReceived()
const endSessionType = conversationPrimary.isSessionResetReceived()
? 'ongoing'
: 'done';
this.set({ endSessionType });
@ -2427,11 +2480,11 @@
message.attributes.body &&
message.attributes.body.indexOf(`@${ourNumber}`) !== -1
) {
conversation.set({ mentionedUs: true });
conversationPrimary.set({ mentionedUs: true });
}
conversation.set({
unreadCount: conversation.get('unreadCount') + 1,
conversationPrimary.set({
unreadCount: conversationPrimary.get('unreadCount') + 1,
isArchived: false,
});
}
@ -2439,7 +2492,7 @@
if (type === 'outgoing') {
const reads = Whisper.ReadReceipts.forMessage(
conversation,
conversationPrimary,
message
);
if (reads.length) {
@ -2450,39 +2503,35 @@
}
// A sync'd message to ourself is automatically considered read and delivered
if (conversation.isMe()) {
if (conversationPrimary.isMe()) {
message.set({
read_by: conversation.getRecipients(),
delivered_to: conversation.getRecipients(),
read_by: conversationPrimary.getRecipients(),
delivered_to: conversationPrimary.getRecipients(),
});
}
message.set({ recipients: conversation.getRecipients() });
message.set({ recipients: conversationPrimary.getRecipients() });
}
const conversationTimestamp = conversation.get('timestamp');
const conversationTimestamp = conversationPrimary.get('timestamp');
if (
!conversationTimestamp ||
message.get('sent_at') > conversationTimestamp
) {
conversation.lastMessage = message.getNotificationText();
conversation.set({
conversationPrimary.lastMessage = message.getNotificationText();
conversationPrimary.set({
timestamp: message.get('sent_at'),
});
}
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
source,
'private'
);
if (dataMessage.profileKey) {
const profileKey = dataMessage.profileKey.toString('base64');
if (source === textsecure.storage.user.getNumber()) {
conversation.set({ profileSharing: true });
} else if (conversation.isPrivate()) {
conversation.setProfileKey(profileKey);
conversationPrimary.set({ profileSharing: true });
} else if (conversationPrimary.isPrivate()) {
conversationPrimary.setProfileKey(profileKey);
} else {
sendingDeviceConversation.setProfileKey(profileKey);
conversationOrigin.setProfileKey(profileKey);
}
}
@ -2508,8 +2557,9 @@
- We are friends with the user,
and that user just sent us a friend request.
*/
const isFriend = sendingDeviceConversation.isFriend();
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
const isFriend = conversationOrigin.isFriend();
const hasSentFriendRequest = conversationOrigin.hasSentFriendRequest();
autoAccept = isFriend || hasSentFriendRequest;
if (autoAccept) {
@ -2523,13 +2573,13 @@
if (isFriend) {
window.Whisper.events.trigger('endSession', source);
} else if (hasSentFriendRequest) {
await sendingDeviceConversation.onFriendRequestAccepted();
await conversationOrigin.onFriendRequestAccepted();
} else {
await sendingDeviceConversation.onFriendRequestReceived();
await conversationOrigin.onFriendRequestReceived();
}
} else if (message.get('type') !== 'outgoing') {
// Ignore 'outgoing' messages because they are sync messages
await sendingDeviceConversation.onFriendRequestAccepted();
await conversationOrigin.onFriendRequestAccepted();
}
}
@ -2551,11 +2601,11 @@
await window.Signal.Data.updateConversation(
conversationId,
conversation.attributes,
conversationPrimary.attributes,
{ Conversation: Whisper.Conversation }
);
conversation.trigger('newmessage', message);
conversationPrimary.trigger('newmessage', message);
try {
// We go to the database here because, between the message save above and
@ -2593,9 +2643,9 @@
if (message.get('unread')) {
// Need to do this here because the conversation has already changed states
if (autoAccept) {
await conversation.notifyFriendRequest(source, 'accepted');
await conversationPrimary.notifyFriendRequest(source, 'accepted');
} else {
await conversation.notify(message);
await conversationPrimary.notify(message);
}
}

@ -414,9 +414,7 @@ function splitBytes(buffer, ...lengths) {
if (total !== buffer.byteLength) {
throw new Error(
`Requested lengths total ${total} does not match source total ${
buffer.byteLength
}`
`Requested lengths total ${total} does not match source total ${buffer.byteLength}`
);
}

417
js/modules/data.d.ts vendored

@ -1,3 +1,418 @@
import { ConversationType } from '../../ts/state/ducks/conversations';
import { Mesasge } from '../../ts/types/Message';
type IdentityKey = {
id: string;
publicKey: ArrayBuffer;
firstUse: boolean;
verified: number;
nonblockingApproval: boolean;
};
type PreKey = {
id: number;
publicKey: ArrayBuffer;
privateKey: ArrayBuffer;
recipient: string;
};
type SignedPreKey = {
id: number;
publicKey: ArrayBuffer;
privateKey: ArrayBuffer;
created_at: number;
confirmed: boolean;
signature: ArrayBuffer;
};
type ContactPreKey = {
id: number;
identityKeyString: string;
publicKey: ArrayBuffer;
keyId: number;
};
type ContactSignedPreKey = {
id: number;
identityKeyString: string;
publicKey: ArrayBuffer;
keyId: number;
signature: ArrayBuffer;
created_at: number;
confirmed: boolean;
};
type PairingAuthorisation = {
primaryDevicePubKey: string;
secondaryDevicePubKey: string;
requestSignature: ArrayBuffer;
grantSignature: ArrayBuffer | null;
};
type GuardNode = {
ed25519PubKey: string;
};
type SwarmNode = {
address: string;
ip: string;
port: string;
pubkey_ed25519: string;
pubkey_x25519: string;
};
type StorageItem = {
id: string;
value: any;
};
type SessionDataInfo = {
id: string;
number: string;
deviceId: number;
record: string;
};
type ServerToken = {
serverUrl: string;
token: string;
};
// Basic
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;
export function getPrimaryDeviceFor(pubKey: string): Promise<string | null>;
export function shutdown(): Promise<void>;
export function close(): Promise<void>;
export function removeDB(): Promise<void>;
export function removeIndexedDBFiles(): Promise<void>;
export function getPasswordHash(): Promise<string | null>;
// Identity Keys
export function createOrUpdateIdentityKey(data: IdentityKey): Promise<void>;
export function getIdentityKeyById(id: string): Promise<IdentityKey | null>;
export function bulkAddIdentityKeys(array: Array<IdentityKey>): Promise<void>;
export function removeIdentityKeyById(id: string): Promise<void>;
export function removeAllIdentityKeys(): Promise<void>;
// Pre Keys
export function createOrUpdatePreKey(data: PreKey): Promise<void>;
export function getPreKeyById(id: number): Promise<PreKey | null>;
export function getPreKeyByRecipient(recipient: string): Promise<PreKey | null>;
export function bulkAddPreKeys(data: Array<PreKey>): Promise<void>;
export function removePreKeyById(id: number): Promise<void>;
export function getAllPreKeys(): Promise<Array<PreKey>>;
// Signed Pre Keys
export function createOrUpdateSignedPreKey(data: SignedPreKey): Promise<void>;
export function getSignedPreKeyById(id: number): Promise<SignedPreKey | null>;
export function getAllSignedPreKeys(): Promise<SignedPreKey | null>;
export function bulkAddSignedPreKeys(array: Array<SignedPreKey>): Promise<void>;
export function removeSignedPreKeyById(id: number): Promise<void>;
export function removeAllSignedPreKeys(): Promise<void>;
// Contact Pre Key
export function createOrUpdateContactPreKey(data: ContactPreKey): Promise<void>;
export function getContactPreKeyById(id: number): Promise<ContactPreKey | null>;
export function getContactPreKeyByIdentityKey(
key: string
): Promise<ContactPreKey | null>;
export function getContactPreKeys(
keyId: number,
identityKeyString: string
): Promise<Array<ContactPreKey>>;
export function getAllContactPreKeys(): Promise<Array<ContactPreKey>>;
export function bulkAddContactPreKeys(
array: Array<ContactPreKey>
): Promise<void>;
export function removeContactPreKeyByIdentityKey(id: number): Promise<void>;
export function removeAllContactPreKeys(): Promise<void>;
// Contact Signed Pre Key
export function createOrUpdateContactSignedPreKey(
data: ContactSignedPreKey
): Promise<void>;
export function getContactSignedPreKeyById(
id: number
): Promise<ContactSignedPreKey | null>;
export function getContactSignedPreKeyByIdentityKey(
key: string
): Promise<ContactSignedPreKey | null>;
export function getContactSignedPreKeys(
keyId: number,
identityKeyString: string
): Promise<Array<ContactSignedPreKey>>;
export function bulkAddContactSignedPreKeys(
array: Array<ContactSignedPreKey>
): Promise<void>;
export function removeContactSignedPreKeyByIdentityKey(
id: string
): Promise<void>;
export function removeAllContactSignedPreKeys(): Promise<void>;
// Authorisations & Linking
export function createOrUpdatePairingAuthorisation(
data: PairingAuthorisation
): Promise<void>;
export function removePairingAuthorisationForSecondaryPubKey(
pubKey: string
): Promise<void>;
export function getGrantAuthorisationsForPrimaryPubKey(
pubKey: string
): Promise<Array<PairingAuthorisation>>;
export function getGrantAuthorisationForSecondaryPubKey(
pubKey: string
): Promise<PairingAuthorisation | null>;
export function getAuthorisationForSecondaryPubKey(
pubKey: string
): Promise<PairingAuthorisation | null>;
export function getSecondaryDevicesFor(
primaryDevicePubKey: string
): Promise<Array<string>>;
export function getPrimaryDeviceFor(
secondaryDevicePubKey: string
): Promise<string | null>;
export function getPairedDevicesFor(pubKey: string): Promise<Array<string>>;
// Guard Nodes
export function getGuardNodes(): Promise<GuardNode>;
export function updateGuardNodes(nodes: Array<string>): Promise<void>;
// Storage Items
export function createOrUpdateItem(data: StorageItem): Promise<void>;
export function getItemById(id: string): Promise<StorageItem | undefined>;
export function getAlItems(): Promise<Array<StorageItem>>;
export function bulkAddItems(array: Array<StorageItem>): Promise<void>;
export function removeItemById(id: string): Promise<void>;
export function removeAllItems(): Promise<void>;
// Sessions
export function createOrUpdateSession(data: SessionDataInfo): Promise<void>;
export function getAllSessions(): Promise<Array<SessionDataInfo>>;
export function getSessionById(id: string): Promise<SessionDataInfo>;
export function getSessionsByNumber(number: string): Promise<SessionDataInfo>;
export function bulkAddSessions(array: Array<SessionDataInfo>): Promise<void>;
export function removeSessionById(id: string): Promise<void>;
export function removeSessionsByNumber(number: string): Promise<void>;
export function removeAllSessions(): Promise<void>;
// Conversations
export function getConversationCount(): Promise<number>;
export function saveConversation(data: ConversationType): Promise<void>;
export function saveConversations(data: Array<ConversationType>): Promise<void>;
export function updateConversation(data: ConversationType): Promise<void>;
export function removeConversation(id: string): Promise<void>;
export function getAllConversations({
ConversationCollection,
}: {
ConversationCollection: any;
}): Promise<Array<ConversationCollection>>;
export function getAllConversationIds(): Promise<Array<string>>;
export function getAllPrivateConversations(): Promise<Array<string>>;
export function getAllPublicConversations(): Promise<Array<string>>;
export function getPublicConversationsByServer(
server: string,
{ ConversationCollection }: { ConversationCollection: any }
): Promise<ConversationCollection>;
export function getPubkeysInPublicConversation(
id: string
): Promise<Array<string>>;
export function savePublicServerToken(data: ServerToken): Promise<void>;
export function getPublicServerTokenByServerUrl(
serverUrl: string
): Promise<string>;
export function getAllGroupsInvolvingId(
id: string,
{ ConversationCollection }: { ConversationCollection: any }
): Promise<Array<ConversationCollection>>;
// Returns conversation row
// TODO: Make strict return types for search
export function searchConversations(query: string): Promise<any>;
export function searchMessages(query: string): Promise<any>;
export function searchMessagesInConversation(
query: string,
conversationId: string,
{ limit }?: { limit: any }
): Promise<any>;
export function getMessageCount(): Promise<number>;
export function saveMessage(
data: Mesasge,
{ forceSave, Message }?: { forceSave: any; Message: any }
): Promise<string>;
export function cleanSeenMessages(): Promise<void>;
export function cleanLastHashes(): Promise<void>;
export function saveSeenMessageHash(data: {
expiresAt: number;
hash: string;
}): Promise<void>;
// TODO: Strictly type the following
export function updateLastHash(data: any): Promise<any>;
export function saveSeenMessageHashes(data: any): Promise<any>;
export function saveLegacyMessage(data: any): Promise<any>;
export function saveMessages(
arrayOfMessages: any,
{ forceSave }?: any
): Promise<any>;
export function removeMessage(id: string, { Message }?: any): Promise<any>;
export function getUnreadByConversation(
conversationId: string,
{ MessageCollection }?: any
): Promise<any>;
export function removeAllMessagesInConversation(
conversationId: string,
{ MessageCollection }?: any
): Promise<void>;
export function getMessageBySender(
{
source,
sourceDevice,
sent_at,
}: { source: any; sourceDevice: any; sent_at: any },
{ Message }: { Message: any }
): Promise<any>;
export function getMessageIdsFromServerIds(
serverIds: any,
conversationId: any
): Promise<any>;
export function getMessageById(
id: string,
{ Message }: { Message: any }
): Promise<any>;
export function getAllMessages({
MessageCollection,
}: {
MessageCollection: any;
}): Promise<any>;
export function getAllUnsentMessages({
MessageCollection,
}: {
MessageCollection: any;
}): Promise<any>;
export function getAllMessageIds(): Promise<any>;
export function getMessagesBySentAt(
sentAt: any,
{ MessageCollection }: { MessageCollection: any }
): Promise<any>;
export function getExpiredMessages({
MessageCollection,
}: {
MessageCollection: any;
}): Promise<any>;
export function getOutgoingWithoutExpiresAt({
MessageCollection,
}: any): Promise<any>;
export function getNextExpiringMessage({
MessageCollection,
}: {
MessageCollection: any;
}): Promise<any>;
export function getNextExpiringMessage({
MessageCollection,
}: {
MessageCollection: any;
}): Promise<any>;
export function getMessagesByConversation(
conversationId: any,
{
limit,
receivedAt,
MessageCollection,
type,
}: {
limit?: number;
receivedAt?: number;
MessageCollection: any;
type?: string;
}
): Promise<any>;
export function getSeenMessagesByHashList(hashes: any): Promise<any>;
export function getLastHashBySnode(convoId: any, snode: any): Promise<any>;
// Unprocessed
export function getUnprocessedCount(): Promise<any>;
export function getAllUnprocessed(): Promise<any>;
export function getUnprocessedById(id: any): Promise<any>;
export function saveUnprocessed(
data: any,
{
forceSave,
}?: {
forceSave: any;
}
): Promise<any>;
export function saveUnprocesseds(
arrayOfUnprocessed: any,
{
forceSave,
}?: {
forceSave: any;
}
): Promise<void>;
export function updateUnprocessedAttempts(
id: any,
attempts: any
): Promise<void>;
export function updateUnprocessedWithData(id: any, data: any): Promise<void>;
export function removeUnprocessed(id: any): Promise<void>;
export function removeAllUnprocessed(): Promise<void>;
// Attachment Downloads
export function getNextAttachmentDownloadJobs(limit: any): Promise<any>;
export function saveAttachmentDownloadJob(job: any): Promise<void>;
export function setAttachmentDownloadJobPending(
id: any,
pending: any
): Promise<void>;
export function resetAttachmentDownloadPending(): Promise<void>;
export function removeAttachmentDownloadJob(id: any): Promise<void>;
export function removeAllAttachmentDownloadJobs(): Promise<void>;
// Other
export function removeAll(): Promise<void>;
export function removeAllConfiguration(): Promise<void>;
export function removeAllConversations(): Promise<void>;
export function removeAllPrivateConversations(): Promise<void>;
export function removeOtherData(): Promise<void>;
export function cleanupOrphanedAttachments(): Promise<void>;
// Getters
export function getMessagesNeedingUpgrade(
limit: any,
{
maxVersion,
}: {
maxVersion?: number;
}
): Promise<any>;
export function getLegacyMessagesNeedingUpgrade(
limit: any,
{
maxVersion,
}: {
maxVersion?: number;
}
): Promise<any>;
export function getMessagesWithVisualMediaAttachments(
conversationId: any,
{
limit,
}: {
limit: any;
}
): Promise<any>;
export function getMessagesWithFileAttachments(
conversationId: any,
{
limit,
}: {
limit: any;
}
): Promise<any>;
// Sender Keys
export function getSenderKeys(groupId: any, senderIdentity: any): Promise<any>;
export function createOrUpdateSenderKeys(data: any): Promise<void>;

@ -1,7 +1,8 @@
/* global window, setTimeout, clearTimeout, IDBKeyRange, dcodeIO */
const electron = require('electron');
const { ipcRenderer } = electron;
// TODO: this results in poor readability, would be
// much better to explicitly call with `_`.
const {
@ -21,12 +22,6 @@ const _ = require('lodash');
const { base64ToArrayBuffer, arrayBufferToBase64 } = require('./crypto');
const MessageType = require('./types/message');
const { ipcRenderer } = electron;
// We listen to a lot of events on ipcRenderer, often on the same channel. This prevents
// any warnings that might be sent to the console in that case.
ipcRenderer.setMaxListeners(0);
const DATABASE_UPDATE_TIMEOUT = 2 * 60 * 1000; // two minutes
const SQL_CHANNEL_KEY = 'sql-channel';
@ -44,6 +39,7 @@ let _shutdownPromise = null;
const channels = {};
module.exports = {
init,
_jobs,
_cleanData,
@ -212,6 +208,42 @@ module.exports = {
createOrUpdateSenderKeys,
};
function init() {
// We listen to a lot of events on ipcRenderer, often on the same channel. This prevents
// any warnings that might be sent to the console in that case.
ipcRenderer.setMaxListeners(0);
forEach(module.exports, fn => {
if (isFunction(fn) && fn.name !== 'init') {
makeChannel(fn.name);
}
});
ipcRenderer.on(
`${SQL_CHANNEL_KEY}-done`,
(event, jobId, errorForDisplay, result) => {
const job = _getJob(jobId);
if (!job) {
throw new Error(
`Received SQL channel reply to job ${jobId}, but did not have it in our registry!`
);
}
const { resolve, reject, fnName } = job;
if (errorForDisplay) {
return reject(
new Error(
`Error received from SQL channel job ${jobId} (${fnName}): ${errorForDisplay}`
)
);
}
return resolve(result);
}
);
}
// When IPC arguments are prepared for the cross-process send, they are JSON.stringified.
// We can't send ArrayBuffers or BigNumbers (what we get from proto library for dates).
function _cleanData(data) {
@ -352,30 +384,6 @@ function _getJob(id) {
return _jobs[id];
}
ipcRenderer.on(
`${SQL_CHANNEL_KEY}-done`,
(event, jobId, errorForDisplay, result) => {
const job = _getJob(jobId);
if (!job) {
throw new Error(
`Received SQL channel reply to job ${jobId}, but did not have it in our registry!`
);
}
const { resolve, reject, fnName } = job;
if (errorForDisplay) {
return reject(
new Error(
`Error received from SQL channel job ${jobId} (${fnName}): ${errorForDisplay}`
)
);
}
return resolve(result);
}
);
function makeChannel(fnName) {
channels[fnName] = (...args) => {
const jobId = _makeJob(fnName);
@ -398,12 +406,6 @@ function makeChannel(fnName) {
};
}
forEach(module.exports, fn => {
if (isFunction(fn)) {
makeChannel(fn.name);
}
});
function keysToArrayBuffer(keys, data) {
const updated = cloneDeep(data);
for (let i = 0, max = keys.length; i < max; i += 1) {

@ -97,16 +97,12 @@ const sendViaOnion = async (srvPubKey, url, fetchOptions, options = {}) => {
pathNodes = await lokiSnodeAPI.getOnionPath();
} catch (e) {
log.error(
`loki_app_dot_net:::sendViaOnion #${
options.requestNumber
} - getOnionPath Error ${e.code} ${e.message}`
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - getOnionPath Error ${e.code} ${e.message}`
);
}
if (!pathNodes || !pathNodes.length) {
log.warn(
`loki_app_dot_net:::sendViaOnion #${
options.requestNumber
} - failing, no path available`
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - failing, no path available`
);
// should we retry?
return {};
@ -139,9 +135,7 @@ const sendViaOnion = async (srvPubKey, url, fetchOptions, options = {}) => {
// handle error/retries
if (!result.status) {
log.error(
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - Retry #${
options.retry
} Couldnt handle onion request, retrying`,
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - Retry #${options.retry} Couldnt handle onion request, retrying`,
payloadObj
);
return sendViaOnion(srvPubKey, url, fetchOptions, {
@ -187,7 +181,7 @@ const sendViaOnion = async (srvPubKey, url, fetchOptions, options = {}) => {
// https://chat-dev.lokinet.org/loki/v1/channel/1/deletes?count=200&since_id=
// difference in response than all the other calls....
log.info(
`loki_app_dot_net:::sendViaOnion - got object response ${url.toString()}`
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - got object response ${url.toString()}`
);
}
// result.status has the http response code
@ -303,11 +297,7 @@ const sendToProxy = async (srvPubKey, endpoint, fetchOptions, options = {}) => {
randSnode
);
log.warn(
`loki_app_dot_net:::sendToProxy - Marking random snode bad, internet address ${
randSnode.ip
}:${
randSnode.port
}. ${randomPoolRemainingCount} snodes remaining in randomPool`
`loki_app_dot_net:::sendToProxy - Marking random snode bad, internet address ${randSnode.ip}:${randSnode.port}. ${randomPoolRemainingCount} snodes remaining in randomPool`
);
// retry (hopefully with new snode)
// FIXME: max number of retries...
@ -608,6 +598,14 @@ class LokiAppDotNetServerAPI {
// set up pubKey & pubKeyHex properties
// optionally called for mainly file server comms
getPubKeyForUrl() {
if (
!window.lokiFeatureFlags.useSnodeProxy &&
!window.lokiFeatureFlags.useOnionRequests
) {
// pubkeys don't matter
return '';
}
// Hard coded
let pubKeyAB;
if (urlPubkeyMap && urlPubkeyMap[this.baseServerUrl]) {
@ -1251,9 +1249,7 @@ class LokiPublicChannelAPI {
// end properties
log.info(
`registered LokiPublicChannel ${channelId} on ${
this.serverAPI.baseServerUrl
}`
`registered LokiPublicChannel ${channelId} on ${this.serverAPI.baseServerUrl}`
);
// start polling
this.open();
@ -1287,15 +1283,11 @@ class LokiPublicChannelAPI {
open() {
log.info(
`LokiPublicChannel open ${this.channelId} on ${
this.serverAPI.baseServerUrl
}`
`LokiPublicChannel open ${this.channelId} on ${this.serverAPI.baseServerUrl}`
);
if (this.running) {
log.warn(
`LokiPublicChannel already open ${this.channelId} on ${
this.serverAPI.baseServerUrl
}`
`LokiPublicChannel already open ${this.channelId} on ${this.serverAPI.baseServerUrl}`
);
}
this.running = true;
@ -1316,15 +1308,11 @@ class LokiPublicChannelAPI {
stop() {
log.info(
`LokiPublicChannel close ${this.channelId} on ${
this.serverAPI.baseServerUrl
}`
`LokiPublicChannel close ${this.channelId} on ${this.serverAPI.baseServerUrl}`
);
if (!this.running) {
log.warn(
`LokiPublicChannel already open ${this.channelId} on ${
this.serverAPI.baseServerUrl
}`
`LokiPublicChannel already open ${this.channelId} on ${this.serverAPI.baseServerUrl}`
);
}
this.running = false;
@ -1508,9 +1496,7 @@ class LokiPublicChannelAPI {
// used for sending messages
getEndpoint() {
const endpoint = `${this.serverAPI.baseServerUrl}/${
this.baseChannelUrl
}/messages`;
const endpoint = `${this.serverAPI.baseServerUrl}/${this.baseChannelUrl}/messages`;
return endpoint;
}
@ -1668,9 +1654,7 @@ class LokiPublicChannelAPI {
log.error(`pollOnceForDeletions Error ${res.err}`);
} else {
log.error(
`pollOnceForDeletions Error: Received incorrect response ${
res.response
}`
`pollOnceForDeletions Error: Received incorrect response ${res.response}`
);
}
break;

@ -1,4 +1,4 @@
/* global log, libloki, process, window */
/* global log, libloki, window */
/* global storage: false */
/* global Signal: false */
/* global log: false */
@ -20,13 +20,8 @@ class LokiFileServerInstance {
// LokiAppDotNetAPI (base) should not know about LokiFileServer.
async establishConnection(serverUrl, options) {
// why don't we extend this?
if (process.env.USE_STUBBED_NETWORK) {
// eslint-disable-next-line global-require
const StubAppDotNetAPI = require('../../integration_test/stubs/stub_app_dot_net_api.js');
this._server = new StubAppDotNetAPI(this.ourKey, serverUrl);
} else {
this._server = new LokiAppDotNetAPI(this.ourKey, serverUrl);
}
this._server = new LokiAppDotNetAPI(this.ourKey, serverUrl);
// make sure pubKey & pubKeyHex are set in _server
this.pubKey = this._server.getPubKeyForUrl();
@ -120,9 +115,7 @@ class LokiFileServerInstance {
newSlavePrimaryMap[slaveKey] !== auth.primaryDevicePubKey
) {
log.warn(
`file server user annotation primaryKey mismatch, had ${
newSlavePrimaryMap[slaveKey]
} now ${auth.primaryDevicePubKey} for ${slaveKey}`
`file server user annotation primaryKey mismatch, had ${newSlavePrimaryMap[slaveKey]} now ${auth.primaryDevicePubKey} for ${slaveKey}`
);
return;
}

@ -58,9 +58,7 @@ async function _retrieveNextMessages(nodeData, pubkey) {
if (result === false) {
// make a note of it because of caller doesn't care...
log.warn(
`loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${
nodeData.ip
}:${nodeData.port}`
`loki_message:::_retrieveNextMessages - lokiRpc could not talk to ${nodeData.ip}:${nodeData.port}`
);
return [];
@ -74,6 +72,8 @@ class LokiMessageAPI {
this.jobQueue = new window.JobQueue();
this.sendingData = {};
this.ourKey = ourKey;
// stop polling for a group if its id is no longer found here
this.groupIdsToPoll = {};
}
async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) {
@ -151,9 +151,7 @@ class LokiMessageAPI {
snode = await primitives.firstTrue(promises);
} catch (e) {
log.warn(
`loki_message:::sendMessage - ${e.code} ${e.message} to ${pubKey} via ${
snode.ip
}:${snode.port}`
`loki_message:::sendMessage - ${e.code} ${e.message} to ${pubKey} via ${snode.ip}:${snode.port}`
);
if (e instanceof textsecure.WrongDifficultyError) {
// Force nonce recalculation
@ -173,9 +171,7 @@ class LokiMessageAPI {
);
}
log.info(
`loki_message:::sendMessage - Successfully stored message to ${pubKey} via ${
snode.ip
}:${snode.port}`
`loki_message:::sendMessage - Successfully stored message to ${pubKey} via ${snode.ip}:${snode.port}`
);
}
@ -244,9 +240,7 @@ class LokiMessageAPI {
if (result === false) {
// this means the node we asked for is likely down
log.warn(
`loki_message:::_sendToNode - Try #${successiveFailures}/${MAX_ACCEPTABLE_FAILURES} ${
targetNode.ip
}:${targetNode.port} failed`
`loki_message:::_sendToNode - Try #${successiveFailures}/${MAX_ACCEPTABLE_FAILURES} ${targetNode.ip}:${targetNode.port} failed`
);
successiveFailures += 1;
// eslint-disable-next-line no-continue
@ -301,9 +295,7 @@ class LokiMessageAPI {
targetNode
);
log.error(
`loki_message:::_sendToNode - Too many successive failures trying to send to node ${
targetNode.ip
}:${targetNode.port}, ${remainingSwarmSnodes.lengt} remaining swarm nodes`
`loki_message:::_sendToNode - Too many successive failures trying to send to node ${targetNode.ip}:${targetNode.port}, ${remainingSwarmSnodes.lengt} remaining swarm nodes`
);
return false;
}
@ -325,7 +317,7 @@ class LokiMessageAPI {
);
// eslint-disable-next-line no-constant-condition
while (true) {
while (this.groupIdsToPoll[groupId]) {
try {
let messages = await _retrieveNextMessages(node, groupId);
@ -384,6 +376,13 @@ class LokiMessageAPI {
async pollForGroupId(groupId, onMessages) {
log.info(`Starting to poll for group id: ${groupId}`);
if (this.groupIdsToPoll[groupId]) {
log.warn(`Already polling for group id: ${groupId}`);
return;
}
this.groupIdsToPoll[groupId] = true;
// Get nodes for groupId
const nodes = await lokiSnodeAPI.refreshSwarmNodesForPubKey(groupId);
@ -394,6 +393,16 @@ class LokiMessageAPI {
);
}
async stopPollingForGroup(groupId) {
if (!this.groupIdsToPoll[groupId]) {
log.warn(`Already not polling for group id: ${groupId}`);
return;
}
log.warn(`Stop polling for group id: ${groupId}`);
delete this.groupIdsToPoll[groupId];
}
async _openRetrieveConnection(pSwarmPool, stopPollingPromise, onMessages) {
const swarmPool = pSwarmPool; // lint
let stopPollingResult = false;
@ -515,9 +524,7 @@ class LokiMessageAPI {
// Start polling for medium size groups as well (they might be in different swarms)
{
const convos = window
.getConversations()
.filter(c => c.get('is_medium_group'));
const convos = window.getConversations().filter(c => c.isMediumGroup());
const self = this;

@ -115,9 +115,7 @@ function abortableIterator(array, iterator) {
accum.push(await iterator(item));
} catch (e) {
log.error(
`loki_primitives:::abortableIterator - error ${e.code} ${
e.message
}`
`loki_primitives:::abortableIterator - error ${e.code} ${e.message}`
);
throw e;
}

@ -67,7 +67,7 @@ const makeOnionRequest = async (
for (let i = firstPos; i > -1; i -= 1) {
let dest;
const relayingToFinalDestination = i === 0; // if last position
const relayingToFinalDestination = i === firstPos; // if last position
if (relayingToFinalDestination && finalRelayOptions) {
dest = {
@ -284,9 +284,7 @@ const processOnionResponse = async (
if (response.status !== 200) {
log.warn(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${
response.status
}`
`(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}`
);
return false;
}
@ -463,9 +461,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
const ciphertext = await response.text();
log.warn(
`lokiRpc:::sendToProxy -`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${targetNode.port}`,
`snode is decom or dereg: `,
ciphertext,
// `marking random snode bad ${randomPoolRemainingCount} remaining`
@ -510,9 +506,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
const ciphertext = await response.text();
log.warn(
`lokiRpc:::sendToProxy -`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${targetNode.port}`,
`code ${response.status} error`,
ciphertext,
// `marking random snode bad ${randomPoolRemainingCount} remaining`
@ -527,9 +521,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// lets mark the target node bad for now
// we'll just rotate it back in if it's a net problem
log.warn(
`lokiRpc:::sendToProxy - Failing ${targetNode.ip}:${
targetNode.port
} after 5 retries`
`lokiRpc:::sendToProxy - Failing ${targetNode.ip}:${targetNode.port} after 5 retries`
);
if (options.ourPubKey) {
lokiSnodeAPI.unreachableNode(options.ourPubKey, targetNode);
@ -555,9 +547,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
log.warn(
'lokiRpc:::sendToProxy - fetch non-200 statusCode',
response.status,
`from snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`
`from snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${targetNode.port}`
);
return false;
}
@ -595,9 +585,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
'lokiRpc:::sendToProxy - decode error',
e.code,
e.message,
`from ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
} ciphertext:`,
`from ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${targetNode.port} ciphertext:`,
ciphertext
);
if (ciphertextBuffer) {
@ -633,9 +621,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
if (retryNumber) {
log.debug(
`lokiRpc:::sendToProxy - request succeeded,`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${
targetNode.port
}`,
`snode ${randSnode.ip}:${randSnode.port} to ${targetNode.ip}:${targetNode.port}`,
`on retry #${retryNumber}`
);
}

@ -5,7 +5,8 @@
dcodeIO,
libloki,
log,
crypto
crypto,
textsecure
*/
/* eslint-disable more/no-then */
@ -39,9 +40,7 @@ async function saveSenderKeysInner(
}
// Save somebody else's key
async function saveSenderKeys(groupId, senderIdentity, chainKey) {
// New key, so index 0
const keyIdx = 0;
async function saveSenderKeys(groupId, senderIdentity, chainKey, keyIdx) {
const messageKeys = {};
await saveSenderKeysInner(
groupId,
@ -133,7 +132,7 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
return null;
throw new textsecure.SenderKeyMissing(senderIdentity);
}
// Normally keyIdx will be 1 behind, in which case we stepRatchet one time only
@ -178,7 +177,10 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
curMessageKey = messageKey;
break;
} else if (nextKeyIdx > idx) {
log.error('Developer error: nextKeyIdx > idx');
log.error(
`Could not decrypt for an older ratchet step: (${nextKeyIdx})nextKeyIdx > (${idx})idx`
);
throw new Error(`Cannot revert ratchet for group ${groupId}!`);
} else {
// Store keys for skipped nextKeyIdx, we might need them to decrypt
// messages that arrive out-of-order
@ -289,9 +291,16 @@ async function encryptWithSenderKeyInner(plaintext, groupId, ourIdentity) {
return { ciphertext, keyIdx };
}
async function getSenderKeys(groupId, senderIdentity) {
const { chainKey, keyIdx } = await loadChainKey(groupId, senderIdentity);
return { chainKey, keyIdx };
}
module.exports = {
createSenderKeyForGroup,
encryptWithSenderKey,
decryptWithSenderKey,
saveSenderKeys,
getSenderKeys,
};

@ -17,6 +17,7 @@ const snodeHttpsAgent = new https.Agent({
const RANDOM_SNODES_TO_USE_FOR_PUBKEY_SWARM = 3;
const SEED_NODE_RETRIES = 3;
const SNODE_VERSION_RETRIES = 3;
const MIN_GUARD_COUNT = 2;
const compareSnodes = (current, search) =>
current.pubkey_ed25519 === search.pubkey_ed25519;
@ -27,7 +28,7 @@ async function tryGetSnodeListFromLokidSeednode(
) {
if (!seedNodes.length) {
log.info(
'LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - seedNodes are empty'
'loki_snode_api::tryGetSnodeListFromLokidSeednode - seedNodes are empty'
);
return [];
}
@ -47,7 +48,7 @@ async function tryGetSnodeListFromLokidSeednode(
const seedNode = seedNodes[Math.floor(Math.random() * seedNodes.length)];
if (!seedNode) {
log.warn(
'LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - Could not select random snodes from',
'loki_snode_api::tryGetSnodeListFromLokidSeednode - Could not select random snodes from',
seedNodes
);
return [];
@ -65,7 +66,7 @@ async function tryGetSnodeListFromLokidSeednode(
);
if (!response) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid response from seed ${urlObj.toString()}:`,
`loki_snode_api:::tryGetSnodeListFromLokidSeednode - invalid response from seed ${urlObj.toString()}:`,
response
);
return [];
@ -74,7 +75,7 @@ async function tryGetSnodeListFromLokidSeednode(
// should we try to JSON.parse this?
if (typeof response === 'string') {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid string response from seed ${urlObj.toString()}:`,
`loki_snode_api:::tryGetSnodeListFromLokidSeednode - invalid string response from seed ${urlObj.toString()}:`,
response
);
return [];
@ -82,7 +83,7 @@ async function tryGetSnodeListFromLokidSeednode(
if (!response.result) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid result from seed ${urlObj.toString()}:`,
`loki_snode_api:::tryGetSnodeListFromLokidSeednode - invalid result from seed ${urlObj.toString()}:`,
response
);
return [];
@ -97,7 +98,7 @@ async function tryGetSnodeListFromLokidSeednode(
// throw before clearing the lock, so the retries can kick in
if (snodes.length === 0) {
log.warn(
`LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - ${
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${
seedNode.url
} did not return any snodes, falling back to IP`,
seedNode.ip_url
@ -107,7 +108,7 @@ async function tryGetSnodeListFromLokidSeednode(
snodes = await getSnodesFromSeedUrl(tryIpUrl);
if (snodes.length === 0) {
log.warn(
`LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - ${
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${
seedNode.ip_url
} did not return any snodes`
);
@ -117,11 +118,13 @@ async function tryGetSnodeListFromLokidSeednode(
);
}
}
log.info(
`LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - ${
seedNode.url
} returned ${snodes.length} snodes`
);
if (snodes.length) {
log.info(
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${
seedNode.url
} returned ${snodes.length} snodes`
);
}
return snodes;
} catch (e) {
log.warn(
@ -144,7 +147,7 @@ async function getSnodeListFromLokidSeednode(
) {
if (!seedNodes.length) {
log.info(
'LokiSnodeAPI::getSnodeListFromLokidSeednode - seedNodes are empty'
'loki_snode_api::getSnodeListFromLokidSeednode - seedNodes are empty'
);
return [];
}
@ -153,7 +156,7 @@ async function getSnodeListFromLokidSeednode(
snodes = await tryGetSnodeListFromLokidSeednode(seedNodes);
} catch (e) {
log.warn(
'LokiSnodeAPI::getSnodeListFromLokidSeednode - error',
'loki_snode_api::getSnodeListFromLokidSeednode - error',
e.code,
e.message
);
@ -161,7 +164,7 @@ async function getSnodeListFromLokidSeednode(
if (retries < SEED_NODE_RETRIES) {
setTimeout(() => {
log.info(
'LokiSnodeAPI::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
retries,
'seed nodes total',
seedNodes.length
@ -169,7 +172,7 @@ async function getSnodeListFromLokidSeednode(
getSnodeListFromLokidSeednode(seedNodes, retries + 1);
}, retries * retries * 5000);
} else {
log.error('LokiSnodeAPI::getSnodeListFromLokidSeednode - failing');
log.error('loki_snode_api::getSnodeListFromLokidSeednode - failing');
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
}
}
@ -279,20 +282,17 @@ class LokiSnodeAPI {
let guardNodes = [];
const DESIRED_GUARD_COUNT = 3;
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(
`Could not select guard nodes: node pool is not big enough, pool size ${
shuffled.length
}, need ${DESIRED_GUARD_COUNT}, attempting to refresh randomPool`
`Could not select guard nodes: node pool is not big enough, pool size ${shuffled.length}, need ${DESIRED_GUARD_COUNT}, attempting to refresh randomPool`
);
await this.refreshRandomPool();
nodePool = await this.getRandomSnodePool();
shuffled = _.shuffle(nodePool);
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(
`Could not select guard nodes: node pool is not big enough, pool size ${
shuffled.length
}, need ${DESIRED_GUARD_COUNT}, failing...`
`Could not select guard nodes: node pool is not big enough, pool size ${shuffled.length}, need ${DESIRED_GUARD_COUNT}, failing...`
);
return [];
}
@ -342,7 +342,7 @@ class LokiSnodeAPI {
let goodPaths = this.onionPaths.filter(x => !x.bad);
let attemptNumber = 0;
while (goodPaths.length < 2) {
while (goodPaths.length < MIN_GUARD_COUNT) {
log.error(
`Must have at least 2 good onion paths, actual: ${
goodPaths.length
@ -382,7 +382,7 @@ class LokiSnodeAPI {
// await this.buildNewOnionPaths();
// and restart call?
log.error(
`loki_snode_api::getOnionPath - no paths without`,
`LokiSnodeAPI::getOnionPath - no paths without`,
toExclude.pubkey_ed25519,
'path count',
paths.length,
@ -452,7 +452,8 @@ class LokiSnodeAPI {
}
// If guard nodes is still empty (the old nodes are now invalid), select new ones:
if (this.guardNodes.length === 0) {
if (this.guardNodes.length < MIN_GUARD_COUNT) {
// TODO: don't throw away potentially good guard nodes
this.guardNodes = await this.selectGuardNodes();
}
}
@ -644,12 +645,12 @@ class LokiSnodeAPI {
// give stats
const diff = Date.now() - verionStart;
log.debug(
`loki_snode:::_getAllVerionsForRandomSnodePool - ${count}/${total} pool version status update, has taken ${diff.toLocaleString()}ms`
`LokiSnodeAPI:::_getAllVerionsForRandomSnodePool - ${count}/${total} pool version status update, has taken ${diff.toLocaleString()}ms`
);
Object.keys(this.versionPools).forEach(version => {
const nodes = this.versionPools[version].length;
log.debug(
`loki_snode:::_getAllVerionsForRandomSnodePool - version ${version} has ${nodes.toLocaleString()} snodes`
`LokiSnodeAPI:::_getAllVerionsForRandomSnodePool - version ${version} has ${nodes.toLocaleString()} snodes`
);
});
}
@ -687,7 +688,7 @@ class LokiSnodeAPI {
if (!seedNodes.length) {
if (!window.seedNodeList || !window.seedNodeList.length) {
log.error(
`loki_snodes:::refreshRandomPool - seedNodeList has not been loaded yet`
`LokiSnodeAPI:::refreshRandomPool - seedNodeList has not been loaded yet`
);
return [];
}
@ -724,7 +725,7 @@ class LokiSnodeAPI {
log.warn('LokiSnodeAPI::refreshRandomPool - error', e.code, e.message);
/*
log.error(
'loki_snodes:::refreshRandomPoolPromise - Giving up trying to contact seed node'
'LokiSnodeAPI:::refreshRandomPoolPromise - Giving up trying to contact seed node'
);
*/
if (snodes.length === 0) {
@ -898,73 +899,122 @@ class LokiSnodeAPI {
}
}
async getLnsMapping(lnsName) {
async getLnsMapping(lnsName, timeout) {
// Returns { pubkey, error }
// pubkey is
// undefined when unconfirmed or no mapping found
// string when found
// timeout parameter optional (ms)
// How many nodes to fetch data from?
const numRequests = 5;
// How many nodes must have the same response value?
const numRequiredConfirms = 3;
let ciphertextHex;
let pubkey;
let error;
const _ = window.Lodash;
const input = Buffer.from(lnsName);
const output = await window.blake2b(input);
const nameHash = dcodeIO.ByteBuffer.wrap(output).toString('base64');
// Timeouts
const maxTimeoutVal = 2 ** 31 - 1;
const timeoutPromise = () =>
new Promise((_resolve, reject) =>
setTimeout(() => reject(), timeout || maxTimeoutVal)
);
// Get nodes capable of doing LNS
const lnsNodes = this.getNodesMinVersion('2.0.3');
// randomPool should already be shuffled
// lnsNodes = _.shuffle(lnsNodes);
const lnsNodes = await this.getNodesMinVersion(
window.CONSTANTS.LNS_CAPABLE_NODES_VERSION
);
// Loop until 3 confirmations
// Enough nodes?
if (lnsNodes.length < numRequiredConfirms) {
error = { lnsTooFewNodes: window.i18n('lnsTooFewNodes') };
return { pubkey, error };
}
// We don't trust any single node, so we accumulate
// answers here and select a dominating answer
const allResults = [];
let ciphertextHex = null;
const confirmedNodes = [];
while (!ciphertextHex) {
if (lnsNodes.length < 3) {
log.error('Not enough nodes for lns lookup');
return false;
}
// Promise is only resolved when a consensus is found
let cipherResolve;
const cipherPromise = () =>
new Promise(resolve => {
cipherResolve = resolve;
});
// extract 3 and make requests in parallel
const nodes = lnsNodes.splice(0, 3);
const decryptHex = async cipherHex => {
const ciphertext = new Uint8Array(StringView.hexToArrayBuffer(cipherHex));
// eslint-disable-next-line no-await-in-loop
const results = await Promise.all(
nodes.map(node => this._requestLnsMapping(node, nameHash))
);
const res = await window.decryptLnsEntry(lnsName, ciphertext);
const publicKey = StringView.arrayBufferToHex(res);
results.forEach(res => {
if (
res &&
res.result &&
res.result.status === 'OK' &&
res.result.entries &&
res.result.entries.length > 0
) {
allResults.push(results[0].result.entries[0].encrypted_value);
}
});
return publicKey;
};
const [winner, count] = _.maxBy(
_.entries(_.countBy(allResults)),
x => x[1]
);
const fetchFromNode = async node => {
const res = await this._requestLnsMapping(node, nameHash);
// Do validation
if (res && res.result && res.result.status === 'OK') {
const hasMapping = res.result.entries && res.result.entries.length > 0;
const resValue = hasMapping
? res.result.entries[0].encrypted_value
: null;
confirmedNodes.push(resValue);
if (confirmedNodes.length >= numRequiredConfirms) {
if (ciphertextHex) {
// Result already found, dont worry
return;
}
const [winner, count] = _.maxBy(
_.entries(_.countBy(confirmedNodes)),
x => x[1]
);
if (count >= 3) {
// eslint-disable-next-lint prefer-destructuring
ciphertextHex = winner;
if (count >= numRequiredConfirms) {
ciphertextHex = winner === String(null) ? null : winner;
// null represents no LNS mapping
if (ciphertextHex === null) {
error = { lnsMappingNotFound: window.i18n('lnsMappingNotFound') };
}
cipherResolve({ ciphertextHex });
}
}
}
}
};
const ciphertext = new Uint8Array(
StringView.hexToArrayBuffer(ciphertextHex)
);
const nodes = lnsNodes.splice(0, numRequests);
const res = await window.decryptLnsEntry(lnsName, ciphertext);
// Start fetching from nodes
nodes.forEach(node => fetchFromNode(node));
const pubkey = StringView.arrayBufferToHex(res);
// Timeouts (optional parameter)
// Wait for cipher to be found; race against timeout
// eslint-disable-next-line more/no-then
await Promise.race([cipherPromise, timeoutPromise].map(f => f()))
.then(async () => {
if (ciphertextHex !== null) {
pubkey = await decryptHex(ciphertextHex);
}
})
.catch(() => {
error = { lnsLookupTimeout: window.i18n('lnsLookupTimeout') };
});
return pubkey;
return { pubkey, error };
}
// get snodes for pubkey from random snode
@ -1011,9 +1061,7 @@ class LokiSnodeAPI {
'LokiSnodeAPI::_getSnodesForPubkey - error',
e.code,
e.message,
`for ${snode.ip}:${
snode.port
}. ${randomPoolRemainingCount} snodes remaining in randomPool`
`for ${snode.ip}:${snode.port}. ${randomPoolRemainingCount} snodes remaining in randomPool`
);
return [];
}

@ -263,6 +263,8 @@ function initializeMigrations({
exports.setup = (options = {}) => {
const { Attachments, userDataPath, getRegionCode, logger } = options;
Data.init();
const Migrations = initializeMigrations({
userDataPath,
getRegionCode,

@ -1,4 +1,4 @@
/* global Whisper, i18n, textsecure, _ */
/* global Whisper, i18n, textsecure, libloki, _ */
// eslint-disable-next-line func-names
(function() {
@ -179,7 +179,8 @@
this.$el.append(this.dialogView.el);
return this;
},
onSubmit(newMembers) {
async onSubmit(newMembers) {
const _ = window.Lodash;
const ourPK = textsecure.storage.user.getNumber();
const allMembers = window.Lodash.concat(newMembers, [ourPK]);
@ -187,11 +188,31 @@
const notPresentInOld = allMembers.filter(
m => !this.existingMembers.includes(m)
);
const notPresentInNew = this.existingMembers.filter(
m => !allMembers.includes(m)
);
// would be easer with _.xor but for some reason we do not have it
const xor = notPresentInNew.concat(notPresentInOld);
// Filter out all linked devices for cases in which one device
// exists in group, but hasn't yet synced with its other devices.
const getDevicesForRemoved = async () => {
const promises = notPresentInNew.map(member =>
libloki.storage.getPairedDevicesFor(member)
);
const devices = _.flatten(await Promise.all(promises));
return devices;
};
// Get all devices for notPresentInNew
const allDevicesOfMembersToRemove = await getDevicesForRemoved();
// If any extra devices of removed exist in newMembers, ensure that you filter them
const filteredMemberes = allMembers.filter(
member => !_.includes(allDevicesOfMembersToRemove, member)
);
const xor = _.xor(notPresentInNew, notPresentInOld);
if (xor.length === 0) {
window.console.log(
'skipping group update: no detected changes in group member list'
@ -203,7 +224,7 @@
window.doUpdateGroup(
this.groupId,
this.groupName,
allMembers,
filteredMemberes,
this.avatarPath
);
},

@ -462,7 +462,7 @@
blob = window.dataURLToBlobSync(
canvas.toDataURL('image/jpeg', quality)
);
quality = quality * maxSize / blob.size;
quality = (quality * maxSize) / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax

@ -7,8 +7,8 @@
window.Whisper = window.Whisper || {};
/*
* Render an avatar identicon to an svg for use in a notification.
*/
* Render an avatar identicon to an svg for use in a notification.
*/
Whisper.IdenticonSVGView = Whisper.View.extend({
templateName: 'identicon-svg',
initialize(options) {

@ -7,9 +7,9 @@
window.Whisper = window.Whisper || {};
/*
* Generic list view that watches a given collection, wraps its members in
* a given child view and adds the child view elements to its own element.
*/
* Generic list view that watches a given collection, wraps its members in
* a given child view and adds the child view elements to its own element.
*/
Whisper.ListView = Backbone.View.extend({
tagName: 'ul',
itemView: Backbone.View,

@ -21,8 +21,7 @@
const debugFlags = DebugFlagsEnum.ALL;
const debugLogFn = (...args) => {
if (true) {
// process.env.NODE_ENV.includes('test-integration') ||
if (window.lokiFeatureFlags.debugMessageLogs) {
window.console.warn(...args);
}
};
@ -46,7 +45,7 @@
}
function logContactSync(...args) {
if (debugFlags & DebugFlagsEnum.GROUP_CONTACT_MESSAGES) {
if (debugFlags & DebugFlagsEnum.CONTACT_SYNC_MESSAGES) {
debugLogFn(...args);
}
}
@ -90,38 +89,22 @@
const message = textsecure.OutgoingMessage.buildSessionEstablishedMessage(
pubKey
);
await message.sendToNumber(pubKey);
await message.sendToNumber(pubKey, false);
}
async function sendBackgroundMessage(pubKey, debugMessageType) {
const primaryPubKey = await getPrimaryDevicePubkey(pubKey);
if (primaryPubKey !== pubKey) {
// if we got the secondary device pubkey first,
// call ourself again with the primary device pubkey
await sendBackgroundMessage(primaryPubKey, debugMessageType);
return;
}
const backgroundMessage = textsecure.OutgoingMessage.buildBackgroundMessage(
pubKey,
debugMessageType
);
await backgroundMessage.sendToNumber(pubKey);
await backgroundMessage.sendToNumber(pubKey, false);
}
async function sendAutoFriendRequestMessage(pubKey) {
const primaryPubKey = await getPrimaryDevicePubkey(pubKey);
if (primaryPubKey !== pubKey) {
// if we got the secondary device pubkey first,
// call ourself again with the primary device pubkey
await sendAutoFriendRequestMessage(primaryPubKey);
return;
}
const autoFrMessage = textsecure.OutgoingMessage.buildAutoFriendRequestMessage(
pubKey
);
await autoFrMessage.sendToNumber(pubKey);
await autoFrMessage.sendToNumber(pubKey, false);
}
function createPairingAuthorisationProtoMessage({
@ -157,7 +140,7 @@
const unpairingMessage = textsecure.OutgoingMessage.buildUnpairingMessage(
pubKey
);
return unpairingMessage.sendToNumber(pubKey);
return unpairingMessage.sendToNumber(pubKey, false);
}
// Serialise as <Element0.length><Element0><Element1.length><Element1>...
// This is an implementation of the reciprocal of contacts_parser.js
@ -220,6 +203,7 @@
});
return syncMessage;
}
function createGroupSyncProtoMessage(sessionGroup) {
// We are getting a single open group here
@ -296,7 +280,7 @@
callback
);
pairingRequestMessage.sendToNumber(recipientPubKey);
pairingRequestMessage.sendToNumber(recipientPubKey, false);
});
return p;
}
@ -346,6 +330,7 @@
createContactSyncProtoMessage,
createGroupSyncProtoMessage,
createOpenGroupsSyncProtoMessage,
getPrimaryDevicePubkey,
debug,
};
})();

@ -149,10 +149,13 @@
myPrivateKey
);
const ivAndCiphertext = await DHEncrypt(symmetricKey, plaintext);
const binaryIvAndCiphertext = dcodeIO.ByteBuffer.wrap(
ivAndCiphertext
).toString('binary');
return {
type: textsecure.protobuf.Envelope.Type.FRIEND_REQUEST,
body: ivAndCiphertext,
registrationId: null,
body: binaryIvAndCiphertext,
registrationId: undefined,
};
}
@ -170,9 +173,7 @@
return await DHDecrypt(symmetricKey, ivAndCiphertext);
} catch (e) {
throw new FallBackDecryptionError(
`Could not decrypt message from ${
this.identityKeyString
} using FallBack encryption.`
`Could not decrypt message from ${this.identityKeyString} using FallBack encryption.`
);
}
}

@ -47,7 +47,11 @@ describe('ServiceNodes', () => {
it('should return the union of all lists when threshold is 0', () => {
const result = libloki.serviceNodes.consolidateLists(
[['a', 'b', 'c', 'h'], ['d', 'e', 'f', 'g'], ['g', 'h']],
[
['a', 'b', 'c', 'h'],
['d', 'e', 'f', 'g'],
['g', 'h'],
],
0
);
assert.deepEqual(result.sort(), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']);
@ -68,7 +72,10 @@ describe('ServiceNodes', () => {
{ id: 6, val: 'f' },
{ id: 7, val: 'g' },
],
[{ id: 7, val: 'g' }, { id: 8, val: 'h' }],
[
{ id: 7, val: 'g' },
{ id: 8, val: 'h' },
],
],
0,
x => x.id
@ -83,12 +90,19 @@ describe('ServiceNodes', () => {
{ id: 7, val: 'g' },
{ id: 8, val: 'h' },
];
assert.deepEqual(result.sort((a, b) => a.val > b.val), expected);
assert.deepEqual(
result.sort((a, b) => a.val > b.val),
expected
);
});
it('should return the intersection of all lists when threshold is 1', () => {
const result = libloki.serviceNodes.consolidateLists(
[['a', 'b', 'c', 'd'], ['a', 'e', 'f', 'g'], ['a', 'h']],
[
['a', 'b', 'c', 'd'],
['a', 'e', 'f', 'g'],
['a', 'h'],
],
1
);
assert.deepEqual(result, ['a']);

@ -619,10 +619,13 @@
}
);
// Send sync messages
const conversations = window.getConversations().models;
textsecure.messaging.sendContactSyncMessage(conversations);
textsecure.messaging.sendGroupSyncMessage(conversations);
textsecure.messaging.sendOpenGroupsSyncMessage(conversations);
// bad hack to send sync messages when secondary device is ready to process them
setTimeout(async () => {
const conversations = window.getConversations().models;
await textsecure.messaging.sendGroupSyncMessage(conversations);
await textsecure.messaging.sendOpenGroupsSyncMessage(conversations);
await textsecure.messaging.sendContactSyncMessage(conversations);
}, 5000);
},
validatePubKeyHex(pubKey) {
const c = new Whisper.Conversation({

@ -263,6 +263,19 @@
}
}
function SenderKeyMissing(senderIdentity) {
this.name = 'SenderKeyMissing';
this.senderIdentity = senderIdentity;
Error.call(this, this.name);
// Maintains proper stack trace, where our error was thrown (only available on V8)
// via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
if (Error.captureStackTrace) {
Error.captureStackTrace(this);
}
}
window.textsecure.UnregisteredUserError = UnregisteredUserError;
window.textsecure.SendMessageNetworkError = SendMessageNetworkError;
window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError;
@ -282,4 +295,5 @@
window.textsecure.TimestampError = TimestampError;
window.textsecure.PublicChatError = PublicChatError;
window.textsecure.PublicTokenError = PublicTokenError;
window.textsecure.SenderKeyMissing = SenderKeyMissing;
})();

@ -101,6 +101,9 @@
NUM_CONCURRENT_CONNECTIONS,
stopPolling,
messages => {
if (this.calledStop) {
return; // don't handle those messages
}
connected = true;
messages.forEach(message => {
this.handleMessage(message.data, {
@ -127,6 +130,9 @@
// Exhausted all our snodes urls, trying again later from scratch
setTimeout(() => {
if (this.calledStop) {
return; // don't restart
}
window.log.info(
`http-resource: Exhausted all our snodes urls, trying again in ${EXHAUSTED_SNODES_RETRY_DELAY /
1000}s from scratch`

@ -59,7 +59,7 @@ function MessageReceiver(username, password, signalingKey, options = {}) {
openGroupBound = true;
}
} else {
window.log.error('Can not handle open group data, API is not available');
window.log.warn('Can not handle open group data, API is not available');
}
}
@ -682,7 +682,7 @@ MessageReceiver.prototype.extend({
const { senderIdentity } = envelope;
const {
ciphertext: ciphertext2,
ciphertext: outerCiphertext,
ephemeralKey,
} = textsecure.protobuf.MediumGroupContent.decode(ciphertextObj);
@ -692,16 +692,16 @@ MessageReceiver.prototype.extend({
'hex'
).toArrayBuffer();
const res = await libloki.crypto.decryptForPubkey(
const mediumGroupCiphertext = await libloki.crypto.decryptForPubkey(
secretKey,
ephemKey,
ciphertext2.toArrayBuffer()
outerCiphertext.toArrayBuffer()
);
const {
ciphertext,
keyIdx,
} = textsecure.protobuf.MediumGroupCiphertext.decode(res);
} = textsecure.protobuf.MediumGroupCiphertext.decode(mediumGroupCiphertext);
const plaintext = await window.SenderKeyAPI.decryptWithSenderKey(
ciphertext.toArrayBuffer(),
@ -849,6 +849,22 @@ MessageReceiver.prototype.extend({
return promise
.then(plaintext => this.postDecrypt(envelope, plaintext))
.catch(error => {
if (error && error instanceof textsecure.SenderKeyMissing) {
const groupId = envelope.source;
const { senderIdentity } = error;
log.info(
'Requesting missing key for identity: ',
senderIdentity,
'groupId: ',
groupId
);
textsecure.messaging.requestSenderKeys(senderIdentity, groupId);
return;
}
let errorToThrow = error;
const noSession =
@ -876,7 +892,7 @@ MessageReceiver.prototype.extend({
ev.confirm = this.removeFromCache.bind(this, envelope);
const returnError = () => Promise.reject(errorToThrow);
return this.dispatchAndWait(ev).then(returnError, returnError);
this.dispatchAndWait(ev).then(returnError, returnError);
});
},
async decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address) {
@ -900,7 +916,7 @@ MessageReceiver.prototype.extend({
},
// handle a SYNC message for a message
// sent by another device
handleSentMessage(envelope, sentContainer, msg) {
async handleSentMessage(envelope, sentContainer, msg) {
const {
destination,
timestamp,
@ -908,41 +924,64 @@ MessageReceiver.prototype.extend({
unidentifiedStatus,
} = sentContainer;
let p = Promise.resolve();
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
p = this.handleEndSession(destination);
await this.handleEndSession(destination);
}
return p.then(() =>
this.processDecrypted(envelope, msg).then(message => {
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
// handle profileKey and avatar updates
if (envelope.source === primaryDevicePubKey) {
const { profileKey, profile } = message;
const primaryConversation = ConversationController.get(
primaryDevicePubKey
);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
}
}
// if (msg.mediumGroupUpdate) {
// await this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
// return;
// }
const ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination,
timestamp: timestamp.toNumber(),
device: envelope.sourceDevice,
unidentifiedStatus,
message,
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
return this.dispatchAndWait(ev);
})
);
const message = await this.processDecrypted(envelope, msg);
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
// const groupId = message.group && message.group.id;
// const isBlocked = this.isGroupBlocked(groupId);
//
// const isMe =
// envelope.source === textsecure.storage.user.getNumber() ||
// envelope.source === primaryDevicePubKey;
// const isLeavingGroup = Boolean(
// message.group &&
// message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
// );
// if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
// window.log.warn(
// `Message ${this.getEnvelopeId(
// envelope
// )} ignored; destined for blocked group`
// );
// this.removeFromCache(envelope);
// return;
// }
// handle profileKey and avatar updates
if (envelope.source === primaryDevicePubKey) {
const { profileKey, profile } = message;
const primaryConversation = ConversationController.get(
primaryDevicePubKey
);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
}
}
const ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination,
timestamp: timestamp.toNumber(),
device: envelope.sourceDevice,
unidentifiedStatus,
message,
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
this.dispatchAndWait(ev);
},
async handleLokiAddressMessage(envelope) {
window.log.warn('Ignoring a Loki address message');
@ -1163,96 +1202,180 @@ MessageReceiver.prototype.extend({
},
async handleMediumGroupUpdate(envelope, groupUpdate) {
const {
groupId,
groupSecretKey,
senderKey,
members,
groupName,
} = groupUpdate;
const { type, groupId } = groupUpdate;
const convoExists = window.ConversationController.get(groupId, 'group');
const ourIdentity = await textsecure.storage.user.getNumber();
const senderIdentity = envelope.source;
if (convoExists) {
// If the group already exists, check that `members` is empty,
// and if so, it is sender key message
if (
type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST
) {
log.debug('[sender key] sender key request from:', senderIdentity);
// TODO: introduce TYPE into this message instead?
if (!members || !members.length) {
log.info('[sender key] got a new sender key from:', envelope.source);
const proto = new textsecure.protobuf.DataMessage();
// We probably don't need to await here
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
const { chainKey, keyIdx } = await window.SenderKeyAPI.getSenderKeys(
groupId,
ourIdentity
);
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY) {
const { senderKey } = groupUpdate;
log.debug('[sender key] got a new sender key from:', senderIdentity);
await window.SenderKeyAPI.saveSenderKeys(
groupId,
senderIdentity,
senderKey.chainKey,
senderKey.keyIdx
);
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP) {
const maybeConvo = await window.ConversationController.get(groupId);
const groupExists = !!maybeConvo;
const {
members: membersBinary,
groupSecretKey,
groupName,
senderKey,
admins,
} = groupUpdate;
const members = membersBinary.map(pk =>
StringView.arrayBufferToHex(pk.toArrayBuffer())
);
const convo = groupExists
? maybeConvo
: await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
this.removeFromCache(envelope);
return;
{
// 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: groupName,
members,
},
});
const messageId = await window.Signal.Data.saveMessage(
message.attributes,
{
Message: Whisper.Message,
}
);
message.set({ id: messageId });
}
log.error(`Conversation for groupId ${groupId} already exists`);
}
if (groupExists) {
// ***** Updating the group *****
log.info('Received a group update for medium group:', groupId);
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
// Check that the sender is admin (make sure it words with multidevice)
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKey,
});
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
this.removeFromCache(envelope);
return;
}
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
convo.set('name', groupName);
convo.set('members', members);
// TODO: Check that we are even a part of this group?
const ourIdentity = await textsecure.storage.user.getNumber();
// TODO: check that we are still in the group (when we enable deleting members)
convo.saveChangesToDB();
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
// 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: Send own key to every member
// TODO: Check that we are even a part of this group?
const otherMembers = _.without(members, ourIdentity);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.set('groupAdmins', admins);
const proto = new textsecure.protobuf.DataMessage();
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
update.groupId = groupId;
update.senderKey = ownSenderKey;
const secretKeyHex = StringView.arrayBufferToHex(
groupSecretKey.toArrayBuffer()
);
proto.mediumGroupUpdate = update;
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey.chainKey,
senderKey.keyIdx
);
// TODO: send to our linked devices too?
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
// Don't need to await here
{
// Send own key to every member
const otherMembers = _.without(members, ourIdentity);
// TODO: Some of the members might not have a session with us, so
// we should send a session request
const proto = new textsecure.protobuf.DataMessage();
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// We reuse the same message type for sender keys
const update = new textsecure.protobuf.MediumGroupUpdate();
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY;
update.groupId = groupId;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: ownSenderKey,
keyIdx: 0,
});
// Subscribe to this group
this.pollForAdditionalId(groupId);
proto.mediumGroupUpdate = update;
// All further messages (maybe rather than 'control' messages) should come to this group's swarm
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
this.removeFromCache(envelope);
// Subscribe to this group
this.pollForAdditionalId(groupId);
}
this.removeFromCache(envelope);
}
},
async handleDataMessage(envelope, msg) {
window.log.info('data message from', this.getEnvelopeId(envelope));
@ -1308,12 +1431,40 @@ MessageReceiver.prototype.extend({
!_.isEmpty(message.body) &&
friendRequestStatusNoneOrExpired;
// Build a 'message' event i.e. a received message event
const ev = new Event('message');
const source = envelope.senderIdentity || senderPubKey;
const isOwnDevice = async pubkey => {
const primaryDevice = window.storage.get('primaryDevicePubKey');
const secondaryDevices = await window.libloki.storage.getPairedDevicesFor(
primaryDevice
);
const allDevices = [primaryDevice, ...secondaryDevices];
return allDevices.includes(pubkey);
};
const ownDevice = await isOwnDevice(source);
let ev;
if (conversation.isMediumGroup() && ownDevice) {
// Data messages for medium groups don't arrive as sync messages. Instead,
// linked devices poll for group messages independently, thus they need
// to recognise some of those messages at their own.
ev = new Event('sent');
} else {
ev = new Event('message');
}
if (envelope.senderIdentity) {
message.group = {
id: envelope.source,
};
}
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
friendRequest: isFriendRequest,
source: senderPubKey,
source,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt,
@ -1331,6 +1482,7 @@ MessageReceiver.prototype.extend({
contact,
preview,
groupInvitation,
mediumGroupUpdate,
}) {
return (
!flags &&
@ -1340,7 +1492,8 @@ MessageReceiver.prototype.extend({
_.isEmpty(quote) &&
_.isEmpty(contact) &&
_.isEmpty(preview) &&
_.isEmpty(groupInvitation)
_.isEmpty(groupInvitation) &&
_.isEmpty(mediumGroupUpdate)
);
},
handleLegacyMessage(envelope) {
@ -1523,7 +1676,7 @@ MessageReceiver.prototype.extend({
const ourNumber = textsecure.storage.user.getNumber();
const ourPrimaryNumber = window.storage.get('primaryDevicePubKey');
const ourOtherDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
window.storage.get('primaryDevicePubKey')
ourPrimaryNumber
);
const ourDevices = new Set([
ourNumber,
@ -1709,6 +1862,7 @@ MessageReceiver.prototype.extend({
isBlocked(number) {
return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
},
cleanAttachment(attachment) {
return {
..._.omit(attachment, 'thumbnail'),
@ -1731,9 +1885,7 @@ MessageReceiver.prototype.extend({
if (!size || size !== data.byteLength) {
throw new Error(
`downloadAttachment: Size ${size} did not match downloaded attachment size ${
data.byteLength
}`
`downloadAttachment: Size ${size} did not match downloaded attachment size ${data.byteLength}`
);
}
}

@ -6,7 +6,6 @@
libloki,
StringView,
lokiMessageAPI,
i18n,
log
*/
@ -167,8 +166,8 @@ function OutgoingMessage(
isMediumGroup,
publicSendData,
debugMessageType,
} =
options || {};
autoSession,
} = options || {};
this.numberInfo = numberInfo;
this.isPublic = isPublic;
this.isMediumGroup = !!isMediumGroup;
@ -182,6 +181,7 @@ function OutgoingMessage(
this.online = online;
this.messageType = messageType || 'outgoing';
this.debugMessageType = debugMessageType;
this.autoSession = autoSession || false;
}
OutgoingMessage.prototype = {
@ -216,9 +216,17 @@ OutgoingMessage.prototype = {
this.errors[this.errors.length] = error;
this.numberCompleted();
},
reloadDevicesAndSend(primaryPubKey) {
reloadDevicesAndSend(primaryPubKey, multiDevice = true) {
const ourNumber = textsecure.storage.user.getNumber();
if (!multiDevice) {
if (primaryPubKey === ourNumber) {
return Promise.resolve();
}
return this.doSendMessage(primaryPubKey, [primaryPubKey]);
}
return (
libloki.storage
.getAllDevicePubKeysForPrimaryPubKey(primaryPubKey)
@ -319,6 +327,7 @@ OutgoingMessage.prototype = {
// Default ttl to 24 hours if no value provided
async transmitMessage(number, data, timestamp, ttl = 24 * 60 * 60 * 1000) {
const pubKey = number;
try {
// TODO: Make NUM_CONCURRENT_CONNECTIONS a global constant
const options = {
@ -330,7 +339,7 @@ OutgoingMessage.prototype = {
}
await lokiMessageAPI.sendMessage(pubKey, data, timestamp, ttl, options);
} catch (e) {
if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) {
if (e.name === 'HTTPError' && e.code !== 409 && e.code !== 410) {
// 409 and 410 should bubble and be handled by doSendMessage
// 404 should throw UnregisteredUserError
// all other network errors can be retried later.
@ -349,7 +358,7 @@ OutgoingMessage.prototype = {
const updatedDevices = await getStaleDeviceIdsForNumber(devicePubKey);
const keysFound = await this.getKeysForNumber(devicePubKey, updatedDevices);
let isMultiDeviceRequest = false;
// let isMultiDeviceRequest = false;
let thisDeviceMessageType = this.messageType;
if (
thisDeviceMessageType !== 'pairing-request' &&
@ -370,7 +379,7 @@ OutgoingMessage.prototype = {
// - We haven't received a friend request from this device
// - We haven't sent a friend request recently
if (conversation.friendRequestTimerIsExpired()) {
isMultiDeviceRequest = true;
// isMultiDeviceRequest = true;
thisDeviceMessageType = 'friend-request';
} else {
// Throttle automated friend requests
@ -412,27 +421,11 @@ OutgoingMessage.prototype = {
window.log.info('attaching prekeys to outgoing message');
}
let messageBuffer;
let logDetails;
if (isMultiDeviceRequest) {
const tempMessage = new textsecure.protobuf.Content();
const tempDataMessage = new textsecure.protobuf.DataMessage();
tempDataMessage.body = i18n('secondaryDeviceDefaultFR');
if (this.message.dataMessage && this.message.dataMessage.profile) {
tempDataMessage.profile = this.message.dataMessage.profile;
}
tempMessage.preKeyBundleMessage = this.message.preKeyBundleMessage;
tempMessage.dataMessage = tempDataMessage;
messageBuffer = tempMessage.toArrayBuffer();
logDetails = {
tempMessage,
};
} else {
messageBuffer = this.message.toArrayBuffer();
logDetails = {
message: this.message,
};
}
const messageBuffer = this.message.toArrayBuffer();
const logDetails = {
message: this.message,
};
const messageTypeStr = this.debugMessageType;
const ourPubKey = textsecure.storage.user.getNumber();
@ -448,9 +441,7 @@ OutgoingMessage.prototype = {
aliasedPubkey = 'OUR SECONDARY PUBKEY';
}
libloki.api.debug.logSessionMessageSending(
`Sending ${messageTypeStr}:${
this.messageType
} message to ${aliasedPubkey} details:`,
`Sending ${messageTypeStr}:${this.messageType} message to ${aliasedPubkey} details:`,
logDetails
);
@ -489,6 +480,7 @@ OutgoingMessage.prototype = {
plaintext,
pubKey,
isSessionRequest,
isFriendRequest,
enableFallBackEncryption,
} = clearMessage;
// Session doesn't use the deviceId scheme, it's always 1.
@ -534,7 +526,7 @@ OutgoingMessage.prototype = {
sourceDevice,
content,
pubKey,
isFriendRequest: enableFallBackEncryption,
isFriendRequest,
isSessionRequest,
};
},
@ -647,6 +639,7 @@ OutgoingMessage.prototype = {
this.timestamp,
ttl
);
if (!this.isGroup && isFriendRequest && !isSessionRequest) {
const conversation = ConversationController.get(destination);
if (conversation) {
@ -660,16 +653,10 @@ OutgoingMessage.prototype = {
this.errors.push(e);
}
});
await Promise.all(promises);
// TODO: the retrySend should only send to the devices
// for which the transmission failed.
// ensure numberCompleted() will execute the callback
this.numbersCompleted += this.errors.length + this.successfulNumbers.length;
// Absorb errors if message sent to at least 1 device
if (this.successfulNumbers.length > 0) {
this.errors = [];
}
this.numbersCompleted += this.successfulNumbers.length;
this.numberCompleted();
},
async buildAndEncrypt(devicePubKey) {
@ -708,14 +695,14 @@ OutgoingMessage.prototype = {
return promise;
},
sendToNumber(number) {
sendToNumber(number, multiDevice = true) {
let conversation;
try {
conversation = ConversationController.get(number);
} catch (e) {
// do nothing
}
return this.reloadDevicesAndSend(number).catch(error => {
return this.reloadDevicesAndSend(number, multiDevice).catch(error => {
conversation.resetPendingSend();
if (error.message === 'Identity key changed') {
// eslint-disable-next-line no-param-reassign
@ -740,14 +727,15 @@ OutgoingMessage.prototype = {
OutgoingMessage.buildAutoFriendRequestMessage = function buildAutoFriendRequestMessage(
pubKey
) {
const dataMessage = new textsecure.protobuf.DataMessage({});
const body = 'Please accept to enable messages to be synced across devices';
const dataMessage = new textsecure.protobuf.DataMessage({ body });
const content = new textsecure.protobuf.Content({
dataMessage,
});
const options = {
messageType: 'onlineBroadcast',
messageType: 'friend-request',
debugMessageType: DebugMessageType.AUTO_FR_REQUEST,
};
// Send a empty message with information about how to contact us directly
@ -767,9 +755,10 @@ OutgoingMessage.buildSessionRequestMessage = function buildSessionRequestMessage
) {
const body =
'(If you see this message, you must be using an out-of-date client)';
const flags = textsecure.protobuf.DataMessage.Flags.SESSION_REQUEST;
const dataMessage = new textsecure.protobuf.DataMessage({ body, flags });
const dataMessage = new textsecure.protobuf.DataMessage({ flags, body });
const content = new textsecure.protobuf.Content({
dataMessage,

@ -18,9 +18,7 @@
}
const protos = result.build('signalservice');
if (!protos) {
const text = `Error loading protos from ${filename} (root: ${
window.PROTO_ROOT
})`;
const text = `Error loading protos from ${filename} (root: ${window.PROTO_ROOT})`;
window.log.error(text);
throw new Error(text);
}

@ -430,7 +430,7 @@ MessageSender.prototype = {
let keysFound = false;
// If we don't have a session but we already have prekeys to
// start communication then we should use them
if (!haveSession && !options.isPublic) {
if (!haveSession && !options.isPublic && !options.isMediumGroup) {
keysFound = await hasKeys(number);
}
@ -460,7 +460,7 @@ MessageSender.prototype = {
message.dataMessage.group
);
// If it was a message to a group then we need to send a session request
if (isGroupMessage) {
if (isGroupMessage || options.autoSession) {
const sessionRequestMessage = textsecure.OutgoingMessage.buildSessionRequestMessage(
number
);
@ -536,9 +536,11 @@ MessageSender.prototype = {
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
textsecure.storage.user.getNumber();
const allOurDevices = (await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
))
const allOurDevices = (
await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
)
)
// Don't send to ourselves
.filter(pubKey => pubKey !== textsecure.storage.user.getNumber());
if (allOurDevices.length === 0) {
@ -664,16 +666,58 @@ MessageSender.prototype = {
if (!primaryDeviceKey) {
return Promise.resolve();
}
// Extract required contacts information out of conversations
const sessionContacts = conversations.filter(
c => c.isPrivate() && !c.isSecondaryDevice() && c.isFriend()
// first get all friends with primary devices
const sessionContactsPrimary =
conversations.filter(
c =>
c.isPrivate() &&
!c.isOurLocalDevice() &&
c.isFriend() &&
!c.get('secondaryStatus')
) || [];
// then get all friends with secondary devices
let sessionContactsSecondary = conversations.filter(
c =>
c.isPrivate() &&
!c.isOurLocalDevice() &&
c.isFriend() &&
c.get('secondaryStatus')
);
// then morph all secondary conversation to their primary
sessionContactsSecondary =
(await Promise.all(
// eslint-disable-next-line arrow-body-style
sessionContactsSecondary.map(async c => {
return window.ConversationController.getOrCreateAndWait(
c.getPrimaryDevicePubKey(),
'private'
);
})
)) || [];
// filter out our primary pubkey if it was added.
sessionContactsSecondary = sessionContactsSecondary.filter(
c => c.id !== primaryDeviceKey
);
if (sessionContacts.length === 0) {
const contactsSet = new Set([
...sessionContactsPrimary,
...sessionContactsSecondary,
]);
if (contactsSet.size === 0) {
window.console.info('No contacts to sync.');
return Promise.resolve();
}
libloki.api.debug.logContactSync('Triggering contact sync message with:', [
...contactsSet,
]);
// We need to sync across 3 contacts at a time
// This is to avoid hitting storage server limit
const chunked = _.chunk(sessionContacts, 3);
const chunked = _.chunk([...contactsSet], 3);
const syncMessages = await Promise.all(
chunked.map(c => libloki.api.createContactSyncProtoMessage(c))
);
@ -710,7 +754,11 @@ MessageSender.prototype = {
}
// We only want to sync across closed groups that we haven't left
const sessionGroups = conversations.filter(
c => c.isClosedGroup() && !c.get('left') && c.isFriend()
c =>
c.isClosedGroup() &&
!c.get('left') &&
c.isFriend() &&
!c.isMediumGroup()
);
if (sessionGroups.length === 0) {
window.console.info('No closed group to sync.');
@ -975,7 +1023,12 @@ MessageSender.prototype = {
});
},
sendGroupProto(providedNumbers, proto, timestamp = Date.now(), options = {}) {
async sendGroupProto(
providedNumbers,
proto,
timestamp = Date.now(),
options = {}
) {
// We always assume that only primary device is a member in the group
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
@ -1014,12 +1067,13 @@ MessageSender.prototype = {
);
});
return sendPromise.then(result => {
// Sync the group message to our other devices
const encoded = textsecure.protobuf.DataMessage.encode(proto);
this.sendSyncMessage(encoded, timestamp, null, null, [], [], options);
return result;
});
const result = await sendPromise;
// Sync the group message to our other devices
const encoded = textsecure.protobuf.DataMessage.encode(proto);
this.sendSyncMessage(encoded, timestamp, null, null, [], [], options);
return result;
},
async getMessageProto(
@ -1201,12 +1255,18 @@ MessageSender.prototype = {
},
async updateMediumGroup(members, groupUpdateProto) {
// Automatically request session if not found (updates use pairwise sessions)
const autoSession = true;
await this.sendGroupProto(members, groupUpdateProto, Date.now(), {
isPublic: false,
autoSession,
});
return true;
},
async updateGroup(
async sendGroupUpdate(
groupId,
name,
avatar,
@ -1282,6 +1342,16 @@ MessageSender.prototype = {
return this.sendGroupProto(groupNumbers, proto, Date.now(), options);
},
requestSenderKeys(sender, groupId) {
const proto = new textsecure.protobuf.DataMessage();
const update = new textsecure.protobuf.MediumGroupUpdate();
update.type = textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST;
update.groupId = groupId;
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup([sender], proto);
},
leaveGroup(groupId, groupNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
@ -1385,12 +1455,13 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.resetSession = sender.resetSession.bind(sender);
this.sendMessageToGroup = sender.sendMessageToGroup.bind(sender);
this.sendTypingMessage = sender.sendTypingMessage.bind(sender);
this.updateGroup = sender.updateGroup.bind(sender);
this.sendGroupUpdate = sender.sendGroupUpdate.bind(sender);
this.updateMediumGroup = sender.updateMediumGroup.bind(sender);
this.addNumberToGroup = sender.addNumberToGroup.bind(sender);
this.setGroupName = sender.setGroupName.bind(sender);
this.setGroupAvatar = sender.setGroupAvatar.bind(sender);
this.requestGroupInfo = sender.requestGroupInfo.bind(sender);
this.requestSenderKeys = sender.requestSenderKeys.bind(sender);
this.leaveGroup = sender.leaveGroup.bind(sender);
this.sendSyncMessage = sender.sendSyncMessage.bind(sender);
this.getProfile = sender.getProfile.bind(sender);

@ -6,24 +6,24 @@
(function() {
window.StringView = {
/*
* These functions from the Mozilla Developer Network
* and have been placed in the public domain.
* https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
* https://developer.mozilla.org/en-US/docs/MDN/About#Copyrights_and_licenses
*/
* These functions from the Mozilla Developer Network
* and have been placed in the public domain.
* https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
* https://developer.mozilla.org/en-US/docs/MDN/About#Copyrights_and_licenses
*/
b64ToUint6(nChr) {
return nChr > 64 && nChr < 91
? nChr - 65
: nChr > 96 && nChr < 123
? nChr - 71
: nChr > 47 && nChr < 58
? nChr + 4
: nChr === 43
? 62
: nChr === 47
? 63
: 0;
? nChr - 71
: nChr > 47 && nChr < 58
? nChr + 4
: nChr === 43
? 62
: nChr === 47
? 63
: 0;
},
// This is not a "standard" base64, do not use!
@ -64,14 +64,14 @@
return nUint6 < 26
? nUint6 + 65
: nUint6 < 52
? nUint6 + 71
: nUint6 < 62
? nUint6 - 4
: nUint6 === 62
? 43
: nUint6 === 63
? 47
: 65;
? nUint6 + 71
: nUint6 < 62
? nUint6 - 4
: nUint6 === 62
? 43
: nUint6 === 63
? 47
: 65;
},
bytesToBase64(aBytes) {
@ -83,7 +83,7 @@
nIdx += 1
) {
nMod3 = nIdx % 3;
if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) {
if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
sB64Enc += '\r\n';
}
nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);

@ -24,10 +24,10 @@
receiver.addEventListener('groupsync', this.ongroup);
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
);
const {
wrap,
sendOptions,
} = ConversationController.prepareForSend(ourNumber, { syncMessage: true });
window.log.info('SyncRequest created. Sending config sync request...');
wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));

@ -50,12 +50,12 @@ describe('Key generation', function thisNeeded() {
describe('the first time', () => {
let result;
/* result should have this format
* {
* preKeys: [ { keyId, publicKey }, ... ],
* signedPreKey: { keyId, publicKey, signature },
* identityKey: <ArrayBuffer>
* }
*/
* {
* preKeys: [ { keyId, publicKey }, ... ],
* signedPreKey: { keyId, publicKey, signature },
* identityKey: <ArrayBuffer>
* }
*/
before(() => {
const accountManager = new textsecure.AccountManager('');
return accountManager.generateKeys(count).then(res => {

@ -3,26 +3,26 @@
// eslint-disable-next-line func-names
(function() {
/*
* WebSocket-Resources
*
* Create a request-response interface over websockets using the
* WebSocket-Resources sub-protocol[1].
*
* var client = new WebSocketResource(socket, function(request) {
* request.respond(200, 'OK');
* });
*
* client.sendRequest({
* verb: 'PUT',
* path: '/v1/messages',
* body: '{ some: "json" }',
* success: function(message, status, request) {...},
* error: function(message, status, request) {...}
* });
*
* 1. https://github.com/signalapp/WebSocket-Resources
*
*/
* WebSocket-Resources
*
* Create a request-response interface over websockets using the
* WebSocket-Resources sub-protocol[1].
*
* var client = new WebSocketResource(socket, function(request) {
* request.respond(200, 'OK');
* });
*
* client.sendRequest({
* verb: 'PUT',
* path: '/v1/messages',
* body: '{ some: "json" }',
* success: function(message, status, request) {...},
* error: function(message, status, request) {...}
* });
*
* 1. https://github.com/signalapp/WebSocket-Resources
*
*/
const Request = function Request(options) {
this.verb = options.verb || options.type;

@ -206,19 +206,33 @@ function captureClicks(window) {
window.webContents.on('new-window', handleUrl);
}
const DEFAULT_WIDTH = 880;
// add contact button needs to be visible (on HiDpi screens?)
// otherwise integration test fail
const DEFAULT_HEIGHT = 820;
const MIN_WIDTH = 880;
const MIN_HEIGHT = 820;
const BOUNDS_BUFFER = 100;
const WINDOW_SIZE = Object.freeze({
defaultWidth: 880,
defaultHeight: 820,
minWidth: 880,
minHeight: 600,
});
function getWindowSize() {
const { screen } = electron;
const screenSize = screen.getPrimaryDisplay().workAreaSize;
const { minWidth, minHeight, defaultWidth, defaultHeight } = WINDOW_SIZE;
// Ensure that the screen can fit within the default size
const width = Math.min(defaultWidth, Math.max(minWidth, screenSize.width));
const height = Math.min(
defaultHeight,
Math.max(minHeight, screenSize.height)
);
return { width, height, minWidth, minHeight };
}
function isVisible(window, bounds) {
const boundsX = _.get(bounds, 'x') || 0;
const boundsY = _.get(bounds, 'y') || 0;
const boundsWidth = _.get(bounds, 'width') || DEFAULT_WIDTH;
const boundsHeight = _.get(bounds, 'height') || DEFAULT_HEIGHT;
const boundsWidth = _.get(bounds, 'width') || WINDOW_SIZE.defaultWidth;
const boundsHeight = _.get(bounds, 'height') || WINDOW_SIZE.defaultHeight;
const BOUNDS_BUFFER = 100;
// requiring BOUNDS_BUFFER pixels on the left or right side
const rightSideClearOfLeftBound =
@ -241,13 +255,14 @@ function isVisible(window, bounds) {
async function createWindow() {
const { screen } = electron;
const { minWidth, minHeight, width, height } = getWindowSize();
const windowOptions = Object.assign(
{
show: !startInTray, // allow to start minimised in tray
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
width,
height,
minWidth,
minHeight,
autoHideMenuBar: false,
backgroundColor: '#fff',
webPreferences: {
@ -270,11 +285,11 @@ async function createWindow() {
])
);
if (!_.isNumber(windowOptions.width) || windowOptions.width < MIN_WIDTH) {
windowOptions.width = DEFAULT_WIDTH;
if (!_.isNumber(windowOptions.width) || windowOptions.width < minWidth) {
windowOptions.width = Math.max(minWidth, width);
}
if (!_.isNumber(windowOptions.height) || windowOptions.height < MIN_HEIGHT) {
windowOptions.height = DEFAULT_HEIGHT;
if (!_.isNumber(windowOptions.height) || windowOptions.height < minHeight) {
windowOptions.height = Math.max(minHeight, height);
}
if (!_.isBoolean(windowOptions.maximized)) {
delete windowOptions.maximized;
@ -516,13 +531,13 @@ function showPasswordWindow() {
passwordWindow.show();
return;
}
const { minWidth, minHeight, width, height } = getWindowSize();
const windowOptions = {
show: true, // allow to start minimised in tray
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
width,
height,
minWidth,
minHeight,
autoHideMenuBar: false,
webPreferences: {
nodeIntegration: false,
@ -631,8 +646,8 @@ async function showDebugLogWindow() {
const theme = await getThemeFromMainWindow();
const size = mainWindow.getSize();
const options = {
width: Math.max(size[0] - 100, MIN_WIDTH),
height: Math.max(size[1] - 100, MIN_HEIGHT),
width: Math.max(size[0] - 100, WINDOW_SIZE.minWidth),
height: Math.max(size[1] - 100, WINDOW_SIZE.minHeight),
resizable: false,
title: locale.messages.signalDesktopPreferences.message,
autoHideMenuBar: true,

@ -2,7 +2,7 @@
"name": "session-messenger-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"version": "1.0.8",
"version": "1.0.9",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -15,15 +15,9 @@
"main": "main.js",
"scripts": {
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "electron .",
"start-multi": "cross-env NODE_APP_INSTANCE=1 electron .",
"start-multi2": "cross-env NODE_APP_INSTANCE=2 electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod electron .",
"start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 electron .",
"start-prod-multi-2": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod2 electron .",
"start-swarm-test": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=1 electron .",
"start-swarm-test-2": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=2 electron .",
"start-swarm-test-3": "cross-env NODE_ENV=swarm-testing NODE_APP_INSTANCE=3 electron .",
"start": "cross-env NODE_APP_INSTANCE=$MULTI electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod$MULTI electron .",
"start-swarm-test": "cross-env NODE_ENV=production NODE_APP_INSTANCE=$MULTI electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
@ -38,8 +32,9 @@
"test-loki-view": "NODE_ENV=test-loki yarn run start",
"test-electron": "yarn grunt test",
"test-integration": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js",
"test-integration-parts": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'registration' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'openGroup' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'addFriends' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'linkDevice' && ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'closedGroup'",
"test-node": "mocha --recursive --exit test/app test/modules ts/test libloki/test/node",
"test-node": "mocha --recursive --exit test/app test/modules ts/test libloki/test/node --timeout 10000",
"test-session": "mocha --recursive --exit ts/test/session --timeout 10000",
"test-medium-groups": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js --grep 'senderkeys'",
"eslint": "eslint --cache .",
"eslint-fix": "eslint --fix .",
"eslint-full": "eslint .",
@ -120,6 +115,7 @@
},
"devDependencies": {
"@types/chai": "4.1.2",
"@types/chai-as-promised": "^7.1.2",
"@types/classnames": "2.2.3",
"@types/color": "^3.0.0",
"@types/config": "0.0.34",
@ -143,7 +139,7 @@
"@types/redux-logger": "3.0.7",
"@types/rimraf": "2.0.2",
"@types/semver": "5.5.0",
"@types/sinon": "4.3.1",
"@types/sinon": "9.0.4",
"@types/uuid": "3.4.4",
"arraybuffer-loader": "1.0.3",
"asar": "0.14.0",
@ -175,17 +171,18 @@
"node-gyp": "3.8.0",
"node-sass-import-once": "1.2.0",
"nyc": "11.4.1",
"prettier": "1.12.0",
"prettier": "1.19.0",
"qs": "6.5.1",
"react-docgen-typescript": "1.2.6",
"react-styleguidist": "7.0.1",
"sinon": "4.4.2",
"sinon": "9.0.2",
"spectron": "^10.0.0",
"ts-loader": "4.1.0",
"ts-mock-imports": "^1.3.0",
"tslint": "5.13.0",
"tslint-microsoft-contrib": "6.0.0",
"tslint-react": "3.6.0",
"typescript": "3.3.3333",
"typescript": "3.7.4",
"webpack": "4.4.1"
},
"engines": {

@ -56,6 +56,8 @@ if (
process.env.NODE_ENV.includes('test-integration')
) {
window.electronRequire = require;
// during test-integration, file server is started on localhost
window.getDefaultFileServer = () => 'http://127.0.0.1:7070';
}
window.isBeforeVersion = (toCheck, baseVersion) => {
@ -70,18 +72,31 @@ window.isBeforeVersion = (toCheck, baseVersion) => {
}
};
window.CONSTANTS = {
MAX_LOGIN_TRIES: 3,
MAX_PASSWORD_LENGTH: 64,
MAX_USERNAME_LENGTH: 20,
MAX_GROUP_NAME_LENGTH: 64,
DEFAULT_PUBLIC_CHAT_URL: appConfig.get('defaultPublicChatServer'),
MAX_CONNECTION_DURATION: 5000,
MAX_MESSAGE_BODY_LENGTH: 64 * 1024,
// eslint-disable-next-line func-names
window.CONSTANTS = new (function() {
this.MAX_LOGIN_TRIES = 3;
this.MAX_PASSWORD_LENGTH = 64;
this.MAX_USERNAME_LENGTH = 20;
this.MAX_GROUP_NAME_LENGTH = 64;
this.DEFAULT_PUBLIC_CHAT_URL = appConfig.get('defaultPublicChatServer');
this.MAX_CONNECTION_DURATION = 5000;
this.MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
// Limited due to the proof-of-work requirement
SMALL_GROUP_SIZE_LIMIT: 10,
NOTIFICATION_ENABLE_TIMEOUT_SECONDS: 10, // number of seconds to turn on notifications after reconnect/start of app
};
this.SMALL_GROUP_SIZE_LIMIT = 10;
// Number of seconds to turn on notifications after reconnect/start of app
this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10;
this.SESSION_ID_LENGTH = 66;
// Loki Name System (LNS)
this.LNS_DEFAULT_LOOKUP_TIMEOUT = 6000;
// Minimum nodes version for LNS lookup
this.LNS_CAPABLE_NODES_VERSION = '2.0.3';
this.LNS_MAX_LENGTH = 64;
// Conforms to naming rules here
// https://loki.network/2020/03/25/loki-name-system-the-facts/
this.LNS_REGEX = `^[a-zA-Z0-9_]([a-zA-Z0-9_-]{0,${this.LNS_MAX_LENGTH -
2}}[a-zA-Z0-9_]){0,1}$`;
})();
window.versionInfo = {
environment: window.getEnvironment(),
@ -151,7 +166,7 @@ window.open = () => null;
window.eval = global.eval = () => null;
window.drawAttention = () => {
// window.log.info('draw attention');
// window.log.debug('draw attention');
ipc.send('draw-attention');
};
window.showWindow = () => {
@ -269,8 +284,6 @@ window.setSettingValue = (settingID, value) => {
}
};
// Get the message TTL setting
window.getMessageTTL = () => window.storage.get('message-ttl', 24);
window.getMediaPermissions = () => ipc.sendSync('get-media-permissions');
// Auto update setting
@ -324,16 +337,31 @@ window.lokiSnodeAPI = new LokiSnodeAPI({
localUrl: config.localUrl,
});
window.LokiMessageAPI = require('./js/modules/loki_message_api');
if (process.env.USE_STUBBED_NETWORK) {
window.StubMessageAPI = require('./integration_test/stubs/stub_message_api');
window.StubAppDotNetApi = require('./integration_test/stubs/stub_app_dot_net_api');
const StubMessageAPI = require('./integration_test/stubs/stub_message_api');
window.LokiMessageAPI = StubMessageAPI;
const StubAppDotNetAPI = require('./integration_test/stubs/stub_app_dot_net_api');
window.LokiAppDotNetServerAPI = StubAppDotNetAPI;
const StubSnodeAPI = require('./integration_test/stubs/stub_snode_api');
window.lokiSnodeAPI = new StubSnodeAPI({
serverUrl: config.serverUrl,
localUrl: config.localUrl,
});
} else {
window.lokiSnodeAPI = new LokiSnodeAPI({
serverUrl: config.serverUrl,
localUrl: config.localUrl,
});
window.LokiMessageAPI = require('./js/modules/loki_message_api');
window.LokiAppDotNetServerAPI = require('./js/modules/loki_app_dot_net_api');
}
window.LokiPublicChatAPI = require('./js/modules/loki_public_chat_api');
window.LokiAppDotNetServerAPI = require('./js/modules/loki_app_dot_net_api');
window.LokiFileServerAPI = require('./js/modules/loki_file_server_api');
window.LokiRssAPI = require('./js/modules/loki_rss_api');
@ -417,7 +445,9 @@ window.lokiFeatureFlags = {
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useOnionRequests: true,
useFileOnionRequests: false,
onionRequestHops: 1,
enableSenderKeys: false,
onionRequestHops: 3,
debugMessageLogs: process.env.ENABLE_MESSAGE_LOGS,
};
// eslint-disable-next-line no-extend-native,func-names
@ -428,7 +458,8 @@ Promise.prototype.ignore = function() {
if (
config.environment.includes('test') &&
!config.environment.includes('swarm-testing')
!config.environment.includes('swarm-testing') &&
!config.environment.includes('test-integration')
) {
const isWindows = process.platform === 'win32';
/* eslint-disable global-require, import/no-extraneous-dependencies */
@ -443,12 +474,15 @@ if (
};
/* eslint-enable global-require, import/no-extraneous-dependencies */
window.lokiFeatureFlags = {};
window.lokiSnodeAPI = {}; // no need stub out each function here
window.lokiSnodeAPI = new window.StubLokiSnodeAPI(); // no need stub out each function here
}
if (config.environment.includes('test-integration')) {
window.lokiFeatureFlags = {
multiDeviceUnpairing: true,
privateGroupChats: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useOnionRequests: false,
debugMessageLogs: true,
enableSenderKeys: true,
};
}

@ -51,17 +51,27 @@ message MediumGroupContent {
optional bytes ephemeralKey = 2;
}
message SenderKey {
optional string chainKey = 1;
optional uint32 keyIdx = 2;
}
message MediumGroupUpdate {
enum Type {
NEW_GROUP = 0; // groupId, groupName, groupSecretKey, members, senderKey
GROUP_INFO = 1; // groupId, groupName, members, senderKey
SENDER_KEY_REQUEST = 2; // groupId
SENDER_KEY = 3; // groupId, SenderKey
}
optional string groupName = 1;
optional string groupId = 2; // should this be bytes?
optional string groupSecretKey = 3;
optional string senderKey = 4;
repeated string members = 5;
}
message SenderKeyUpdate {
optional string groupId = 1;
optional string senderKey = 2;
optional bytes groupSecretKey = 3;
optional SenderKey senderKey = 4;
repeated bytes members = 5;
repeated string admins = 6;
optional Type type = 7;
}
message LokiAddressMessage {
@ -424,4 +434,5 @@ message GroupDetails {
optional string color = 7;
optional bool blocked = 8;
repeated string admins = 9;
optional bool is_medium_group = 10;
}

@ -0,0 +1 @@
Subproject commit 52b77bf3039aec88b3900e8a7ed6e62d30a4d0d4

@ -91,6 +91,7 @@ textarea {
width: auto;
display: flex;
justify-content: center;
align-items: center;
font-weight: 700;
user-select: none;
white-space: nowrap;
@ -189,7 +190,7 @@ textarea {
&.brand {
min-width: 165px;
height: 45px;
line-height: 40px;
align-items: center;
padding: 0px $session-margin-lg;
font-size: $session-font-md;
font-family: $session-font-accent;
@ -558,7 +559,9 @@ label {
max-width: 70vw;
background-color: $session-shade-4;
border: 1px solid $session-shade-8;
padding-bottom: $session-margin-lg;
overflow: hidden;
display: flex;
flex-direction: column;
&__header {
display: flex;
@ -609,6 +612,8 @@ label {
font-family: $session-font-accent;
line-height: $session-font-md;
font-size: $session-font-sm;
overflow-y: auto;
overflow-x: hidden;
.message {
text-align: center;
@ -669,6 +674,17 @@ label {
}
}
.sealed-sender-toggle {
display: flex;
padding: 6px;
}
.sender-keys-description {
display: flex;
align-items: center;
padding-left: 10px;
}
.create-group-dialog .session-modal__body {
display: flex;
flex-direction: column;
@ -1061,7 +1077,8 @@ label {
flex-direction: column;
&-list {
overflow-y: scroll;
overflow-y: auto;
overflow-x: hidden;
}
&-header {
@ -1131,6 +1148,7 @@ label {
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
}
&__version-info {
@ -1566,6 +1584,14 @@ input {
text-align: center;
padding: 20px;
}
// Height at which scroll bar appears on the group member list
@media (max-height: 804px) {
&__container {
overflow-y: visible;
max-height: none;
}
}
}
.create-group-name-input {
.session-id-editable {

@ -239,7 +239,8 @@ $session-compose-margin: 20px;
display: flex;
flex-direction: column;
align-items: center;
height: -webkit-fill-available;
overflow-y: auto;
overflow-x: hidden;
.session-icon .exit {
padding: 13px;
}
@ -339,7 +340,8 @@ $session-compose-margin: 20px;
.session-left-pane-section-content {
display: flex;
flex-direction: column;
flex-grow: 1;
flex: 1;
overflow: hidden;
}
.user-search-dropdown {
@ -404,8 +406,6 @@ $session-compose-margin: 20px;
@mixin bottom-buttons() {
display: flex;
flex-direction: row;
position: absolute;
bottom: 2px;
width: 100%;
@at-root .light-theme #{&} {
@ -471,7 +471,8 @@ $session-compose-margin: 20px;
&-content {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
flex: 1;
.module-conversation-list-item {
background-color: $session-shade-4;
@ -534,6 +535,7 @@ $session-compose-margin: 20px;
&-section {
display: flex;
flex-direction: column;
flex: 1;
}
&-category-list-item {

@ -10,6 +10,7 @@
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
&-accent {
flex-grow: 1;
@ -28,21 +29,32 @@
}
&-registration {
height: 45%;
padding-right: 128px;
}
&-header {
display: flex;
flex-direction: row;
width: 100%;
justify-content: space-between;
padding: 17px 20px;
}
&-body {
display: flex;
flex-direction: row;
flex: 1;
align-items: center;
width: 100%;
padding-bottom: 20px;
}
&-close-button {
position: absolute;
top: 17px;
left: 20px;
display: flex;
align-items: center;
}
&-session-button {
position: absolute;
top: 17px;
right: 20px;
img {
width: 30px;
}
@ -246,6 +258,8 @@
display: inline-block;
font-family: $session-font-mono;
user-select: all;
overflow: hidden;
resize: none;
}
}
}

@ -279,9 +279,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
<div
className={classNames(
'module-conversation-list-item__message__status-icon',
`module-conversation-list-item__message__status-icon--${
lastMessage.status
}`
`module-conversation-list-item__message__status-icon--${lastMessage.status}`
)}
/>
) : null}

@ -112,7 +112,7 @@ export class JazzIcon extends React.PureComponent<Props> {
const firstRot = generator.random();
const angle = Math.PI * 2 * firstRot;
const velocity =
diameter / total * generator.random() + i * diameter / total;
(diameter / total) * generator.random() + (i * diameter) / total;
const tx = Math.cos(angle) * velocity;
const ty = Math.sin(angle) * velocity;
const translate = `translate(${tx} ${ty})`;

@ -11,7 +11,7 @@ import { LeftPaneSectionHeader } from './session/LeftPaneSectionHeader';
import { ConversationType } from '../state/ducks/conversations';
import { LeftPaneContactSection } from './session/LeftPaneContactSection';
import { LeftPaneSettingSection } from './session/LeftPaneSettingSection';
import { LeftPaneChannelSection } from './session/LeftPaneChannelSection';
import { SessionIconType } from './session/icon';
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
export type RowRendererParamsType = {
@ -59,6 +59,7 @@ export class LeftPane extends React.Component<Props, State> {
labels: Array<string>,
onTabSelected?: any,
buttonLabel?: string,
buttonIcon?: SessionIconType,
buttonClicked?: any,
notificationCount?: number
): JSX.Element {
@ -68,6 +69,7 @@ export class LeftPane extends React.Component<Props, State> {
selectedTab={0}
labels={labels}
buttonLabel={buttonLabel}
buttonIcon={buttonIcon}
buttonClicked={buttonClicked}
notificationCount={notificationCount}
/>
@ -100,8 +102,6 @@ export class LeftPane extends React.Component<Props, State> {
return this.renderMessageSection();
case SectionType.Contact:
return this.renderContactSection();
case SectionType.Channel:
return this.renderChannelSection();
case SectionType.Settings:
return this.renderSettingSection();
case SectionType.Moon:
@ -176,30 +176,4 @@ export class LeftPane extends React.Component<Props, State> {
return <LeftPaneSettingSection isSecondaryDevice={isSecondaryDevice} />;
}
private renderChannelSection() {
const {
openConversationInternal,
conversations,
searchResults,
searchTerm,
isSecondaryDevice,
updateSearchTerm,
search,
clearSearch,
} = this.props;
return (
<LeftPaneChannelSection
openConversationInternal={openConversationInternal}
conversations={conversations}
searchResults={searchResults}
searchTerm={searchTerm}
isSecondaryDevice={isSecondaryDevice}
updateSearchTerm={updateSearchTerm}
search={search}
clearSearch={clearSearch}
/>
);
}
}

@ -23,9 +23,11 @@ interface Props {
close: () => void;
i18n: LocalizerType;
media: Array<MediaItemType>;
onSave?: (
options: { attachment: AttachmentType; message: Message; index: number }
) => void;
onSave?: (options: {
attachment: AttachmentType;
message: Message;
index: number;
}) => void;
selectedIndex: number;
}

@ -7,29 +7,14 @@ import {
} from './session/settings/SessionSettings';
export const MainViewController = {
renderMessageView: () => {
if (document.getElementById('main-view')) {
ReactDOM.render(<MessageView />, document.getElementById('main-view'));
}
},
renderSettingsView: (category: SessionSettingCategory) => {
// tslint:disable-next-line: no-backbone-get-set-outside-model
const isSecondaryDevice = !!window.textsecure.storage.get(
'isSecondaryDevice'
);
if (document.getElementById('main-view')) {
ReactDOM.render(
<SettingsView
category={category}
isSecondaryDevice={isSecondaryDevice}
/>,
document.getElementById('main-view')
);
}
},
joinChannelStateManager,
createClosedGroup,
renderMessageView,
renderSettingsView,
};
import { ContactType } from './session/SessionMemberListItem';
export class MessageView extends React.Component {
public render() {
return (
@ -50,3 +35,153 @@ export class MessageView extends React.Component {
);
}
}
// /////////////////////////////////////
// //////////// Management /////////////
// /////////////////////////////////////
function joinChannelStateManager(
thisRef: any,
serverURL: string,
onSuccess?: any
) {
// Any component that uses this function MUST have the keys [loading, connectSuccess]
// in their State
// TODO: Make this not hard coded
const channelId = 1;
thisRef.setState({ loading: true });
const connectionResult = window.attemptConnection(serverURL, channelId);
// Give 5s maximum for promise to revole. Else, throw error.
const connectionTimeout = setTimeout(() => {
if (!thisRef.state.connectSuccess) {
thisRef.setState({ loading: false });
window.pushToast({
title: window.i18n('connectToServerFail'),
type: 'error',
id: 'connectToServerFail',
});
return;
}
}, window.CONSTANTS.MAX_CONNECTION_DURATION);
connectionResult
.then(() => {
clearTimeout(connectionTimeout);
if (thisRef.state.loading) {
thisRef.setState({
connectSuccess: true,
loading: false,
});
window.pushToast({
title: window.i18n('connectToServerSuccess'),
id: 'connectToServerSuccess',
type: 'success',
});
if (onSuccess) {
onSuccess();
}
}
})
.catch((connectionError: string) => {
clearTimeout(connectionTimeout);
thisRef.setState({
connectSuccess: true,
loading: false,
});
window.pushToast({
title: connectionError,
id: 'connectToServerFail',
type: 'error',
});
return false;
});
return true;
}
async function createClosedGroup(
groupName: string,
groupMembers: Array<ContactType>,
senderKeys: boolean,
onSuccess: any
) {
// Validate groupName and groupMembers length
if (
groupName.length === 0 ||
groupName.length > window.CONSTANTS.MAX_GROUP_NAME_LENGTH
) {
window.pushToast({
title: window.i18n(
'invalidGroupName',
window.CONSTANTS.MAX_GROUP_NAME_LENGTH
),
type: 'error',
id: 'invalidGroupName',
});
return;
}
// >= because we add ourself as a member after this. so a 10 group is already invalid as it will be 11 with ourself
if (
groupMembers.length === 0 ||
groupMembers.length >= window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
) {
window.pushToast({
title: window.i18n(
'invalidGroupSize',
window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
),
type: 'error',
id: 'invalidGroupSize',
});
return;
}
const groupMemberIds = groupMembers.map(m => m.id);
if (senderKeys) {
await window.createMediumSizeGroup(groupName, groupMemberIds);
} else {
await window.doCreateGroup(groupName, groupMemberIds);
}
if (onSuccess) {
onSuccess();
}
return true;
}
// /////////////////////////////////////
// ///////////// Rendering /////////////
// /////////////////////////////////////
function renderMessageView() {
if (document.getElementById('main-view')) {
ReactDOM.render(<MessageView />, document.getElementById('main-view'));
}
}
function renderSettingsView(category: SessionSettingCategory) {
// tslint:disable-next-line: no-backbone-get-set-outside-model
const isSecondaryDevice = !!window.textsecure.storage.get(
'isSecondaryDevice'
);
if (document.getElementById('main-view')) {
ReactDOM.render(
<SettingsView
category={category}
isSecondaryDevice={isSecondaryDevice}
/>,
document.getElementById('main-view')
);
}
}

@ -441,22 +441,15 @@ export class ConversationHeader extends React.Component<Props> {
isGroup,
isFriend,
isKickedFromGroup,
isArchived,
isPublic,
isRss,
onResetSession,
onSetDisappearingMessages,
// onShowAllMedia,
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
onMoveToInbox,
timerOptions,
onBlockUser,
onUnblockUser,
// hasNickname,
// onClearNickname,
// onChangeNickname,
} = this.props;
if (isPublic || isRss) {
@ -468,65 +461,43 @@ export class ConversationHeader extends React.Component<Props> {
const blockTitle = isBlocked ? i18n('unblockUser') : i18n('blockUser');
const blockHandler = isBlocked ? onUnblockUser : onBlockUser;
const disappearingMessagesMenuItem = isFriend &&
!isKickedFromGroup && (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
);
const disappearingMessagesMenuItem = isFriend && !isKickedFromGroup && (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
key={item.value}
onClick={() => {
onSetDisappearingMessages(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
);
const showMembersMenuItem = isGroup && (
<MenuItem onClick={onShowGroupMembers}>{i18n('showMembers')}</MenuItem>
);
const showSafetyNumberMenuItem = !isGroup &&
!isMe && (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
</MenuItem>
);
const resetSessionMenuItem = isFriend &&
!isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const blockHandlerMenuItem = !isMe &&
!isGroup &&
!isRss && <MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>;
// const changeNicknameMenuItem = !isMe &&
// !isGroup && (
// <MenuItem onClick={onChangeNickname}>{i18n('changeNickname')}</MenuItem>
// );
// const clearNicknameMenuItem = !isMe &&
// !isGroup &&
// hasNickname && (
// <MenuItem onClick={onClearNickname}>{i18n('clearNickname')}</MenuItem>
// );
const archiveConversationMenuItem = isArchived ? (
<MenuItem onClick={onMoveToInbox}>
{i18n('moveConversationToInbox')}
const showSafetyNumberMenuItem = !isGroup && !isMe && (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
</MenuItem>
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
);
const resetSessionMenuItem = isFriend && !isGroup && (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
);
const blockHandlerMenuItem = !isMe && !isGroup && !isRss && (
<MenuItem onClick={blockHandler}>{blockTitle}</MenuItem>
);
return (
<React.Fragment>
{/* <MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem> */}
{disappearingMessagesMenuItem}
{showMembersMenuItem}
{showSafetyNumberMenuItem}
{resetSessionMenuItem}
{blockHandlerMenuItem}
{/* {changeNicknameMenuItem}
{clearNicknameMenuItem} */}
{archiveConversationMenuItem}
</React.Fragment>
);
}

@ -8,6 +8,7 @@ declare global {
interface Window {
Lodash: any;
doCreateGroup: any;
createMediumSizeGroup: any;
SMALL_GROUP_SIZE_LIMIT: number;
}
}

@ -108,9 +108,11 @@ export class InviteFriendsDialog extends React.Component<Props, State> {
private renderMemberList() {
const members = this.state.friendList;
return members.map((member: ContactType) => (
return members.map((member: ContactType, index: number) => (
<SessionMemberListItem
member={member}
key={index}
index={index}
isSelected={false}
onSelect={(selectedMember: ContactType) => {
this.onMemberClicked(selectedMember);

@ -147,9 +147,10 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
private renderMemberList() {
const members = this.state.friendList;
return members.map((member: ContactType) => (
return members.map((member: ContactType, index: number) => (
<SessionMemberListItem
member={member}
index={index}
isSelected={!member.checkmarked}
onSelect={this.onMemberClicked}
onUnselect={this.onMemberClicked}

@ -136,7 +136,6 @@ export class ActionsPanel extends React.Component<Props, State> {
const isProfilePageSelected = selectedSection === SectionType.Profile;
const isMessagePageSelected = selectedSection === SectionType.Message;
const isContactPageSelected = selectedSection === SectionType.Contact;
const isChannelPageSelected = selectedSection === SectionType.Channel;
const isSettingsPageSelected = selectedSection === SectionType.Settings;
const isMoonPageSelected = selectedSection === SectionType.Moon;
@ -154,11 +153,6 @@ export class ActionsPanel extends React.Component<Props, State> {
onSelect={this.handleSectionSelect}
notificationCount={unreadMessageCount}
/>
<this.Section
type={SectionType.Channel}
isSelected={isChannelPageSelected}
onSelect={this.handleSectionSelect}
/>
<this.Section
type={SectionType.Contact}
isSelected={isContactPageSelected}

@ -1,508 +0,0 @@
import React from 'react';
import { AutoSizer, List } from 'react-virtualized';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from '../ConversationListItem';
import { LeftPane, RowRendererParamsType } from '../LeftPane';
import {
SessionButton,
SessionButtonColor,
SessionButtonType,
} from './SessionButton';
import {
PropsData as SearchResultsProps,
SearchResults,
} from '../SearchResults';
import { SearchOptions } from '../../types/Search';
import { debounce } from 'lodash';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { SessionSearchInput } from './SessionSearchInput';
import { SessionClosableOverlay } from './SessionClosableOverlay';
import { MainViewController } from '../MainViewController';
import { ContactType } from './SessionMemberListItem';
export interface Props {
searchTerm: string;
isSecondaryDevice: boolean;
conversations?: Array<ConversationListItemPropsType>;
searchResults?: SearchResultsProps;
updateSearchTerm: (searchTerm: string) => void;
search: (query: string, options: SearchOptions) => void;
openConversationInternal: (id: string, messageId?: string) => void;
clearSearch: () => void;
}
export enum SessionGroupType {
Open = 'open-group',
Closed = 'closed-group',
}
interface State {
channelUrlPasted: string;
loading: boolean;
connectSuccess: boolean;
// The type of group that is being added. Undefined in default view.
groupAddType: SessionGroupType | undefined;
}
export class LeftPaneChannelSection extends React.Component<Props, State> {
private readonly updateSearchBound: (searchedString: string) => void;
private readonly debouncedSearch: (searchTerm: string) => void;
public constructor(props: Props) {
super(props);
this.state = {
channelUrlPasted: '',
loading: false,
connectSuccess: false,
groupAddType: undefined,
};
this.handleOnPasteUrl = this.handleOnPasteUrl.bind(this);
this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind(
this
);
this.handleToggleOverlay = this.handleToggleOverlay.bind(this);
this.updateSearchBound = this.updateSearch.bind(this);
this.debouncedSearch = debounce(this.search.bind(this), 20);
}
public componentWillUnmount() {
this.updateSearch('');
}
public getCurrentConversations():
| Array<ConversationListItemPropsType>
| undefined {
const { conversations } = this.props;
let conversationList = conversations;
if (conversationList !== undefined) {
conversationList = conversationList.filter(
// a channel is either a public group or a rss group
conversation => conversation && conversation.type === 'group'
);
}
return conversationList;
}
public renderRow = ({
index,
key,
style,
}: RowRendererParamsType): JSX.Element => {
const { openConversationInternal } = this.props;
const conversations = this.getCurrentConversations();
if (!conversations) {
throw new Error('renderRow: Tried to render without conversations');
}
const conversation = conversations[index];
return (
<ConversationListItem
key={key}
style={style}
{...conversation}
onClick={openConversationInternal}
i18n={window.i18n}
/>
);
};
public renderList(): JSX.Element | Array<JSX.Element | null> {
const { openConversationInternal, searchResults } = this.props;
if (searchResults) {
return (
<SearchResults
{...searchResults}
openConversation={openConversationInternal}
i18n={window.i18n}
/>
);
}
const conversations = this.getCurrentConversations();
if (!conversations) {
throw new Error(
'render: must provided conversations if no search results are provided'
);
}
const length = conversations.length;
// Note: conversations is not a known prop for List, but it is required to ensure that
// it re-renders when our conversation data changes. Otherwise it would just render
// on startup and scroll.
const list = (
<div className="module-left-pane__list" key={0}>
<AutoSizer>
{({ height, width }) => (
<List
className="module-left-pane__virtual-list"
conversations={conversations}
height={height}
rowCount={length}
rowHeight={64}
rowRenderer={this.renderRow}
width={width}
autoHeight={true}
/>
)}
</AutoSizer>
</div>
);
return [list];
}
public renderHeader(): JSX.Element {
const labels = [window.i18n('groups')];
return LeftPane.RENDER_HEADER(labels, null);
}
public componentDidMount() {
MainViewController.renderMessageView();
}
public componentDidUpdate() {
MainViewController.renderMessageView();
}
public render(): JSX.Element {
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
{this.state.groupAddType
? this.renderClosableOverlay(this.state.groupAddType)
: this.renderGroups()}
</div>
);
}
public renderGroups() {
return (
<div className="module-conversations-list-content">
<SessionSearchInput
searchString={this.props.searchTerm}
onChange={this.updateSearchBound}
placeholder={window.i18n('search')}
/>
{this.renderList()}
{this.renderBottomButtons()}
</div>
);
}
public updateSearch(searchTerm: string) {
const { updateSearchTerm, clearSearch } = this.props;
if (!searchTerm) {
clearSearch();
return;
}
this.setState({ channelUrlPasted: '' });
if (updateSearchTerm) {
updateSearchTerm(searchTerm);
}
if (searchTerm.length < 2) {
return;
}
const cleanedTerm = cleanSearchTerm(searchTerm);
if (!cleanedTerm) {
return;
}
this.debouncedSearch(cleanedTerm);
}
public clearSearch() {
this.props.clearSearch();
}
public search() {
const { search } = this.props;
const { searchTerm, isSecondaryDevice } = this.props;
if (search) {
search(searchTerm, {
noteToSelf: window.i18n('noteToSelf').toLowerCase(),
ourNumber: window.textsecure.storage.user.getNumber(),
regionCode: '',
isSecondaryDevice,
});
}
}
private handleToggleOverlay(groupType?: SessionGroupType) {
// If no groupType, return to default view.
// Close the overlay with handleToggleOverlay(undefined)
switch (groupType) {
case SessionGroupType.Open:
this.setState({
groupAddType: SessionGroupType.Open,
});
break;
case SessionGroupType.Closed:
this.setState({
groupAddType: SessionGroupType.Closed,
});
break;
default:
// Exit overlay
this.setState({
groupAddType: undefined,
});
}
}
private renderClosableOverlay(groupType: SessionGroupType) {
const { searchTerm } = this.props;
const { loading } = this.state;
const openGroupElement = (
<SessionClosableOverlay
overlayMode={SessionGroupType.Open}
onChangeSessionID={this.handleOnPasteUrl}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={this.handleJoinChannelButtonClick}
searchTerm={searchTerm}
updateSearch={this.updateSearchBound}
showSpinner={loading}
/>
);
const closedGroupElement = (
<SessionClosableOverlay
overlayMode={SessionGroupType.Closed}
onChangeSessionID={this.handleOnPasteUrl}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={async (
groupName: string,
groupMembers: Array<ContactType>
) => this.onCreateClosedGroup(groupName, groupMembers)}
searchTerm={searchTerm}
updateSearch={this.updateSearchBound}
showSpinner={loading}
/>
);
return groupType === SessionGroupType.Open
? openGroupElement
: closedGroupElement;
}
private renderBottomButtons(): JSX.Element {
const edit = window.i18n('edit');
const joinOpenGroup = window.i18n('joinOpenGroup');
const createClosedGroup = window.i18n('createClosedGroup');
const showEditButton = false;
return (
<div className="left-pane-contact-bottom-buttons">
{showEditButton && (
<SessionButton
text={edit}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
/>
)}
<SessionButton
text={joinOpenGroup}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.Green}
onClick={() => {
this.handleToggleOverlay(SessionGroupType.Open);
}}
/>
<SessionButton
text={createClosedGroup}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
onClick={() => {
this.handleToggleOverlay(SessionGroupType.Closed);
}}
/>
</div>
);
}
private handleOnPasteUrl(value: string) {
this.setState({ channelUrlPasted: value });
}
private handleJoinChannelButtonClick(groupUrl: string) {
const { loading } = this.state;
if (loading) {
return false;
}
// longest TLD is now (20/02/06) 24 characters per https://jasontucker.blog/8945/what-is-the-longest-tld-you-can-get-for-a-domain-name
const regexURL = /(http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?/;
if (groupUrl.length <= 0) {
window.pushToast({
title: window.i18n('noServerURL'),
type: 'error',
id: 'connectToServerFail',
});
return false;
}
if (!regexURL.test(groupUrl)) {
window.pushToast({
title: window.i18n('noServerURL'),
type: 'error',
id: 'connectToServerFail',
});
return false;
}
joinChannelStateManager(this, groupUrl, () => {
this.handleToggleOverlay(undefined);
});
return true;
}
private async onCreateClosedGroup(
groupName: string,
groupMembers: Array<ContactType>
) {
// Validate groupName and groupMembers length
if (
groupName.length === 0 ||
groupName.length > window.CONSTANTS.MAX_GROUP_NAME_LENGTH
) {
window.pushToast({
title: window.i18n(
'invalidGroupName',
window.CONSTANTS.MAX_GROUP_NAME_LENGTH
),
type: 'error',
id: 'invalidGroupName',
});
return;
}
// >= because we add ourself as a member after this. so a 10 group is already invalid as it will be 11 with ourself
if (
groupMembers.length === 0 ||
groupMembers.length >= window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
) {
window.pushToast({
title: window.i18n(
'invalidGroupSize',
window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
),
type: 'error',
id: 'invalidGroupSize',
});
return;
}
const groupMemberIds = groupMembers.map(m => m.id);
await window.doCreateGroup(groupName, groupMemberIds);
this.handleToggleOverlay(undefined);
window.pushToast({
title: window.i18n('closedGroupCreatedToastTitle'),
type: 'success',
});
return true;
}
}
export function joinChannelStateManager(
thisRef: any,
serverURL: string,
onSuccess?: any
) {
// Any component that uses this function MUST have the keys [loading, connectSuccess]
// in their State
// TODO: Make this not hard coded
const channelId = 1;
thisRef.setState({ loading: true });
const connectionResult = window.attemptConnection(serverURL, channelId);
// Give 5s maximum for promise to revole. Else, throw error.
const connectionTimeout = setTimeout(() => {
if (!thisRef.state.connectSuccess) {
thisRef.setState({ loading: false });
window.pushToast({
title: window.i18n('connectToServerFail'),
type: 'error',
id: 'connectToServerFail',
});
return;
}
}, window.CONSTANTS.MAX_CONNECTION_DURATION);
connectionResult
.then(() => {
clearTimeout(connectionTimeout);
if (thisRef.state.loading) {
thisRef.setState({
connectSuccess: true,
loading: false,
});
window.pushToast({
title: window.i18n('connectToServerSuccess'),
id: 'connectToServerSuccess',
type: 'success',
});
if (onSuccess) {
onSuccess();
}
}
})
.catch((connectionError: string) => {
clearTimeout(connectionTimeout);
thisRef.setState({
connectSuccess: true,
loading: false,
});
window.pushToast({
title: connectionError,
id: 'connectToServerFail',
type: 'error',
});
return false;
});
return true;
}

@ -17,7 +17,10 @@ import {
import { AutoSizer, List } from 'react-virtualized';
import { validateNumber } from '../../types/PhoneNumber';
import { ConversationType } from '../../state/ducks/conversations';
import { SessionClosableOverlay } from './SessionClosableOverlay';
import {
SessionClosableOverlay,
SessionClosableOverlayType,
} from './SessionClosableOverlay';
import { MainViewController } from '../MainViewController';
export interface Props {
@ -89,6 +92,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
labels,
this.handleTabSelected,
undefined,
undefined,
this.handleToggleFriendRequestPopup,
receivedFriendRequestCount
);
@ -140,7 +144,8 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
style,
}: RowRendererParamsType): JSX.Element | undefined => {
const { sentFriendsRequest } = this.props;
const friends = window.getFriendsFromContacts(this.props.friends);
const contacts = this.props.friends.filter(f => f.type === 'direct');
const friends = contacts.filter(c => c.isFriend);
const combined = [...sentFriendsRequest, ...friends];
const item = combined[index];
@ -203,7 +208,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
private renderClosableOverlay() {
return (
<SessionClosableOverlay
overlayMode="contact"
overlayMode={SessionClosableOverlayType.Contact}
onChangeSessionID={this.handleRecipientSessionIDChanged}
onCloseClick={this.handleToggleOverlay}
onButtonClick={this.handleOnAddContact}
@ -322,7 +327,8 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
private renderList() {
const { sentFriendsRequest } = this.props;
const friends = window.getFriendsFromContacts(this.props.friends);
const contacts = this.props.friends.filter(f => f.type === 'direct');
const friends = contacts.filter(c => c.isFriend);
const length = Number(sentFriendsRequest.length) + Number(friends.length);
const combined = [...sentFriendsRequest, ...friends];

@ -17,22 +17,23 @@ import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { SearchOptions } from '../../types/Search';
import { validateNumber } from '../../types/PhoneNumber';
import { LeftPane, RowRendererParamsType } from '../LeftPane';
import { SessionClosableOverlay } from './SessionClosableOverlay';
import { SessionIconButton, SessionIconSize, SessionIconType } from './icon';
import {
SessionClosableOverlay,
SessionClosableOverlayType,
} from './SessionClosableOverlay';
import { SessionIconType } from './icon';
import { ContactType } from './SessionMemberListItem';
import {
SessionButton,
SessionButtonColor,
SessionButtonType,
} from './SessionButton';
import { SessionSpinner } from './SessionSpinner';
import { joinChannelStateManager } from './LeftPaneChannelSection';
export interface Props {
searchTerm: string;
isSecondaryDevice: boolean;
conversations?: Array<ConversationListItemPropsType>;
searchResults?: SearchResultsProps;
updateSearchTerm: (searchTerm: string) => void;
@ -41,17 +42,40 @@ export interface Props {
clearSearch: () => void;
}
export class LeftPaneMessageSection extends React.Component<Props, any> {
export enum SessionComposeToType {
Message = 'message',
OpenGroup = 'open-group',
ClosedGroup = 'closed-group',
}
export const SessionGroupType = {
OpenGroup: SessionComposeToType.OpenGroup,
ClosedGroup: SessionComposeToType.ClosedGroup,
};
export type SessionGroupType = SessionComposeToType;
interface State {
loading: boolean;
overlay: false | SessionComposeToType;
valuePasted: string;
connectSuccess: boolean;
}
export class LeftPaneMessageSection extends React.Component<Props, State> {
private readonly updateSearchBound: (searchedString: string) => void;
private readonly debouncedSearch: (searchTerm: string) => void;
public constructor(props: Props) {
super(props);
this.state = {
loading: false,
overlay: false,
valuePasted: '',
connectSuccess: false,
};
const conversations = this.getCurrentConversations();
const renderOnboardingSetting = window.getSettingValue(
'render-message-onboarding'
);
const realConversations: Array<ConversationListItemPropsType> = [];
if (conversations) {
@ -64,23 +88,21 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
});
}
const length = realConversations.length;
this.state = {
showComposeView: false,
pubKeyPasted: '',
shouldRenderMessageOnboarding:
length === 0 && renderOnboardingSetting && false,
connectSuccess: false,
loading: false,
};
this.updateSearchBound = this.updateSearch.bind(this);
this.handleOnPaste = this.handleOnPaste.bind(this);
this.handleToggleOverlay = this.handleToggleOverlay.bind(this);
this.handleCloseOnboarding = this.handleCloseOnboarding.bind(this);
this.handleJoinPublicChat = this.handleJoinPublicChat.bind(this);
this.handleOnPasteSessionID = this.handleOnPasteSessionID.bind(this);
this.handleMessageButtonClick = this.handleMessageButtonClick.bind(this);
this.handleNewSessionButtonClick = this.handleNewSessionButtonClick.bind(
this
);
this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind(
this
);
this.onCreateClosedGroup = this.onCreateClosedGroup.bind(this);
this.renderClosableOverlay = this.renderClosableOverlay.bind(this);
this.debouncedSearch = debounce(this.search.bind(this), 20);
}
@ -97,7 +119,7 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
if (conversationList !== undefined) {
conversationList = conversationList.filter(
conversation =>
!conversation.isSecondary && !conversation.isPendingFriendRequest
!conversation.isPendingFriendRequest && !conversation.isSecondary
);
}
@ -197,17 +219,20 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
return LeftPane.RENDER_HEADER(
labels,
null,
window.i18n('newSession'),
this.handleToggleOverlay
undefined,
SessionIconType.Plus,
this.handleNewSessionButtonClick
);
}
public render(): JSX.Element {
const { overlay } = this.state;
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
{this.state.showComposeView
? this.renderClosableOverlay()
{overlay
? this.renderClosableOverlay(overlay)
: this.renderConversations()}
</div>
);
@ -216,90 +241,17 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
public renderConversations() {
return (
<div className="module-conversations-list-content">
{this.state.shouldRenderMessageOnboarding ? (
<>{this.renderMessageOnboarding()}</>
) : (
<>
<SessionSearchInput
searchString={this.props.searchTerm}
onChange={this.updateSearchBound}
placeholder={window.i18n('searchForAKeyPhrase')}
/>
{this.renderList()}
</>
)}
</div>
);
}
public renderMessageOnboarding() {
return (
<div className="onboarding-message-section">
<div className="onboarding-message-section__exit">
<SessionIconButton
iconType={SessionIconType.Exit}
iconSize={SessionIconSize.Medium}
onClick={this.handleCloseOnboarding}
/>
</div>
<div className="onboarding-message-section__container">
<div className="onboarding-message-section__title">
<h1>{window.i18n('welcomeToSession')}</h1>
</div>
<div className="onboarding-message-section__icons">
<img
src="./images/session/chat-bubbles.svg"
alt=""
role="presentation"
/>
</div>
<div className="onboarding-message-section__info">
<div className="onboarding-message-section__info--title">
{window.i18n('noMessagesTitle')}
</div>
<div className="onboarding-message-section__info--subtitle">
{window.i18n('noMessagesSubtitle')}
</div>
</div>
<>
{this.state.loading ? (
<div className="onboarding-message-section__spinner-container">
<SessionSpinner />
</div>
) : (
<div className="onboarding-message-section__buttons">
<SessionButton
text={window.i18n('joinPublicChat')}
buttonType={SessionButtonType.BrandOutline}
buttonColor={SessionButtonColor.Green}
onClick={this.handleJoinPublicChat}
/>
<SessionButton
text={window.i18n('noThankyou')}
buttonType={SessionButtonType.Brand}
buttonColor={SessionButtonColor.Secondary}
onClick={this.handleCloseOnboarding}
/>
</div>
)}
</>
</div>
<SessionSearchInput
searchString={this.props.searchTerm}
onChange={this.updateSearchBound}
placeholder={window.i18n('searchForAKeyPhrase')}
/>
{this.renderList()}
{this.renderBottomButtons()}
</div>
);
}
public handleCloseOnboarding() {
window.setSettingValue('render-message-onboarding', false);
this.setState({
shouldRenderMessageOnboarding: false,
});
}
public updateSearch(searchTerm: string) {
const { updateSearchTerm, clearSearch } = this.props;
@ -308,8 +260,9 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
return;
}
// reset our pubKeyPasted, we can either have a pasted sessionID or a sessionID got from a search
this.setState({ pubKeyPasted: '' });
this.setState({ valuePasted: '' });
if (updateSearchTerm) {
updateSearchTerm(searchTerm);
@ -345,41 +298,126 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
}
}
private renderClosableOverlay() {
private renderClosableOverlay(overlay: SessionComposeToType) {
const { searchTerm, searchResults } = this.props;
const { loading } = this.state;
return (
const openGroupElement = (
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.OpenGroup}
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={this.handleJoinChannelButtonClick}
searchTerm={searchTerm}
updateSearch={this.updateSearchBound}
showSpinner={loading}
/>
);
const closedGroupElement = (
<SessionClosableOverlay
overlayMode="message"
onChangeSessionID={this.handleOnPasteSessionID}
onCloseClick={this.handleToggleOverlay}
overlayMode={SessionClosableOverlayType.ClosedGroup}
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={async (
groupName: string,
groupMembers: Array<ContactType>,
senderKeys: boolean
) => this.onCreateClosedGroup(groupName, groupMembers, senderKeys)}
searchTerm={searchTerm}
updateSearch={this.updateSearchBound}
showSpinner={loading}
/>
);
const messageElement = (
<SessionClosableOverlay
overlayMode={SessionClosableOverlayType.Message}
onChangeSessionID={this.handleOnPaste}
onCloseClick={() => {
this.handleToggleOverlay(undefined);
}}
onButtonClick={this.handleMessageButtonClick}
searchTerm={searchTerm}
searchResults={searchResults}
updateSearch={this.updateSearchBound}
/>
);
let overlayElement;
switch (overlay) {
case SessionComposeToType.OpenGroup:
overlayElement = openGroupElement;
break;
case SessionComposeToType.ClosedGroup:
overlayElement = closedGroupElement;
break;
default:
overlayElement = messageElement;
}
return overlayElement;
}
private handleToggleOverlay() {
this.setState((state: any) => {
return { showComposeView: !state.showComposeView };
});
// empty our generalized searchedString (one for the whole app)
this.updateSearch('');
private renderBottomButtons(): JSX.Element {
const edit = window.i18n('edit');
const joinOpenGroup = window.i18n('joinOpenGroup');
const createClosedGroup = window.i18n('createClosedGroup');
const showEditButton = false;
return (
<div className="left-pane-contact-bottom-buttons">
{showEditButton && (
<SessionButton
text={edit}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
/>
)}
<SessionButton
text={joinOpenGroup}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.Green}
onClick={() => {
this.handleToggleOverlay(SessionComposeToType.OpenGroup);
}}
/>
<SessionButton
text={createClosedGroup}
buttonType={SessionButtonType.SquareOutline}
buttonColor={SessionButtonColor.White}
onClick={() => {
this.handleToggleOverlay(SessionComposeToType.ClosedGroup);
}}
/>
</div>
);
}
private handleOnPasteSessionID(value: string) {
// reset our search, we can either have a pasted sessionID or a sessionID got from a search
private handleToggleOverlay(conversationType?: SessionComposeToType) {
const { overlay } = this.state;
const overlayState = overlay ? false : conversationType || false;
this.setState({ overlay: overlayState });
// empty our generalized searchedString (one for the whole app)
this.updateSearch('');
}
this.setState({ pubKeyPasted: value });
private handleOnPaste(value: string) {
this.setState({ valuePasted: value });
}
private handleMessageButtonClick() {
const { openConversationInternal } = this.props;
if (!this.state.pubKeyPasted && !this.props.searchTerm) {
if (!this.state.valuePasted && !this.props.searchTerm) {
window.pushToast({
title: window.i18n('invalidNumberError'),
type: 'error',
@ -389,7 +427,7 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
return;
}
let pubkey: string;
pubkey = this.state.pubKeyPasted || this.props.searchTerm;
pubkey = this.state.valuePasted || this.props.searchTerm;
pubkey = pubkey.trim();
const error = validateNumber(pubkey);
@ -404,8 +442,64 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
}
}
private handleJoinPublicChat() {
const serverURL = window.CONSTANTS.DEFAULT_PUBLIC_CHAT_URL;
joinChannelStateManager(this, serverURL, this.handleCloseOnboarding);
private handleJoinChannelButtonClick(groupUrl: string) {
const { loading } = this.state;
if (loading) {
return false;
}
// longest TLD is now (20/02/06) 24 characters per https://jasontucker.blog/8945/what-is-the-longest-tld-you-can-get-for-a-domain-name
const regexURL = /(http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?/;
if (groupUrl.length <= 0) {
window.pushToast({
title: window.i18n('noServerURL'),
type: 'error',
id: 'connectToServerFail',
});
return false;
}
if (!regexURL.test(groupUrl)) {
window.pushToast({
title: window.i18n('noServerURL'),
type: 'error',
id: 'connectToServerFail',
});
return false;
}
MainViewController.joinChannelStateManager(this, groupUrl, () => {
this.handleToggleOverlay(undefined);
});
return true;
}
private async onCreateClosedGroup(
groupName: string,
groupMembers: Array<ContactType>,
senderKeys: boolean
) {
await MainViewController.createClosedGroup(
groupName,
groupMembers,
senderKeys,
() => {
this.handleToggleOverlay(undefined);
window.pushToast({
title: window.i18n('closedGroupCreatedToastTitle'),
type: 'success',
});
}
);
}
private handleNewSessionButtonClick() {
this.handleToggleOverlay(SessionComposeToType.Message);
}
}

@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import { SessionButton } from './SessionButton';
import { SessionIcon, SessionIconSize, SessionIconType } from './icon';
import {
NotificationCountSize,
SessionNotificationCount,
@ -43,6 +44,7 @@ interface Props {
labels: Array<string>;
notificationCount?: number;
buttonLabel?: string;
buttonIcon?: SessionIconType;
buttonClicked?: any;
}
@ -65,10 +67,13 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
const {
labels,
buttonLabel,
buttonIcon,
buttonClicked,
notificationCount,
} = this.props;
const hasButton = buttonLabel || buttonIcon;
const children = [];
//loop to create children
for (let i = 0; i < labels.length; i++) {
@ -83,15 +88,19 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
);
}
if (buttonLabel && !notificationCount) {
children.push(
<SessionButton
text={buttonLabel}
onClick={buttonClicked}
key="compose"
disabled={false}
/>
if (hasButton && !notificationCount) {
const buttonContent = buttonIcon ? (
<SessionIcon iconType={buttonIcon} iconSize={SessionIconSize.Small} />
) : (
buttonLabel
);
const button = (
<SessionButton onClick={buttonClicked} key="compose" disabled={false}>
{buttonContent}
</SessionButton>
);
children.push(button);
} else if (buttonLabel && notificationCount && notificationCount > 0) {
children.push(
<div className="contact-notification-section">
@ -105,6 +114,7 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
count={notificationCount}
size={NotificationCountSize.ON_HEADER}
onClick={this.props.buttonClicked}
key="notification-count" // we can only have one of those here
/>
</div>
);
@ -114,11 +124,12 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
count={notificationCount}
size={NotificationCountSize.ON_HEADER}
onClick={this.props.buttonClicked}
key="notificationCount"
/>
);
}
//Create the parent and add the children
// Create the parent and add the children
return <div className="module-left-pane__header">{children}</div>;
}

@ -62,6 +62,7 @@ export class LeftPaneSettingSection extends React.Component<Props, State> {
null,
undefined,
undefined,
undefined,
undefined
);
}

@ -1,6 +1,7 @@
import React from 'react';
import { SessionIconButton, SessionIconSize, SessionIconType } from './icon';
import { SessionToggle } from './SessionToggle';
import { SessionIdEditable } from './SessionIdEditable';
import { UserSearchDropdown } from './UserSearchDropdown';
import { ContactType, SessionMemberListItem } from './SessionMemberListItem';
@ -11,11 +12,18 @@ import {
SessionButtonType,
} from './SessionButton';
import { SessionSpinner } from './SessionSpinner';
import { SessionGroupType } from './LeftPaneChannelSection';
import { PillDivider } from './PillDivider';
import classNames from 'classnames';
export enum SessionClosableOverlayType {
Contact = 'contact',
Message = 'message',
OpenGroup = 'open-group',
ClosedGroup = 'closed-group',
}
interface Props {
overlayMode: 'message' | 'contact' | SessionGroupType;
overlayMode: SessionClosableOverlayType;
onChangeSessionID: any;
onCloseClick: any;
onButtonClick: any;
@ -29,6 +37,7 @@ interface Props {
interface State {
groupName: string;
selectedMembers: Array<ContactType>;
senderKeys: boolean;
}
export class SessionClosableOverlay extends React.Component<Props, State> {
@ -40,10 +49,14 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
this.state = {
groupName: '',
selectedMembers: [],
senderKeys: false,
};
this.inputRef = React.createRef();
this.onKeyUp = this.onKeyUp.bind(this);
this.onGroupNameChanged = this.onGroupNameChanged.bind(this);
window.addEventListener('keyup', this.onKeyUp);
}
public componentDidMount() {
@ -97,11 +110,12 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
onButtonClick,
} = this.props;
const isAddContactView = overlayMode === 'contact';
const isMessageView = overlayMode === 'message';
const isOpenGroupView = overlayMode === SessionGroupType.Open;
const isClosedGroupView = overlayMode === SessionGroupType.Closed;
const isAddContactView = overlayMode === SessionClosableOverlayType.Contact;
const isMessageView = overlayMode === SessionClosableOverlayType.Message;
const isOpenGroupView =
overlayMode === SessionClosableOverlayType.OpenGroup;
const isClosedGroupView =
overlayMode === SessionClosableOverlayType.ClosedGroup;
let title;
let buttonText;
@ -140,12 +154,14 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
default:
}
const { groupName, selectedMembers } = this.state;
const { groupName, selectedMembers, senderKeys } = this.state;
const ourSessionID = window.textsecure.storage.user.getNumber();
const contacts = this.getContacts();
const noContactsForClosedGroup =
overlayMode === SessionGroupType.Closed && contacts.length === 0;
overlayMode === SessionClosableOverlayType.ClosedGroup &&
contacts.length === 0;
return (
<div className="module-left-pane-overlay">
@ -193,7 +209,6 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
{isClosedGroupView && (
<>
<div className="spacer-lg" />
<div className="group-member-list__container">
{noContactsForClosedGroup ? (
<div className="group-member-list__no-contacts">
@ -201,7 +216,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
</div>
) : (
<div className="group-member-list__selection">
{this.renderMemberList()}
{this.renderMemberList(contacts)}
</div>
)}
</div>
@ -234,23 +249,43 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
/>
)}
{isClosedGroupView && window.lokiFeatureFlags.enableSenderKeys && (
<div className="sealed-sender-toggle">
<SessionToggle
active={Boolean(false)}
onClick={() => {
const value = this.state.senderKeys;
this.setState({ senderKeys: !value });
}}
/>
<span
className={classNames(
'session-settings-item__description',
'sender-keys-description'
)}
>
{window.i18n('useSenderKeys')}
</span>
</div>
)}
<SessionButton
buttonColor={SessionButtonColor.Green}
buttonType={SessionButtonType.BrandOutline}
text={buttonText}
disabled={noContactsForClosedGroup}
onClick={() => onButtonClick(groupName, selectedMembers)}
onClick={() => onButtonClick(groupName, selectedMembers, senderKeys)}
/>
</div>
);
}
private renderMemberList() {
const members = this.getContacts();
return members.map((member: ContactType) => (
private renderMemberList(members: any) {
return members.map((member: ContactType, index: number) => (
<SessionMemberListItem
member={member}
index={index}
isSelected={false}
key={member.id}
onSelect={(selectedMember: ContactType) => {
@ -286,4 +321,11 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
groupName: event,
});
}
private onKeyUp(event: any) {
if (event.key === 'Escape') {
window.removeEventListener('keyup', this.onKeyUp);
this.props.onCloseClick();
}
}
}

@ -222,8 +222,8 @@ export class SessionGroupSettings extends React.Component<Props, any> {
const leaveGroupString = isPublic
? window.i18n('leaveOpenGroup')
: isKickedFromGroup
? window.i18n('youAreKickedFromThisGroup')
: window.i18n('leaveClosedGroup');
? window.i18n('youAreKickedFromThisGroup')
: window.i18n('leaveClosedGroup');
const disappearingMessagesOptions = timerOptions.map(option => {
return {

@ -55,6 +55,7 @@ export class SessionIdEditable extends React.PureComponent<Props> {
private handleChange(e: any) {
const { editable, onChange } = this.props;
if (editable) {
onChange(e.target.value);
}

@ -18,6 +18,7 @@ export interface ContactType {
interface Props {
member: ContactType;
index: number; // index in the list
isSelected: boolean;
onSelect?: any;
onUnselect?: any;
@ -54,7 +55,11 @@ export class SessionMemberListItem extends React.Component<Props, State> {
return (
<div
className={classNames('session-member-item', isSelected && 'selected')}
className={classNames(
`session-member-item-${this.props.index}`,
'session-member-item',
isSelected && 'selected'
)}
onClick={this.handleSelectionAction}
role="button"
>

@ -8,24 +8,27 @@ export const SessionRegistrationView: React.FC = () => (
<div className="session-content">
<div id="session-toast-container" />
<div id="error" className="collapse" />
<div className="session-content-close-button">
<SessionIconButton
iconSize={SessionIconSize.Medium}
iconType={SessionIconType.Exit}
onClick={() => {
window.close();
}}
/>
<div className="session-content-header">
<div className="session-content-close-button">
<SessionIconButton
iconSize={SessionIconSize.Medium}
iconType={SessionIconType.Exit}
onClick={() => {
window.close();
}}
/>
</div>
<div className="session-content-session-button">
<img alt="brand" src="./images/session/brand.svg" />
</div>
</div>
<div className="session-content-accent">
<AccentText />
</div>
<div className="session-content-registration">
<RegistrationTabs />
</div>
<div className="session-content-session-button">
<img alt="brand" src="./images/session/brand.svg" />
<div className="session-content-body">
<div className="session-content-accent">
<AccentText />
</div>
<div className="session-content-registration">
<RegistrationTabs />
</div>
</div>
</div>
);

@ -23,6 +23,7 @@ export enum SessionIconType {
Microphone = 'microphone',
Moon = 'moon',
Pencil = 'pencil',
Plus = 'plus',
Reply = 'reply',
Search = 'search',
Send = 'send',
@ -160,6 +161,11 @@ export const icons = {
'M4,16.4142136 L4,20 L7.58578644,20 L19.5857864,8 L16,4.41421356 L4,16.4142136 Z M16.7071068,2.29289322 L21.7071068,7.29289322 C22.0976311,7.68341751 22.0976311,8.31658249 21.7071068,8.70710678 L8.70710678,21.7071068 C8.5195704,21.8946432 8.26521649,22 8,22 L3,22 C2.44771525,22 2,21.5522847 2,21 L2,16 C2,15.7347835 2.10535684,15.4804296 2.29289322,15.2928932 L15.2928932,2.29289322 C15.6834175,1.90236893 16.3165825,1.90236893 16.7071068,2.29289322 Z',
viewBox: '1 1 21 21',
},
[SessionIconType.Plus]: {
path:
'm405.332031 192h-170.664062v-170.667969c0-11.773437-9.558594-21.332031-21.335938-21.332031-11.773437 0-21.332031 9.558594-21.332031 21.332031v170.667969h-170.667969c-11.773437 0-21.332031 9.558594-21.332031 21.332031 0 11.777344 9.558594 21.335938 21.332031 21.335938h170.667969v170.664062c0 11.777344 9.558594 21.335938 21.332031 21.335938 11.777344 0 21.335938-9.558594 21.335938-21.335938v-170.664062h170.664062c11.777344 0 21.335938-9.558594 21.335938-21.335938 0-11.773437-9.558594-21.332031-21.335938-21.332031zm0 0',
viewBox: '0 0 427 427',
},
[SessionIconType.Reply]: {
path:
'M4,3 C4.55228475,3 5,3.44771525 5,4 L5,4 L5,11 C5,12.6568542 6.34314575,14 8,14 L8,14 L17.585,14 L14.2928932,10.7071068 C13.9324093,10.3466228 13.9046797,9.77939176 14.2097046,9.38710056 L14.2928932,9.29289322 C14.6834175,8.90236893 15.3165825,8.90236893 15.7071068,9.29289322 L15.7071068,9.29289322 L20.7071068,14.2928932 C20.7355731,14.3213595 20.7623312,14.3515341 20.787214,14.3832499 C20.788658,14.3849951 20.7902348,14.3870172 20.7918027,14.389044 C20.8140715,14.4179625 20.8348358,14.4480862 20.8539326,14.4793398 C20.8613931,14.4913869 20.8685012,14.5036056 20.8753288,14.5159379 C20.8862061,14.5357061 20.8966234,14.5561086 20.9063462,14.5769009 C20.914321,14.5939015 20.9218036,14.6112044 20.9287745,14.628664 C20.9366843,14.6484208 20.9438775,14.6682023 20.9504533,14.6882636 C20.9552713,14.7031487 20.9599023,14.7185367 20.9641549,14.734007 C20.9701664,14.7555635 20.9753602,14.7772539 20.9798348,14.7992059 C20.9832978,14.8166247 20.9863719,14.834051 20.9889822,14.8515331 C20.9962388,14.8996379 21,14.9493797 21,15 L20.9962979,14.9137692 C20.9978436,14.9317345 20.9989053,14.9497336 20.9994829,14.9677454 L21,15 C21,15.0112225 20.9998151,15.0224019 20.9994483,15.0335352 C20.9988772,15.050591 20.997855,15.0679231 20.996384,15.0852242 C20.994564,15.1070574 20.9920941,15.1281144 20.9889807,15.1489612 C20.9863719,15.165949 20.9832978,15.1833753 20.9797599,15.2007258 C20.9753602,15.2227461 20.9701664,15.2444365 20.964279,15.2658396 C20.9599023,15.2814633 20.9552713,15.2968513 20.9502619,15.3121425 C20.9438775,15.3317977 20.9366843,15.3515792 20.928896,15.3710585 C20.9218036,15.3887956 20.914321,15.4060985 20.9063266,15.4232215 C20.8974314,15.4421635 20.8879327,15.4609002 20.8778732,15.4792864 C20.8703855,15.4931447 20.862375,15.5070057 20.8540045,15.5207088 C20.8382813,15.546275 20.8215099,15.5711307 20.8036865,15.5951593 C20.774687,15.6343256 20.7425008,15.6717127 20.7071068,15.7071068 L20.787214,15.6167501 C20.7849289,15.6196628 20.7826279,15.6225624 20.7803112,15.625449 L20.7071068,15.7071068 L15.7071068,20.7071068 C15.3165825,21.0976311 14.6834175,21.0976311 14.2928932,20.7071068 C13.9023689,20.3165825 13.9023689,19.6834175 14.2928932,19.2928932 L14.2928932,19.2928932 L17.585,16 L8,16 C5.3112453,16 3.11818189,13.8776933 3.00461951,11.2168896 L3,11 L3,4 C3,3.44771525 3.44771525,3 4,3',

@ -139,21 +139,18 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
return (
<div key={setting.id}>
{shouldRenderSettings &&
!setting.hidden && (
<SessionSettingListItem
title={setting.title}
description={description}
type={setting.type}
value={value}
onClick={onClickFn}
onSliderChange={sliderFn}
content={content}
confirmationDialogParams={
setting.confirmationDialogParams
}
/>
)}
{shouldRenderSettings && !setting.hidden && (
<SessionSettingListItem
title={setting.title}
description={description}
type={setting.type}
value={value}
onClick={onClickFn}
onSliderChange={sliderFn}
content={content}
confirmationDialogParams={setting.confirmationDialogParams}
/>
)}
</div>
);
})}

8
ts/global.d.ts vendored

@ -1,3 +1,4 @@
// TODO: Delete this and depend on window.ts instead
interface Window {
CONSTANTS: any;
versionInfo: any;
@ -63,3 +64,10 @@ interface Window {
interface Promise<T> {
ignore(): void;
}
// Types also correspond to messages.json keys
enum LnsLookupErrorType {
lnsTooFewNodes,
lnsLookupTimeout,
lnsMappingNotFound,
}

@ -0,0 +1,99 @@
import { EncryptionType } from '../types/EncryptionType';
import { SignalService } from '../../protobuf';
import { libloki, libsignal, Signal, textsecure } from '../../window';
import { CipherTextObject } from '../../window/types/libsignal-protocol';
import { UserUtil } from '../../util';
export function padPlainTextBuffer(messageBuffer: Uint8Array): Uint8Array {
const plaintext = new Uint8Array(
getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
);
plaintext.set(new Uint8Array(messageBuffer));
plaintext[messageBuffer.byteLength] = 0x80;
return plaintext;
}
function getPaddedMessageLength(originalLength: number): number {
const messageLengthWithTerminator = originalLength + 1;
let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
if (messageLengthWithTerminator % 160 !== 0) {
messagePartCount += 1;
}
return messagePartCount * 160;
}
export type Base64String = string;
/**
* Encrypt `plainTextBuffer` with given `encryptionType` for `device`.
*
* @param device The device to encrypt for.
* @param plainTextBuffer The unpadded plaintext buffer.
* @param encryptionType The type of encryption.
* @returns The envelope type and the base64 encoded cipher text
*/
export async function encrypt(
device: string,
plainTextBuffer: Uint8Array,
encryptionType: EncryptionType
): Promise<{
envelopeType: SignalService.Envelope.Type;
cipherText: Base64String;
}> {
const plainText = padPlainTextBuffer(plainTextBuffer);
const address = new libsignal.SignalProtocolAddress(device, 1);
if (encryptionType === EncryptionType.MediumGroup) {
// TODO: Do medium group stuff here
throw new Error('Encryption is not yet supported');
}
let innerCipherText: CipherTextObject;
if (encryptionType === EncryptionType.SessionReset) {
const cipher = new libloki.crypto.FallBackSessionCipher(address);
innerCipherText = await cipher.encrypt(plainText.buffer);
} else {
const cipher = new libsignal.SessionCipher(
textsecure.storage.protocol,
address
);
innerCipherText = await cipher.encrypt(plainText.buffer);
}
return encryptUsingSealedSender(device, innerCipherText);
}
async function encryptUsingSealedSender(
device: string,
innerCipherText: CipherTextObject
): Promise<{
envelopeType: SignalService.Envelope.Type;
cipherText: Base64String;
}> {
const ourNumber = await UserUtil.getCurrentDevicePubKey();
if (!ourNumber) {
throw new Error('Failed to fetch current device public key.');
}
const certificate = SignalService.SenderCertificate.create({
sender: ourNumber,
senderDevice: 1,
});
const cipher = new Signal.Metadata.SecretSessionCipher(
textsecure.storage.protocol
);
const cipherTextBuffer = await cipher.encrypt(
device,
certificate,
innerCipherText
);
return {
envelopeType: SignalService.Envelope.Type.UNIDENTIFIED_SENDER,
cipherText: Buffer.from(cipherTextBuffer).toString('base64'),
};
}

@ -0,0 +1,3 @@
import * as MessageEncrypter from './MessageEncrypter';
export { MessageEncrypter };

@ -0,0 +1,8 @@
import * as Messages from './messages';
import * as Protocols from './protocols';
// TODO: Do we export class instances here?
// E.g
// export const messageQueue = new MessageQueue()
export { Messages, Protocols };

@ -0,0 +1,3 @@
import * as Outgoing from './outgoing';
export { Outgoing };

@ -0,0 +1,19 @@
import { v4 as uuid } from 'uuid';
export interface MessageParams {
timestamp: number;
identifier?: string;
}
export abstract class Message {
public readonly timestamp: number;
public readonly identifier: string;
constructor({ timestamp, identifier }: MessageParams) {
this.timestamp = timestamp;
if (identifier && identifier.length === 0) {
throw new Error('Cannot set empty identifier');
}
this.identifier = identifier || uuid();
}
}

@ -0,0 +1,32 @@
import { Message, MessageParams } from './Message';
import { AttachmentType } from '../../../types/Attachment';
import { QuotedAttachmentType } from '../../../components/conversation/Quote';
interface OpenGroupMessageParams extends MessageParams {
server: string;
attachments?: Array<AttachmentType>;
body?: string;
quote?: QuotedAttachmentType;
}
export class OpenGroupMessage extends Message {
public readonly server: string;
public readonly body?: string;
public readonly attachments?: Array<AttachmentType>;
public readonly quote?: QuotedAttachmentType;
constructor({
timestamp,
server,
attachments,
body,
quote,
identifier,
}: OpenGroupMessageParams) {
super({ timestamp, identifier });
this.server = server;
this.body = body;
this.attachments = attachments;
this.quote = quote;
}
}

@ -0,0 +1,20 @@
import { Message } from '../Message';
import { SignalService } from '../../../../protobuf';
export abstract class ContentMessage extends Message {
public plainTextBuffer(): Uint8Array {
return SignalService.Content.encode(this.contentProto()).finish();
}
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 {
// 1 day default for any other message
return 24 * 60 * 60 * 1000;
}
}

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

Loading…
Cancel
Save