Merge remote-tracking branch 'upstream/clearnet' into message-sending-refactor

This commit is contained in:
Audric Ackermann 2020-06-09 12:18:25 +10:00
commit b9aa0ad8d5
No known key found for this signature in database
GPG key ID: 999F434D76324AD4
95 changed files with 3553 additions and 1885 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

4
.gitmodules vendored Normal file
View file

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

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

@ -124,9 +124,6 @@
'loki/session_icon_128.png',
]);
// Set server-client time difference
window.LokiPublicChatAPI.setClockParams();
// We add this to window here because the default Node context is erased at the end
// of preload.js processing
window.setImmediate = window.nodeSetImmediate;
@ -289,7 +286,10 @@
// Update zoom
window.updateZoomFactor();
if (window.lokiFeatureFlags.useOnionRequests) {
if (
window.lokiFeatureFlags.useOnionRequests ||
window.lokiFeatureFlags.useFileOnionRequests
) {
// Initialize paths for onion requests
window.lokiSnodeAPI.buildNewOnionPaths();
}
@ -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 () => {
@ -53,6 +52,8 @@
}
// no message logged means serverRequest never returned...
};
// don't wait for this to finish
checkForUpgrades();
window.extension = window.extension || {};
@ -92,4 +93,45 @@
// yes we know
cb(expiredVersion);
};
const getServerTime = async () => {
let timestamp = NaN;
try {
const res = await window.tokenlessFileServerAdnAPI.serverRequest(
'loki/v1/time'
);
if (res.statusCode === 200) {
timestamp = res.response;
}
} catch (e) {
return timestamp;
}
return Number(timestamp);
};
const getTimeDifferential = async () => {
// Get time differential between server and client in seconds
const serverTime = await getServerTime();
const clientTime = Math.ceil(Date.now() / 1000);
if (Number.isNaN(serverTime)) {
log.error('expire:::getTimeDifferential - serverTime is not valid');
return 0;
}
return serverTime - clientTime;
};
// require for PoW to work
const setClockParams = async () => {
// Set server-client time difference
const maxTimeDifferential = 30 + 15; // + 15 for onion requests
const timeDifferential = await getTimeDifferential();
log.info('expire:::setClockParams - Clock difference', timeDifferential);
window.clientClockSynced = Math.abs(timeDifferential) < maxTimeDifferential;
return window.clientClockSynced;
};
setClockParams();
})();

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

25
js/modules/loki_app_dot_net_api.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
import {
Quote,
AttachmentPointer,
Preview,
} from '../../ts/session/messages/outgoing';
declare class LokiAppDotNetServerAPI {
constructor(ourKey: string, url: string);
findOrCreateChannel(
api: LokiPublicChatFactoryAPI,
channelId: number,
conversationId: string
): Promise<LokiPublicChannelAPI>;
}
export interface LokiPublicChannelAPI {
sendMessage(data: {
quote?: Quote;
attachments: Array<AttachmentPointer>;
preview: Array<Preview>;
body?: string;
}): Promise<boolean>;
}
export default LokiAppDotNetServerAPI;

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,153 @@ 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 || 'GET',
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 {};
}
if (result === lokiRpcUtils.BAD_PATH) {
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,
});
}
if (options.noJson) {
return {
result,
txtResponse: result.body,
response: {
data: result.body,
headers: result.headers,
},
};
}
// get the return variables we need
let response = {};
let txtResponse = '';
let { body } = result;
if (typeof body === 'string') {
// adn does uses this path
// log.info(`loki_app_dot_net:::sendViaOnion - got text response ${url.toString()}`);
txtResponse = result.body;
try {
body = JSON.parse(result.body);
} catch (e) {
log.error(
`loki_app_dot_net:::sendViaOnion #${options.requestNumber} - Cant decode JSON body`,
typeof result.body,
result.body
);
}
} else {
// why is
// https://chat-dev.lokinet.org/loki/v1/channel/1/deletes?count=200&since_id=
// difference in response than all the other calls....
log.info(
`loki_app_dot_net:::sendViaOnion #${
options.requestNumber
} - got object response ${url.toString()}`
);
}
// result.status has the http response code
if (!txtResponse) {
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 +202,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 +237,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 +248,9 @@ 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
// but I think sync might be more apt in cases 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 +405,25 @@ 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';
({ response, txtResponse, result } = await sendViaOnion(
srvPubKey,
url,
fetchOptions,
options
));
} else if (window.lokiFeatureFlags.useFileOnionRequests && srvPubKey) {
mode = 'sendViaOnionOG';
({ response, txtResponse, result } = await sendViaOnion(
srvPubKey,
url,
fetchOptions,
options
));
} else if (
window.lokiFeatureFlags.useSnodeProxy &&
FILESERVER_HOSTS.includes(host)
) {
@ -275,9 +446,11 @@ const serverRequest = async (endpoint, options = {}) => {
result = await nodeFetch(url, fetchOptions);
// always make sure this check is enabled
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
txtResponse = await result.text();
// cloudflare timeouts (504s) will be html...
response = options.textResponse ? txtResponse : JSON.parse(txtResponse);
// result.status will always be 200
// emulate the correct http code if available
if (response && response.meta && response.meta.code) {
@ -313,6 +486,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 +595,58 @@ 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 && urlPubkeyMap[this.baseServerUrl]) {
pubKeyAB = window.Signal.Crypto.base64ToArrayBuffer(
urlPubkeyMap[this.baseServerUrl]
);
}
// 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
if (window.lokiFeatureFlags.useFileOnionRequests) {
if (
window.lokiPublicChatAPI &&
window.lokiPublicChatAPI.openGroupPubKeys &&
window.lokiPublicChatAPI.openGroupPubKeys[this.baseServerUrl]
) {
pubKeyAB =
window.lokiPublicChatAPI.openGroupPubKeys[this.baseServerUrl];
}
} else if (window.lokiFeatureFlags.useSnodeProxy) {
// if in proxy mode, replace with "file."...
// it only supports this host...
pubKeyAB = window.Signal.Crypto.base64ToArrayBuffer(
LOKIFOUNDATION_FILESERVER_PUBKEY
);
}
// else will fail validation later
// 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
/*
@ -884,7 +1117,11 @@ class LokiAppDotNetServerAPI {
if (res.err || !res.response || !res.response.data) {
if (res.err) {
log.error(`getUsers Error ${res.err}`);
log.error(
`loki_app_dot_net:::getUsers - Error: ${res.err} for ${pubKeys.join(
','
)}`
);
}
return [];
}
@ -2104,4 +2341,9 @@ class LokiPublicChannelAPI {
}
}
LokiAppDotNetServerAPI.serverRequest = serverRequest;
LokiAppDotNetServerAPI.sendViaOnion = sendViaOnion;
// These files are expected to be in commonjs so we can't use es6 syntax :(
// If we move these to TS then we should be able to use es6
module.exports = LokiAppDotNetServerAPI;

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;

11
js/modules/loki_message_api.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare class LokiMessageAPI {
constructor(ourKey: string);
sendMessage(
pubKey: string,
data: Uint8Array,
messageTimeStamp: number,
ttl: number
): Promise<void>;
}
export default LokiMessageAPI;

View file

@ -72,8 +72,20 @@ 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 = {};
}
/**
* Refactor note: We should really clean this up ... it's very messy
*
* We need to split it into 2 sends:
* - Snodes
* - Open Groups
*
* Mikunj:
* Temporarily i've made it so `MessageSender` handles open group sends and calls this function for regular sends.
*/
async sendMessage(pubKey, data, messageTimeStamp, ttl, options = {}) {
const {
isPublic = false,
@ -315,7 +327,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 +386,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 +403,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 +534,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;
@ -584,4 +611,6 @@ class LokiMessageAPI {
}
}
// These files are expected to be in commonjs so we can't use es6 syntax :(
// If we move these to TS then we should be able to use es6
module.exports = LokiMessageAPI;

13
js/modules/loki_public_chat_api.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
import { LokiPublicChannelAPI } from './loki_app_dot_net_api';
declare class LokiPublicChatFactoryAPI {
constructor(ourKey: string);
findOrCreateServer(url: string): Promise<void>;
findOrCreateChannel(
url: string,
channelId: number,
conversationId: string
): Promise<LokiPublicChannelAPI>;
}
export default LokiPublicChatFactoryAPI;

View file

@ -1,14 +1,87 @@
/* global log, window, process, URL */
/* global log, window, process, URL, dcodeIO */
const EventEmitter = require('events');
const nodeFetch = require('node-fetch');
const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
const nodeFetch = require('node-fetch');
const validOpenGroupServer = async serverUrl => {
// test to make sure it's online (and maybe has a valid SSL cert)
try {
const url = new URL(serverUrl);
if (window.lokiFeatureFlags.useFileOnionRequests) {
// check for LSRPC
// this is safe (as long as node's in your trust model)
// because
const result = await window.tokenlessFileServerAdnAPI.serverRequest(
`loki/v1/getOpenGroupKey/${url.hostname}`
);
if (result.response.meta.code === 200) {
// supports it
const obj = JSON.parse(result.response.data);
const pubKey = dcodeIO.ByteBuffer.wrap(
obj.data,
'base64'
).toArrayBuffer();
// verify it works...
// get around the FILESERVER_HOSTS filter by not using serverRequest
const res = await LokiAppDotNetAPI.sendViaOnion(
pubKey,
url,
{ method: 'GET' },
{ noJson: true }
);
if (res.result.status === 200) {
log.info(
`loki_public_chat::validOpenGroupServer - onion routing enabled on ${url.toString()}`
);
// save pubkey for use...
window.lokiPublicChatAPI.openGroupPubKeys[serverUrl] = pubKey;
return true;
}
// otherwise fall back
} else if (result.response.meta.code !== 404) {
// unknown error code
log.warn(
'loki_public_chat::validOpenGroupServer - unknown error code',
result.response.meta
);
}
}
// doesn't support it, fallback
log.info(
`loki_public_chat::validOpenGroupServer - directly contacting ${url.toString()}`
);
// allow .loki (may only need an agent but not sure
// until we have a .loki to test with)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = url.host.match(/\.loki$/i)
? '0'
: '1';
await nodeFetch(serverUrl);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
// const txt = await res.text();
} catch (e) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
log.warn(
`loki_public_chat::validOpenGroupServer - failing to create ${serverUrl}`,
e.code,
e.message
);
// bail out if not valid enough
return false;
}
return true;
};
class LokiPublicChatFactoryAPI extends EventEmitter {
constructor(ourKey) {
super();
this.ourKey = ourKey;
this.servers = [];
this.allMembers = [];
this.openGroupPubKeys = {};
// Multidevice states
this.primaryUserProfileName = {};
}
@ -24,37 +97,20 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
await Promise.all(this.servers.map(server => server.close()));
}
static async validServer(serverUrl) {
// test to make sure it's online (and maybe has a valid SSL cert)
try {
const url = new URL(serverUrl);
// allow .loki (may only need an agent but not sure
// until we have a .loki to test with)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = url.host.match(/\.loki$/i)
? '0'
: '1';
// FIXME: use proxy when we have open groups that work with proxy
await nodeFetch(serverUrl);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
// const txt = await res.text();
} catch (e) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1';
log.warn(`failing to created ${serverUrl}`, e.code, e.message);
// bail out if not valid enough
return false;
}
return true;
}
// server getter/factory
async findOrCreateServer(serverUrl) {
let thisServer = this.servers.find(
server => server.baseServerUrl === serverUrl
);
if (!thisServer) {
log.info(`LokiAppDotNetAPI creating ${serverUrl}`);
log.info(`loki_public_chat::findOrCreateServer - creating ${serverUrl}`);
if (!(await this.constructor.validServer(serverUrl))) {
const serverIsValid = await validOpenGroupServer(serverUrl);
if (!serverIsValid) {
// FIXME: add toast?
log.error(
`loki_public_chat::findOrCreateServer - error: ${serverUrl} is not valid`
);
return null;
}
@ -65,15 +121,27 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
thisServer = new StubAppDotNetAPI(this.ourKey, serverUrl);
} else {
thisServer = new LokiAppDotNetAPI(this.ourKey, serverUrl);
if (this.openGroupPubKeys[serverUrl]) {
thisServer.getPubKeyForUrl();
if (!thisServer.pubKeyHex) {
log.warn(
`loki_public_chat::findOrCreateServer - failed to set public key`
);
}
}
}
const gotToken = await thisServer.getOrRefreshServerToken();
if (!gotToken) {
log.warn(`Invalid server ${serverUrl}`);
log.warn(
`loki_public_chat::findOrCreateServer - Invalid server ${serverUrl}`
);
return null;
}
if (window.isDev) {
log.info(`set token ${thisServer.token} for ${serverUrl}`);
log.info(
`loki_public_chat::findOrCreateServer - set token ${thisServer.token} for ${serverUrl}`
);
}
this.servers.push(thisServer);
@ -81,42 +149,6 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
return thisServer;
}
static async getServerTime() {
const url = `${window.getDefaultFileServer()}/loki/v1/time`;
let timestamp = NaN;
try {
const res = await nodeFetch(url);
if (res.ok) {
timestamp = await res.text();
}
} catch (e) {
return timestamp;
}
return Number(timestamp);
}
static async getTimeDifferential() {
// Get time differential between server and client in seconds
const serverTime = await this.getServerTime();
const clientTime = Math.ceil(Date.now() / 1000);
if (Number.isNaN(serverTime)) {
return 0;
}
return serverTime - clientTime;
}
static async setClockParams() {
// Set server-client time difference
const maxTimeDifferential = 30;
const timeDifferential = await this.getTimeDifferential();
window.clientClockSynced = Math.abs(timeDifferential) < maxTimeDifferential;
return window.clientClockSynced;
}
// channel getter/factory
async findOrCreateChannel(serverUrl, channelId, conversationId) {
const server = await this.findOrCreateServer(serverUrl);
@ -200,4 +232,6 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
}
}
// These files are expected to be in commonjs so we can't use es6 syntax :(
// If we move these to TS then we should be able to use es6
module.exports = LokiPublicChatFactoryAPI;

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
);
@ -439,7 +656,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => {
const result = await response.json();
log.warn(
`lokirpc:::lokiFetch ${type} - wrong swarm, now looking at snodes`,
result.snode
result.snodes
);
const newSwarm = result.snodes ? result.snodes : [];
throw new textsecure.WrongSwarmError(newSwarm);
@ -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,6 @@ const lokiRpc = (
module.exports = {
lokiRpc,
BAD_PATH,
sendOnionRequestLsrpcDest,
};

View file

@ -63,7 +63,7 @@ class LokiRssAPI extends EventEmitter {
log.warn('LokiRssAPI unsupported rss feed', this.feedUrl);
return;
}
const result = await window.lokiFileServerAPI._server.serverRequest(
const result = await window.tokenlessFileServerAdnAPI.serverRequest(
map[this.feedUrl]
);
if (!result) {
@ -77,6 +77,7 @@ class LokiRssAPI extends EventEmitter {
if (!result.response.data) {
log.error(
'LokiRssAPI rss proxy error, no data, response',
typeof result.response,
result.response
);
return;

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,21 @@ 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.info(
'loki_snode_api::tryGetSnodeListFromLokidSeednode - seedNodes are empty'
);
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
@ -38,10 +45,14 @@ async function tryGetSnodeListFromLokidSeednode(
},
};
// FIXME: use sample
const seedNode = seedNodes.splice(
Math.floor(Math.random() * seedNodes.length),
1
)[0];
const seedNode = seedNodes[Math.floor(Math.random() * seedNodes.length)];
if (!seedNode) {
log.warn(
'loki_snode_api::tryGetSnodeListFromLokidSeednode - Could not select random snodes from',
seedNodes
);
return [];
}
let snodes = [];
try {
const getSnodesFromSeedUrl = async urlObj => {
@ -53,6 +64,30 @@ async function tryGetSnodeListFromLokidSeednode(
{}, // Options
'/json_rpc' // Seed request endpoint
);
if (!response) {
log.error(
`loki_snode_api:::tryGetSnodeListFromLokidSeednode - invalid response from seed ${urlObj.toString()}:`,
response
);
return [];
}
// should we try to JSON.parse this?
if (typeof response === 'string') {
log.error(
`loki_snode_api:::tryGetSnodeListFromLokidSeednode - invalid string response from seed ${urlObj.toString()}:`,
response
);
return [];
}
if (!response.result) {
log.error(
`loki_snode_api:::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'
@ -62,22 +97,36 @@ async function tryGetSnodeListFromLokidSeednode(
snodes = await getSnodesFromSeedUrl(tryUrl);
// throw before clearing the lock, so the retries can kick in
if (snodes.length === 0) {
log.warn(
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.url} did not return any snodes, falling back to IP`,
seedNode.ip_url
);
// fall back on ip_url
const tryIpUrl = new URL(seedNode.ip_url);
snodes = await getSnodesFromSeedUrl(tryIpUrl);
if (snodes.length === 0) {
log.warn(
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.ip_url} did not return any snodes`
);
// does this error message need to be exactly this?
throw new window.textsecure.SeedNodeError(
'Failed to contact seed node'
);
}
}
if (snodes.length) {
log.info(
`loki_snode_api::tryGetSnodeListFromLokidSeednode - ${seedNode.url} returned ${snodes.length} snodes`
);
}
return snodes;
} catch (e) {
log.warn(
'loki_snodes:::tryGetSnodeListFromLokidSeednode - error',
'LokiSnodeAPI::tryGetSnodeListFromLokidSeednode - error',
e.code,
e.message
e.message,
'on',
seedNode
);
if (snodes.length === 0) {
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
@ -87,15 +136,21 @@ async function tryGetSnodeListFromLokidSeednode(
}
async function getSnodeListFromLokidSeednode(
seedNodes = [...window.seedNodeList],
seedNodes = window.seedNodeList,
retries = 0
) {
if (!seedNodes.length) {
log.info(
'loki_snode_api::getSnodeListFromLokidSeednode - seedNodes are empty'
);
return [];
}
let snodes = [];
try {
snodes = await tryGetSnodeListFromLokidSeednode(seedNodes);
} catch (e) {
log.warn(
'loki_snodes:::getSnodeListFromLokidSeednode - error',
'loki_snode_api::getSnodeListFromLokidSeednode - error',
e.code,
e.message
);
@ -103,13 +158,15 @@ async function getSnodeListFromLokidSeednode(
if (retries < SEED_NODE_RETRIES) {
setTimeout(() => {
log.info(
'loki_snodes:::refreshRandomPoolPromise - Retrying initialising random snode pool, try #',
retries
'loki_snode_api::getSnodeListFromLokidSeednode - Retrying initialising random snode pool, try #',
retries,
'seed nodes total',
seedNodes.length
);
getSnodeListFromLokidSeednode(seedNodes, retries + 1);
}, retries * retries * 5000);
} else {
log.error('loki_snodes:::getSnodeListFromLokidSeednode - failing');
log.error('loki_snode_api::getSnodeListFromLokidSeednode - failing');
throw new window.textsecure.SeedNodeError('Failed to contact seed node');
}
}
@ -129,6 +186,12 @@ class LokiSnodeAPI {
this.onionPaths = [];
this.guardNodes = [];
this.onionRequestCounter = 0; // Request index for debugging
}
assignOnionRequestNumber() {
this.onionRequestCounter += 1;
return this.onionRequestCounter;
}
async getRandomSnodePool() {
@ -179,6 +242,8 @@ class LokiSnodeAPI {
let response;
try {
// Log this line for testing
// curl -k -X POST -H 'Content-Type: application/json' -d '"+fetchOptions.body.replace(/"/g, "\\'")+"'", url
response = await nodeFetch(url, fetchOptions);
} catch (e) {
if (e.type === 'request-timeout') {
@ -202,7 +267,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 +276,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 [];
}
@ -267,19 +333,33 @@ class LokiSnodeAPI {
async getOnionPath(toExclude = null) {
const _ = window.Lodash;
const goodPaths = this.onionPaths.filter(x => !x.bad);
let goodPaths = this.onionPaths.filter(x => !x.bad);
if (goodPaths.length < 2) {
let attemptNumber = 0;
while (goodPaths.length < MIN_GUARD_COUNT) {
log.error(
`Must have at least 2 good onion paths, actual: ${goodPaths.length}`
`Must have at least 2 good onion paths, actual: ${goodPaths.length}, attempt #${attemptNumber} fetching more...`
);
// eslint-disable-next-line no-await-in-loop
await this.buildNewOnionPaths();
// should we add a delay? buildNewOnionPaths should act as one
// reload goodPaths now
attemptNumber += 1;
goodPaths = this.onionPaths.filter(x => !x.bad);
}
const paths = _.shuffle(goodPaths);
if (!toExclude) {
return paths[0];
if (!paths[0]) {
log.error('LokiSnodeAPI::getOnionPath - no path in', paths);
return [];
}
if (!paths[0].path) {
log.error('LokiSnodeAPI::getOnionPath - no path in', paths[0]);
}
return paths[0].path;
}
// Select a path that doesn't contain `toExclude`
@ -290,14 +370,37 @@ class LokiSnodeAPI {
if (otherPaths.length === 0) {
// This should never happen!
// well it did happen, should we
// await this.buildNewOnionPaths();
// and restart call?
log.error(
`LokiSnodeAPI::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');
}
if (!otherPaths[0].path) {
log.error(
'LokiSnodeAPI::getOnionPath - otherPaths no path in',
otherPaths[0]
);
}
return otherPaths[0].path;
}
async markPathAsBad(path) {
markPathAsBad(path) {
this.onionPaths.forEach(p => {
if (!p.path) {
log.error('LokiSnodeAPI::markPathAsBad - no path in', p);
}
if (p.path === path) {
// eslint-disable-next-line no-param-reassign
p.bad = true;
@ -305,13 +408,14 @@ class LokiSnodeAPI {
});
}
// FIXME: need a lock because it is being called multiple times in parallel
async buildNewOnionPaths() {
// Note: this function may be called concurrently, so
// might consider blocking the other calls
const _ = window.Lodash;
log.info('building new onion paths');
log.info('LokiSnodeAPI::buildNewOnionPaths - building new onion paths');
const allNodes = await this.getRandomSnodePool();
@ -320,7 +424,9 @@ class LokiSnodeAPI {
const nodes = await window.libloki.storage.getGuardNodes();
if (nodes.length === 0) {
log.warn('no guard nodes in DB. Will be selecting new guards nodes...');
log.warn(
'LokiSnodeAPI::buildNewOnionPaths - no guard nodes in DB. Will be selecting new guards nodes...'
);
} else {
// We only store the nodes' keys, need to find full entries:
const edKeys = nodes.map(x => x.ed25519PubKey);
@ -330,13 +436,14 @@ class LokiSnodeAPI {
if (this.guardNodes.length < edKeys.length) {
log.warn(
`could not find some guard nodes: ${this.guardNodes.length}/${edKeys.length} left`
`LokiSnodeAPI::buildNewOnionPaths - could not find some guard nodes: ${this.guardNodes.length}/${edKeys.length} left`
);
}
}
// 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();
}
}
@ -345,7 +452,11 @@ class LokiSnodeAPI {
let otherNodes = _.difference(allNodes, this.guardNodes);
if (otherNodes.length < 2) {
log.error('Too few nodes to build an onion path!');
log.warn(
'LokiSnodeAPI::buildNewOnionPaths - Too few nodes to build an onion path! Refreshing pool and retrying'
);
await this.refreshRandomPool();
await this.buildNewOnionPaths();
return;
}
@ -414,7 +525,7 @@ class LokiSnodeAPI {
await this.refreshRandomPool();
} catch (e) {
log.error(
`loki_snode:::getRandomProxySnodeAddress - error ${e.code} ${e.message}`
`LokiSnodeAPI::getRandomProxySnodeAddress - error ${e.code} ${e.message}`
);
throw e;
}
@ -426,7 +537,7 @@ class LokiSnodeAPI {
if (!goodPool.length) {
// FIXME: retry
log.warn(
`loki_snode:::getRandomProxySnodeAddress - no good versions yet`
`LokiSnodeAPI::getRandomProxySnodeAddress - no good versions yet`
);
return false;
}
@ -458,7 +569,7 @@ class LokiSnodeAPI {
} else {
// maybe already marked bad...
log.debug(
`loki_snode:::_getVersion - can't find ${node.ip}:${node.port} in randomSnodePool`
`LokiSnodeAPI::_getVersion - can't find ${node.ip}:${node.port} in randomSnodePool`
);
}
}
@ -473,13 +584,13 @@ class LokiSnodeAPI {
const randomNodesLeft = this.getRandomPoolLength();
// clean up these error messages to be a little neater
log.warn(
`loki_snode:::_getVersion - ${node.ip}:${node.port} is offline, removing, leaving ${randomNodesLeft} in the randomPool`
`LokiSnodeAPI::_getVersion - ${node.ip}:${node.port} is offline, removing, leaving ${randomNodesLeft} in the randomPool`
);
// if not ECONNREFUSED, it's mostly ECONNRESETs
// ENOTFOUND could mean no internet or hiccup
} else if (retries < SNODE_VERSION_RETRIES) {
log.warn(
'loki_snode:::_getVersion - Error',
'LokiSnodeAPI::_getVersion - Error',
e.code,
e.message,
`on ${node.ip}:${node.port} retrying in 1s`
@ -490,7 +601,7 @@ class LokiSnodeAPI {
this.markRandomNodeUnreachable(node);
const randomNodesLeft = this.getRandomPoolLength();
log.warn(
`loki_snode:::_getVersion - failing to get version for ${node.ip}:${node.port}, removing, leaving ${randomNodesLeft} in the randomPool`
`LokiSnodeAPI::_getVersion - failing to get version for ${node.ip}:${node.port}, removing, leaving ${randomNodesLeft} in the randomPool`
);
}
// maybe throw?
@ -516,19 +627,19 @@ class LokiSnodeAPI {
// give stats
const diff = Date.now() - verionStart;
log.debug(
`loki_snode:::_getAllVerionsForRandomSnodePool - ${count}/${total} pool version status update, has taken ${diff.toLocaleString()}ms`
`LokiSnodeAPI:::_getAllVerionsForRandomSnodePool - ${count}/${total} pool version status update, has taken ${diff.toLocaleString()}ms`
);
Object.keys(this.versionPools).forEach(version => {
const nodes = this.versionPools[version].length;
log.debug(
`loki_snode:::_getAllVerionsForRandomSnodePool - version ${version} has ${nodes.toLocaleString()} snodes`
`LokiSnodeAPI:::_getAllVerionsForRandomSnodePool - version ${version} has ${nodes.toLocaleString()} snodes`
);
});
}
*/
} catch (e) {
log.error(
`loki_snode:::_getAllVerionsForRandomSnodePool - error`,
`LokiSnodeAPI::_getAllVerionsForRandomSnodePool - error`,
e.code,
e.message
);
@ -548,12 +659,22 @@ class LokiSnodeAPI {
return curVal;
}, []);
log.debug(
`loki_snode:::_getAllVerionsForRandomSnodePool - ${versions.length} versions retrieved from network!:`,
`LokiSnodeAPI::_getAllVerionsForRandomSnodePool - ${versions.length} versions retrieved from network!:`,
versions.join(',')
);
}
async refreshRandomPool(seedNodes = [...window.seedNodeList]) {
async refreshRandomPool(seedNodes = window.seedNodeList) {
if (!seedNodes.length) {
if (!window.seedNodeList || !window.seedNodeList.length) {
log.error(
`LokiSnodeAPI:::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) {
@ -574,17 +695,17 @@ class LokiSnodeAPI {
pubkey_ed25519: snode.pubkey_ed25519,
}));
log.info(
'loki_snodes:::refreshRandomPool - Refreshed random snode pool with',
'LokiSnodeAPI::refreshRandomPool - Refreshed random snode pool with',
this.randomSnodePool.length,
'snodes'
);
// start polling versions but no need to await it
this._getAllVerionsForRandomSnodePool();
} catch (e) {
log.warn('loki_snodes:::refreshRandomPool - error', e.code, e.message);
log.warn('LokiSnodeAPI::refreshRandomPool - error', e.code, e.message);
/*
log.error(
'loki_snodes:::refreshRandomPoolPromise - Giving up trying to contact seed node'
'LokiSnodeAPI:::refreshRandomPoolPromise - Giving up trying to contact seed node'
);
*/
if (snodes.length === 0) {
@ -603,7 +724,7 @@ class LokiSnodeAPI {
const swarmNodes = [...conversation.get('swarmNodes')];
if (typeof unreachableNode === 'string') {
log.warn(
'loki_snodes:::unreachableNode - String passed as unreachableNode to unreachableNode'
'LokiSnodeAPI::unreachableNode - String passed as unreachableNode to unreachableNode'
);
return swarmNodes;
}
@ -620,13 +741,13 @@ class LokiSnodeAPI {
});
if (!found) {
log.warn(
`loki_snodes:::unreachableNode - snode ${unreachableNode.ip}:${unreachableNode.port} has already been marked as bad`
`LokiSnodeAPI::unreachableNode - snode ${unreachableNode.ip}:${unreachableNode.port} has already been marked as bad`
);
}
try {
await conversation.updateSwarmNodes(filteredNodes);
} catch (e) {
log.error(`loki_snodes:::unreachableNode - error ${e.code} ${e.message}`);
log.error(`LokiSnodeAPI::unreachableNode - error ${e.code} ${e.message}`);
throw e;
}
return filteredNodes;
@ -664,7 +785,7 @@ class LokiSnodeAPI {
node.address
);
log.debug(
`loki_snode:::getSwarmNodesForPubKey - ${j} ${node.ip}:${node.port}`
`LokiSnodeAPI::getSwarmNodesForPubKey - ${j} ${node.ip}:${node.port}`
);
swarmNodes[j] = {
...node,
@ -691,7 +812,7 @@ class LokiSnodeAPI {
return filteredNodes;
} catch (e) {
log.error(
`loki_snodes:::updateSwarmNodes - error ${e.code} ${e.message}`
`LokiSnodeAPI::updateSwarmNodes - error ${e.code} ${e.message}`
);
throw new window.textsecure.ReplayableError({
message: 'Could not get conversation',
@ -704,6 +825,10 @@ class LokiSnodeAPI {
async refreshSwarmNodesForPubKey(pubKey) {
// FIXME: handle rejections
const newNodes = await this._getFreshSwarmNodes(pubKey);
log.debug(
'LokiSnodeAPI::refreshSwarmNodesForPubKey - newNodes',
newNodes.length
);
const filteredNodes = this.updateSwarmNodes(pubKey, newNodes);
return filteredNodes;
}
@ -715,7 +840,7 @@ class LokiSnodeAPI {
newSwarmNodes = await this._getSwarmNodes(pubKey);
} catch (e) {
log.error(
'loki_snodes:::_getFreshSwarmNodes - error',
'LokiSnodeAPI::_getFreshSwarmNodes - error',
e.code,
e.message
);
@ -750,78 +875,127 @@ 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
async _getSnodesForPubkey(pubKey) {
let snode = {};
let snode = { ip: '', port: 0 };
try {
snode = await this.getRandomSnodeAddress();
const result = await lokiRpc(
@ -837,7 +1011,7 @@ class LokiSnodeAPI {
);
if (!result) {
log.warn(
`loki_snode:::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value`,
`LokiSnodeAPI::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value`,
result
);
return [];
@ -845,7 +1019,7 @@ class LokiSnodeAPI {
if (!result.snodes) {
// we hit this when snode gives 500s
log.warn(
`loki_snode:::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value for snodes`,
`LokiSnodeAPI::_getSnodesForPubkey - lokiRpc on ${snode.ip}:${snode.port} returned falsish value for snodes`,
result
);
return [];
@ -856,7 +1030,7 @@ class LokiSnodeAPI {
this.markRandomNodeUnreachable(snode);
const randomPoolRemainingCount = this.getRandomPoolLength();
log.error(
'loki_snodes:::_getSnodesForPubkey - error',
'LokiSnodeAPI::_getSnodesForPubkey - error',
e.code,
e.message,
`for ${snode.ip}:${snode.port}. ${randomPoolRemainingCount} snodes remaining in randomPool`
@ -871,9 +1045,14 @@ class LokiSnodeAPI {
const questions = [...Array(RANDOM_SNODES_TO_USE_FOR_PUBKEY_SWARM).keys()];
// FIXME: handle rejections
await Promise.all(
questions.map(async () => {
questions.map(async qNum => {
// allow exceptions to pass through upwards
const resList = await this._getSnodesForPubkey(pubKey);
log.info(
`LokiSnodeAPI::_getSwarmNodes - question ${qNum} got`,
resList.length,
'snodes'
);
resList.map(item => {
const hasItem = snodes.some(n => compareSnodes(n, item));
if (!hasItem) {
@ -883,7 +1062,7 @@ class LokiSnodeAPI {
});
})
);
// should we only activate entries that are in all results?
// should we only activate entries that are in all results? yes
return snodes;
}
}

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

@ -1,4 +1,4 @@
/* global libsignal, libloki, textsecure, StringView */
/* global libsignal, libloki, textsecure, StringView, dcodeIO */
'use strict';
@ -31,7 +31,11 @@ describe('Crypto', () => {
it('should encrypt and then decrypt a message with the same result', async () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
const { body } = await fallbackCipher.encrypt(arr.buffer);
const result = await fallbackCipher.decrypt(body);
const bufferBody = dcodeIO.ByteBuffer.wrap(
body,
'binary'
).toArrayBuffer();
const result = await fallbackCipher.decrypt(bufferBody);
assert.deepEqual(result, arr.buffer);
});
});

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

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

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

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

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

@ -25,8 +25,6 @@ function getPaddedMessageLength(originalLength: number): number {
return messagePartCount * 160;
}
export type Base64String = string;
/**
* Encrypt `plainTextBuffer` with given `encryptionType` for `device`.
*
@ -41,7 +39,7 @@ export async function encrypt(
encryptionType: EncryptionType
): Promise<{
envelopeType: SignalService.Envelope.Type;
cipherText: Base64String;
cipherText: Uint8Array;
}> {
const plainText = padPlainTextBuffer(plainTextBuffer);
const address = new libsignal.SignalProtocolAddress(device, 1);
@ -71,7 +69,7 @@ async function encryptUsingSealedSender(
innerCipherText: CipherTextObject
): Promise<{
envelopeType: SignalService.Envelope.Type;
cipherText: Base64String;
cipherText: Uint8Array;
}> {
const ourNumber = await UserUtil.getCurrentDevicePubKey();
if (!ourNumber) {
@ -94,6 +92,6 @@ async function encryptUsingSealedSender(
return {
envelopeType: SignalService.Envelope.Type.UNIDENTIFIED_SENDER,
cipherText: Buffer.from(cipherTextBuffer).toString('base64'),
cipherText: new Uint8Array(cipherTextBuffer),
};
}

View file

@ -1,32 +1,41 @@
import { Message, MessageParams } from './Message';
import { AttachmentType } from '../../../types/Attachment';
import { QuotedAttachmentType } from '../../../components/conversation/Quote';
import { AttachmentPointer, Preview, Quote } from './content';
interface OpenGroup {
server: string;
channel: number;
conversationId: string;
}
interface OpenGroupMessageParams extends MessageParams {
server: string;
attachments?: Array<AttachmentType>;
group: OpenGroup;
attachments?: Array<AttachmentPointer>;
preview?: Array<Preview>;
body?: string;
quote?: QuotedAttachmentType;
quote?: Quote;
}
export class OpenGroupMessage extends Message {
public readonly server: string;
public readonly group: OpenGroup;
public readonly body?: string;
public readonly attachments?: Array<AttachmentType>;
public readonly quote?: QuotedAttachmentType;
public readonly attachments: Array<AttachmentPointer>;
public readonly quote?: Quote;
public readonly preview: Array<Preview>;
constructor({
timestamp,
server,
group,
attachments,
body,
quote,
identifier,
preview,
}: OpenGroupMessageParams) {
super({ timestamp, identifier });
this.server = server;
this.group = group;
this.body = body;
this.attachments = attachments;
this.attachments = attachments ?? [];
this.quote = quote;
this.preview = preview ?? [];
}
}

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,
identifier: params.identifier ?? params.chatMessage.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

@ -3,10 +3,7 @@ import {
MessageQueueInterface,
MessageQueueInterfaceEvents,
} from './MessageQueueInterface';
import {
ContentMessage as OutgoingContentMessage,
OpenGroupMessage,
} from '../messages/outgoing';
import { ContentMessage, OpenGroupMessage } from '../messages/outgoing';
import { PendingMessageCache } from './PendingMessageCache';
import { JobQueue, TypedEventEmitter } from '../utils';
@ -21,16 +18,16 @@ export class MessageQueue implements MessageQueueInterface {
this.processAllPending();
}
public sendUsingMultiDevice(user: string, message: OutgoingContentMessage) {
public sendUsingMultiDevice(user: string, message: ContentMessage) {
throw new Error('Method not implemented.');
}
public send(device: string, message: OutgoingContentMessage) {
public send(device: string, message: ContentMessage) {
throw new Error('Method not implemented.');
}
public sendToGroup(message: OutgoingContentMessage | OpenGroupMessage) {
public sendToGroup(message: ContentMessage | OpenGroupMessage) {
throw new Error('Method not implemented.');
}
public sendSyncMessage(message: OutgoingContentMessage) {
public sendSyncMessage(message: ContentMessage) {
throw new Error('Method not implemented.');
}
@ -42,7 +39,7 @@ export class MessageQueue implements MessageQueueInterface {
// TODO: Get all devices which are pending here
}
private queue(device: string, message: OutgoingContentMessage) {
private queue(device: string, message: ContentMessage) {
// TODO: implement
}

View file

@ -1,12 +1,12 @@
import {
ContentMessage as OutgoingContentMessage,
ContentMessage,
OpenGroupMessage,
} from '../messages/outgoing';
import { RawMessage } from '../types/RawMessage';
import { TypedEventEmitter } from '../utils';
import { ClosedGroupMessage } from '../messages/outgoing/content/data/group';
// TODO: add all group messages here, replace OutgoingContentMessage with them
type GroupMessageType = OpenGroupMessage | OutgoingContentMessage;
type GroupMessageType = OpenGroupMessage | ClosedGroupMessage;
export interface MessageQueueInterfaceEvents {
success: (message: RawMessage) => void;
@ -15,8 +15,8 @@ export interface MessageQueueInterfaceEvents {
export interface MessageQueueInterface {
events: TypedEventEmitter<MessageQueueInterfaceEvents>;
sendUsingMultiDevice(user: string, message: OutgoingContentMessage): void;
send(device: string, message: OutgoingContentMessage): void;
sendUsingMultiDevice(user: string, message: ContentMessage): void;
send(device: string, message: ContentMessage): void;
sendToGroup(message: GroupMessageType): void;
sendSyncMessage(message: OutgoingContentMessage): void;
sendSyncMessage(message: ContentMessage): void;
}

View file

@ -2,13 +2,110 @@
import { RawMessage } from '../types/RawMessage';
import { OpenGroupMessage } from '../messages/outgoing';
import { SignalService } from '../../protobuf';
import { UserUtil } from '../../util';
import { MessageEncrypter } from '../crypto';
import { lokiMessageAPI, lokiPublicChatAPI, textsecure } from '../../window';
export async function send(message: RawMessage): Promise<void> {
return Promise.resolve();
// ================ Regular ================
export function canSendToSnode(): boolean {
// Seems like lokiMessageAPI is not always guaranteed to be initialized
return Boolean(lokiMessageAPI);
}
export async function send({
device,
plainTextBuffer,
encryption,
timestamp,
ttl,
}: RawMessage): Promise<void> {
if (!canSendToSnode()) {
throw new Error('lokiMessageAPI is not initialized.');
}
const { envelopeType, cipherText } = await MessageEncrypter.encrypt(
device,
plainTextBuffer,
encryption
);
const envelope = await buildEnvelope(envelopeType, timestamp, cipherText);
const data = wrapEnvelope(envelope);
// TODO: Somehow differentiate between Retryable and Regular erros
return lokiMessageAPI.sendMessage(device, data, timestamp, ttl);
}
async function buildEnvelope(
type: SignalService.Envelope.Type,
timestamp: number,
content: Uint8Array
): Promise<SignalService.Envelope> {
let source: string | undefined;
if (type !== SignalService.Envelope.Type.UNIDENTIFIED_SENDER) {
source = await UserUtil.getCurrentDevicePubKey();
}
return SignalService.Envelope.create({
type,
source,
sourceDevice: 1,
timestamp,
content,
});
}
/**
* This is an outdated practice and we should probably just send the envelope data directly.
* Something to think about in the future.
*/
function wrapEnvelope(envelope: SignalService.Envelope): Uint8Array {
const request = SignalService.WebSocketRequestMessage.create({
id: 0,
body: SignalService.Envelope.encode(envelope).finish(),
});
const websocket = SignalService.WebSocketMessage.create({
type: SignalService.WebSocketMessage.Type.REQUEST,
request,
});
return SignalService.WebSocketMessage.encode(websocket).finish();
}
// ================ Open Group ================
export async function sendToOpenGroup(
message: OpenGroupMessage
): Promise<void> {
return Promise.resolve();
): Promise<boolean> {
const { group, quote, attachments, preview, body } = message;
const channelAPI = await lokiPublicChatAPI.findOrCreateChannel(
group.server,
group.channel,
group.conversationId
);
// Don't think returning true/false on `sendMessage` is a good way
// We should either: return nothing (success) or throw an error (failure)
return channelAPI.sendMessage({
quote,
attachments: attachments || [],
preview,
body,
});
// TODO: The below should be handled in whichever class calls this
/*
const res = await sendToOpenGroup(message);
if (!res) {
throw new textsecure.PublicChatError('Failed to send public chat message');
}
const messageEventData = {
pubKey,
timestamp: messageTimeStamp,
};
messageEventData.serverId = res;
window.Whisper.events.trigger('publicMessageSent', messageEventData);
*/
}

View file

@ -1,5 +1,5 @@
import { RawMessage } from '../types/RawMessage';
import { ContentMessage as OutgoingContentMessage } from '../messages/outgoing';
import { ContentMessage } from '../messages/outgoing';
// TODO: We should be able to import functions straight from the db here without going through the window object
@ -12,7 +12,7 @@ export class PendingMessageCache {
public addPendingMessage(
device: string,
message: OutgoingContentMessage
message: ContentMessage
): RawMessage {
// TODO: Maybe have a util for converting OutgoingContentMessage to RawMessage?
// TODO: Raw message has uuid, how are we going to set that? maybe use a different identifier?

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

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

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

@ -59,4 +59,31 @@ describe('ClosedGroupChatMessage', () => {
'identifier cannot be undefined'
);
});
it('should use the identifier passed into it over the one set in chatMessage', () => {
const chatMessage = new ChatMessage({
timestamp: Date.now(),
body: 'body',
identifier: 'chatMessage',
});
const message = new ClosedGroupChatMessage({
groupId: '12',
chatMessage,
identifier: 'closedGroupMessage',
});
expect(message.identifier).to.be.equal('closedGroupMessage');
});
it('should use the identifier of the chatMessage if one is not specified on the closed group message', () => {
const chatMessage = new ChatMessage({
timestamp: Date.now(),
body: 'body',
identifier: 'chatMessage',
});
const message = new ClosedGroupChatMessage({
groupId: '12',
chatMessage,
});
expect(message.identifier).to.be.equal('chatMessage');
});
});

View file

@ -1,94 +1,88 @@
import { expect } from 'chai';
import { OpenGroupMessage } from '../../../session/messages/outgoing';
import { AttachmentType } from '../../../types/Attachment';
import {
AttachmentPointer,
OpenGroupMessage,
} from '../../../session/messages/outgoing';
import * as MIME from '../../../../ts/types/MIME';
import { QuotedAttachmentType } from '../../../components/conversation/Quote';
describe('OpenGroupMessage', () => {
it('can create empty message with just a timestamp and server', () => {
const group = {
server: 'server',
channel: 1,
conversationId: '0',
};
it('can create empty message with just a timestamp and group', () => {
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
group,
});
expect(message)
.to.have.property('timestamp')
.to.be.approximately(Date.now(), 10);
expect(message).to.have.deep.property('server', 'server');
expect(message?.timestamp).to.be.approximately(Date.now(), 10);
expect(message?.group).to.deep.equal(group);
expect(message?.body).to.be.equal(undefined, 'body should be undefined');
expect(message?.quote).to.be.equal(undefined, 'quote should be undefined');
expect(message?.attachments).to.have.lengthOf(0);
expect(message?.preview).to.have.lengthOf(0);
});
it('can create message with a body', () => {
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
body: 'body',
});
expect(message).to.have.deep.property('body', 'body');
});
it('can create message with a expire timer', () => {
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
group,
body: 'body',
});
expect(message).to.have.deep.property('body', 'body');
});
it('can create message with a quote', () => {
let quote: QuotedAttachmentType;
quote = {
const attachment = {
contentType: MIME.IMAGE_JPEG,
fileName: 'fileName',
isVoiceMessage: false,
};
const quote = {
id: 0,
author: 'me',
text: 'hi',
attachments: [attachment],
};
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
group,
quote,
});
expect(message?.quote).to.have.property('contentType', MIME.IMAGE_JPEG);
expect(message?.quote).to.have.deep.property('fileName', 'fileName');
expect(message?.quote).to.have.deep.property('isVoiceMessage', false);
expect(message?.quote).to.deep.equal(quote);
});
it('can create message with an attachment', () => {
let attachment: AttachmentType;
attachment = {
url: 'url',
const attachment: AttachmentPointer = {
id: 0,
contentType: 'type',
key: new Uint8Array(1),
size: 10,
thumbnail: new Uint8Array(2),
digest: new Uint8Array(3),
filename: 'filename',
flags: 0,
width: 10,
height: 20,
caption: 'caption',
fileName: 'fileName',
contentType: MIME.AUDIO_AAC,
url: 'url',
};
const attachments = new Array<AttachmentType>();
attachments.push(attachment);
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
attachments: attachments,
group,
attachments: [attachment],
});
expect(message?.attachments).to.have.lengthOf(1);
expect(message)
.to.have.nested.property('attachments[0].caption')
.to.have.be.deep.equal('caption');
expect(message)
.to.have.nested.property('attachments[0].fileName')
.to.have.be.deep.equal('fileName');
expect(message)
.to.have.nested.property('attachments[0].contentType')
.to.be.deep.equal(MIME.AUDIO_AAC);
expect(message)
.to.have.nested.property('attachments[0].url')
.to.be.deep.equal('url');
expect(message?.attachments[0]).to.deep.equal(attachment);
});
it('has an identifier', () => {
const message = new OpenGroupMessage({
timestamp: Date.now(),
server: 'server',
group,
});
expect(message.identifier).to.not.equal(null, 'identifier cannot be null');
expect(message.identifier).to.not.equal(

View file

@ -1,6 +1,8 @@
import { LibsignalProtocol } from './types/libsignal-protocol';
import { SignalInterface } from './types/signal';
import { LocalizerType } from '../types/Util';
import LokiMessageAPI from '../../js/modules/loki_message_api';
import LokiPublicChatFactoryAPI from '../../js/modules/loki_public_chat_api';
interface WindowInterface extends Window {
seedNodeList: any;
@ -8,7 +10,6 @@ interface WindowInterface extends Window {
WebAPI: any;
LokiSnodeAPI: any;
SenderKeyAPI: any;
LokiMessageAPI: any;
StubMessageAPI: any;
StubAppDotNetApi: any;
LokiPublicChatAPI: any;
@ -72,6 +73,9 @@ interface WindowInterface extends Window {
lokiFeatureFlags: any;
resetDatabase: any;
lokiMessageAPI: LokiMessageAPI;
lokiPublicChatAPI: LokiPublicChatFactoryAPI;
}
// In the case for tests
@ -133,3 +137,6 @@ export const attemptConnection = window.attemptConnection;
export const libloki = window.libloki;
export const libsignal = window.libsignal;
export const textsecure = window.textsecure;
export const lokiMessageAPI = window.lokiMessageAPI;
export const lokiPublicChatAPI = window.lokiPublicChatAPI;

View file

@ -26,8 +26,8 @@
"strict": true, // Enable all strict type-checking options.
"skipLibCheck": true,
// Additional Checks
"noUnusedLocals": true, // Report errors on unused locals.
"noUnusedParameters": true, // Report errors on unused parameters.
"noUnusedLocals": false, // Report errors on unused locals. Loki - Enable this after refactor
"noUnusedParameters": false, // Report errors on unused parameters. Loki - Enable this after refactor
"noImplicitReturns": true, // Report error when not all code paths in function return a value.
"noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement.