Finalized cache

This commit is contained in:
Vincent 2020-06-05 16:56:47 +10:00
commit b203dc4493
87 changed files with 3067 additions and 1665 deletions

View File

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

View File

@ -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:

View File

@ -25,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:

View File

@ -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
View File

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

3
.gitmodules vendored Normal file
View File

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

View File

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

View File

@ -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.

View File

@ -683,7 +683,7 @@
"description": "Shown to separate the types of search results"
},
"messagesHeader": {
"message": "Session",
"message": "Conversations",
"description": "Shown to separate the types of search results"
},
"settingsHeader": {
@ -985,10 +985,6 @@
"message": " Type your message",
"description": "Placeholder text in the message entry field"
},
"secondaryDeviceDefaultFR": {
"message": "Please accept to enable messages to be synced across devices",
"description": "Placeholder text in the message entry field when it is disabled because a secondary device conversation is visible"
},
"sendMessageDisabledSecondary": {
"message": "This pubkey belongs to a secondary device. You should never see this message",
"description": "Placeholder text in the message entry field when it is disabled because a secondary device conversation is visible"
@ -2232,13 +2228,28 @@
"message": "Remove"
},
"invalidHexId": {
"message": "Invalid Hex ID",
"description": "Error string shown when user type an invalid pubkey hex string"
"message": "Invalid Session ID or LNS Name",
"description": "Error string shown when user types an invalid pubkey hex string"
},
"invalidLnsFormat": {
"message": "Invalid LNS Name",
"description": "Error string shown when user types an invalid LNS name"
},
"invalidPubkeyFormat": {
"message": "Invalid Pubkey Format",
"description": "Error string shown when user types an invalid pubkey format"
},
"lnsMappingNotFound": {
"message": "There is no LNS mapping associated with this name",
"description": "Shown in toast if user enters an unknown LNS name"
},
"lnsLookupTimeout": {
"message": "LNS lookup timed out",
"description": "Shown in toast if user enters an unknown LNS name"
},
"lnsTooFewNodes": {
"message": "Not enough nodes currently active for LNS lookup"
},
"conversationsTab": {
"message": "Conversations",
"description": "conversation tab title"
@ -2417,7 +2428,7 @@
"message": "Enter Session ID"
},
"pasteSessionIDRecipient": {
"message": "Enter a Session ID"
"message": "Enter a Session ID or LNS name"
},
"usersCanShareTheir...": {
"message": "Users can share their Session ID from their account settings, or by sharing their QR code."
@ -2597,5 +2608,8 @@
"example": "10"
}
}
},
"useSenderKeys": {
"message": "Use Sender Keys"
}
}

View File

@ -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;`,

View File

@ -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}`
);
});
});

View File

@ -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();

View File

@ -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]);
}
let ori = this.messages[pubkey];
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) {
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 (!pubkey) {
console.warn('NO PUBKEY');
response.writeHead(400, { 'Content-Type': 'text/html' });
response.end();
return;
}
if (request.method === 'POST') {
if (ENABLE_LOG) {
console.warn(
'POST',
pubkey.substr(2, 3),
data.substr(4, 10),
timestamp
);
}
let ori = this.messages[pubkey];
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(

View File

@ -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

View File

@ -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);
});
});

View File

@ -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

View File

@ -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 alice2Props = {};
const app2Props = {
mnemonic: common.TEST_MNEMONIC2,
displayName: common.TEST_DISPLAY_NAME2,
stubSnode: true,
};
[Alice1, Bob1] = await common.startAppsAsFriends(); // Alice and Bob are friends
[app, app2] = await Promise.all([
common.startAndStub(app1Props),
common.startAndStubN(app2Props, 2),
]);
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
it('unlink two devices', async () => {
await common.linkApp2ToApp(app, app2);
await common.timeout(1000);
await common.triggerUnlinkApp2FromApp(app, app2);
// 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
);
// 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
);
});
});

View File

@ -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(

View File

@ -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) =>

View File

@ -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'
),

View File

@ -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"]',
};

View File

@ -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;

View File

@ -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);
});

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

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

View File

@ -636,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'
@ -717,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) => {
@ -744,66 +769,73 @@
identityKeys.privKey
);
// Constructing a "create group" message
const proto = new textsecure.protobuf.DataMessage();
const primary = window.storage.get('primaryDevicePubKey');
const groupUpdate = new textsecure.protobuf.MediumGroupUpdate();
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(
@ -820,6 +852,7 @@
window.friends.friendRequestStatusEnum.friends
);
textsecure.messaging.sendGroupSyncMessage([convo]);
appView.openConversation(groupId, {});
};
@ -955,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()
@ -1011,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();
@ -1428,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);
});
}
@ -1760,7 +1777,6 @@
const details = ev.contactDetails;
const id = details.number;
libloki.api.debug.logContactSync(
'Got sync contact message with',
id,
@ -1815,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(
@ -1918,6 +1943,7 @@
members: details.members,
color: details.color,
type: 'group',
is_medium_group: details.is_medium_group || false,
};
if (details.active) {

View File

@ -1,4 +1,4 @@
/* global LokiAppDotNetServerAPI, LokiFileServerAPI, semver, log */
/* global LokiAppDotNetServerAPI, semver, log */
// eslint-disable-next-line func-names
(function() {
'use strict';
@ -12,9 +12,8 @@
);
// use the anonymous access token
window.tokenlessFileServerAdnAPI.token = 'loki';
window.tokenlessFileServerAdnAPI.pubKey = window.Signal.Crypto.base64ToArrayBuffer(
LokiFileServerAPI.secureRpcPubKey
);
// configure for file server comms
window.tokenlessFileServerAdnAPI.getPubKeyForUrl();
let nextWaitSeconds = 5;
const checkForUpgrades = async () => {

View File

@ -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) {

View File

@ -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();
}
}
@ -2051,6 +2098,7 @@
return false;
},
async handleSessionRequest(source, confirm) {
window.console.log(`Received SESSION_REQUEST from source: ${source}`);
window.libloki.api.sendSessionEstablishedMessage(source);
confirm();
},
@ -2202,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');
@ -2219,8 +2266,9 @@
try {
const now = new Date().getTime();
let attributes = {
...conversation.attributes,
...conversationPrimary.attributes,
};
if (dataMessage.group) {
let groupUpdate = null;
attributes = {
@ -2236,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');
@ -2261,7 +2309,7 @@
// Check if anyone got kicked:
const removedMembers = _.difference(
conversation.get('members'),
conversationPrimary.get('members'),
attributes.members
);
@ -2283,7 +2331,7 @@
groupUpdate = { left: source };
}
attributes.members = _.without(
conversation.get('members'),
conversationPrimary.get('members'),
source
);
}
@ -2316,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,
@ -2330,7 +2378,7 @@
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
conversation,
conversationPrimary,
message
);
receipts.forEach(receipt =>
@ -2343,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({
@ -2355,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 });
}
@ -2367,7 +2415,7 @@
message.isExpirationTimerUpdate() || expireTimer;
if (shouldLogExpireTimerChange) {
window.log.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
id: conversationPrimary.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
@ -2375,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'),
@ -2386,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 });
@ -2429,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,
});
}
@ -2441,7 +2492,7 @@
if (type === 'outgoing') {
const reads = Whisper.ReadReceipts.forMessage(
conversation,
conversationPrimary,
message
);
if (reads.length) {
@ -2452,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);
}
}
@ -2510,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) {
@ -2525,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();
}
}
@ -2553,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
@ -2595,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);
}
}

View File

@ -7,6 +7,8 @@ const FormData = require('form-data');
const https = require('https');
const path = require('path');
const lokiRpcUtils = require('./loki_rpc');
// Can't be less than 1200 if we have unauth'd requests
const PUBLICCHAT_MSG_POLL_EVERY = 1.5 * 1000; // 1.5s
const PUBLICCHAT_CHAN_POLL_EVERY = 20 * 1000; // 20s
@ -14,6 +16,7 @@ const PUBLICCHAT_DELETION_POLL_EVERY = 5 * 1000; // 5s
const PUBLICCHAT_MOD_POLL_EVERY = 30 * 1000; // 30s
const PUBLICCHAT_MIN_TIME_BETWEEN_DUPLICATE_MESSAGES = 10 * 1000; // 10s
// FIXME: replace with something on urlPubkeyMap...
const FILESERVER_HOSTS = [
'file-dev.lokinet.org',
'file.lokinet.org',
@ -21,6 +24,17 @@ const FILESERVER_HOSTS = [
'file.getsession.org',
];
const LOKIFOUNDATION_DEVFILESERVER_PUBKEY =
'BSZiMVxOco/b3sYfaeyiMWv/JnqokxGXkHoclEx8TmZ6';
const LOKIFOUNDATION_FILESERVER_PUBKEY =
'BWJQnVm97sQE3Q1InB4Vuo+U/T1hmwHBv0ipkiv8tzEc';
const urlPubkeyMap = {
'https://file-dev.getsession.org': LOKIFOUNDATION_DEVFILESERVER_PUBKEY,
'https://file-dev.lokinet.org': LOKIFOUNDATION_DEVFILESERVER_PUBKEY,
'https://file.getsession.org': LOKIFOUNDATION_FILESERVER_PUBKEY,
'https://file.lokinet.org': LOKIFOUNDATION_FILESERVER_PUBKEY,
};
const HOMESERVER_USER_ANNOTATION_TYPE = 'network.loki.messenger.homeserver';
const AVATAR_USER_ANNOTATION_TYPE = 'network.loki.messenger.avatar';
const SETTINGS_CHANNEL_ANNOTATION_TYPE = 'net.patter-app.settings';
@ -34,12 +48,120 @@ const snodeHttpsAgent = new https.Agent({
const timeoutDelay = ms => new Promise(resolve => setTimeout(resolve, ms));
const sendToProxy = async (
srvPubKey,
endpoint,
pFetchOptions,
options = {}
) => {
const sendViaOnion = async (srvPubKey, url, fetchOptions, options = {}) => {
if (!srvPubKey) {
log.error(
'loki_app_dot_net:::sendViaOnion - called without a server public key'
);
return {};
}
// set retry count
if (options.retry === undefined) {
// eslint-disable-next-line no-param-reassign
options.retry = 0;
// eslint-disable-next-line no-param-reassign
options.requestNumber = window.lokiSnodeAPI.assignOnionRequestNumber();
}
const payloadObj = {
method: fetchOptions.method,
body: fetchOptions.body,
// safety issue with file server, just safer to have this
headers: fetchOptions.headers || {},
// no initial /
endpoint: url.pathname.replace(/^\//, ''),
};
if (url.search) {
payloadObj.endpoint += `?${url.search}`;
}
// from https://github.com/sindresorhus/is-stream/blob/master/index.js
if (
payloadObj.body &&
typeof payloadObj.body === 'object' &&
typeof payloadObj.body.pipe === 'function'
) {
const fData = payloadObj.body.getBuffer();
const fHeaders = payloadObj.body.getHeaders();
// update headers for boundary
payloadObj.headers = { ...payloadObj.headers, ...fHeaders };
// update body with base64 chunk
payloadObj.body = {
fileUpload: fData.toString('base64'),
};
}
let pathNodes = [];
try {
pathNodes = await lokiSnodeAPI.getOnionPath();
} catch (e) {
log.error(
`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`
);
// should we retry?
return {};
}
// do the request
let result;
try {
result = await lokiRpcUtils.sendOnionRequestLsrpcDest(
0,
pathNodes,
srvPubKey,
url.host,
payloadObj,
options.requestNumber
);
} catch (e) {
log.error(
'loki_app_dot_net:::sendViaOnion - lokiRpcUtils error',
e.code,
e.message
);
return {};
}
// handle error/retries
if (!result.status) {
log.error(
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - Retry #${options.retry} Couldnt handle onion request, retrying`,
payloadObj
);
return sendViaOnion(srvPubKey, url, fetchOptions, {
...options,
retry: options.retry + 1,
counter: options.requestNumber,
});
}
// get the return variables we need
let response = {};
let txtResponse = '';
let body = '';
try {
body = JSON.parse(result.body);
} catch (e) {
log.error(
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - Cant decode JSON body`,
result.body
);
}
// result.status has the http response code
txtResponse = JSON.stringify(body);
response = body;
response.headers = result.headers;
return { result, txtResponse, response };
};
const sendToProxy = async (srvPubKey, endpoint, fetchOptions, options = {}) => {
if (!srvPubKey) {
log.error(
'loki_app_dot_net:::sendToProxy - called without a server public key'
@ -47,17 +169,12 @@ const sendToProxy = async (
return {};
}
const fetchOptions = pFetchOptions; // make lint happy
// safety issue with file server, just safer to have this
if (fetchOptions.headers === undefined) {
fetchOptions.headers = {};
}
const payloadObj = {
body: fetchOptions.body, // might need to b64 if binary...
endpoint,
method: fetchOptions.method,
headers: fetchOptions.headers,
// safety issue with file server, just safer to have this
headers: fetchOptions.headers || {},
};
// from https://github.com/sindresorhus/is-stream/blob/master/index.js
@ -87,7 +204,7 @@ const sendToProxy = async (
log.warn('proxy random snode pool is not ready, retrying 10s', endpoint);
// no nodes in the pool yet, give it some time and retry
await timeoutDelay(1000);
return sendToProxy(srvPubKey, endpoint, pFetchOptions, options);
return sendToProxy(srvPubKey, endpoint, fetchOptions, options);
}
const url = `https://${randSnode.ip}:${randSnode.port}/file_proxy`;
@ -98,7 +215,10 @@ const sendToProxy = async (
payloadObj.body = false; // free memory
// make temporary key for this request/response
const ephemeralKey = await libsignal.Curve.async.generateKeyPair();
// async maybe preferable to avoid cpu spikes
// tho I think sync might be more apt in certain cases here...
// like sending
const ephemeralKey = await libloki.crypto.generateEphemeralKeyPair();
// mix server pub key with our priv key
const symKey = await libsignal.Curve.async.calculateAgreement(
@ -253,6 +373,21 @@ const serverRequest = async (endpoint, options = {}) => {
const host = url.host.toLowerCase();
// log.info('host', host, FILESERVER_HOSTS);
if (
window.lokiFeatureFlags.useFileOnionRequests &&
FILESERVER_HOSTS.includes(host)
) {
mode = 'sendViaOnion';
// url.search automatically includes the ? part
// const search = url.search || '';
// strip first slash
// const endpointWithQS = `${url.pathname}${search}`.replace(/^\//, '');
({ response, txtResponse, result } = await sendViaOnion(
srvPubKey,
url,
fetchOptions,
options
));
} else if (
window.lokiFeatureFlags.useSnodeProxy &&
FILESERVER_HOSTS.includes(host)
) {
@ -313,6 +448,14 @@ const serverRequest = async (endpoint, options = {}) => {
err: e,
};
}
if (!result) {
return {
err: 'noResult',
response,
};
}
// if it's a response style with a meta
if (result.status !== 200) {
if (!forceFreshToken && (!response.meta || response.meta.code === 401)) {
@ -414,6 +557,74 @@ class LokiAppDotNetServerAPI {
this.channels.splice(i, 1);
}
// 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) {
pubKeyAB = window.Signal.Crypto.base64ToArrayBuffer(
urlPubkeyMap[this.baseServerUrl]
);
}
// else will fail validation later
// if in proxy mode, don't allow "file-dev."...
// it only supports "file."... host.
if (
window.lokiFeatureFlags.useSnodeProxy &&
!window.lokiFeatureFlags.useOnionRequests
) {
pubKeyAB = window.Signal.Crypto.base64ToArrayBuffer(
LOKIFOUNDATION_FILESERVER_PUBKEY
);
}
// do we have their pubkey locally?
// FIXME: this._server won't be set yet...
// can't really do this for the file server because we'll need the key
// before we can communicate with lsrpc
/*
// get remote pubKey
this._server.serverRequest('loki/v1/public_key').then(keyRes => {
// we don't need to delay to protect identity because the token request
// should only be done over lokinet-lite
this.delayToken = true;
if (keyRes.err || !keyRes.response || !keyRes.response.data) {
if (keyRes.err) {
log.error(`Error ${keyRes.err}`);
}
} else {
// store it
this.pubKey = dcodeIO.ByteBuffer.wrap(
keyRes.response.data,
'base64'
).toArrayBuffer();
// write it to a file
}
});
*/
// now that key is loaded, lets verify
if (pubKeyAB && pubKeyAB.byteLength && pubKeyAB.byteLength !== 33) {
log.error('FILESERVER PUBKEY is invalid, length:', pubKeyAB.byteLength);
process.exit(1);
}
this.pubKey = pubKeyAB;
this.pubKeyHex = StringView.arrayBufferToHex(pubKeyAB);
return pubKeyAB;
}
async setProfileName(profileName) {
// when we add an annotation, may need this
/*

View File

@ -1,4 +1,4 @@
/* global log, libloki, process, window */
/* global log, libloki, window */
/* global storage: false */
/* global Signal: false */
/* global log: false */
@ -8,49 +8,11 @@ const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
const DEVICE_MAPPING_USER_ANNOTATION_TYPE =
'network.loki.messenger.devicemapping';
// const LOKIFOUNDATION_DEVFILESERVER_PUBKEY =
// 'BSZiMVxOco/b3sYfaeyiMWv/JnqokxGXkHoclEx8TmZ6';
const LOKIFOUNDATION_FILESERVER_PUBKEY =
'BWJQnVm97sQE3Q1InB4Vuo+U/T1hmwHBv0ipkiv8tzEc';
// can have multiple of these instances as each user can have a
// different home server
class LokiFileServerInstance {
constructor(ourKey) {
this.ourKey = ourKey;
// do we have their pubkey locally?
/*
// get remote pubKey
this._server.serverRequest('loki/v1/public_key').then(keyRes => {
// we don't need to delay to protect identity because the token request
// should only be done over lokinet-lite
this.delayToken = true;
if (keyRes.err || !keyRes.response || !keyRes.response.data) {
if (keyRes.err) {
log.error(`Error ${keyRes.err}`);
}
} else {
// store it
this.pubKey = dcodeIO.ByteBuffer.wrap(
keyRes.response.data,
'base64'
).toArrayBuffer();
// write it to a file
}
});
*/
// Hard coded
this.pubKey = window.Signal.Crypto.base64ToArrayBuffer(
LOKIFOUNDATION_FILESERVER_PUBKEY
);
if (this.pubKey.byteLength && this.pubKey.byteLength !== 33) {
log.error(
'FILESERVER PUBKEY is invalid, length:',
this.pubKey.byteLength
);
process.exit(1);
}
}
// FIXME: this is not file-server specific
@ -58,16 +20,10 @@ 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);
// configure proxy
this._server.pubKey = this.pubKey;
// make sure pubKey & pubKeyHex are set in _server
this.pubKey = this._server.getPubKeyForUrl();
if (options !== undefined && options.skipToken) {
return;
@ -80,6 +36,7 @@ class LokiFileServerInstance {
log.error('You are blacklisted form this home server');
}
}
async getUserDeviceMapping(pubKey) {
const annotations = await this._server.getUserAnnotations(pubKey);
const deviceMapping = annotations.find(
@ -331,7 +288,5 @@ class LokiFileServerFactoryAPI {
return thisServer;
}
}
// smuggle some data out of this joint (for expire.js/version upgrade check)
LokiFileServerFactoryAPI.secureRpcPubKey = LOKIFOUNDATION_FILESERVER_PUBKEY;
module.exports = LokiFileServerFactoryAPI;

View File

@ -72,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 = {}) {
@ -315,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);
@ -374,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);
@ -384,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;
@ -505,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;

View File

@ -11,88 +11,257 @@ const snodeHttpsAgent = new https.Agent({
const endpointBase = '/storage_rpc/v1';
// Request index for debugging
let onionReqIdx = 0;
const encryptForNode = async (node, payloadStr) => {
const textEncoder = new TextEncoder();
const plaintext = textEncoder.encode(payloadStr);
return libloki.crypto.encryptForPubkey(node.pubkey_x25519, plaintext);
};
// Returns the actual ciphertext, symmetric key that will be used
// for decryption, and an ephemeral_key to send to the next hop
const encryptForDestination = async (node, payload) => {
// Do we still need "headers"?
const reqStr = JSON.stringify({ body: payload, headers: '' });
const encryptForPubKey = async (pubKeyX25519hex, reqObj) => {
const reqStr = JSON.stringify(reqObj);
return encryptForNode(node, reqStr);
const textEncoder = new TextEncoder();
const plaintext = textEncoder.encode(reqStr);
return libloki.crypto.encryptForPubkey(pubKeyX25519hex, plaintext);
};
// `ctx` holds info used by `node` to relay further
const encryptForRelay = async (node, nextNode, ctx) => {
const encryptForRelay = async (relayX25519hex, destination, ctx) => {
// ctx contains: ciphertext, symmetricKey, ephemeralKey
const payload = ctx.ciphertext;
const reqJson = {
if (!destination.host && !destination.destination) {
log.warn(`loki_rpc::encryptForRelay - no destination`, destination);
}
const reqObj = {
...destination,
ciphertext: dcodeIO.ByteBuffer.wrap(payload).toString('base64'),
ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeralKey),
destination: nextNode.pubkey_ed25519,
};
const reqStr = JSON.stringify(reqJson);
return encryptForNode(node, reqStr);
return encryptForPubKey(relayX25519hex, reqObj);
};
const BAD_PATH = 'bad_path';
// May return false BAD_PATH, indicating that we should try a new
const sendOnionRequest = async (reqIdx, nodePath, targetNode, plaintext) => {
const ctxes = [await encryptForDestination(targetNode, plaintext)];
// from (3) 2 to 0
const firstPos = nodePath.length - 1;
for (let i = firstPos; i > -1; i -= 1) {
// this nodePath points to the previous (i + 1) context
ctxes.push(
// eslint-disable-next-line no-await-in-loop
await encryptForRelay(
nodePath[i],
i === firstPos ? targetNode : nodePath[i + 1],
ctxes[ctxes.length - 1]
)
);
}
const guardCtx = ctxes[ctxes.length - 1]; // last ctx
const makeGuardPayload = guardCtx => {
const ciphertextBase64 = dcodeIO.ByteBuffer.wrap(
guardCtx.ciphertext
).toString('base64');
const payload = {
const guardPayloadObj = {
ciphertext: ciphertextBase64,
ephemeral_key: StringView.arrayBufferToHex(guardCtx.ephemeralKey),
};
return guardPayloadObj;
};
const fetchOptions = {
// we just need the targetNode.pubkey_ed25519 for the encryption
// targetPubKey is ed25519 if snode is the target
const makeOnionRequest = async (
nodePath,
destCtx,
targetED25519Hex,
finalRelayOptions = false,
id = ''
) => {
const ctxes = [destCtx];
// from (3) 2 to 0
const firstPos = nodePath.length - 1;
for (let i = firstPos; i > -1; i -= 1) {
let dest;
const relayingToFinalDestination = i === firstPos; // if last position
if (relayingToFinalDestination && finalRelayOptions) {
dest = {
host: finalRelayOptions.host,
target: '/loki/v1/lsrpc',
method: 'POST',
};
} else {
// set x25519 if destination snode
let pubkeyHex = targetED25519Hex; // relayingToFinalDestination
// or ed25519 snode destination
if (!relayingToFinalDestination) {
pubkeyHex = nodePath[i + 1].pubkey_ed25519;
if (!pubkeyHex) {
log.error(
`loki_rpc:::makeOnionRequest ${id} - no ed25519 for`,
nodePath[i + 1],
'path node',
i + 1
);
}
}
// destination takes a hex key
dest = {
destination: pubkeyHex,
};
}
try {
// eslint-disable-next-line no-await-in-loop
const ctx = await encryptForRelay(
nodePath[i].pubkey_x25519,
dest,
ctxes[ctxes.length - 1]
);
ctxes.push(ctx);
} catch (e) {
log.error(
`loki_rpc:::makeOnionRequest ${id} - encryptForRelay failure`,
e.code,
e.message
);
throw e;
}
}
const guardCtx = ctxes[ctxes.length - 1]; // last ctx
const payloadObj = makeGuardPayload(guardCtx);
// all these requests should use AesGcm
return payloadObj;
};
// finalDestOptions is an object
// FIXME: internally track reqIdx, not externally
const sendOnionRequest = async (
reqIdx,
nodePath,
destX25519Any,
finalDestOptions,
finalRelayOptions = false,
lsrpcIdx
) => {
if (!destX25519Any) {
log.error('loki_rpc::sendOnionRequest - no destX25519Any given');
return {};
}
// loki-storage may need this to function correctly
// but ADN calls will not always have a body
/*
if (!finalDestOptions.body) {
finalDestOptions.body = '';
}
*/
let id = '';
if (lsrpcIdx !== undefined) {
id += `${lsrpcIdx}=>`;
}
if (reqIdx !== undefined) {
id += `${reqIdx}`;
}
// get destination pubkey in array buffer format
let destX25519hex = destX25519Any;
if (typeof destX25519hex !== 'string') {
// convert AB to hex
destX25519hex = StringView.arrayBufferToHex(destX25519Any);
}
// safely build destination
let targetEd25519hex;
if (finalDestOptions) {
if (finalDestOptions.destination_ed25519_hex) {
// snode destination
targetEd25519hex = finalDestOptions.destination_ed25519_hex;
// eslint-disable-next-line no-param-reassign
delete finalDestOptions.destination_ed25519_hex;
}
// else it's lsrpc...
} else {
// eslint-disable-next-line no-param-reassign
finalDestOptions = {};
log.warn(`loki_rpc::sendOnionRequest ${id} - no finalDestOptions`);
return {};
}
const options = finalDestOptions; // lint
// do we need this?
if (options.headers === undefined) {
options.headers = '';
}
let destCtx;
try {
destCtx = await encryptForPubKey(destX25519hex, options);
} catch (e) {
log.error(
`loki_rpc::sendOnionRequest ${id} - encryptForPubKey failure [`,
e.code,
e.message,
'] destination X25519',
destX25519hex.substr(0, 32),
'...',
destX25519hex.substr(32),
'options',
options
);
throw e;
}
const payloadObj = await makeOnionRequest(
nodePath,
destCtx,
targetEd25519hex,
finalRelayOptions,
id
);
const guardFetchOptions = {
method: 'POST',
body: JSON.stringify(payload),
body: JSON.stringify(payloadObj),
// we are talking to a snode...
agent: snodeHttpsAgent,
};
const url = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`;
const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`;
const response = await nodeFetch(guardUrl, guardFetchOptions);
// we only proxy to snodes...
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const response = await nodeFetch(url, fetchOptions);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
return processOnionResponse(reqIdx, response, ctxes[0].symmetricKey, true);
return processOnionResponse(reqIdx, response, destCtx.symmetricKey, true);
};
const sendOnionRequestSnodeDest = async (
reqIdx,
nodePath,
targetNode,
plaintext
) =>
sendOnionRequest(reqIdx, nodePath, targetNode.pubkey_x25519, {
destination_ed25519_hex: targetNode.pubkey_ed25519,
body: plaintext,
});
// need relay node's pubkey_x25519_hex
// always the same target: /loki/v1/lsrpc
const sendOnionRequestLsrpcDest = async (
reqIdx,
nodePath,
destX25519Any,
host,
payloadObj,
lsrpcIdx = 0
) =>
sendOnionRequest(
reqIdx,
nodePath,
destX25519Any,
payloadObj,
{ host },
lsrpcIdx
);
const BAD_PATH = 'bad_path';
// Process a response as it arrives from `nodeFetch`, handling
// http errors and attempting to decrypt the body with `sharedKey`
const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => {
// May return false BAD_PATH, indicating that we should try a new path.
const processOnionResponse = async (
reqIdx,
response,
sharedKey,
useAesGcm,
debug
) => {
// FIXME: 401/500 handling?
// detect SNode is not ready (not in swarm; not done syncing)
@ -115,16 +284,24 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => {
if (response.status !== 200) {
log.warn(
`(${reqIdx}) [path] fetch unhandled error code: ${response.status}`
`(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}`
);
return false;
}
const ciphertext = await response.text();
if (!ciphertext) {
log.warn(`(${reqIdx}) [path]: Target node return empty ciphertext`);
log.warn(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext`
);
return false;
}
if (debug) {
log.debug(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertext`,
ciphertext
);
}
let plaintext;
let ciphertextBuffer;
@ -134,22 +311,52 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => {
'base64'
).toArrayBuffer();
const decryptFn = useAesGcm
? window.libloki.crypto.DecryptGCM
: window.libloki.crypto.DHDecrypt;
if (debug) {
log.debug(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
StringView.arrayBufferToHex(ciphertextBuffer),
'useAesGcm',
useAesGcm
);
}
const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer);
const decryptFn = useAesGcm
? libloki.crypto.DecryptGCM
: libloki.crypto.DHDecrypt;
const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer, debug);
if (debug) {
log.debug(
'lokiRpc::processOnionResponse - plaintextBuffer',
plaintextBuffer.toString()
);
}
const textDecoder = new TextDecoder();
plaintext = textDecoder.decode(plaintextBuffer);
} catch (e) {
log.error(`(${reqIdx}) [path] decode error`);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - decode error`,
e.code,
e.message
);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - symKey`,
StringView.arrayBufferToHex(sharedKey)
);
if (ciphertextBuffer) {
log.error(`(${reqIdx}) [path] ciphertextBuffer`, ciphertextBuffer);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
StringView.arrayBufferToHex(ciphertextBuffer)
);
}
return false;
}
if (debug) {
log.debug('lokiRpc::processOnionResponse - plaintext', plaintext);
}
try {
const jsonRes = JSON.parse(plaintext);
// emulate nodeFetch response...
@ -158,13 +365,22 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => {
const res = JSON.parse(jsonRes.body);
return res;
} catch (e) {
log.error(`(${reqIdx}) [path] parse error json: `, jsonRes.body);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error inner json: `,
jsonRes.body
);
}
return false;
};
return jsonRes;
} catch (e) {
log.error('[path] parse error', e.code, e.message, `json:`, plaintext);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error outer json`,
e.code,
e.message,
`json:`,
plaintext
);
return false;
}
};
@ -206,7 +422,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
const snPubkeyHex = StringView.hexToArrayBuffer(targetNode.pubkey_x25519);
const myKeys = await window.libloki.crypto.generateEphemeralKeyPair();
const myKeys = await libloki.crypto.generateEphemeralKeyPair();
const symmetricKey = await libsignal.Curve.async.calculateAgreement(
snPubkeyHex,
@ -217,7 +433,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
const body = JSON.stringify(options);
const plainText = textEncoder.encode(body);
const ivAndCiphertext = await window.libloki.crypto.DHEncrypt(
const ivAndCiphertext = await libloki.crypto.DHEncrypt(
symmetricKey,
plainText
);
@ -277,6 +493,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
// grab a fresh random one
return sendToProxy(options, targetNode, pRetryNumber);
}
// 502 is "Next node not found"
// detect SNode is not ready (not in swarm; not done syncing)
// 503 can be proxy target or destination in pre 2.0.3
@ -356,7 +573,7 @@ const sendToProxy = async (options = {}, targetNode, retryNumber = 0) => {
'base64'
).toArrayBuffer();
const plaintextBuffer = await window.libloki.crypto.DHDecrypt(
const plaintextBuffer = await libloki.crypto.DHDecrypt(
symmetricKey,
ciphertextBuffer
);
@ -448,6 +665,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
// Wrong PoW difficulty
if (response.status === 432) {
const result = await response.json();
log.error(`lokirpc:::lokiFetch ${type} - WRONG POW`, result);
throw new textsecure.WrongDifficultyError(result.difficulty);
}
@ -468,11 +686,10 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
// Get a path excluding `targetNode`:
// eslint-disable-next-line no-await-in-loop
const path = await lokiSnodeAPI.getOnionPath(targetNode);
const thisIdx = onionReqIdx;
onionReqIdx += 1;
const thisIdx = window.lokiSnodeAPI.assignOnionRequestNumber();
// eslint-disable-next-line no-await-in-loop
const result = await sendOnionRequest(
const result = await sendOnionRequestSnodeDest(
thisIdx,
path,
targetNode,
@ -628,4 +845,5 @@ const lokiRpc = (
module.exports = {
lokiRpc,
sendOnionRequestLsrpcDest,
};

View File

@ -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,
};

View File

@ -17,14 +17,24 @@ 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;
// just get the filtered list
async function tryGetSnodeListFromLokidSeednode(
seedNodes = [...window.seedNodeList]
seedNodes = window.seedNodeList
) {
if (!seedNodes.length) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - no seedNodes given`,
seedNodes,
'window',
window.seedNodeList
);
return [];
}
// Removed limit until there is a way to get snode info
// for individual nodes (needed for guard nodes); this way
// we get all active nodes
@ -42,6 +52,13 @@ async function tryGetSnodeListFromLokidSeednode(
Math.floor(Math.random() * seedNodes.length),
1
)[0];
if (!seedNode) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - seedNode selection failure - seedNodes`,
seedNodes
);
return [];
}
let snodes = [];
try {
const getSnodesFromSeedUrl = async urlObj => {
@ -53,6 +70,30 @@ async function tryGetSnodeListFromLokidSeednode(
{}, // Options
'/json_rpc' // Seed request endpoint
);
if (!response) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid response from seed ${urlObj.toString()}:`,
response
);
return [];
}
// should we try to JSON.parse this?
if (typeof response === 'string') {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid string response from seed ${urlObj.toString()}:`,
response
);
return [];
}
if (!response.result) {
log.error(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - invalid result from seed ${urlObj.toString()}:`,
response
);
return [];
}
// Filter 0.0.0.0 nodes which haven't submitted uptime proofs
return response.result.service_node_states.filter(
snode => snode.public_ip !== '0.0.0.0'
@ -72,6 +113,11 @@ async function tryGetSnodeListFromLokidSeednode(
);
}
}
if (snodes.length) {
log.info(
`loki_snodes:::tryGetSnodeListFromLokidSeednode - got ${snodes.length} service nodes from seed`
);
}
return snodes;
} catch (e) {
log.warn(
@ -87,9 +133,18 @@ async function tryGetSnodeListFromLokidSeednode(
}
async function getSnodeListFromLokidSeednode(
seedNodes = [...window.seedNodeList],
seedNodes = window.seedNodeList,
retries = 0
) {
if (!seedNodes.length) {
log.error(
`loki_snodes:::getSnodeListFromLokidSeednode - no seedNodes given`,
seedNodes,
'window',
window.seedNodeList
);
return [];
}
let snodes = [];
try {
snodes = await tryGetSnodeListFromLokidSeednode(seedNodes);
@ -129,6 +184,12 @@ class LokiSnodeAPI {
this.onionPaths = [];
this.guardNodes = [];
this.onionRequestCounter = 0; // Request index for debugging
}
assignOnionRequestNumber() {
this.onionRequestCounter += 1;
return this.onionRequestCounter;
}
async getRandomSnodePool() {
@ -202,7 +263,7 @@ class LokiSnodeAPI {
// FIXME: handle rejections
let nodePool = await this.getRandomSnodePool();
if (nodePool.length === 0) {
log.error(`Could not select guarn nodes: node pool is empty`);
log.error(`Could not select guard nodes: node pool is empty`);
return [];
}
@ -211,16 +272,17 @@ class LokiSnodeAPI {
let guardNodes = [];
const DESIRED_GUARD_COUNT = 3;
if (shuffled.length < DESIRED_GUARD_COUNT) {
log.error(
`Could not select guarn 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 guarn 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 [];
}
@ -269,17 +331,20 @@ class LokiSnodeAPI {
const goodPaths = this.onionPaths.filter(x => !x.bad);
if (goodPaths.length < 2) {
if (goodPaths.length < MIN_GUARD_COUNT) {
log.error(
`Must have at least 2 good onion paths, actual: ${goodPaths.length}`
);
await this.buildNewOnionPaths();
// should we add a delay? buildNewOnionPaths should act as one
// reload goodPaths now
return this.getOnionPath(toExclude);
}
const paths = _.shuffle(goodPaths);
if (!toExclude) {
return paths[0];
return paths[0].path;
}
// Select a path that doesn't contain `toExclude`
@ -290,6 +355,19 @@ class LokiSnodeAPI {
if (otherPaths.length === 0) {
// This should never happen!
// well it did happen, should we
// await this.buildNewOnionPaths();
// and restart call?
log.error(
`loki_snode_api::getOnionPath - no paths without`,
toExclude.pubkey_ed25519,
'path count',
paths.length,
'goodPath count',
goodPaths.length,
'paths',
paths
);
throw new Error('No onion paths available after filtering');
}
@ -305,6 +383,7 @@ class LokiSnodeAPI {
});
}
// Does this get called multiple times on startup??
async buildNewOnionPaths() {
// Note: this function may be called concurrently, so
// might consider blocking the other calls
@ -336,7 +415,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();
}
}
@ -553,7 +633,17 @@ class LokiSnodeAPI {
);
}
async refreshRandomPool(seedNodes = [...window.seedNodeList]) {
async refreshRandomPool(seedNodes = window.seedNodeList) {
if (!seedNodes.length) {
if (!window.seedNodeList || !window.seedNodeList.length) {
log.error(
`loki_snodes:::refreshRandomPool - seedNodeList has not been loaded yet`
);
return [];
}
// eslint-disable-next-line no-param-reassign
seedNodes = window.seedNodeList;
}
return primitives.allowOnlyOneAtATime('refreshRandomPool', async () => {
// are we running any _getAllVerionsForRandomSnodePool
if (this.stopGetAllVersionPromiseControl !== false) {
@ -750,73 +840,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);
// Loop until 3 confirmations
// We don't trust any single node, so we accumulate
// answers here and select a dominating answer
const allResults = [];
let ciphertextHex = null;
while (!ciphertextHex) {
if (lnsNodes.length < 3) {
log.error('Not enough nodes for lns lookup');
return false;
}
// extract 3 and make requests in parallel
const nodes = lnsNodes.splice(0, 3);
// eslint-disable-next-line no-await-in-loop
const results = await Promise.all(
nodes.map(node => this._requestLnsMapping(node, nameHash))
);
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);
}
});
const [winner, count] = _.maxBy(
_.entries(_.countBy(allResults)),
x => x[1]
);
if (count >= 3) {
// eslint-disable-next-lint prefer-destructuring
ciphertextHex = winner;
}
}
const ciphertext = new Uint8Array(
StringView.hexToArrayBuffer(ciphertextHex)
const lnsNodes = await this.getNodesMinVersion(
window.CONSTANTS.LNS_CAPABLE_NODES_VERSION
);
const res = await window.decryptLnsEntry(lnsName, ciphertext);
// Enough nodes?
if (lnsNodes.length < numRequiredConfirms) {
error = { lnsTooFewNodes: window.i18n('lnsTooFewNodes') };
return { pubkey, error };
}
const pubkey = StringView.arrayBufferToHex(res);
const confirmedNodes = [];
return pubkey;
// Promise is only resolved when a consensus is found
let cipherResolve;
const cipherPromise = () =>
new Promise(resolve => {
cipherResolve = resolve;
});
const decryptHex = async cipherHex => {
const ciphertext = new Uint8Array(StringView.hexToArrayBuffer(cipherHex));
const res = await window.decryptLnsEntry(lnsName, ciphertext);
const publicKey = StringView.arrayBufferToHex(res);
return publicKey;
};
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 >= numRequiredConfirms) {
ciphertextHex = winner === String(null) ? null : winner;
// null represents no LNS mapping
if (ciphertextHex === null) {
error = { lnsMappingNotFound: window.i18n('lnsMappingNotFound') };
}
cipherResolve({ ciphertextHex });
}
}
}
};
const nodes = lnsNodes.splice(0, numRequests);
// Start fetching from nodes
nodes.forEach(node => fetchFromNode(node));
// 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, error };
}
// get snodes for pubkey from random snode

View File

@ -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
);
},

View File

@ -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,
};
})();

View File

@ -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({

View File

@ -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;
})();

View File

@ -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`

View File

@ -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');
if (convoExists) {
// If the group already exists, check that `members` is empty,
// and if so, it is sender key message
// TODO: introduce TYPE into this message instead?
if (!members || !members.length) {
log.info('[sender key] got a new sender key from:', envelope.source);
// We probably don't need to await here
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
this.removeFromCache(envelope);
return;
}
log.error(`Conversation for groupId ${groupId} already exists`);
}
const convo = await window.ConversationController.getOrCreateAndWait(
groupId,
'group'
);
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: groupSecretKey,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey
);
// TODO: Check that we are even a part of this group?
const ourIdentity = await textsecure.storage.user.getNumber();
const senderIdentity = envelope.source;
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// TODO: Send own key to every member
const otherMembers = _.without(members, ourIdentity);
if (
type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY_REQUEST
) {
log.debug('[sender key] sender key request from:', senderIdentity);
const proto = new textsecure.protobuf.DataMessage();
// 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 = ownSenderKey;
update.senderKey = new textsecure.protobuf.SenderKey({
chainKey: StringView.arrayBufferToHex(chainKey),
keyIdx,
});
proto.mediumGroupUpdate = update;
// TODO: send to our linked devices too?
textsecure.messaging.updateMediumGroup([senderIdentity], proto);
// Don't need to await here
this.removeFromCache(envelope);
} else if (type === textsecure.protobuf.MediumGroupUpdate.Type.SENDER_KEY) {
const { senderKey } = groupUpdate;
// TODO: Some of the members might not have a session with us, so
// we should send a session request
log.debug('[sender key] got a new sender key from:', senderIdentity);
textsecure.messaging.updateMediumGroup(otherMembers, proto);
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'
);
{
// 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 });
}
if (groupExists) {
// ***** Updating the group *****
log.info('Received a group update for medium group:', groupId);
// Check that the sender is admin (make sure it words with multidevice)
const isAdmin = convo.get('groupAdmins').includes(senderIdentity);
if (!isAdmin) {
log.warn('Rejected attempt to update a group by non-admin');
this.removeFromCache(envelope);
return;
}
convo.set('name', groupName);
convo.set('members', members);
// TODO: check that we are still in the group (when we enable deleting members)
convo.saveChangesToDB();
// Update other fields. Add a corresponding "update" message to the conversation
} else {
// ***** Creating a new group *****
log.info('Received a new medium group:', groupId);
// TODO: Check that we are even a part of this group?
convo.set('is_medium_group', true);
convo.set('active_at', Date.now());
convo.set('name', groupName);
convo.set('groupAdmins', admins);
convo.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
const secretKeyHex = StringView.arrayBufferToHex(
groupSecretKey.toArrayBuffer()
);
await window.Signal.Data.createOrUpdateIdentityKey({
id: groupId,
secretKey: secretKeyHex,
});
// Save sender's key
await window.SenderKeyAPI.saveSenderKeys(
groupId,
envelope.source,
senderKey.chainKey,
senderKey.keyIdx
);
const ownSenderKey = await window.SenderKeyAPI.createSenderKeyForGroup(
groupId,
ourIdentity
);
{
// Send own key to every member
const otherMembers = _.without(members, ourIdentity);
const proto = new textsecure.protobuf.DataMessage();
// 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,
});
proto.mediumGroupUpdate = update;
textsecure.messaging.updateMediumGroup(otherMembers, proto);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
}
this.removeFromCache(envelope);
}
// Subscribe to this group
this.pollForAdditionalId(groupId);
// All further messages (maybe rather than 'control' messages) should come to this group's swarm
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'),

View File

@ -6,7 +6,6 @@
libloki,
StringView,
lokiMessageAPI,
i18n,
log
*/
@ -167,6 +166,7 @@ function OutgoingMessage(
isMediumGroup,
publicSendData,
debugMessageType,
autoSession,
} = options || {};
this.numberInfo = numberInfo;
this.isPublic = isPublic;
@ -181,6 +181,7 @@ function OutgoingMessage(
this.online = online;
this.messageType = messageType || 'outgoing';
this.debugMessageType = debugMessageType;
this.autoSession = autoSession || false;
}
OutgoingMessage.prototype = {
@ -215,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)
@ -318,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 = {
@ -348,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' &&
@ -369,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
@ -411,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();
@ -486,6 +480,7 @@ OutgoingMessage.prototype = {
plaintext,
pubKey,
isSessionRequest,
isFriendRequest,
enableFallBackEncryption,
} = clearMessage;
// Session doesn't use the deviceId scheme, it's always 1.
@ -531,7 +526,7 @@ OutgoingMessage.prototype = {
sourceDevice,
content,
pubKey,
isFriendRequest: enableFallBackEncryption,
isFriendRequest,
isSessionRequest,
};
},
@ -644,6 +639,7 @@ OutgoingMessage.prototype = {
this.timestamp,
ttl
);
if (!this.isGroup && isFriendRequest && !isSessionRequest) {
const conversation = ConversationController.get(destination);
if (conversation) {
@ -657,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 = [];
}
await Promise.all(promises);
this.numbersCompleted += this.successfulNumbers.length;
this.numberCompleted();
},
async buildAndEncrypt(devicePubKey) {
@ -705,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
@ -737,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
@ -764,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,

View File

@ -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
);
@ -666,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')
);
if (sessionContacts.length === 0) {
// 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
);
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))
);
@ -712,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.');
@ -977,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') ||
@ -1016,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(
@ -1203,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,
@ -1284,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();
@ -1387,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);

63
main.js
View File

@ -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,

View File

@ -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",
@ -40,6 +34,7 @@
"test-integration": "ELECTRON_DISABLE_SANDBOX=1 mocha --exit --timeout 10000 integration_test/integration_test.js",
"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 .",

View File

@ -57,6 +57,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) => {
@ -71,18 +73,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(),
@ -155,7 +170,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 = () => {
@ -326,16 +341,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');
@ -418,7 +448,10 @@ window.lokiFeatureFlags = {
privateGroupChats: true,
useSnodeProxy: !process.env.USE_STUBBED_NETWORK,
useOnionRequests: true,
onionRequestHops: 1,
useFileOnionRequests: false,
enableSenderKeys: false,
onionRequestHops: 3,
debugMessageLogs: process.env.ENABLE_MESSAGE_LOGS,
};
// eslint-disable-next-line no-extend-native,func-names
@ -429,7 +462,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 */
@ -444,12 +478,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,
};
}

View File

@ -51,17 +51,27 @@ message MediumGroupContent {
optional bytes ephemeralKey = 2;
}
message MediumGroupUpdate {
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 SenderKey {
optional string chainKey = 1;
optional uint32 keyIdx = 2;
}
message SenderKeyUpdate {
optional string groupId = 1;
optional string senderKey = 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 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;
}

1
session-file-server Submodule

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

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -492,13 +492,14 @@
<script type="text/javascript" src="test.js"></script>
<script type="text/javascript" src="../js/registration.js" data-cover></script>
<script type="text/javascript" src="../js/expire.js" data-cover></script>
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
<script type="text/javascript" src="../js/database.js" data-cover></script>
<script type="text/javascript" src="../js/storage.js" data-cover></script>
<script type="text/javascript" src="../js/signal_protocol_store.js" data-cover></script>
<script type="text/javascript" src="../js/libtextsecure.js" data-cover></script>
<script type="text/javascript" src="../js/libloki.js" data-cover></script>
<!-- needs the network comms libraries to work -->
<script type="text/javascript" src="../js/expire.js" data-cover></script>
<script type="text/javascript" src="../js/models/messages.js" data-cover></script>
<script type="text/javascript" src="../js/models/conversations.js" data-cover></script>

View File

@ -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}
/>
);
}
}

View File

@ -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')
);
}
}

View File

@ -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) {
@ -485,6 +478,7 @@ export class ConversationHeader extends React.Component<Props> {
const showMembersMenuItem = isGroup && (
<MenuItem onClick={onShowGroupMembers}>{i18n('showMembers')}</MenuItem>
);
const showSafetyNumberMenuItem = !isGroup && !isMe && (
<MenuItem onClick={onShowSafetyNumber}>
{i18n('showSafetyNumber')}
@ -496,34 +490,14 @@ export class ConversationHeader extends React.Component<Props> {
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')}
</MenuItem>
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
);
return (
<React.Fragment>
{/* <MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem> */}
{disappearingMessagesMenuItem}
{showMembersMenuItem}
{showSafetyNumberMenuItem}
{resetSessionMenuItem}
{blockHandlerMenuItem}
{/* {changeNicknameMenuItem}
{clearNicknameMenuItem} */}
{archiveConversationMenuItem}
</React.Fragment>
);
}

View File

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

View File

@ -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);

View File

@ -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}

View File

@ -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}

View File

@ -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;
}

View File

@ -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];

View File

@ -27,6 +27,12 @@ import {
import { SessionSpinner } from './SessionSpinner';
import { joinChannelStateManager } from './LeftPaneChannelSection';
// HIJACKING BUTTON FOR TESTING
import { PendingMessageCache } from '../../session/sending/PendingMessageCache';
import { MessageQueue } from '../../session/sending';
import { ExampleMessage } from '../../session/sending/MessageQueue';
export interface Props {
searchTerm: string;
isSecondaryDevice: boolean;
@ -45,6 +51,10 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
private readonly updateSearchBound: (searchedString: string) => void;
private readonly debouncedSearch: (searchTerm: string) => void;
// HIJACKED FOR TESTING
private readonly messageQueue: any;
private readonly pendingMessageCache: any;
public constructor(props: Props) {
super(props);
@ -82,6 +92,11 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
this.handleOnPasteSessionID = this.handleOnPasteSessionID.bind(this);
this.handleMessageButtonClick = this.handleMessageButtonClick.bind(this);
this.debouncedSearch = debounce(this.search.bind(this), 20);
// HIJACKING FOR TESTING
this.messageQueue = new MessageQueue();
this.pendingMessageCache = new PendingMessageCache();
}
public componentWillUnmount() {
@ -97,7 +112,7 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
if (conversationList !== undefined) {
conversationList = conversationList.filter(
conversation =>
!conversation.isSecondary && !conversation.isPendingFriendRequest
!conversation.isPendingFriendRequest && !conversation.isSecondary
);
}
@ -361,12 +376,29 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
);
}
private handleToggleOverlay() {
this.setState((state: any) => {
return { showComposeView: !state.showComposeView };
});
// empty our generalized searchedString (one for the whole app)
this.updateSearch('');
private async handleToggleOverlay() {
// HIJACKING BUTTON FOR TESTING
console.log('[vince] pendingMessageCache:', this.pendingMessageCache);
const pubkey = window.textsecure.storage.user.getNumber();
const exampleMessage = new ExampleMessage();
console.log('[vince] exampleMessage:', exampleMessage);
const devices = this.pendingMessageCache.getPendingDevices();
console.log('[vince] devices:', devices);
if ($('.session-search-input input').val()) {
this.pendingMessageCache.removePendingMessageByIdentifier(exampleMessage.identifier);
} else {
this.pendingMessageCache.addPendingMessage(pubkey, exampleMessage);
}
// this.setState((state: any) => {
// return { showComposeView: !state.showComposeView };
// });
// // empty our generalized searchedString (one for the whole app)
// this.updateSearch('');
}
private handleOnPasteSessionID(value: string) {
@ -408,4 +440,4 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
const serverURL = window.CONSTANTS.DEFAULT_PUBLIC_CHAT_URL;
joinChannelStateManager(this, serverURL, this.handleCloseOnboarding);
}
}
}

View File

@ -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>;
}

View File

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

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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"
>

View File

@ -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>
);

View File

@ -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',

7
ts/global.d.ts vendored
View File

@ -64,3 +64,10 @@ interface Window {
interface Promise<T> {
ignore(): void;
}
// Types also correspond to messages.json keys
enum LnsLookupErrorType {
lnsTooFewNodes,
lnsLookupTimeout,
lnsMappingNotFound,
}

View File

@ -1,7 +1,6 @@
import { DataMessage } from './DataMessage';
import { SignalService } from '../../../../../protobuf';
import { ChatMessage } from './ChatMessage';
import { TextEncoder } from 'util';
import { SignalService } from '../../../../../../protobuf';
import { ChatMessage } from '../ChatMessage';
import { ClosedGroupMessage } from './ClosedGroupMessage';
interface ClosedGroupChatMessageParams {
identifier?: string;
@ -9,16 +8,15 @@ interface ClosedGroupChatMessageParams {
chatMessage: ChatMessage;
}
export class ClosedGroupChatMessage extends DataMessage {
private readonly groupId: string;
export class ClosedGroupChatMessage extends ClosedGroupMessage {
private readonly chatMessage: ChatMessage;
constructor(params: ClosedGroupChatMessageParams) {
super({
timestamp: params.chatMessage.timestamp,
identifier: params.identifier,
groupId: params.groupId,
});
this.groupId = params.groupId;
this.chatMessage = params.chatMessage;
}
@ -26,11 +24,13 @@ export class ClosedGroupChatMessage extends DataMessage {
return this.getDefaultTTL();
}
protected groupContextType(): SignalService.GroupContext.Type {
return SignalService.GroupContext.Type.DELIVER;
}
protected dataProto(): SignalService.DataMessage {
const messageProto = this.chatMessage.dataProto();
const id = new TextEncoder().encode(this.groupId);
const type = SignalService.GroupContext.Type.DELIVER;
messageProto.group = new SignalService.GroupContext({ id, type });
messageProto.group = this.groupContext();
return messageProto;
}

View File

@ -0,0 +1,36 @@
import { DataMessage } from '../DataMessage';
import { SignalService } from '../../../../../../protobuf';
import { TextEncoder } from 'util';
import { MessageParams } from '../../../Message';
interface ClosedGroupMessageParams extends MessageParams {
groupId: string;
}
export abstract class ClosedGroupMessage extends DataMessage {
protected readonly groupId: string;
constructor(params: ClosedGroupMessageParams) {
super({
timestamp: params.timestamp,
identifier: params.identifier,
});
this.groupId = params.groupId;
}
protected abstract groupContextType(): SignalService.GroupContext.Type;
protected groupContext(): SignalService.GroupContext {
const id = new TextEncoder().encode(this.groupId);
const type = this.groupContextType();
return new SignalService.GroupContext({ id, type });
}
protected dataProto(): SignalService.DataMessage {
const dataMessage = new SignalService.DataMessage();
dataMessage.group = this.groupContext();
return dataMessage;
}
}

View File

@ -0,0 +1,2 @@
export * from './ClosedGroupMessage';
export * from './ClosedGroupChatMessage';

View File

@ -1,5 +1,5 @@
export * from './ClosedGroupChatMessage';
export * from './DataMessage';
export * from './DeviceUnlinkMessage';
export * from './GroupInvitationMessage';
export * from './ChatMessage';
export * from './group';

View File

@ -0,0 +1,56 @@
// TODO: Need to flesh out these functions
// Structure of this can be changed for example sticking this all in a class
// The reason i haven't done it is to avoid having instances of the protocol, rather you should be able to call the functions directly
import { SessionResetMessage } from '../messages/outgoing';
export function hasSession(device: string): boolean {
return false; // TODO: Implement
}
export function hasSentSessionRequest(device: string): boolean {
// TODO: need a way to keep track of if we've sent a session request
// My idea was to use the timestamp of when it was sent but there might be another better approach
return false;
}
export async function sendSessionRequestIfNeeded(
device: string
): Promise<void> {
if (hasSession(device) || hasSentSessionRequest(device)) {
return Promise.resolve();
}
// TODO: Call sendSessionRequest with SessionReset
return Promise.reject(new Error('Need to implement this function'));
}
export async function sendSessionRequest(
message: SessionResetMessage
): Promise<void> {
// TODO: Optimistically store timestamp of when session request was sent
// TODO: Send out the request via MessageSender
// TODO: On failure, unset the timestamp
return Promise.resolve();
}
export function sessionEstablished(device: string) {
// TODO: this is called when we receive an encrypted message from the other user
// Maybe it should be renamed to something else
// TODO: This should make `hasSentSessionRequest` return `false`
}
export function shouldProcessSessionRequest(
device: string,
messageTimestamp: number
): boolean {
// TODO: Need to do the following here
// messageTimestamp > session request sent timestamp && messageTimestamp > session request processed timestamp
return false;
}
export function sessionRequestProcessed(device: string) {
// TODO: this is called when we process the session request
// This should store the processed timestamp
// Again naming is crap so maybe some other name is better
}

View File

@ -0,0 +1,60 @@
import { EventEmitter } from 'events';
import {
MessageQueueInterface,
MessageQueueInterfaceEvents,
} from './MessageQueueInterface';
import { ContentMessage, OpenGroupMessage } from '../messages/outgoing';
import { PendingMessageCache } from './PendingMessageCache';
import { JobQueue, TypedEventEmitter } from '../utils';
export class MessageQueue implements MessageQueueInterface {
public readonly events: TypedEventEmitter<MessageQueueInterfaceEvents>;
private readonly jobQueues: Map<string, JobQueue> = new Map();
private readonly cache: PendingMessageCache;
constructor() {
this.events = new EventEmitter();
this.cache = new PendingMessageCache();
this.processAllPending();
}
public sendUsingMultiDevice(user: string, message: ContentMessage) {
throw new Error('Method not implemented.');
}
public send(device: string, message: ContentMessage) {
throw new Error('Method not implemented.');
}
public sendToGroup(message: ContentMessage | OpenGroupMessage) {
throw new Error('Method not implemented.');
}
public sendSyncMessage(message: ContentMessage) {
throw new Error('Method not implemented.');
}
public processPending(device: string) {
// TODO: implement
}
private processAllPending() {
// TODO: Get all devices which are pending here
}
private queue(device: string, message: ContentMessage) {
// TODO: implement
}
private queueOpenGroupMessage(message: OpenGroupMessage) {
// TODO: Do we need to queue open group messages?
// If so we can get open group job queue and add the send job here
}
private getJobQueue(device: string): JobQueue {
let queue = this.jobQueues.get(device);
if (!queue) {
queue = new JobQueue();
this.jobQueues.set(device, queue);
}
return queue;
}
}

View File

@ -0,0 +1,22 @@
import {
ClosedGroupMessage,
ContentMessage,
OpenGroupMessage,
} from '../messages/outgoing';
import { RawMessage } from '../types/RawMessage';
import { TypedEventEmitter } from '../utils';
type GroupMessageType = OpenGroupMessage | ClosedGroupMessage;
export interface MessageQueueInterfaceEvents {
success: (message: RawMessage) => void;
fail: (message: RawMessage, error: Error) => void;
}
export interface MessageQueueInterface {
events: TypedEventEmitter<MessageQueueInterfaceEvents>;
sendUsingMultiDevice(user: string, message: ContentMessage): void;
send(device: string, message: ContentMessage): void;
sendToGroup(message: GroupMessageType): void;
sendSyncMessage(message: ContentMessage): void;
}

View File

@ -1,8 +1,8 @@
import { getItemById, createOrUpdateItem } from '../../../js/modules/data';
import { createOrUpdateItem, getItemById } from '../../../js/modules/data';
import { RawMessage } from '../types/RawMessage';
import { ContentMessage } from '../messages/outgoing';
import * as MessageUtils from '../utils';
import { PubKey } from '../types';
import * as MessageUtils from '../utils';
// This is an abstraction for storing pending messages.
// Ideally we want to store pending messages in the database so that
@ -12,19 +12,44 @@ import { PubKey } from '../types';
// memory and sync its state with the database on modification (add or remove).
export class PendingMessageCache {
private cache: Array<RawMessage>;
public readonly isReady: Promise<boolean>;
private cache: Array<any>;
constructor() {
// Load pending messages from the database
// You should await init() on making a new PendingMessageCache
// You should await isReady on making a new PendingMessageCache
// if you'd like to have instant access to the cache
this.cache = [];
void this.init();
this.cache = ['bleep'];
this.isReady = new Promise(async resolve => {
await this.loadFromDB();
resolve(true);
});
}
public get(): Array<RawMessage> {
public getAllPending(): Array<RawMessage> {
// Get all pending from cache, sorted with oldest first
return this.cache.sort((a, b) => a.timestamp - b.timestamp);
return [...this.cache].sort((a, b) => a.timestamp - b.timestamp);
}
public getForDevice(device: PubKey): Array<RawMessage> {
const pending = this.cache.filter(m => m.device === device.key);
return pending.sort((a, b) => a.timestamp - b.timestamp);
}
public getDevices(): Array<PubKey> {
// Gets all unique devices with pending messages
const pubkeyStrings = [...new Set(this.cache.map(m => m.device))];
const pubkeys: Array<PubKey> = [];
pubkeyStrings.forEach(pubkey => {
if (PubKey.validate(pubkey)) {
pubkeys.push(new PubKey(pubkey));
}
});
return pubkeys;
}
public async add(
@ -39,7 +64,7 @@ export class PendingMessageCache {
}
this.cache.push(rawMessage);
await this.syncCacheWithDB();
await this.saveToDB();
return rawMessage;
}
@ -59,7 +84,7 @@ export class PendingMessageCache {
m => m.identifier !== message.identifier
);
this.cache = updatedCache;
await this.syncCacheWithDB();
await this.saveToDB();
return updatedCache;
}
@ -71,39 +96,18 @@ export class PendingMessageCache {
);
}
public getForDevice(device: PubKey): Array<RawMessage> {
const pending = this.cache.filter(m => m.device === device.key);
return pending.sort((a, b) => a.timestamp - b.timestamp);
}
public async clear() {
// Clears the cache and syncs to DB
this.cache = [];
await this.syncCacheWithDB();
await this.saveToDB();
}
public getDevices(): Array<PubKey> {
// Gets all unique devices with pending messages
const pubkeyStrings = [...new Set(this.cache.map(m => m.device))];
const pubkeys: Array<PubKey> = [];
pubkeyStrings.forEach(pubkey => {
if (PubKey.validate(pubkey)) {
pubkeys.push(new PubKey(pubkey));
}
});
return pubkeys;
}
public async init() {
public async loadFromDB() {
const messages = await this.getFromStorage();
this.cache = messages;
}
private async getFromStorage(): Promise<Array<RawMessage>> {
// tslint:disable-next-line: no-backbone-get-set-outside-model
const data = await getItemById('pendingMessages');
if (!data || !data.value) {
return [];
@ -111,14 +115,8 @@ export class PendingMessageCache {
const barePending = JSON.parse(String(data.value));
// tslint:disable-next-line: no-unnecessary-local-variable
const pending = barePending.map((message: any) => {
const { identifier, timestamp, device, ttl, encryption } = message;
// Recreate buffers
const rawBuffer = message.plainTextBuffer;
const bufferValues: Array<number> = Object.values(rawBuffer);
const plainTextBuffer = Uint8Array.from(bufferValues);
const { identifier, plainTextBuffer, timestamp, device, ttl, encryption } = message;
return {
identifier,
@ -133,9 +131,9 @@ export class PendingMessageCache {
return pending as Array<RawMessage>;
}
private async syncCacheWithDB() {
private async saveToDB() {
// Only call when adding / removing from cache.
const encodedPendingMessages = JSON.stringify(this.cache) || '';
const encodedPendingMessages = JSON.stringify(this.cache) || '[]';
await createOrUpdateItem({
id: 'pendingMessages',
value: encodedPendingMessages,

6
ts/session/tslint.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": ["../../tslint.json"],
"rules": {
"no-unused-variable": false
}
}

View File

@ -1,7 +1,6 @@
import * as crypto from 'crypto';
export class PubKey {
private static readonly PUBKEY_LEN = 66;
public static readonly PUBKEY_LEN = 66;
private static readonly regex: string = `^05[0-9a-fA-F]{${PubKey.PUBKEY_LEN -
2}}$`;
public readonly key: string;
@ -28,12 +27,4 @@ export class PubKey {
return false;
}
public static generateFake(): PubKey {
// Generates a mock pubkey for testing
const numBytes = PubKey.PUBKEY_LEN / 2 - 1;
const hexBuffer = crypto.randomBytes(numBytes).toString('hex');
const pubkeyString = `05${hexBuffer}`;
return new PubKey(pubkeyString);
}
}

View File

@ -1,7 +1,5 @@
import uuid from 'uuid';
import { RawMessage } from '../types/RawMessage';
import { ChatMessage, ContentMessage } from '../messages/outgoing';
import { ContentMessage } from '../messages/outgoing';
import { EncryptionType, PubKey } from '../types';
export function toRawMessage(
@ -24,16 +22,3 @@ export function toRawMessage(
return rawMessage;
}
export function generateUniqueChatMessage(): ChatMessage {
return new ChatMessage({
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
identifier: uuid(),
timestamp: Date.now(),
attachments: undefined,
quote: undefined,
expireTimer: undefined,
lokiProfile: undefined,
preview: undefined,
});
}

View File

@ -55,6 +55,7 @@ export type ConversationType = {
isFriend?: boolean;
isSecondary?: boolean;
primaryDevice: string;
isPendingFriendRequest?: boolean;
hasReceivedFriendRequest?: boolean;
hasSentFriendRequest?: boolean;
};

View File

@ -129,7 +129,15 @@ export const _getLeftPaneLists = (
}
if (conversation.hasReceivedFriendRequest) {
allReceivedFriendsRequest.push(conversation);
// Friend requests should always appear as coming from primary
const primaryConversation =
conversations.find(c => c.id === conversation.primaryDevice) ||
conversation;
primaryConversation.hasReceivedFriendRequest =
conversation.hasReceivedFriendRequest;
primaryConversation.isPendingFriendRequest =
conversation.isPendingFriendRequest;
allReceivedFriendsRequest.push(primaryConversation);
} else if (
unreadCount < 9 &&
conversation.isFriend &&
@ -139,7 +147,9 @@ export const _getLeftPaneLists = (
}
if (conversation.hasSentFriendRequest) {
if (!conversation.isFriend) {
allSentFriendsRequest.push(conversation);
if (!conversation.isSecondary) {
allSentFriendsRequest.push(conversation);
}
}
}
@ -160,22 +170,23 @@ export const _getLeftPaneLists = (
group: Array<ConversationType | ConversationListItemPropsType>
): T => {
const secondariesToRemove: Array<string> = [];
group.forEach(device => {
if (!device.isSecondary) {
return;
}
const devicePrimary = group.find(c => c.id === device.primaryDevice);
// Remove secondary where primary already exists in group
if (group.some(c => c === devicePrimary)) {
secondariesToRemove.push(device.id);
}
});
// tslint:disable-next-line: no-unnecessary-local-variable
const filteredGroup = group.filter(
c => !secondariesToRemove.find(s => s === c.id)
);
const filteredGroup = [
...new Set(group.filter(c => !secondariesToRemove.find(s => s === c.id))),
];
return filteredGroup as T;
};

View File

@ -3,7 +3,6 @@ import { expect } from 'chai';
import * as MessageUtils from '../../../session/utils';
import { TestUtils } from '../../../test/test-utils';
import { PendingMessageCache } from '../../../session/sending/PendingMessageCache';
import { PubKey } from '../../../session/types';
// Equivalent to Data.StorageItem
interface StorageItem {
@ -30,11 +29,13 @@ describe('PendingMessageCache', () => {
});
TestUtils.stubData('createOrUpdateItem').callsFake((item: StorageItem) => {
data = item;
if (item.id === storageID) {
data = item;
}
});
pendingMessageCacheStub = new PendingMessageCache();
await pendingMessageCacheStub.init();
await pendingMessageCacheStub.isReady;
});
afterEach(() => {
@ -42,7 +43,7 @@ describe('PendingMessageCache', () => {
});
it('can initialize cache', async () => {
const cache = pendingMessageCacheStub.get();
const cache = pendingMessageCacheStub.getAllPending();
// We expect the cache to initialise as an empty array
expect(cache).to.be.instanceOf(Array);
@ -50,14 +51,14 @@ describe('PendingMessageCache', () => {
});
it('can add to cache', async () => {
const device = PubKey.generateFake();
const message = MessageUtils.generateUniqueChatMessage();
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
// Verify that the message is in the cache
const finalCache = pendingMessageCacheStub.get();
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(1);
@ -67,19 +68,19 @@ describe('PendingMessageCache', () => {
});
it('can remove from cache', async () => {
const device = PubKey.generateFake();
const message = MessageUtils.generateUniqueChatMessage();
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
const initialCache = pendingMessageCacheStub.get();
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(1);
// Remove the message
await pendingMessageCacheStub.remove(rawMessage);
const finalCache = pendingMessageCacheStub.get();
const finalCache = pendingMessageCacheStub.getAllPending();
// Verify that the message was removed
expect(finalCache).to.have.length(0);
@ -88,16 +89,16 @@ describe('PendingMessageCache', () => {
it('can get devices', async () => {
const cacheItems = [
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
@ -105,7 +106,7 @@ describe('PendingMessageCache', () => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const cache = pendingMessageCacheStub.get();
const cache = pendingMessageCacheStub.getAllPending();
expect(cache).to.have.length(cacheItems.length);
// Get list of devices
@ -120,12 +121,12 @@ describe('PendingMessageCache', () => {
it('can get pending for device', async () => {
const cacheItems = [
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
@ -133,7 +134,7 @@ describe('PendingMessageCache', () => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const initialCache = pendingMessageCacheStub.get();
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(cacheItems.length);
// Get pending for each specific device
@ -147,8 +148,8 @@ describe('PendingMessageCache', () => {
});
it('can find nothing when empty', async () => {
const device = PubKey.generateFake();
const message = MessageUtils.generateUniqueChatMessage();
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
const foundMessage = pendingMessageCacheStub.find(rawMessage);
@ -156,13 +157,13 @@ describe('PendingMessageCache', () => {
});
it('can find message in cache', async () => {
const device = PubKey.generateFake();
const message = MessageUtils.generateUniqueChatMessage();
const device = TestUtils.generateFakePubkey();
const message = TestUtils.generateUniqueChatMessage();
const rawMessage = MessageUtils.toRawMessage(device, message);
await pendingMessageCacheStub.add(device, message);
const finalCache = pendingMessageCacheStub.get();
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(1);
const foundMessage = pendingMessageCacheStub.find(rawMessage);
@ -173,16 +174,16 @@ describe('PendingMessageCache', () => {
it('can clear cache', async () => {
const cacheItems = [
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
{
device: PubKey.generateFake(),
message: MessageUtils.generateUniqueChatMessage(),
device: TestUtils.generateFakePubkey(),
message: TestUtils.generateUniqueChatMessage(),
},
];
@ -190,13 +191,13 @@ describe('PendingMessageCache', () => {
await pendingMessageCacheStub.add(item.device, item.message);
});
const initialCache = pendingMessageCacheStub.get();
const initialCache = pendingMessageCacheStub.getAllPending();
expect(initialCache).to.have.length(cacheItems.length);
// Clear cache
await pendingMessageCacheStub.clear();
const finalCache = pendingMessageCacheStub.get();
const finalCache = pendingMessageCacheStub.getAllPending();
expect(finalCache).to.have.length(0);
});
});

View File

@ -1,7 +1,13 @@
import * as sinon from 'sinon';
import { ImportMock } from 'ts-mock-imports';
import * as DataShape from '../../../js/modules/data';
import * as crypto from 'crypto';
import * as window from '../../window';
import * as DataShape from '../../../js/modules/data';
import { v4 as uuid } from 'uuid';
import { ImportMock } from 'ts-mock-imports';
import { PubKey } from '../../../ts/session/types';
import { ChatMessage } from '../../session/messages/outgoing';
const sandbox = sinon.createSandbox();
@ -40,3 +46,25 @@ export function restoreStubs() {
ImportMock.restore();
sandbox.restore();
}
export function generateFakePubkey(): PubKey {
// Generates a mock pubkey for testing
const numBytes = PubKey.PUBKEY_LEN / 2 - 1;
const hexBuffer = crypto.randomBytes(numBytes).toString('hex');
const pubkeyString = `05${hexBuffer}`;
return new PubKey(pubkeyString);
}
export function generateUniqueChatMessage(): ChatMessage {
return new ChatMessage({
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
identifier: uuid(),
timestamp: Date.now(),
attachments: undefined,
quote: undefined,
expireTimer: undefined,
lokiProfile: undefined,
preview: undefined,
});
}