mirror of
https://github.com/oxen-io/session-desktop.git
synced 2023-12-14 02:12:57 +01:00
Merge remote-tracking branch 'upstream/clearnet' into message-sending-refactor
This commit is contained in:
commit
b9aa0ad8d5
95 changed files with 3553 additions and 1885 deletions
|
@ -30,3 +30,4 @@ ts/**/*.js
|
|||
# Libloki specific files
|
||||
libloki/test/components.js
|
||||
libloki/modules/mnemonic.js
|
||||
session-file-server/**
|
||||
|
|
3
.github/workflows/build-binaries.yml
vendored
3
.github/workflows/build-binaries.yml
vendored
|
@ -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:
|
||||
|
|
9
.github/workflows/pull-request.yml
vendored
9
.github/workflows/pull-request.yml
vendored
|
@ -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:
|
||||
|
|
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
@ -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
3
.gitignore
vendored
|
@ -37,3 +37,6 @@ ts/protobuf/*.d.ts
|
|||
|
||||
# Ctags
|
||||
tags
|
||||
|
||||
proxy.key
|
||||
proxy.pub
|
||||
|
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
[submodule "session-file-server"]
|
||||
path = session-file-server
|
||||
url = https://github.com/loki-project/session-file-server/
|
||||
branch = session
|
|
@ -54,4 +54,4 @@ stylesheets/_intlTelInput.scss
|
|||
# Coverage
|
||||
coverage/**
|
||||
.nyc_output/**
|
||||
|
||||
session-file-server/**
|
||||
|
|
|
@ -94,9 +94,9 @@ There are a few scripts which you can use:
|
|||
|
||||
```
|
||||
yarn start - Start development
|
||||
yarn start-multi - Start second instance of development
|
||||
MULTI=1 yarn start - Start second instance of development
|
||||
yarn start-prod - Start production but in development mode
|
||||
yarn start-prod-multi - Start another instance of production
|
||||
MULTI=1 yarn start-prod - Start another instance of production
|
||||
```
|
||||
|
||||
For more than 2 clients, you may run the above command with `NODE_APP_INSTANCE` set before them.
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
11
app/sql.js
11
app/sql.js
|
@ -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;`,
|
||||
|
|
|
@ -18,18 +18,16 @@ describe('Add friends', function() {
|
|||
const app1Props = {
|
||||
mnemonic: common.TEST_MNEMONIC1,
|
||||
displayName: common.TEST_DISPLAY_NAME1,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const app2Props = {
|
||||
mnemonic: common.TEST_MNEMONIC2,
|
||||
displayName: common.TEST_DISPLAY_NAME2,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
[app, app2] = await Promise.all([
|
||||
common.startAndStub(app1Props),
|
||||
common.startAndStub2(app2Props),
|
||||
common.startAndStubN(app2Props, 2),
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -116,5 +114,25 @@ describe('Add friends', function() {
|
|||
ConversationPage.acceptedFriendRequestMessage,
|
||||
5000
|
||||
);
|
||||
|
||||
// app trigger the friend request logic first
|
||||
const aliceLogs = await app.client.getRenderProcessLogs();
|
||||
const bobLogs = await app2.client.getRenderProcessLogs();
|
||||
await common.logsContains(
|
||||
aliceLogs,
|
||||
`Sending undefined:friend-request message to ${common.TEST_PUBKEY2}`
|
||||
);
|
||||
await common.logsContains(
|
||||
bobLogs,
|
||||
`Received a NORMAL_FRIEND_REQUEST from source: ${common.TEST_PUBKEY1}, primarySource: ${common.TEST_PUBKEY1},`
|
||||
);
|
||||
await common.logsContains(
|
||||
bobLogs,
|
||||
`Sending incoming-friend-request-accept:onlineBroadcast message to ${common.TEST_PUBKEY1}`
|
||||
);
|
||||
await common.logsContains(
|
||||
aliceLogs,
|
||||
`Sending outgoing-friend-request-accepted:onlineBroadcast message to ${common.TEST_PUBKEY2}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,11 +25,10 @@ describe('Closed groups', function() {
|
|||
});
|
||||
|
||||
it('closedGroup: can create a closed group with a friend and send/receive a message', async () => {
|
||||
await app.client.element(ConversationPage.globeButtonSection).click();
|
||||
await app.client.element(ConversationPage.createClosedGroupButton).click();
|
||||
const useSenderKeys = false;
|
||||
|
||||
// create group and add new friend
|
||||
await common.addFriendToNewClosedGroup(app, app2);
|
||||
await common.addFriendToNewClosedGroup([app, app2], useSenderKeys);
|
||||
|
||||
// send a message from app and validate it is received on app2
|
||||
const textMessage = common.generateSendMessageText();
|
||||
|
|
|
@ -20,6 +20,13 @@ chai.should();
|
|||
chai.use(chaiAsPromised);
|
||||
chai.config.includeStack = true;
|
||||
|
||||
// From https://github.com/chaijs/chai/issues/200
|
||||
chai.use((_chai, _) => {
|
||||
_chai.Assertion.addMethod('withMessage', msg => {
|
||||
_.flag(this, 'message', msg);
|
||||
});
|
||||
});
|
||||
|
||||
const STUB_SNODE_SERVER_PORT = 3000;
|
||||
const ENABLE_LOG = false;
|
||||
|
||||
|
@ -29,19 +36,19 @@ module.exports = {
|
|||
'faxed mechanic mocked agony unrest loincloth pencil eccentric boyfriend oasis speedy ribbon faxed',
|
||||
TEST_PUBKEY1:
|
||||
'0552b85a43fb992f6bdb122a5a379505a0b99a16f0628ab8840249e2a60e12a413',
|
||||
TEST_DISPLAY_NAME1: 'integration_tester_1',
|
||||
TEST_DISPLAY_NAME1: 'tester_Alice',
|
||||
|
||||
TEST_MNEMONIC2:
|
||||
'guide inbound jerseys bays nouns basin sulking awkward stockpile ostrich ascend pylons ascend',
|
||||
TEST_PUBKEY2:
|
||||
'054e1ca8681082dbd9aad1cf6fc89a32254e15cba50c75b5a73ac10a0b96bcbd2a',
|
||||
TEST_DISPLAY_NAME2: 'integration_tester_2',
|
||||
TEST_DISPLAY_NAME2: 'tester_Bob',
|
||||
|
||||
TEST_MNEMONIC3:
|
||||
'alpine lukewarm oncoming blender kiwi fuel lobster upkeep vogue simplest gasp fully simplest',
|
||||
TEST_PUBKEY3:
|
||||
'05f8662b6e83da5a31007cc3ded44c601f191e07999acb6db2314a896048d9036c',
|
||||
TEST_DISPLAY_NAME3: 'integration_tester_3',
|
||||
TEST_DISPLAY_NAME3: 'tester_Charlie',
|
||||
|
||||
/* ************** OPEN GROUPS ****************** */
|
||||
VALID_GROUP_URL: 'https://chat.getsession.org',
|
||||
|
@ -184,20 +191,11 @@ module.exports = {
|
|||
async startAndStub({
|
||||
mnemonic,
|
||||
displayName,
|
||||
stubSnode = false,
|
||||
stubOpenGroups = false,
|
||||
env = 'test-integration-session',
|
||||
}) {
|
||||
const app = await this.startAndAssureCleanedApp(env);
|
||||
|
||||
if (stubSnode) {
|
||||
await this.startStubSnodeServer();
|
||||
this.stubSnodeCalls(app);
|
||||
}
|
||||
|
||||
if (stubOpenGroups) {
|
||||
this.stubOpenGroupsCalls(app);
|
||||
}
|
||||
await this.startStubSnodeServer();
|
||||
|
||||
if (mnemonic && displayName) {
|
||||
await this.restoreFromMnemonic(app, mnemonic, displayName);
|
||||
|
@ -242,23 +240,8 @@ module.exports = {
|
|||
);
|
||||
},
|
||||
|
||||
async startAppsAsFriends() {
|
||||
const app1Props = {
|
||||
mnemonic: this.TEST_MNEMONIC1,
|
||||
displayName: this.TEST_DISPLAY_NAME1,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const app2Props = {
|
||||
mnemonic: this.TEST_MNEMONIC2,
|
||||
displayName: this.TEST_DISPLAY_NAME2,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const [app1, app2] = await Promise.all([
|
||||
this.startAndStub(app1Props),
|
||||
this.startAndStubN(app2Props, 2),
|
||||
]);
|
||||
async makeFriends(app1, client2) {
|
||||
const [app2, pubkey2] = client2;
|
||||
|
||||
/** add each other as friends */
|
||||
const textMessage = this.generateSendMessageText();
|
||||
|
@ -266,11 +249,7 @@ module.exports = {
|
|||
await app1.client.element(ConversationPage.contactsButtonSection).click();
|
||||
await app1.client.element(ConversationPage.addContactButton).click();
|
||||
|
||||
await this.setValueWrapper(
|
||||
app1,
|
||||
ConversationPage.sessionIDInput,
|
||||
this.TEST_PUBKEY2
|
||||
);
|
||||
await this.setValueWrapper(app1, ConversationPage.sessionIDInput, pubkey2);
|
||||
await app1.client.element(ConversationPage.nextButton).click();
|
||||
await app1.client.waitForExist(
|
||||
ConversationPage.sendFriendRequestTextarea,
|
||||
|
@ -317,33 +296,75 @@ module.exports = {
|
|||
ConversationPage.acceptedFriendRequestMessage,
|
||||
5000
|
||||
);
|
||||
},
|
||||
|
||||
async startAppsAsFriends() {
|
||||
const app1Props = {
|
||||
mnemonic: this.TEST_MNEMONIC1,
|
||||
displayName: this.TEST_DISPLAY_NAME1,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const app2Props = {
|
||||
mnemonic: this.TEST_MNEMONIC2,
|
||||
displayName: this.TEST_DISPLAY_NAME2,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const [app1, app2] = await Promise.all([
|
||||
this.startAndStub(app1Props),
|
||||
this.startAndStubN(app2Props, 2),
|
||||
]);
|
||||
|
||||
await this.makeFriends(app1, [app2, this.TEST_PUBKEY2]);
|
||||
|
||||
return [app1, app2];
|
||||
},
|
||||
|
||||
async addFriendToNewClosedGroup(app, app2) {
|
||||
async addFriendToNewClosedGroup(members, useSenderKeys) {
|
||||
const [app, ...others] = members;
|
||||
|
||||
await app.client
|
||||
.element(ConversationPage.conversationButtonSection)
|
||||
.click();
|
||||
await app.client.element(ConversationPage.createClosedGroupButton).click();
|
||||
|
||||
await this.setValueWrapper(
|
||||
app,
|
||||
ConversationPage.closedGroupNameTextarea,
|
||||
this.VALID_CLOSED_GROUP_NAME1
|
||||
);
|
||||
|
||||
await app.client
|
||||
.element(ConversationPage.closedGroupNameTextarea)
|
||||
.getValue()
|
||||
.should.eventually.equal(this.VALID_CLOSED_GROUP_NAME1);
|
||||
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupMemberItem)
|
||||
.isVisible().should.eventually.be.true;
|
||||
// This assumes that app does not have any other friends
|
||||
|
||||
for (let i = 0; i < others.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupMemberItem(i))
|
||||
.isVisible().should.eventually.be.true;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupMemberItem(i))
|
||||
.click();
|
||||
}
|
||||
|
||||
// select the first friend as a member of the groups being created
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupMemberItem)
|
||||
.click();
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupMemberItemSelected)
|
||||
.isVisible().should.eventually.be.true;
|
||||
|
||||
if (useSenderKeys) {
|
||||
// Select Sender Keys
|
||||
await app.client
|
||||
.element(ConversationPage.createClosedGroupSealedSenderToggle)
|
||||
.click();
|
||||
}
|
||||
|
||||
// trigger the creation of the group
|
||||
await app.client
|
||||
.element(ConversationPage.validateCreationClosedGroupButton)
|
||||
|
@ -356,8 +377,9 @@ module.exports = {
|
|||
await app.client.isExisting(
|
||||
ConversationPage.headerTitleGroupName(this.VALID_CLOSED_GROUP_NAME1)
|
||||
).should.eventually.be.true;
|
||||
await app.client.element(ConversationPage.headerTitleMembers(2)).isVisible()
|
||||
.should.eventually.be.true;
|
||||
await app.client
|
||||
.element(ConversationPage.headerTitleMembers(members.length))
|
||||
.isVisible().should.eventually.be.true;
|
||||
|
||||
// validate overlay is closed
|
||||
await app.client
|
||||
|
@ -376,28 +398,32 @@ module.exports = {
|
|||
)
|
||||
).should.eventually.be.true;
|
||||
|
||||
// next check app2 has been invited and has the group in its conversations
|
||||
await app2.client.waitForExist(
|
||||
ConversationPage.rowOpenGroupConversationName(
|
||||
this.VALID_CLOSED_GROUP_NAME1
|
||||
),
|
||||
6000
|
||||
await Promise.all(
|
||||
others.map(async otherApp => {
|
||||
// next check that other members have been invited and have the group in their conversations
|
||||
await otherApp.client.waitForExist(
|
||||
ConversationPage.rowOpenGroupConversationName(
|
||||
this.VALID_CLOSED_GROUP_NAME1
|
||||
),
|
||||
6000
|
||||
);
|
||||
// open the closed group conversation on otherApp
|
||||
await otherApp.client
|
||||
.element(ConversationPage.conversationButtonSection)
|
||||
.click();
|
||||
await this.timeout(500);
|
||||
await otherApp.client
|
||||
.element(
|
||||
ConversationPage.rowOpenGroupConversationName(
|
||||
this.VALID_CLOSED_GROUP_NAME1
|
||||
)
|
||||
)
|
||||
.click();
|
||||
})
|
||||
);
|
||||
// open the closed group conversation on app2
|
||||
await app2.client
|
||||
.element(ConversationPage.conversationButtonSection)
|
||||
.click();
|
||||
await this.timeout(500);
|
||||
await app2.client
|
||||
.element(
|
||||
ConversationPage.rowOpenGroupConversationName(
|
||||
this.VALID_CLOSED_GROUP_NAME1
|
||||
)
|
||||
)
|
||||
.click();
|
||||
},
|
||||
|
||||
async linkApp2ToApp(app1, app2) {
|
||||
async linkApp2ToApp(app1, app2, app1Pubkey) {
|
||||
// app needs to be logged in as user1 and app2 needs to be logged out
|
||||
// start the pairing dialog for the first app
|
||||
await app1.client.element(SettingsPage.settingsButtonSection).click();
|
||||
|
@ -422,20 +448,19 @@ module.exports = {
|
|||
await this.setValueWrapper(
|
||||
app2,
|
||||
RegistrationPage.textareaLinkDevicePubkey,
|
||||
this.TEST_PUBKEY1
|
||||
app1Pubkey
|
||||
);
|
||||
await app2.client.element(RegistrationPage.linkDeviceTriggerButton).click();
|
||||
await app1.client.waitForExist(RegistrationPage.toastWrapper, 7000);
|
||||
let secretWordsapp1 = await app1.client
|
||||
.element(RegistrationPage.secretToastDescription)
|
||||
await app1.client.waitForExist(SettingsPage.secretWordsTextInDialog, 7000);
|
||||
const secretWordsapp1 = await app1.client
|
||||
.element(SettingsPage.secretWordsTextInDialog)
|
||||
.getText();
|
||||
secretWordsapp1 = secretWordsapp1.split(': ')[1];
|
||||
|
||||
await app2.client.waitForExist(RegistrationPage.toastWrapper, 6000);
|
||||
await app2.client
|
||||
.element(RegistrationPage.secretToastDescription)
|
||||
.getText()
|
||||
.should.eventually.be.equal(secretWordsapp1);
|
||||
|
||||
await app1.client.element(ConversationPage.allowPairingButton).click();
|
||||
await app1.client.element(ConversationPage.okButton).click();
|
||||
// validate device paired in settings list with correct secrets
|
||||
|
@ -458,7 +483,7 @@ module.exports = {
|
|||
// validate primary pubkey of app2 is the same that in app1
|
||||
await app2.webContents
|
||||
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
|
||||
.should.eventually.be.equal(this.TEST_PUBKEY1);
|
||||
.should.eventually.be.equal(app1Pubkey);
|
||||
},
|
||||
|
||||
async triggerUnlinkApp2FromApp(app1, app2) {
|
||||
|
@ -466,9 +491,9 @@ module.exports = {
|
|||
await app2.client.isExisting(RegistrationPage.conversationListContainer)
|
||||
.should.eventually.be.true;
|
||||
|
||||
await app1.client.element(ConversationPage.settingsButtonSection).click();
|
||||
await app1.client.element(SettingsPage.settingsButtonSection).click();
|
||||
await app1.client
|
||||
.element(ConversationPage.settingsRowWithText('Devices'))
|
||||
.element(SettingsPage.settingsRowWithText('Devices'))
|
||||
.click();
|
||||
await app1.client.isExisting(ConversationPage.linkDeviceButtonDisabled)
|
||||
.should.eventually.be.true;
|
||||
|
@ -478,7 +503,7 @@ module.exports = {
|
|||
|
||||
await app1.client.waitForExist(
|
||||
ConversationPage.noPairedDeviceMessage,
|
||||
2000
|
||||
5000
|
||||
);
|
||||
await app1.client.element(ConversationPage.linkDeviceButton).isEnabled()
|
||||
.should.eventually.be.true;
|
||||
|
@ -533,23 +558,6 @@ module.exports = {
|
|||
generateSendMessageText: () =>
|
||||
`Test message from integration tests ${Date.now()}`,
|
||||
|
||||
stubOpenGroupsCalls: app1 => {
|
||||
app1.webContents.executeJavaScript(
|
||||
'window.LokiAppDotNetServerAPI = window.StubAppDotNetAPI;'
|
||||
);
|
||||
},
|
||||
|
||||
stubSnodeCalls(app1) {
|
||||
app1.webContents.executeJavaScript(
|
||||
'window.LokiMessageAPI = window.StubMessageAPI;'
|
||||
);
|
||||
},
|
||||
|
||||
logsContainsString: async (app1, str) => {
|
||||
const logs = JSON.stringify(await app1.client.getRenderProcessLogs());
|
||||
return logs.includes(str);
|
||||
},
|
||||
|
||||
async startStubSnodeServer() {
|
||||
if (!this.stubSnode) {
|
||||
this.messages = {};
|
||||
|
@ -557,42 +565,98 @@ module.exports = {
|
|||
const { query } = url.parse(request.url, true);
|
||||
const { pubkey, data, timestamp } = query;
|
||||
|
||||
if (pubkey) {
|
||||
if (request.method === 'POST') {
|
||||
if (ENABLE_LOG) {
|
||||
console.warn('POST', [data, timestamp]);
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
|
@ -13,6 +13,8 @@ require('./link_device_test');
|
|||
require('./closed_group_test');
|
||||
require('./message_functions_test');
|
||||
require('./settings_test');
|
||||
require('./message_sync_test');
|
||||
require('./sender_keys_test');
|
||||
|
||||
before(async () => {
|
||||
// start the app once before all tests to get the platform-dependent
|
||||
|
|
|
@ -18,12 +18,9 @@ describe('Link Device', function() {
|
|||
const app1Props = {
|
||||
mnemonic: common.TEST_MNEMONIC1,
|
||||
displayName: common.TEST_DISPLAY_NAME1,
|
||||
stubSnode: true,
|
||||
};
|
||||
|
||||
const app2Props = {
|
||||
stubSnode: true,
|
||||
};
|
||||
const app2Props = {};
|
||||
|
||||
[app, app2] = await Promise.all([
|
||||
common.startAndStub(app1Props),
|
||||
|
@ -37,12 +34,37 @@ describe('Link Device', function() {
|
|||
});
|
||||
|
||||
it('linkDevice: link two desktop devices', async () => {
|
||||
await common.linkApp2ToApp(app, app2);
|
||||
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
|
||||
});
|
||||
|
||||
it('linkDevice: unlink two devices', async () => {
|
||||
await common.linkApp2ToApp(app, app2);
|
||||
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
|
||||
await common.timeout(1000);
|
||||
await common.triggerUnlinkApp2FromApp(app, app2);
|
||||
});
|
||||
|
||||
it('linkDevice:sync no groups, closed group, nor open groups', async () => {
|
||||
await common.linkApp2ToApp(app, app2, common.TEST_PUBKEY1);
|
||||
await common.timeout(10000);
|
||||
|
||||
// get logs at this stage (getRenderProcessLogs() clears the app logs)
|
||||
const secondaryRenderLogs = await app2.client.getRenderProcessLogs();
|
||||
// pairing request message sent from secondary to primary pubkey
|
||||
await common.logsContains(
|
||||
secondaryRenderLogs,
|
||||
`Sending pairing-request:pairing-request message to ${common.TEST_PUBKEY1}`
|
||||
);
|
||||
|
||||
const primaryRenderLogs = await app.client.getRenderProcessLogs();
|
||||
// primary grant pairing request
|
||||
await common.logsContains(
|
||||
primaryRenderLogs,
|
||||
'Sending pairing-request:pairing-request message to OUR SECONDARY PUBKEY'
|
||||
);
|
||||
|
||||
// no friends, no closed groups, no open groups. we should see those message sync in the log
|
||||
await common.logsContains(primaryRenderLogs, 'No closed group to sync.', 1);
|
||||
await common.logsContains(primaryRenderLogs, 'No open groups to sync', 1);
|
||||
await common.logsContains(primaryRenderLogs, 'No contacts to sync.', 1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const path = require('path');
|
||||
|
||||
const { after, before, describe, it } = require('mocha');
|
||||
const { afterEach, beforeEach, describe, it } = require('mocha');
|
||||
const common = require('./common');
|
||||
const ConversationPage = require('./page-objects/conversation.page');
|
||||
|
||||
|
@ -14,25 +14,22 @@ describe('Message Functions', function() {
|
|||
this.timeout(60000);
|
||||
this.slow(15000);
|
||||
|
||||
before(async () => {
|
||||
beforeEach(async () => {
|
||||
await common.killallElectron();
|
||||
await common.stopStubSnodeServer();
|
||||
|
||||
[app, app2] = await common.startAppsAsFriends();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
afterEach(async () => {
|
||||
await common.stopApp(app);
|
||||
await common.killallElectron();
|
||||
await common.stopStubSnodeServer();
|
||||
});
|
||||
|
||||
it('can send attachment', async () => {
|
||||
await app.client.element(ConversationPage.globeButtonSection).click();
|
||||
await app.client.element(ConversationPage.createClosedGroupButton).click();
|
||||
|
||||
// create group and add new friend
|
||||
await common.addFriendToNewClosedGroup(app, app2);
|
||||
await common.addFriendToNewClosedGroup([app, app2], false);
|
||||
|
||||
// send attachment from app1 to closed group
|
||||
const fileLocation = path.join(__dirname, 'test_attachment');
|
||||
|
@ -53,6 +50,8 @@ describe('Message Functions', function() {
|
|||
});
|
||||
|
||||
it('can delete message', async () => {
|
||||
// create group and add new friend
|
||||
await common.addFriendToNewClosedGroup([app, app2], false);
|
||||
const messageText = 'delete_me';
|
||||
await common.sendMessage(app, messageText);
|
||||
|
||||
|
@ -71,7 +70,7 @@ describe('Message Functions', function() {
|
|||
.click();
|
||||
await app.client.element(ConversationPage.deleteMessageCtxButton).click();
|
||||
|
||||
// delete messaage from modal
|
||||
// delete message from modal
|
||||
await app.client.waitForExist(
|
||||
ConversationPage.deleteMessageModalButton,
|
||||
5000
|
||||
|
|
|
@ -1,49 +1,142 @@
|
|||
/* eslint-disable func-names */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const { afterEach, beforeEach, describe, it } = require('mocha');
|
||||
const { after, before, describe, it } = require('mocha');
|
||||
|
||||
const common = require('./common');
|
||||
|
||||
describe('Message Syncing', function() {
|
||||
let app;
|
||||
let app2;
|
||||
let Alice1;
|
||||
let Bob1;
|
||||
let Alice2;
|
||||
this.timeout(60000);
|
||||
this.slow(15000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// this test suite builds a complex usecase over several tests,
|
||||
// so you need to run all of those tests together (running only one might fail)
|
||||
before(async () => {
|
||||
await common.killallElectron();
|
||||
await common.stopStubSnodeServer();
|
||||
|
||||
const app1Props = {
|
||||
mnemonic: common.TEST_MNEMONIC1,
|
||||
displayName: common.TEST_DISPLAY_NAME1,
|
||||
stubSnode: true,
|
||||
};
|
||||
const 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@ const ConversationPage = require('./page-objects/conversation.page');
|
|||
|
||||
describe('Open groups', function() {
|
||||
let app;
|
||||
this.timeout(30000);
|
||||
this.timeout(40000);
|
||||
this.slow(15000);
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -15,7 +15,6 @@ describe('Open groups', function() {
|
|||
const login = {
|
||||
mnemonic: common.TEST_MNEMONIC1,
|
||||
displayName: common.TEST_DISPLAY_NAME1,
|
||||
stubOpenGroups: true,
|
||||
};
|
||||
app = await common.startAndStub(login);
|
||||
});
|
||||
|
@ -24,46 +23,25 @@ describe('Open groups', function() {
|
|||
await common.killallElectron();
|
||||
});
|
||||
|
||||
// reduce code duplication to get the initial join
|
||||
async function joinOpenGroup(url, name) {
|
||||
await app.client.element(ConversationPage.globeButtonSection).click();
|
||||
await app.client.element(ConversationPage.joinOpenGroupButton).click();
|
||||
|
||||
await common.setValueWrapper(app, ConversationPage.openGroupInputUrl, url);
|
||||
await app.client
|
||||
.element(ConversationPage.openGroupInputUrl)
|
||||
.getValue()
|
||||
.should.eventually.equal(url);
|
||||
await app.client.element(ConversationPage.joinOpenGroupButton).click();
|
||||
|
||||
// validate session loader is shown
|
||||
await app.client.isExisting(ConversationPage.sessionLoader).should
|
||||
.eventually.be.true;
|
||||
// account for slow home internet connection delays...
|
||||
await app.client.waitForExist(
|
||||
ConversationPage.sessionToastJoinOpenGroupSuccess,
|
||||
60 * 1000
|
||||
);
|
||||
|
||||
// validate overlay is closed
|
||||
await app.client.isExisting(ConversationPage.leftPaneOverlay).should
|
||||
.eventually.be.false;
|
||||
|
||||
// validate open chat has been added
|
||||
await app.client.isExisting(
|
||||
ConversationPage.rowOpenGroupConversationName(name)
|
||||
).should.eventually.be.true;
|
||||
}
|
||||
|
||||
it('openGroup: works with valid open group url', async () => {
|
||||
await joinOpenGroup(common.VALID_GROUP_URL, common.VALID_GROUP_NAME);
|
||||
await common.joinOpenGroup(
|
||||
app,
|
||||
common.VALID_GROUP_URL,
|
||||
common.VALID_GROUP_NAME
|
||||
);
|
||||
});
|
||||
|
||||
it('openGroup: cannot join two times the same open group', async () => {
|
||||
await joinOpenGroup(common.VALID_GROUP_URL2, common.VALID_GROUP_NAME2);
|
||||
await common.joinOpenGroup(
|
||||
app,
|
||||
common.VALID_GROUP_URL2,
|
||||
common.VALID_GROUP_NAME2
|
||||
);
|
||||
|
||||
// adding a second time the same open group
|
||||
await app.client.element(ConversationPage.globeButtonSection).click();
|
||||
await app.client
|
||||
.element(ConversationPage.conversationButtonSection)
|
||||
.click();
|
||||
await app.client.element(ConversationPage.joinOpenGroupButton).click();
|
||||
|
||||
await common.setValueWrapper(
|
||||
|
@ -88,7 +66,9 @@ describe('Open groups', function() {
|
|||
|
||||
it('openGroup: can send message to open group', async () => {
|
||||
// join dev-chat group
|
||||
await app.client.element(ConversationPage.globeButtonSection).click();
|
||||
await app.client
|
||||
.element(ConversationPage.conversationButtonSection)
|
||||
.click();
|
||||
await app.client.element(ConversationPage.joinOpenGroupButton).click();
|
||||
|
||||
await common.setValueWrapper(
|
||||
|
|
|
@ -14,6 +14,8 @@ module.exports = {
|
|||
inputWithId: id => `//input[contains(@id, '${id}')]`,
|
||||
textAreaWithPlaceholder: placeholder =>
|
||||
`//textarea[contains(@placeholder, "${placeholder}")]`,
|
||||
textAreaWithClass: classname =>
|
||||
`//textarea[contains(@class, "${classname}")]`,
|
||||
byId: id => `//*[@id="${id}"]`,
|
||||
divWithClass: classname => `//div[contains(@class, "${classname}")]`,
|
||||
divWithClassAndText: (classname, text) =>
|
||||
|
|
|
@ -4,7 +4,7 @@ module.exports = {
|
|||
// conversation view
|
||||
sessionLoader: commonPage.divWithClass('session-loader'),
|
||||
leftPaneOverlay: commonPage.divWithClass('module-left-pane-overlay'),
|
||||
sendMessageTextarea: commonPage.textAreaWithPlaceholder('Type your message'),
|
||||
sendMessageTextarea: commonPage.textAreaWithClass('send-message'),
|
||||
sendFriendRequestTextarea: commonPage.textAreaWithPlaceholder(
|
||||
'Send your first message'
|
||||
),
|
||||
|
@ -40,8 +40,6 @@ module.exports = {
|
|||
'//*[contains(@class, "session-modal")]//div[contains(string(), "Delete") and contains(@class, "session-button")]',
|
||||
|
||||
// channels
|
||||
globeButtonSection:
|
||||
'//*[contains(@class,"session-icon-button") and .//*[contains(@class, "globe")]]',
|
||||
joinOpenGroupButton: commonPage.divRoleButtonWithText('Join Open Group'),
|
||||
openGroupInputUrl: commonPage.textAreaWithPlaceholder('chat.getsession.org'),
|
||||
sessionToastJoinOpenGroupSuccess: commonPage.toastWithText(
|
||||
|
@ -63,7 +61,11 @@ module.exports = {
|
|||
closedGroupNameTextarea: commonPage.textAreaWithPlaceholder(
|
||||
'Enter a group name'
|
||||
),
|
||||
createClosedGroupMemberItem: commonPage.divWithClass('session-member-item'),
|
||||
createClosedGroupMemberItem: idx =>
|
||||
commonPage.divWithClass(`session-member-item-${idx}`),
|
||||
createClosedGroupSealedSenderToggle: commonPage.divWithClass(
|
||||
'session-toggle'
|
||||
),
|
||||
createClosedGroupMemberItemSelected: commonPage.divWithClass(
|
||||
'session-member-item selected'
|
||||
),
|
||||
|
|
|
@ -17,4 +17,7 @@ module.exports = {
|
|||
// Confirm is a boolean. Selects confirmation input
|
||||
passwordSetModalInput: _confirm =>
|
||||
`//input[@id = 'password-modal-input${_confirm ? '-confirm' : ''}']`,
|
||||
|
||||
secretWordsTextInDialog:
|
||||
'//div[@class="device-pairing-dialog__secret-words"]/div[@class="subtle"]',
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
const { afterEach, beforeEach, describe, it } = require('mocha');
|
||||
|
||||
const common = require('./common');
|
||||
const SettingsPage = require('./page-objects/settings.page');
|
||||
const RegistrationPage = require('./page-objects/registration.page');
|
||||
const ConversationPage = require('./page-objects/conversation.page');
|
||||
|
||||
|
@ -104,7 +105,6 @@ describe('Window Test and Login', function() {
|
|||
const login = {
|
||||
mnemonic: common.TEST_MNEMONIC1,
|
||||
displayName: common.TEST_DISPLAY_NAME1,
|
||||
stubOpenGroups: true,
|
||||
};
|
||||
app = await common.startAndStub(login);
|
||||
|
||||
|
@ -117,7 +117,7 @@ describe('Window Test and Login', function() {
|
|||
.executeJavaScript("window.storage.get('primaryDevicePubKey')")
|
||||
.should.eventually.be.equal(common.TEST_PUBKEY1);
|
||||
// delete account
|
||||
await app.client.element(ConversationPage.settingsButtonSection).click();
|
||||
await app.client.element(SettingsPage.settingsButtonSection).click();
|
||||
await app.client.element(ConversationPage.deleteAccountButton).click();
|
||||
await app.client.isExisting(ConversationPage.descriptionDeleteAccount)
|
||||
.should.eventually.be.true;
|
||||
|
|
153
integration_test/sender_keys_test.js
Normal file
153
integration_test/sender_keys_test.js
Normal 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);
|
||||
});
|
|
@ -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);
|
||||
|
|
10
integration_test/stubs/stub_loki_snode_api.js
Normal file
10
integration_test/stubs/stub_loki_snode_api.js
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
9
integration_test/stubs/stub_snode_api.js
Normal file
9
integration_test/stubs/stub_snode_api.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
class StubSnodeAPI {
|
||||
async refreshSwarmNodesForPubKey() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StubSnodeAPI;
|
188
js/background.js
188
js/background.js
|
@ -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) {
|
||||
|
|
50
js/expire.js
50
js/expire.js
|
@ -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();
|
||||
})();
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
log,
|
||||
i18n,
|
||||
Backbone,
|
||||
libloki,
|
||||
ConversationController,
|
||||
MessageController,
|
||||
storage,
|
||||
|
@ -13,7 +14,8 @@
|
|||
clipboard,
|
||||
BlockedNumberController,
|
||||
lokiPublicChatAPI,
|
||||
JobQueue
|
||||
JobQueue,
|
||||
StringView
|
||||
*/
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -234,6 +236,9 @@
|
|||
isBlocked() {
|
||||
return BlockedNumberController.isBlocked(this.id);
|
||||
},
|
||||
isMediumGroup() {
|
||||
return this.get('is_medium_group');
|
||||
},
|
||||
block() {
|
||||
BlockedNumberController.block(this.id);
|
||||
this.trigger('change');
|
||||
|
@ -245,22 +250,29 @@
|
|||
this.messageCollection.forEach(m => m.trigger('change'));
|
||||
},
|
||||
async acceptFriendRequest() {
|
||||
// Friend request message conmfirmations (Accept / Decline) are always
|
||||
// sent to the primary device conversation
|
||||
const messages = await window.Signal.Data.getMessagesByConversation(
|
||||
this.id,
|
||||
this.getPrimaryDevicePubKey(),
|
||||
{
|
||||
limit: 1,
|
||||
limit: 5,
|
||||
MessageCollection: Whisper.MessageCollection,
|
||||
type: 'friend-request',
|
||||
}
|
||||
);
|
||||
const lastMessageModel = messages.at(0);
|
||||
if (lastMessageModel) {
|
||||
lastMessageModel.acceptFriendRequest();
|
||||
|
||||
let lastMessage = null;
|
||||
messages.forEach(m => {
|
||||
m.acceptFriendRequest();
|
||||
lastMessage = m;
|
||||
});
|
||||
|
||||
if (lastMessage) {
|
||||
await this.markRead();
|
||||
window.Whisper.events.trigger(
|
||||
'showConversation',
|
||||
this.id,
|
||||
lastMessageModel.id
|
||||
lastMessage.id
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -549,6 +561,7 @@
|
|||
MessageCollection: Whisper.MessageCollection,
|
||||
}
|
||||
);
|
||||
|
||||
if (typeof status === 'string') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
status = [status];
|
||||
|
@ -584,7 +597,6 @@
|
|||
|
||||
const result = {
|
||||
id: this.id,
|
||||
|
||||
isArchived: this.get('isArchived'),
|
||||
activeAt: this.get('active_at'),
|
||||
avatarPath: this.getAvatarPath(),
|
||||
|
@ -970,25 +982,47 @@
|
|||
Conversation: Whisper.Conversation,
|
||||
});
|
||||
},
|
||||
async respondToAllFriendRequests(options) {
|
||||
async updateAllFriendRequestsMessages(options) {
|
||||
const { response, status, direction = null } = options;
|
||||
// Ignore if no response supplied
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
const primaryConversation = ConversationController.get(
|
||||
|
||||
// Accept FRs from all the user's devices
|
||||
const allDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
|
||||
this.getPrimaryDevicePubKey()
|
||||
);
|
||||
// Should never happen
|
||||
if (!primaryConversation) {
|
||||
|
||||
if (!allDevices.length) {
|
||||
return;
|
||||
}
|
||||
const pending = await primaryConversation.getFriendRequests(
|
||||
direction,
|
||||
status
|
||||
|
||||
const allConversationsWithUser = allDevices.map(d =>
|
||||
ConversationController.get(d)
|
||||
);
|
||||
|
||||
// Search through each conversation (device) for friend request messages
|
||||
const pendingRequestPromises = allConversationsWithUser.map(
|
||||
async conversation => {
|
||||
const request = (
|
||||
await conversation.getFriendRequests(direction, status)
|
||||
)[0];
|
||||
return { conversation, request };
|
||||
}
|
||||
);
|
||||
|
||||
let pendingRequests = await Promise.all(pendingRequestPromises);
|
||||
|
||||
// Filter out all undefined requests
|
||||
pendingRequests = pendingRequests.filter(p => Boolean(p.request));
|
||||
|
||||
// We set all friend request messages from all devices
|
||||
// from a user here to accepted where possible
|
||||
await Promise.all(
|
||||
pending.map(async request => {
|
||||
pendingRequests.map(async friendRequest => {
|
||||
const { conversation, request } = friendRequest;
|
||||
|
||||
if (request.hasErrors()) {
|
||||
return;
|
||||
}
|
||||
|
@ -997,12 +1031,12 @@
|
|||
await window.Signal.Data.saveMessage(request.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
primaryConversation.trigger('updateMessage', request);
|
||||
conversation.trigger('updateMessage', request);
|
||||
})
|
||||
);
|
||||
},
|
||||
async respondToAllPendingFriendRequests(options) {
|
||||
return this.respondToAllFriendRequests({
|
||||
async updateAllPendingFriendRequestsMessages(options) {
|
||||
return this.updateAllFriendRequestsMessages({
|
||||
...options,
|
||||
status: 'pending',
|
||||
});
|
||||
|
@ -1017,7 +1051,7 @@
|
|||
// We have declined an incoming friend request
|
||||
async onDeclineFriendRequest() {
|
||||
this.setFriendRequestStatus(FriendRequestStatusEnum.none);
|
||||
await this.respondToAllPendingFriendRequests({
|
||||
await this.updateAllPendingFriendRequestsMessages({
|
||||
response: 'declined',
|
||||
direction: 'incoming',
|
||||
});
|
||||
|
@ -1031,13 +1065,16 @@
|
|||
},
|
||||
// We have accepted an incoming friend request
|
||||
async onAcceptFriendRequest(options = {}) {
|
||||
if (this.get('type') !== Message.PRIVATE) {
|
||||
return;
|
||||
}
|
||||
if (this.unlockTimer) {
|
||||
clearTimeout(this.unlockTimer);
|
||||
}
|
||||
if (this.hasReceivedFriendRequest()) {
|
||||
this.setFriendRequestStatus(FriendRequestStatusEnum.friends, options);
|
||||
|
||||
await this.respondToAllFriendRequests({
|
||||
await this.updateAllFriendRequestsMessages({
|
||||
response: 'accepted',
|
||||
direction: 'incoming',
|
||||
status: ['pending', 'expired'],
|
||||
|
@ -1047,6 +1084,12 @@
|
|||
window.textsecure.OutgoingMessage.DebugMessageType
|
||||
.INCOMING_FR_ACCEPTED
|
||||
);
|
||||
} else if (this.isFriendRequestStatusNoneOrExpired()) {
|
||||
// send AFR if we haven't sent a message before
|
||||
const autoFrMessage = textsecure.OutgoingMessage.buildAutoFriendRequestMessage(
|
||||
this.id
|
||||
);
|
||||
await autoFrMessage.sendToNumber(this.id, false);
|
||||
}
|
||||
},
|
||||
// Our outgoing friend request has been accepted
|
||||
|
@ -1059,7 +1102,7 @@
|
|||
}
|
||||
if (this.hasSentFriendRequest()) {
|
||||
this.setFriendRequestStatus(FriendRequestStatusEnum.friends);
|
||||
await this.respondToAllFriendRequests({
|
||||
await this.updateAllFriendRequestsMessages({
|
||||
response: 'accepted',
|
||||
status: ['pending', 'expired'],
|
||||
});
|
||||
|
@ -1091,7 +1134,7 @@
|
|||
}
|
||||
|
||||
// Change any pending outgoing friend requests to expired
|
||||
await this.respondToAllPendingFriendRequests({
|
||||
await this.updateAllPendingFriendRequestsMessages({
|
||||
response: 'expired',
|
||||
direction: 'outgoing',
|
||||
});
|
||||
|
@ -1104,7 +1147,7 @@
|
|||
await Promise.all([
|
||||
this.setFriendRequestStatus(FriendRequestStatusEnum.friends),
|
||||
// Accept all outgoing FR
|
||||
this.respondToAllPendingFriendRequests({
|
||||
this.updateAllPendingFriendRequestsMessages({
|
||||
direction: 'outgoing',
|
||||
response: 'accepted',
|
||||
}),
|
||||
|
@ -1658,6 +1701,7 @@
|
|||
|
||||
const model = this.addSingleMessage(attributes);
|
||||
const message = MessageController.register(model.id, model);
|
||||
|
||||
await window.Signal.Data.saveMessage(message.attributes, {
|
||||
forceSave: true,
|
||||
Message: Whisper.Message,
|
||||
|
@ -1753,7 +1797,7 @@
|
|||
let dest = destination;
|
||||
let numbers = groupNumbers;
|
||||
|
||||
if (this.get('is_medium_group')) {
|
||||
if (this.isMediumGroup()) {
|
||||
dest = this.id;
|
||||
numbers = [destination];
|
||||
options.isMediumGroup = true;
|
||||
|
@ -2243,6 +2287,12 @@
|
|||
}
|
||||
},
|
||||
|
||||
async saveChangesToDB() {
|
||||
await window.Signal.Data.updateConversation(this.id, this.attributes, {
|
||||
Conversation: Whisper.Conversation,
|
||||
});
|
||||
},
|
||||
|
||||
async updateGroup(providedGroupUpdate) {
|
||||
let groupUpdate = providedGroupUpdate;
|
||||
|
||||
|
@ -2261,15 +2311,44 @@
|
|||
group_update: groupUpdate,
|
||||
});
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
message.set({ id });
|
||||
const messageId = await window.Signal.Data.saveMessage(
|
||||
message.attributes,
|
||||
{
|
||||
Message: Whisper.Message,
|
||||
}
|
||||
);
|
||||
message.set({ id: messageId });
|
||||
|
||||
const options = this.getSendOptions();
|
||||
|
||||
if (groupUpdate.is_medium_group) {
|
||||
// Constructing a "create group" message
|
||||
const proto = new textsecure.protobuf.DataMessage();
|
||||
|
||||
const mgUpdate = new textsecure.protobuf.MediumGroupUpdate();
|
||||
|
||||
const { id, name, secretKey, senderKey, members } = groupUpdate;
|
||||
|
||||
mgUpdate.type = textsecure.protobuf.MediumGroupUpdate.Type.NEW_GROUP;
|
||||
mgUpdate.groupId = id;
|
||||
mgUpdate.groupSecretKey = secretKey;
|
||||
mgUpdate.senderKey = new textsecure.protobuf.SenderKey(senderKey);
|
||||
mgUpdate.members = members.map(pkHex =>
|
||||
StringView.hexToArrayBuffer(pkHex)
|
||||
);
|
||||
mgUpdate.groupName = name;
|
||||
mgUpdate.admins = this.get('groupAdmins');
|
||||
proto.mediumGroupUpdate = mgUpdate;
|
||||
|
||||
message.send(
|
||||
this.wrapSend(textsecure.messaging.updateMediumGroup(members, proto))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
message.send(
|
||||
this.wrapSend(
|
||||
textsecure.messaging.updateGroup(
|
||||
textsecure.messaging.sendGroupUpdate(
|
||||
this.id,
|
||||
this.get('name'),
|
||||
this.get('avatar'),
|
||||
|
@ -2285,7 +2364,7 @@
|
|||
sendGroupInfo(recipients) {
|
||||
if (this.isClosedGroup()) {
|
||||
const options = this.getSendOptions();
|
||||
textsecure.messaging.updateGroup(
|
||||
textsecure.messaging.sendGroupUpdate(
|
||||
this.id,
|
||||
this.get('name'),
|
||||
this.get('avatar'),
|
||||
|
@ -2299,9 +2378,19 @@
|
|||
|
||||
async leaveGroup() {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.isMediumGroup()) {
|
||||
// NOTE: we should probably remove sender keys for groupId,
|
||||
// and its secret key, but it is low priority
|
||||
|
||||
// TODO: need to reset everyone's sender keys
|
||||
window.lokiMessageAPI.stopPollingForGroup(this.id);
|
||||
}
|
||||
|
||||
if (this.get('type') === 'group') {
|
||||
const groupNumbers = this.getRecipients();
|
||||
this.set({ left: true });
|
||||
|
||||
await window.Signal.Data.updateConversation(this.id, this.attributes, {
|
||||
Conversation: Whisper.Conversation,
|
||||
});
|
||||
|
@ -2439,7 +2528,6 @@
|
|||
},
|
||||
|
||||
// LOKI PROFILES
|
||||
|
||||
async setNickname(nickname) {
|
||||
const trimmed = nickname && nickname.trim();
|
||||
if (this.get('nickname') === trimmed) {
|
||||
|
|
|
@ -418,33 +418,79 @@
|
|||
if (this.get('friendStatus') !== 'pending') {
|
||||
return;
|
||||
}
|
||||
const conversation = await this.getSourceDeviceConversation();
|
||||
// If we somehow received an old friend request (e.g. after having restored
|
||||
// from seed, we won't be able to accept it, we should initiate our own
|
||||
// friend request to reset the session:
|
||||
if (conversation.get('sessionRestoreSeen')) {
|
||||
conversation.sendMessage('', null, null, null, null, {
|
||||
sessionRestoration: true,
|
||||
});
|
||||
return;
|
||||
|
||||
const devicePubKey = this.get('conversationId');
|
||||
const otherDevices = await libloki.storage.getPairedDevicesFor(
|
||||
devicePubKey
|
||||
);
|
||||
const allDevices = [devicePubKey, ...otherDevices];
|
||||
|
||||
// Set profile name to primary conversation
|
||||
let profileName;
|
||||
const allConversationsWithUser = allDevices
|
||||
.map(d => ConversationController.get(d))
|
||||
.filter(c => Boolean(c));
|
||||
allConversationsWithUser.forEach(conversation => {
|
||||
// If we somehow received an old friend request (e.g. after having restored
|
||||
// from seed, we won't be able to accept it, we should initiate our own
|
||||
// friend request to reset the session:
|
||||
if (conversation.get('sessionRestoreSeen')) {
|
||||
conversation.sendMessage('', null, null, null, null, {
|
||||
sessionRestoration: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
profileName = conversation.getProfileName() || profileName;
|
||||
conversation.onAcceptFriendRequest();
|
||||
});
|
||||
|
||||
// If you don't have a profile name for this device, and profileName is set,
|
||||
// add profileName to conversation.
|
||||
const primaryDevicePubKey =
|
||||
(await window.Signal.Data.getPrimaryDeviceFor(devicePubKey)) ||
|
||||
devicePubKey;
|
||||
const primaryConversation = allConversationsWithUser.find(
|
||||
c => c.id === primaryDevicePubKey
|
||||
);
|
||||
if (!primaryConversation.getProfileName() && profileName) {
|
||||
await primaryConversation.setNickname(profileName);
|
||||
}
|
||||
this.set({ friendStatus: 'accepted' });
|
||||
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
conversation.onAcceptFriendRequest();
|
||||
|
||||
this.set({ friendStatus: 'accepted' });
|
||||
|
||||
// Update redux store
|
||||
window.Signal.Data.updateConversation(
|
||||
primaryConversation.id,
|
||||
primaryConversation.attributes,
|
||||
{ Conversation: Whisper.Conversation }
|
||||
);
|
||||
},
|
||||
async declineFriendRequest() {
|
||||
if (this.get('friendStatus') !== 'pending') {
|
||||
return;
|
||||
}
|
||||
const conversation = this.getConversation();
|
||||
|
||||
this.set({ friendStatus: 'declined' });
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
});
|
||||
conversation.onDeclineFriendRequest();
|
||||
|
||||
const devicePubKey = this.attributes.conversationId;
|
||||
const otherDevices = await libloki.storage.getPairedDevicesFor(
|
||||
devicePubKey
|
||||
);
|
||||
const allDevices = [devicePubKey, ...otherDevices];
|
||||
const allConversationsWithUser = allDevices
|
||||
.map(d => ConversationController.get(d))
|
||||
.filter(c => Boolean(c));
|
||||
allConversationsWithUser.forEach(conversation => {
|
||||
conversation.onDeclineFriendRequest();
|
||||
});
|
||||
},
|
||||
getPropsForFriendRequest() {
|
||||
const friendStatus = this.get('friendStatus') || 'pending';
|
||||
|
@ -1444,7 +1490,8 @@
|
|||
if (!this.isFriendRequest()) {
|
||||
const c = this.getConversation();
|
||||
// Don't bother sending sync messages to public chats
|
||||
if (c && !c.isPublic()) {
|
||||
// or groups with sender keys
|
||||
if (c && !c.isPublic() && !c.isMediumGroup()) {
|
||||
this.sendSyncMessage();
|
||||
}
|
||||
}
|
||||
|
@ -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
25
js/modules/loki_app_dot_net_api.d.ts
vendored
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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
11
js/modules/loki_message_api.d.ts
vendored
Normal 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;
|
|
@ -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
13
js/modules/loki_public_chat_api.d.ts
vendored
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
dcodeIO,
|
||||
libloki,
|
||||
log,
|
||||
crypto
|
||||
crypto,
|
||||
textsecure
|
||||
*/
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -39,9 +40,7 @@ async function saveSenderKeysInner(
|
|||
}
|
||||
|
||||
// Save somebody else's key
|
||||
async function saveSenderKeys(groupId, senderIdentity, chainKey) {
|
||||
// New key, so index 0
|
||||
const keyIdx = 0;
|
||||
async function saveSenderKeys(groupId, senderIdentity, chainKey, keyIdx) {
|
||||
const messageKeys = {};
|
||||
await saveSenderKeysInner(
|
||||
groupId,
|
||||
|
@ -133,7 +132,7 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
|
|||
log.error(
|
||||
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
|
||||
);
|
||||
return null;
|
||||
throw new textsecure.SenderKeyMissing(senderIdentity);
|
||||
}
|
||||
|
||||
// Normally keyIdx will be 1 behind, in which case we stepRatchet one time only
|
||||
|
@ -178,7 +177,10 @@ async function advanceRatchet(groupId, senderIdentity, idx) {
|
|||
curMessageKey = messageKey;
|
||||
break;
|
||||
} else if (nextKeyIdx > idx) {
|
||||
log.error('Developer error: nextKeyIdx > idx');
|
||||
log.error(
|
||||
`Could not decrypt for an older ratchet step: (${nextKeyIdx})nextKeyIdx > (${idx})idx`
|
||||
);
|
||||
throw new Error(`Cannot revert ratchet for group ${groupId}!`);
|
||||
} else {
|
||||
// Store keys for skipped nextKeyIdx, we might need them to decrypt
|
||||
// messages that arrive out-of-order
|
||||
|
@ -289,9 +291,16 @@ async function encryptWithSenderKeyInner(plaintext, groupId, ourIdentity) {
|
|||
return { ciphertext, keyIdx };
|
||||
}
|
||||
|
||||
async function getSenderKeys(groupId, senderIdentity) {
|
||||
const { chainKey, keyIdx } = await loadChainKey(groupId, senderIdentity);
|
||||
|
||||
return { chainKey, keyIdx };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSenderKeyForGroup,
|
||||
encryptWithSenderKey,
|
||||
decryptWithSenderKey,
|
||||
saveSenderKeys,
|
||||
getSenderKeys,
|
||||
};
|
||||
|
|
|
@ -17,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -619,10 +619,13 @@
|
|||
}
|
||||
);
|
||||
// Send sync messages
|
||||
const conversations = window.getConversations().models;
|
||||
textsecure.messaging.sendContactSyncMessage(conversations);
|
||||
textsecure.messaging.sendGroupSyncMessage(conversations);
|
||||
textsecure.messaging.sendOpenGroupsSyncMessage(conversations);
|
||||
// bad hack to send sync messages when secondary device is ready to process them
|
||||
setTimeout(async () => {
|
||||
const conversations = window.getConversations().models;
|
||||
await textsecure.messaging.sendGroupSyncMessage(conversations);
|
||||
await textsecure.messaging.sendOpenGroupsSyncMessage(conversations);
|
||||
await textsecure.messaging.sendContactSyncMessage(conversations);
|
||||
}, 5000);
|
||||
},
|
||||
validatePubKeyHex(pubKey) {
|
||||
const c = new Whisper.Conversation({
|
||||
|
|
|
@ -263,6 +263,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
function SenderKeyMissing(senderIdentity) {
|
||||
this.name = 'SenderKeyMissing';
|
||||
this.senderIdentity = senderIdentity;
|
||||
|
||||
Error.call(this, this.name);
|
||||
|
||||
// Maintains proper stack trace, where our error was thrown (only available on V8)
|
||||
// via https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this);
|
||||
}
|
||||
}
|
||||
|
||||
window.textsecure.UnregisteredUserError = UnregisteredUserError;
|
||||
window.textsecure.SendMessageNetworkError = SendMessageNetworkError;
|
||||
window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError;
|
||||
|
@ -282,4 +295,5 @@
|
|||
window.textsecure.TimestampError = TimestampError;
|
||||
window.textsecure.PublicChatError = PublicChatError;
|
||||
window.textsecure.PublicTokenError = PublicTokenError;
|
||||
window.textsecure.SenderKeyMissing = SenderKeyMissing;
|
||||
})();
|
||||
|
|
|
@ -101,6 +101,9 @@
|
|||
NUM_CONCURRENT_CONNECTIONS,
|
||||
stopPolling,
|
||||
messages => {
|
||||
if (this.calledStop) {
|
||||
return; // don't handle those messages
|
||||
}
|
||||
connected = true;
|
||||
messages.forEach(message => {
|
||||
this.handleMessage(message.data, {
|
||||
|
@ -127,6 +130,9 @@
|
|||
|
||||
// Exhausted all our snodes urls, trying again later from scratch
|
||||
setTimeout(() => {
|
||||
if (this.calledStop) {
|
||||
return; // don't restart
|
||||
}
|
||||
window.log.info(
|
||||
`http-resource: Exhausted all our snodes urls, trying again in ${EXHAUSTED_SNODES_RETRY_DELAY /
|
||||
1000}s from scratch`
|
||||
|
|
|
@ -59,7 +59,7 @@ function MessageReceiver(username, password, signalingKey, options = {}) {
|
|||
openGroupBound = true;
|
||||
}
|
||||
} else {
|
||||
window.log.error('Can not handle open group data, API is not available');
|
||||
window.log.warn('Can not handle open group data, API is not available');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -682,7 +682,7 @@ MessageReceiver.prototype.extend({
|
|||
const { senderIdentity } = envelope;
|
||||
|
||||
const {
|
||||
ciphertext: ciphertext2,
|
||||
ciphertext: outerCiphertext,
|
||||
ephemeralKey,
|
||||
} = textsecure.protobuf.MediumGroupContent.decode(ciphertextObj);
|
||||
|
||||
|
@ -692,16 +692,16 @@ MessageReceiver.prototype.extend({
|
|||
'hex'
|
||||
).toArrayBuffer();
|
||||
|
||||
const res = await libloki.crypto.decryptForPubkey(
|
||||
const mediumGroupCiphertext = await libloki.crypto.decryptForPubkey(
|
||||
secretKey,
|
||||
ephemKey,
|
||||
ciphertext2.toArrayBuffer()
|
||||
outerCiphertext.toArrayBuffer()
|
||||
);
|
||||
|
||||
const {
|
||||
ciphertext,
|
||||
keyIdx,
|
||||
} = textsecure.protobuf.MediumGroupCiphertext.decode(res);
|
||||
} = textsecure.protobuf.MediumGroupCiphertext.decode(mediumGroupCiphertext);
|
||||
|
||||
const plaintext = await window.SenderKeyAPI.decryptWithSenderKey(
|
||||
ciphertext.toArrayBuffer(),
|
||||
|
@ -849,6 +849,22 @@ MessageReceiver.prototype.extend({
|
|||
return promise
|
||||
.then(plaintext => this.postDecrypt(envelope, plaintext))
|
||||
.catch(error => {
|
||||
if (error && error instanceof textsecure.SenderKeyMissing) {
|
||||
const groupId = envelope.source;
|
||||
const { senderIdentity } = error;
|
||||
|
||||
log.info(
|
||||
'Requesting missing key for identity: ',
|
||||
senderIdentity,
|
||||
'groupId: ',
|
||||
groupId
|
||||
);
|
||||
|
||||
textsecure.messaging.requestSenderKeys(senderIdentity, groupId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let errorToThrow = error;
|
||||
|
||||
const noSession =
|
||||
|
@ -876,7 +892,7 @@ MessageReceiver.prototype.extend({
|
|||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
|
||||
const returnError = () => Promise.reject(errorToThrow);
|
||||
return this.dispatchAndWait(ev).then(returnError, returnError);
|
||||
this.dispatchAndWait(ev).then(returnError, returnError);
|
||||
});
|
||||
},
|
||||
async decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address) {
|
||||
|
@ -900,7 +916,7 @@ MessageReceiver.prototype.extend({
|
|||
},
|
||||
// handle a SYNC message for a message
|
||||
// sent by another device
|
||||
handleSentMessage(envelope, sentContainer, msg) {
|
||||
async handleSentMessage(envelope, sentContainer, msg) {
|
||||
const {
|
||||
destination,
|
||||
timestamp,
|
||||
|
@ -908,41 +924,64 @@ MessageReceiver.prototype.extend({
|
|||
unidentifiedStatus,
|
||||
} = sentContainer;
|
||||
|
||||
let p = Promise.resolve();
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
|
||||
p = this.handleEndSession(destination);
|
||||
await this.handleEndSession(destination);
|
||||
}
|
||||
return p.then(() =>
|
||||
this.processDecrypted(envelope, msg).then(message => {
|
||||
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
|
||||
|
||||
// handle profileKey and avatar updates
|
||||
if (envelope.source === primaryDevicePubKey) {
|
||||
const { profileKey, profile } = message;
|
||||
const primaryConversation = ConversationController.get(
|
||||
primaryDevicePubKey
|
||||
);
|
||||
if (profile) {
|
||||
this.updateProfile(primaryConversation, profile, profileKey);
|
||||
}
|
||||
}
|
||||
// if (msg.mediumGroupUpdate) {
|
||||
// await this.handleMediumGroupUpdate(envelope, msg.mediumGroupUpdate);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const ev = new Event('sent');
|
||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
ev.data = {
|
||||
destination,
|
||||
timestamp: timestamp.toNumber(),
|
||||
device: envelope.sourceDevice,
|
||||
unidentifiedStatus,
|
||||
message,
|
||||
};
|
||||
if (expirationStartTimestamp) {
|
||||
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
|
||||
}
|
||||
return this.dispatchAndWait(ev);
|
||||
})
|
||||
);
|
||||
const message = await this.processDecrypted(envelope, msg);
|
||||
|
||||
const primaryDevicePubKey = window.storage.get('primaryDevicePubKey');
|
||||
// const groupId = message.group && message.group.id;
|
||||
// const isBlocked = this.isGroupBlocked(groupId);
|
||||
//
|
||||
// const isMe =
|
||||
// envelope.source === textsecure.storage.user.getNumber() ||
|
||||
// envelope.source === primaryDevicePubKey;
|
||||
// const isLeavingGroup = Boolean(
|
||||
// message.group &&
|
||||
// message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
|
||||
// );
|
||||
|
||||
// if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
|
||||
// window.log.warn(
|
||||
// `Message ${this.getEnvelopeId(
|
||||
// envelope
|
||||
// )} ignored; destined for blocked group`
|
||||
// );
|
||||
// this.removeFromCache(envelope);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// handle profileKey and avatar updates
|
||||
if (envelope.source === primaryDevicePubKey) {
|
||||
const { profileKey, profile } = message;
|
||||
const primaryConversation = ConversationController.get(
|
||||
primaryDevicePubKey
|
||||
);
|
||||
if (profile) {
|
||||
this.updateProfile(primaryConversation, profile, profileKey);
|
||||
}
|
||||
}
|
||||
|
||||
const ev = new Event('sent');
|
||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
ev.data = {
|
||||
destination,
|
||||
timestamp: timestamp.toNumber(),
|
||||
device: envelope.sourceDevice,
|
||||
unidentifiedStatus,
|
||||
message,
|
||||
};
|
||||
if (expirationStartTimestamp) {
|
||||
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
|
||||
}
|
||||
this.dispatchAndWait(ev);
|
||||
},
|
||||
async handleLokiAddressMessage(envelope) {
|
||||
window.log.warn('Ignoring a Loki address message');
|
||||
|
@ -1163,96 +1202,180 @@ MessageReceiver.prototype.extend({
|
|||
},
|
||||
|
||||
async handleMediumGroupUpdate(envelope, groupUpdate) {
|
||||
const {
|
||||
groupId,
|
||||
groupSecretKey,
|
||||
senderKey,
|
||||
members,
|
||||
groupName,
|
||||
} = groupUpdate;
|
||||
const { type, groupId } = groupUpdate;
|
||||
|
||||
const convoExists = window.ConversationController.get(groupId, 'group');
|
||||
|
||||
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'),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
63
main.js
|
@ -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,
|
||||
|
|
15
package.json
15
package.json
|
@ -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 .",
|
||||
|
|
79
preload.js
79
preload.js
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
1
session-file-server
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 52b77bf3039aec88b3900e8a7ed6e62d30a4d0d4
|
|
@ -91,6 +91,7 @@ textarea {
|
|||
width: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
@ -189,7 +190,7 @@ textarea {
|
|||
&.brand {
|
||||
min-width: 165px;
|
||||
height: 45px;
|
||||
line-height: 40px;
|
||||
align-items: center;
|
||||
padding: 0px $session-margin-lg;
|
||||
font-size: $session-font-md;
|
||||
font-family: $session-font-accent;
|
||||
|
@ -558,7 +559,9 @@ label {
|
|||
max-width: 70vw;
|
||||
background-color: $session-shade-4;
|
||||
border: 1px solid $session-shade-8;
|
||||
padding-bottom: $session-margin-lg;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
|
@ -609,6 +612,8 @@ label {
|
|||
font-family: $session-font-accent;
|
||||
line-height: $session-font-md;
|
||||
font-size: $session-font-sm;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
|
@ -669,6 +674,17 @@ label {
|
|||
}
|
||||
}
|
||||
|
||||
.sealed-sender-toggle {
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.sender-keys-description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.create-group-dialog .session-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -1061,7 +1077,8 @@ label {
|
|||
flex-direction: column;
|
||||
|
||||
&-list {
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&-header {
|
||||
|
@ -1131,6 +1148,7 @@ label {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__version-info {
|
||||
|
@ -1566,6 +1584,14 @@ input {
|
|||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// Height at which scroll bar appears on the group member list
|
||||
@media (max-height: 804px) {
|
||||
&__container {
|
||||
overflow-y: visible;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.create-group-name-input {
|
||||
.session-id-editable {
|
||||
|
|
|
@ -239,7 +239,8 @@ $session-compose-margin: 20px;
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: -webkit-fill-available;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
.session-icon .exit {
|
||||
padding: 13px;
|
||||
}
|
||||
|
@ -339,7 +340,8 @@ $session-compose-margin: 20px;
|
|||
.session-left-pane-section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-search-dropdown {
|
||||
|
@ -404,8 +406,6 @@ $session-compose-margin: 20px;
|
|||
@mixin bottom-buttons() {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
width: 100%;
|
||||
|
||||
@at-root .light-theme #{&} {
|
||||
|
@ -471,7 +471,8 @@ $session-compose-margin: 20px;
|
|||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
.module-conversation-list-item {
|
||||
background-color: $session-shade-4;
|
||||
|
@ -534,6 +535,7 @@ $session-compose-margin: 20px;
|
|||
&-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-category-list-item {
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
&-accent {
|
||||
flex-grow: 1;
|
||||
|
@ -28,21 +29,32 @@
|
|||
}
|
||||
|
||||
&-registration {
|
||||
height: 45%;
|
||||
padding-right: 128px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 17px 20px;
|
||||
}
|
||||
|
||||
&-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
&-close-button {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-session-button {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
right: 20px;
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
}
|
||||
|
@ -246,6 +258,8 @@
|
|||
display: inline-block;
|
||||
font-family: $session-font-mono;
|
||||
user-select: all;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,29 +7,14 @@ import {
|
|||
} from './session/settings/SessionSettings';
|
||||
|
||||
export const MainViewController = {
|
||||
renderMessageView: () => {
|
||||
if (document.getElementById('main-view')) {
|
||||
ReactDOM.render(<MessageView />, document.getElementById('main-view'));
|
||||
}
|
||||
},
|
||||
|
||||
renderSettingsView: (category: SessionSettingCategory) => {
|
||||
// tslint:disable-next-line: no-backbone-get-set-outside-model
|
||||
const isSecondaryDevice = !!window.textsecure.storage.get(
|
||||
'isSecondaryDevice'
|
||||
);
|
||||
if (document.getElementById('main-view')) {
|
||||
ReactDOM.render(
|
||||
<SettingsView
|
||||
category={category}
|
||||
isSecondaryDevice={isSecondaryDevice}
|
||||
/>,
|
||||
document.getElementById('main-view')
|
||||
);
|
||||
}
|
||||
},
|
||||
joinChannelStateManager,
|
||||
createClosedGroup,
|
||||
renderMessageView,
|
||||
renderSettingsView,
|
||||
};
|
||||
|
||||
import { ContactType } from './session/SessionMemberListItem';
|
||||
|
||||
export class MessageView extends React.Component {
|
||||
public render() {
|
||||
return (
|
||||
|
@ -50,3 +35,153 @@ export class MessageView extends React.Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// /////////////////////////////////////
|
||||
// //////////// Management /////////////
|
||||
// /////////////////////////////////////
|
||||
|
||||
function joinChannelStateManager(
|
||||
thisRef: any,
|
||||
serverURL: string,
|
||||
onSuccess?: any
|
||||
) {
|
||||
// Any component that uses this function MUST have the keys [loading, connectSuccess]
|
||||
// in their State
|
||||
|
||||
// TODO: Make this not hard coded
|
||||
const channelId = 1;
|
||||
thisRef.setState({ loading: true });
|
||||
const connectionResult = window.attemptConnection(serverURL, channelId);
|
||||
|
||||
// Give 5s maximum for promise to revole. Else, throw error.
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (!thisRef.state.connectSuccess) {
|
||||
thisRef.setState({ loading: false });
|
||||
window.pushToast({
|
||||
title: window.i18n('connectToServerFail'),
|
||||
type: 'error',
|
||||
id: 'connectToServerFail',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}, window.CONSTANTS.MAX_CONNECTION_DURATION);
|
||||
|
||||
connectionResult
|
||||
.then(() => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
if (thisRef.state.loading) {
|
||||
thisRef.setState({
|
||||
connectSuccess: true,
|
||||
loading: false,
|
||||
});
|
||||
window.pushToast({
|
||||
title: window.i18n('connectToServerSuccess'),
|
||||
id: 'connectToServerSuccess',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((connectionError: string) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
thisRef.setState({
|
||||
connectSuccess: true,
|
||||
loading: false,
|
||||
});
|
||||
window.pushToast({
|
||||
title: connectionError,
|
||||
id: 'connectToServerFail',
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function createClosedGroup(
|
||||
groupName: string,
|
||||
groupMembers: Array<ContactType>,
|
||||
senderKeys: boolean,
|
||||
onSuccess: any
|
||||
) {
|
||||
// Validate groupName and groupMembers length
|
||||
if (
|
||||
groupName.length === 0 ||
|
||||
groupName.length > window.CONSTANTS.MAX_GROUP_NAME_LENGTH
|
||||
) {
|
||||
window.pushToast({
|
||||
title: window.i18n(
|
||||
'invalidGroupName',
|
||||
window.CONSTANTS.MAX_GROUP_NAME_LENGTH
|
||||
),
|
||||
type: 'error',
|
||||
id: 'invalidGroupName',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// >= because we add ourself as a member after this. so a 10 group is already invalid as it will be 11 with ourself
|
||||
if (
|
||||
groupMembers.length === 0 ||
|
||||
groupMembers.length >= window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
|
||||
) {
|
||||
window.pushToast({
|
||||
title: window.i18n(
|
||||
'invalidGroupSize',
|
||||
window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
|
||||
),
|
||||
type: 'error',
|
||||
id: 'invalidGroupSize',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const groupMemberIds = groupMembers.map(m => m.id);
|
||||
|
||||
if (senderKeys) {
|
||||
await window.createMediumSizeGroup(groupName, groupMemberIds);
|
||||
} else {
|
||||
await window.doCreateGroup(groupName, groupMemberIds);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// /////////////////////////////////////
|
||||
// ///////////// Rendering /////////////
|
||||
// /////////////////////////////////////
|
||||
|
||||
function renderMessageView() {
|
||||
if (document.getElementById('main-view')) {
|
||||
ReactDOM.render(<MessageView />, document.getElementById('main-view'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettingsView(category: SessionSettingCategory) {
|
||||
// tslint:disable-next-line: no-backbone-get-set-outside-model
|
||||
const isSecondaryDevice = !!window.textsecure.storage.get(
|
||||
'isSecondaryDevice'
|
||||
);
|
||||
if (document.getElementById('main-view')) {
|
||||
ReactDOM.render(
|
||||
<SettingsView
|
||||
category={category}
|
||||
isSecondaryDevice={isSecondaryDevice}
|
||||
/>,
|
||||
document.getElementById('main-view')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -441,22 +441,15 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
isGroup,
|
||||
isFriend,
|
||||
isKickedFromGroup,
|
||||
isArchived,
|
||||
isPublic,
|
||||
isRss,
|
||||
onResetSession,
|
||||
onSetDisappearingMessages,
|
||||
// onShowAllMedia,
|
||||
onShowGroupMembers,
|
||||
onShowSafetyNumber,
|
||||
onArchive,
|
||||
onMoveToInbox,
|
||||
timerOptions,
|
||||
onBlockUser,
|
||||
onUnblockUser,
|
||||
// hasNickname,
|
||||
// onClearNickname,
|
||||
// onChangeNickname,
|
||||
} = this.props;
|
||||
|
||||
if (isPublic || isRss) {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ declare global {
|
|||
interface Window {
|
||||
Lodash: any;
|
||||
doCreateGroup: any;
|
||||
createMediumSizeGroup: any;
|
||||
SMALL_GROUP_SIZE_LIMIT: number;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -108,9 +108,11 @@ export class InviteFriendsDialog extends React.Component<Props, State> {
|
|||
private renderMemberList() {
|
||||
const members = this.state.friendList;
|
||||
|
||||
return members.map((member: ContactType) => (
|
||||
return members.map((member: ContactType, index: number) => (
|
||||
<SessionMemberListItem
|
||||
member={member}
|
||||
key={index}
|
||||
index={index}
|
||||
isSelected={false}
|
||||
onSelect={(selectedMember: ContactType) => {
|
||||
this.onMemberClicked(selectedMember);
|
||||
|
|
|
@ -147,9 +147,10 @@ export class UpdateGroupMembersDialog extends React.Component<Props, State> {
|
|||
private renderMemberList() {
|
||||
const members = this.state.friendList;
|
||||
|
||||
return members.map((member: ContactType) => (
|
||||
return members.map((member: ContactType, index: number) => (
|
||||
<SessionMemberListItem
|
||||
member={member}
|
||||
index={index}
|
||||
isSelected={!member.checkmarked}
|
||||
onSelect={this.onMemberClicked}
|
||||
onUnselect={this.onMemberClicked}
|
||||
|
|
|
@ -136,7 +136,6 @@ export class ActionsPanel extends React.Component<Props, State> {
|
|||
const isProfilePageSelected = selectedSection === SectionType.Profile;
|
||||
const isMessagePageSelected = selectedSection === SectionType.Message;
|
||||
const isContactPageSelected = selectedSection === SectionType.Contact;
|
||||
const isChannelPageSelected = selectedSection === SectionType.Channel;
|
||||
const isSettingsPageSelected = selectedSection === SectionType.Settings;
|
||||
const isMoonPageSelected = selectedSection === SectionType.Moon;
|
||||
|
||||
|
@ -154,11 +153,6 @@ export class ActionsPanel extends React.Component<Props, State> {
|
|||
onSelect={this.handleSectionSelect}
|
||||
notificationCount={unreadMessageCount}
|
||||
/>
|
||||
<this.Section
|
||||
type={SectionType.Channel}
|
||||
isSelected={isChannelPageSelected}
|
||||
onSelect={this.handleSectionSelect}
|
||||
/>
|
||||
<this.Section
|
||||
type={SectionType.Contact}
|
||||
isSelected={isContactPageSelected}
|
||||
|
|
|
@ -1,508 +0,0 @@
|
|||
import React from 'react';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
|
||||
import {
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from '../ConversationListItem';
|
||||
|
||||
import { LeftPane, RowRendererParamsType } from '../LeftPane';
|
||||
import {
|
||||
SessionButton,
|
||||
SessionButtonColor,
|
||||
SessionButtonType,
|
||||
} from './SessionButton';
|
||||
import {
|
||||
PropsData as SearchResultsProps,
|
||||
SearchResults,
|
||||
} from '../SearchResults';
|
||||
import { SearchOptions } from '../../types/Search';
|
||||
import { debounce } from 'lodash';
|
||||
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
|
||||
import { SessionSearchInput } from './SessionSearchInput';
|
||||
import { SessionClosableOverlay } from './SessionClosableOverlay';
|
||||
import { MainViewController } from '../MainViewController';
|
||||
import { ContactType } from './SessionMemberListItem';
|
||||
|
||||
export interface Props {
|
||||
searchTerm: string;
|
||||
isSecondaryDevice: boolean;
|
||||
|
||||
conversations?: Array<ConversationListItemPropsType>;
|
||||
|
||||
searchResults?: SearchResultsProps;
|
||||
|
||||
updateSearchTerm: (searchTerm: string) => void;
|
||||
search: (query: string, options: SearchOptions) => void;
|
||||
openConversationInternal: (id: string, messageId?: string) => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export enum SessionGroupType {
|
||||
Open = 'open-group',
|
||||
Closed = 'closed-group',
|
||||
}
|
||||
|
||||
interface State {
|
||||
channelUrlPasted: string;
|
||||
loading: boolean;
|
||||
connectSuccess: boolean;
|
||||
// The type of group that is being added. Undefined in default view.
|
||||
groupAddType: SessionGroupType | undefined;
|
||||
}
|
||||
|
||||
export class LeftPaneChannelSection extends React.Component<Props, State> {
|
||||
private readonly updateSearchBound: (searchedString: string) => void;
|
||||
private readonly debouncedSearch: (searchTerm: string) => void;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
channelUrlPasted: '',
|
||||
loading: false,
|
||||
connectSuccess: false,
|
||||
groupAddType: undefined,
|
||||
};
|
||||
|
||||
this.handleOnPasteUrl = this.handleOnPasteUrl.bind(this);
|
||||
this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind(
|
||||
this
|
||||
);
|
||||
this.handleToggleOverlay = this.handleToggleOverlay.bind(this);
|
||||
this.updateSearchBound = this.updateSearch.bind(this);
|
||||
this.debouncedSearch = debounce(this.search.bind(this), 20);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.updateSearch('');
|
||||
}
|
||||
|
||||
public getCurrentConversations():
|
||||
| Array<ConversationListItemPropsType>
|
||||
| undefined {
|
||||
const { conversations } = this.props;
|
||||
|
||||
let conversationList = conversations;
|
||||
if (conversationList !== undefined) {
|
||||
conversationList = conversationList.filter(
|
||||
// a channel is either a public group or a rss group
|
||||
conversation => conversation && conversation.type === 'group'
|
||||
);
|
||||
}
|
||||
|
||||
return conversationList;
|
||||
}
|
||||
|
||||
public renderRow = ({
|
||||
index,
|
||||
key,
|
||||
style,
|
||||
}: RowRendererParamsType): JSX.Element => {
|
||||
const { openConversationInternal } = this.props;
|
||||
|
||||
const conversations = this.getCurrentConversations();
|
||||
|
||||
if (!conversations) {
|
||||
throw new Error('renderRow: Tried to render without conversations');
|
||||
}
|
||||
|
||||
const conversation = conversations[index];
|
||||
|
||||
return (
|
||||
<ConversationListItem
|
||||
key={key}
|
||||
style={style}
|
||||
{...conversation}
|
||||
onClick={openConversationInternal}
|
||||
i18n={window.i18n}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
public renderList(): JSX.Element | Array<JSX.Element | null> {
|
||||
const { openConversationInternal, searchResults } = this.props;
|
||||
|
||||
if (searchResults) {
|
||||
return (
|
||||
<SearchResults
|
||||
{...searchResults}
|
||||
openConversation={openConversationInternal}
|
||||
i18n={window.i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const conversations = this.getCurrentConversations();
|
||||
|
||||
if (!conversations) {
|
||||
throw new Error(
|
||||
'render: must provided conversations if no search results are provided'
|
||||
);
|
||||
}
|
||||
|
||||
const length = conversations.length;
|
||||
|
||||
// Note: conversations is not a known prop for List, but it is required to ensure that
|
||||
// it re-renders when our conversation data changes. Otherwise it would just render
|
||||
// on startup and scroll.
|
||||
const list = (
|
||||
<div className="module-left-pane__list" key={0}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
className="module-left-pane__virtual-list"
|
||||
conversations={conversations}
|
||||
height={height}
|
||||
rowCount={length}
|
||||
rowHeight={64}
|
||||
rowRenderer={this.renderRow}
|
||||
width={width}
|
||||
autoHeight={true}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return [list];
|
||||
}
|
||||
|
||||
public renderHeader(): JSX.Element {
|
||||
const labels = [window.i18n('groups')];
|
||||
|
||||
return LeftPane.RENDER_HEADER(labels, null);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
MainViewController.renderMessageView();
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
MainViewController.renderMessageView();
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="session-left-pane-section-content">
|
||||
{this.renderHeader()}
|
||||
{this.state.groupAddType
|
||||
? this.renderClosableOverlay(this.state.groupAddType)
|
||||
: this.renderGroups()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderGroups() {
|
||||
return (
|
||||
<div className="module-conversations-list-content">
|
||||
<SessionSearchInput
|
||||
searchString={this.props.searchTerm}
|
||||
onChange={this.updateSearchBound}
|
||||
placeholder={window.i18n('search')}
|
||||
/>
|
||||
{this.renderList()}
|
||||
{this.renderBottomButtons()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public updateSearch(searchTerm: string) {
|
||||
const { updateSearchTerm, clearSearch } = this.props;
|
||||
|
||||
if (!searchTerm) {
|
||||
clearSearch();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ channelUrlPasted: '' });
|
||||
|
||||
if (updateSearchTerm) {
|
||||
updateSearchTerm(searchTerm);
|
||||
}
|
||||
|
||||
if (searchTerm.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedTerm = cleanSearchTerm(searchTerm);
|
||||
if (!cleanedTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debouncedSearch(cleanedTerm);
|
||||
}
|
||||
|
||||
public clearSearch() {
|
||||
this.props.clearSearch();
|
||||
}
|
||||
|
||||
public search() {
|
||||
const { search } = this.props;
|
||||
const { searchTerm, isSecondaryDevice } = this.props;
|
||||
|
||||
if (search) {
|
||||
search(searchTerm, {
|
||||
noteToSelf: window.i18n('noteToSelf').toLowerCase(),
|
||||
ourNumber: window.textsecure.storage.user.getNumber(),
|
||||
regionCode: '',
|
||||
isSecondaryDevice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleToggleOverlay(groupType?: SessionGroupType) {
|
||||
// If no groupType, return to default view.
|
||||
// Close the overlay with handleToggleOverlay(undefined)
|
||||
|
||||
switch (groupType) {
|
||||
case SessionGroupType.Open:
|
||||
this.setState({
|
||||
groupAddType: SessionGroupType.Open,
|
||||
});
|
||||
break;
|
||||
case SessionGroupType.Closed:
|
||||
this.setState({
|
||||
groupAddType: SessionGroupType.Closed,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Exit overlay
|
||||
this.setState({
|
||||
groupAddType: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderClosableOverlay(groupType: SessionGroupType) {
|
||||
const { searchTerm } = this.props;
|
||||
const { loading } = this.state;
|
||||
|
||||
const openGroupElement = (
|
||||
<SessionClosableOverlay
|
||||
overlayMode={SessionGroupType.Open}
|
||||
onChangeSessionID={this.handleOnPasteUrl}
|
||||
onCloseClick={() => {
|
||||
this.handleToggleOverlay(undefined);
|
||||
}}
|
||||
onButtonClick={this.handleJoinChannelButtonClick}
|
||||
searchTerm={searchTerm}
|
||||
updateSearch={this.updateSearchBound}
|
||||
showSpinner={loading}
|
||||
/>
|
||||
);
|
||||
|
||||
const closedGroupElement = (
|
||||
<SessionClosableOverlay
|
||||
overlayMode={SessionGroupType.Closed}
|
||||
onChangeSessionID={this.handleOnPasteUrl}
|
||||
onCloseClick={() => {
|
||||
this.handleToggleOverlay(undefined);
|
||||
}}
|
||||
onButtonClick={async (
|
||||
groupName: string,
|
||||
groupMembers: Array<ContactType>
|
||||
) => this.onCreateClosedGroup(groupName, groupMembers)}
|
||||
searchTerm={searchTerm}
|
||||
updateSearch={this.updateSearchBound}
|
||||
showSpinner={loading}
|
||||
/>
|
||||
);
|
||||
|
||||
return groupType === SessionGroupType.Open
|
||||
? openGroupElement
|
||||
: closedGroupElement;
|
||||
}
|
||||
|
||||
private renderBottomButtons(): JSX.Element {
|
||||
const edit = window.i18n('edit');
|
||||
const joinOpenGroup = window.i18n('joinOpenGroup');
|
||||
const createClosedGroup = window.i18n('createClosedGroup');
|
||||
const showEditButton = false;
|
||||
|
||||
return (
|
||||
<div className="left-pane-contact-bottom-buttons">
|
||||
{showEditButton && (
|
||||
<SessionButton
|
||||
text={edit}
|
||||
buttonType={SessionButtonType.SquareOutline}
|
||||
buttonColor={SessionButtonColor.White}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SessionButton
|
||||
text={joinOpenGroup}
|
||||
buttonType={SessionButtonType.SquareOutline}
|
||||
buttonColor={SessionButtonColor.Green}
|
||||
onClick={() => {
|
||||
this.handleToggleOverlay(SessionGroupType.Open);
|
||||
}}
|
||||
/>
|
||||
<SessionButton
|
||||
text={createClosedGroup}
|
||||
buttonType={SessionButtonType.SquareOutline}
|
||||
buttonColor={SessionButtonColor.White}
|
||||
onClick={() => {
|
||||
this.handleToggleOverlay(SessionGroupType.Closed);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleOnPasteUrl(value: string) {
|
||||
this.setState({ channelUrlPasted: value });
|
||||
}
|
||||
|
||||
private handleJoinChannelButtonClick(groupUrl: string) {
|
||||
const { loading } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// longest TLD is now (20/02/06) 24 characters per https://jasontucker.blog/8945/what-is-the-longest-tld-you-can-get-for-a-domain-name
|
||||
const regexURL = /(http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?/;
|
||||
|
||||
if (groupUrl.length <= 0) {
|
||||
window.pushToast({
|
||||
title: window.i18n('noServerURL'),
|
||||
type: 'error',
|
||||
id: 'connectToServerFail',
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!regexURL.test(groupUrl)) {
|
||||
window.pushToast({
|
||||
title: window.i18n('noServerURL'),
|
||||
type: 'error',
|
||||
id: 'connectToServerFail',
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
joinChannelStateManager(this, groupUrl, () => {
|
||||
this.handleToggleOverlay(undefined);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async onCreateClosedGroup(
|
||||
groupName: string,
|
||||
groupMembers: Array<ContactType>
|
||||
) {
|
||||
// Validate groupName and groupMembers length
|
||||
if (
|
||||
groupName.length === 0 ||
|
||||
groupName.length > window.CONSTANTS.MAX_GROUP_NAME_LENGTH
|
||||
) {
|
||||
window.pushToast({
|
||||
title: window.i18n(
|
||||
'invalidGroupName',
|
||||
window.CONSTANTS.MAX_GROUP_NAME_LENGTH
|
||||
),
|
||||
type: 'error',
|
||||
id: 'invalidGroupName',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// >= because we add ourself as a member after this. so a 10 group is already invalid as it will be 11 with ourself
|
||||
if (
|
||||
groupMembers.length === 0 ||
|
||||
groupMembers.length >= window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
|
||||
) {
|
||||
window.pushToast({
|
||||
title: window.i18n(
|
||||
'invalidGroupSize',
|
||||
window.CONSTANTS.SMALL_GROUP_SIZE_LIMIT
|
||||
),
|
||||
type: 'error',
|
||||
id: 'invalidGroupSize',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const groupMemberIds = groupMembers.map(m => m.id);
|
||||
await window.doCreateGroup(groupName, groupMemberIds);
|
||||
this.handleToggleOverlay(undefined);
|
||||
|
||||
window.pushToast({
|
||||
title: window.i18n('closedGroupCreatedToastTitle'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function joinChannelStateManager(
|
||||
thisRef: any,
|
||||
serverURL: string,
|
||||
onSuccess?: any
|
||||
) {
|
||||
// Any component that uses this function MUST have the keys [loading, connectSuccess]
|
||||
// in their State
|
||||
|
||||
// TODO: Make this not hard coded
|
||||
const channelId = 1;
|
||||
thisRef.setState({ loading: true });
|
||||
const connectionResult = window.attemptConnection(serverURL, channelId);
|
||||
|
||||
// Give 5s maximum for promise to revole. Else, throw error.
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (!thisRef.state.connectSuccess) {
|
||||
thisRef.setState({ loading: false });
|
||||
window.pushToast({
|
||||
title: window.i18n('connectToServerFail'),
|
||||
type: 'error',
|
||||
id: 'connectToServerFail',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}, window.CONSTANTS.MAX_CONNECTION_DURATION);
|
||||
|
||||
connectionResult
|
||||
.then(() => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
if (thisRef.state.loading) {
|
||||
thisRef.setState({
|
||||
connectSuccess: true,
|
||||
loading: false,
|
||||
});
|
||||
window.pushToast({
|
||||
title: window.i18n('connectToServerSuccess'),
|
||||
id: 'connectToServerSuccess',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((connectionError: string) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
thisRef.setState({
|
||||
connectSuccess: true,
|
||||
loading: false,
|
||||
});
|
||||
window.pushToast({
|
||||
title: connectionError,
|
||||
id: 'connectToServerFail',
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
|
@ -17,7 +17,10 @@ import {
|
|||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { validateNumber } from '../../types/PhoneNumber';
|
||||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
import { SessionClosableOverlay } from './SessionClosableOverlay';
|
||||
import {
|
||||
SessionClosableOverlay,
|
||||
SessionClosableOverlayType,
|
||||
} from './SessionClosableOverlay';
|
||||
import { MainViewController } from '../MainViewController';
|
||||
|
||||
export interface Props {
|
||||
|
@ -89,6 +92,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
|
|||
labels,
|
||||
this.handleTabSelected,
|
||||
undefined,
|
||||
undefined,
|
||||
this.handleToggleFriendRequestPopup,
|
||||
receivedFriendRequestCount
|
||||
);
|
||||
|
@ -140,7 +144,8 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
|
|||
style,
|
||||
}: RowRendererParamsType): JSX.Element | undefined => {
|
||||
const { sentFriendsRequest } = this.props;
|
||||
const friends = window.getFriendsFromContacts(this.props.friends);
|
||||
const contacts = this.props.friends.filter(f => f.type === 'direct');
|
||||
const friends = contacts.filter(c => c.isFriend);
|
||||
const combined = [...sentFriendsRequest, ...friends];
|
||||
const item = combined[index];
|
||||
|
||||
|
@ -203,7 +208,7 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
|
|||
private renderClosableOverlay() {
|
||||
return (
|
||||
<SessionClosableOverlay
|
||||
overlayMode="contact"
|
||||
overlayMode={SessionClosableOverlayType.Contact}
|
||||
onChangeSessionID={this.handleRecipientSessionIDChanged}
|
||||
onCloseClick={this.handleToggleOverlay}
|
||||
onButtonClick={this.handleOnAddContact}
|
||||
|
@ -322,7 +327,8 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
|
|||
private renderList() {
|
||||
const { sentFriendsRequest } = this.props;
|
||||
|
||||
const friends = window.getFriendsFromContacts(this.props.friends);
|
||||
const contacts = this.props.friends.filter(f => f.type === 'direct');
|
||||
const friends = contacts.filter(c => c.isFriend);
|
||||
const length = Number(sentFriendsRequest.length) + Number(friends.length);
|
||||
|
||||
const combined = [...sentFriendsRequest, ...friends];
|
||||
|
|
|
@ -17,22 +17,23 @@ import { cleanSearchTerm } from '../../util/cleanSearchTerm';
|
|||
import { SearchOptions } from '../../types/Search';
|
||||
import { validateNumber } from '../../types/PhoneNumber';
|
||||
import { LeftPane, RowRendererParamsType } from '../LeftPane';
|
||||
import { SessionClosableOverlay } from './SessionClosableOverlay';
|
||||
import { SessionIconButton, SessionIconSize, SessionIconType } from './icon';
|
||||
import {
|
||||
SessionClosableOverlay,
|
||||
SessionClosableOverlayType,
|
||||
} from './SessionClosableOverlay';
|
||||
import { SessionIconType } from './icon';
|
||||
import { ContactType } from './SessionMemberListItem';
|
||||
import {
|
||||
SessionButton,
|
||||
SessionButtonColor,
|
||||
SessionButtonType,
|
||||
} from './SessionButton';
|
||||
import { SessionSpinner } from './SessionSpinner';
|
||||
import { joinChannelStateManager } from './LeftPaneChannelSection';
|
||||
|
||||
export interface Props {
|
||||
searchTerm: string;
|
||||
isSecondaryDevice: boolean;
|
||||
|
||||
conversations?: Array<ConversationListItemPropsType>;
|
||||
|
||||
searchResults?: SearchResultsProps;
|
||||
|
||||
updateSearchTerm: (searchTerm: string) => void;
|
||||
|
@ -41,17 +42,40 @@ export interface Props {
|
|||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export class LeftPaneMessageSection extends React.Component<Props, any> {
|
||||
export enum SessionComposeToType {
|
||||
Message = 'message',
|
||||
OpenGroup = 'open-group',
|
||||
ClosedGroup = 'closed-group',
|
||||
}
|
||||
|
||||
export const SessionGroupType = {
|
||||
OpenGroup: SessionComposeToType.OpenGroup,
|
||||
ClosedGroup: SessionComposeToType.ClosedGroup,
|
||||
};
|
||||
export type SessionGroupType = SessionComposeToType;
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
overlay: false | SessionComposeToType;
|
||||
valuePasted: string;
|
||||
connectSuccess: boolean;
|
||||
}
|
||||
|
||||
export class LeftPaneMessageSection extends React.Component<Props, State> {
|
||||
private readonly updateSearchBound: (searchedString: string) => void;
|
||||
private readonly debouncedSearch: (searchTerm: string) => void;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loading: false,
|
||||
overlay: false,
|
||||
valuePasted: '',
|
||||
connectSuccess: false,
|
||||
};
|
||||
|
||||
const conversations = this.getCurrentConversations();
|
||||
const renderOnboardingSetting = window.getSettingValue(
|
||||
'render-message-onboarding'
|
||||
);
|
||||
|
||||
const realConversations: Array<ConversationListItemPropsType> = [];
|
||||
if (conversations) {
|
||||
|
@ -64,23 +88,21 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
|
|||
});
|
||||
}
|
||||
|
||||
const length = realConversations.length;
|
||||
|
||||
this.state = {
|
||||
showComposeView: false,
|
||||
pubKeyPasted: '',
|
||||
shouldRenderMessageOnboarding:
|
||||
length === 0 && renderOnboardingSetting && false,
|
||||
connectSuccess: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
this.updateSearchBound = this.updateSearch.bind(this);
|
||||
|
||||
this.handleOnPaste = this.handleOnPaste.bind(this);
|
||||
this.handleToggleOverlay = this.handleToggleOverlay.bind(this);
|
||||
this.handleCloseOnboarding = this.handleCloseOnboarding.bind(this);
|
||||
this.handleJoinPublicChat = this.handleJoinPublicChat.bind(this);
|
||||
this.handleOnPasteSessionID = this.handleOnPasteSessionID.bind(this);
|
||||
this.handleMessageButtonClick = this.handleMessageButtonClick.bind(this);
|
||||
|
||||
this.handleNewSessionButtonClick = this.handleNewSessionButtonClick.bind(
|
||||
this
|
||||
);
|
||||
this.handleJoinChannelButtonClick = this.handleJoinChannelButtonClick.bind(
|
||||
this
|
||||
);
|
||||
this.onCreateClosedGroup = this.onCreateClosedGroup.bind(this);
|
||||
|
||||
this.renderClosableOverlay = this.renderClosableOverlay.bind(this);
|
||||
this.debouncedSearch = debounce(this.search.bind(this), 20);
|
||||
}
|
||||
|
||||
|
@ -97,7 +119,7 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
|
|||
if (conversationList !== undefined) {
|
||||
conversationList = conversationList.filter(
|
||||
conversation =>
|
||||
!conversation.isSecondary && !conversation.isPendingFriendRequest
|
||||
!conversation.isPendingFriendRequest && !conversation.isSecondary
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -197,17 +219,20 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
|
|||
return LeftPane.RENDER_HEADER(
|
||||
labels,
|
||||
null,
|
||||
window.i18n('newSession'),
|
||||
this.handleToggleOverlay
|
||||
undefined,
|
||||
SessionIconType.Plus,
|
||||
this.handleNewSessionButtonClick
|
||||
);
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
const { overlay } = this.state;
|
||||
|
||||
return (
|
||||
<div className="session-left-pane-section-content">
|
||||
{this.renderHeader()}
|
||||
{this.state.showComposeView
|
||||
? this.renderClosableOverlay()
|
||||
{overlay
|
||||
? this.renderClosableOverlay(overlay)
|
||||
: this.renderConversations()}
|
||||
</div>
|
||||
);
|
||||
|
@ -216,90 +241,17 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
|
|||
public renderConversations() {
|
||||
return (
|
||||
<div className="module-conversations-list-content">
|
||||
{this.state.shouldRenderMessageOnboarding ? (
|
||||
<>{this.renderMessageOnboarding()}</>
|
||||
) : (
|
||||
<>
|
||||
<SessionSearchInput
|
||||
searchString={this.props.searchTerm}
|
||||
onChange={this.updateSearchBound}
|
||||
placeholder={window.i18n('searchForAKeyPhrase')}
|
||||
/>
|
||||
{this.renderList()}
|
||||
</>
|
||||
)}
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { SessionButton } from './SessionButton';
|
||||
import { SessionIcon, SessionIconSize, SessionIconType } from './icon';
|
||||
import {
|
||||
NotificationCountSize,
|
||||
SessionNotificationCount,
|
||||
|
@ -43,6 +44,7 @@ interface Props {
|
|||
labels: Array<string>;
|
||||
notificationCount?: number;
|
||||
buttonLabel?: string;
|
||||
buttonIcon?: SessionIconType;
|
||||
buttonClicked?: any;
|
||||
}
|
||||
|
||||
|
@ -65,10 +67,13 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
|
|||
const {
|
||||
labels,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
buttonClicked,
|
||||
notificationCount,
|
||||
} = this.props;
|
||||
|
||||
const hasButton = buttonLabel || buttonIcon;
|
||||
|
||||
const children = [];
|
||||
//loop to create children
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
|
@ -83,15 +88,19 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
if (buttonLabel && !notificationCount) {
|
||||
children.push(
|
||||
<SessionButton
|
||||
text={buttonLabel}
|
||||
onClick={buttonClicked}
|
||||
key="compose"
|
||||
disabled={false}
|
||||
/>
|
||||
if (hasButton && !notificationCount) {
|
||||
const buttonContent = buttonIcon ? (
|
||||
<SessionIcon iconType={buttonIcon} iconSize={SessionIconSize.Small} />
|
||||
) : (
|
||||
buttonLabel
|
||||
);
|
||||
const button = (
|
||||
<SessionButton onClick={buttonClicked} key="compose" disabled={false}>
|
||||
{buttonContent}
|
||||
</SessionButton>
|
||||
);
|
||||
|
||||
children.push(button);
|
||||
} else if (buttonLabel && notificationCount && notificationCount > 0) {
|
||||
children.push(
|
||||
<div className="contact-notification-section">
|
||||
|
@ -105,6 +114,7 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
|
|||
count={notificationCount}
|
||||
size={NotificationCountSize.ON_HEADER}
|
||||
onClick={this.props.buttonClicked}
|
||||
key="notification-count" // we can only have one of those here
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -114,11 +124,12 @@ export class LeftPaneSectionHeader extends React.Component<Props, State> {
|
|||
count={notificationCount}
|
||||
size={NotificationCountSize.ON_HEADER}
|
||||
onClick={this.props.buttonClicked}
|
||||
key="notificationCount"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
//Create the parent and add the children
|
||||
// Create the parent and add the children
|
||||
return <div className="module-left-pane__header">{children}</div>;
|
||||
}
|
||||
|
||||
|
|
|
@ -62,6 +62,7 @@ export class LeftPaneSettingSection extends React.Component<Props, State> {
|
|||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SessionIconButton, SessionIconSize, SessionIconType } from './icon';
|
||||
import { SessionToggle } from './SessionToggle';
|
||||
import { SessionIdEditable } from './SessionIdEditable';
|
||||
import { UserSearchDropdown } from './UserSearchDropdown';
|
||||
import { ContactType, SessionMemberListItem } from './SessionMemberListItem';
|
||||
|
@ -11,11 +12,18 @@ import {
|
|||
SessionButtonType,
|
||||
} from './SessionButton';
|
||||
import { SessionSpinner } from './SessionSpinner';
|
||||
import { SessionGroupType } from './LeftPaneChannelSection';
|
||||
import { PillDivider } from './PillDivider';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export enum SessionClosableOverlayType {
|
||||
Contact = 'contact',
|
||||
Message = 'message',
|
||||
OpenGroup = 'open-group',
|
||||
ClosedGroup = 'closed-group',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
overlayMode: 'message' | 'contact' | SessionGroupType;
|
||||
overlayMode: SessionClosableOverlayType;
|
||||
onChangeSessionID: any;
|
||||
onCloseClick: any;
|
||||
onButtonClick: any;
|
||||
|
@ -29,6 +37,7 @@ interface Props {
|
|||
interface State {
|
||||
groupName: string;
|
||||
selectedMembers: Array<ContactType>;
|
||||
senderKeys: boolean;
|
||||
}
|
||||
|
||||
export class SessionClosableOverlay extends React.Component<Props, State> {
|
||||
|
@ -40,10 +49,14 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
this.state = {
|
||||
groupName: '',
|
||||
selectedMembers: [],
|
||||
senderKeys: false,
|
||||
};
|
||||
|
||||
this.inputRef = React.createRef();
|
||||
this.onKeyUp = this.onKeyUp.bind(this);
|
||||
this.onGroupNameChanged = this.onGroupNameChanged.bind(this);
|
||||
|
||||
window.addEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
@ -97,11 +110,12 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
onButtonClick,
|
||||
} = this.props;
|
||||
|
||||
const isAddContactView = overlayMode === 'contact';
|
||||
const isMessageView = overlayMode === 'message';
|
||||
|
||||
const isOpenGroupView = overlayMode === SessionGroupType.Open;
|
||||
const isClosedGroupView = overlayMode === SessionGroupType.Closed;
|
||||
const isAddContactView = overlayMode === SessionClosableOverlayType.Contact;
|
||||
const isMessageView = overlayMode === SessionClosableOverlayType.Message;
|
||||
const isOpenGroupView =
|
||||
overlayMode === SessionClosableOverlayType.OpenGroup;
|
||||
const isClosedGroupView =
|
||||
overlayMode === SessionClosableOverlayType.ClosedGroup;
|
||||
|
||||
let title;
|
||||
let buttonText;
|
||||
|
@ -140,12 +154,14 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
default:
|
||||
}
|
||||
|
||||
const { groupName, selectedMembers } = this.state;
|
||||
const { groupName, selectedMembers, senderKeys } = this.state;
|
||||
const ourSessionID = window.textsecure.storage.user.getNumber();
|
||||
|
||||
const contacts = this.getContacts();
|
||||
|
||||
const noContactsForClosedGroup =
|
||||
overlayMode === SessionGroupType.Closed && contacts.length === 0;
|
||||
overlayMode === SessionClosableOverlayType.ClosedGroup &&
|
||||
contacts.length === 0;
|
||||
|
||||
return (
|
||||
<div className="module-left-pane-overlay">
|
||||
|
@ -193,7 +209,6 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
{isClosedGroupView && (
|
||||
<>
|
||||
<div className="spacer-lg" />
|
||||
|
||||
<div className="group-member-list__container">
|
||||
{noContactsForClosedGroup ? (
|
||||
<div className="group-member-list__no-contacts">
|
||||
|
@ -201,7 +216,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
</div>
|
||||
) : (
|
||||
<div className="group-member-list__selection">
|
||||
{this.renderMemberList()}
|
||||
{this.renderMemberList(contacts)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -234,23 +249,43 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
/>
|
||||
)}
|
||||
|
||||
{isClosedGroupView && window.lokiFeatureFlags.enableSenderKeys && (
|
||||
<div className="sealed-sender-toggle">
|
||||
<SessionToggle
|
||||
active={Boolean(false)}
|
||||
onClick={() => {
|
||||
const value = this.state.senderKeys;
|
||||
this.setState({ senderKeys: !value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={classNames(
|
||||
'session-settings-item__description',
|
||||
'sender-keys-description'
|
||||
)}
|
||||
>
|
||||
{window.i18n('useSenderKeys')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionButton
|
||||
buttonColor={SessionButtonColor.Green}
|
||||
buttonType={SessionButtonType.BrandOutline}
|
||||
text={buttonText}
|
||||
disabled={noContactsForClosedGroup}
|
||||
onClick={() => onButtonClick(groupName, selectedMembers)}
|
||||
onClick={() => onButtonClick(groupName, selectedMembers, senderKeys)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderMemberList() {
|
||||
const members = this.getContacts();
|
||||
|
||||
return members.map((member: ContactType) => (
|
||||
private renderMemberList(members: any) {
|
||||
return members.map((member: ContactType, index: number) => (
|
||||
<SessionMemberListItem
|
||||
member={member}
|
||||
index={index}
|
||||
isSelected={false}
|
||||
key={member.id}
|
||||
onSelect={(selectedMember: ContactType) => {
|
||||
|
@ -286,4 +321,11 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
|
|||
groupName: event,
|
||||
});
|
||||
}
|
||||
|
||||
private onKeyUp(event: any) {
|
||||
if (event.key === 'Escape') {
|
||||
window.removeEventListener('keyup', this.onKeyUp);
|
||||
this.props.onCloseClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ export class SessionIdEditable extends React.PureComponent<Props> {
|
|||
|
||||
private handleChange(e: any) {
|
||||
const { editable, onChange } = this.props;
|
||||
|
||||
if (editable) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface ContactType {
|
|||
|
||||
interface Props {
|
||||
member: ContactType;
|
||||
index: number; // index in the list
|
||||
isSelected: boolean;
|
||||
onSelect?: any;
|
||||
onUnselect?: any;
|
||||
|
@ -54,7 +55,11 @@ export class SessionMemberListItem extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={classNames('session-member-item', isSelected && 'selected')}
|
||||
className={classNames(
|
||||
`session-member-item-${this.props.index}`,
|
||||
'session-member-item',
|
||||
isSelected && 'selected'
|
||||
)}
|
||||
onClick={this.handleSelectionAction}
|
||||
role="button"
|
||||
>
|
||||
|
|
|
@ -8,24 +8,27 @@ export const SessionRegistrationView: React.FC = () => (
|
|||
<div className="session-content">
|
||||
<div id="session-toast-container" />
|
||||
<div id="error" className="collapse" />
|
||||
<div className="session-content-close-button">
|
||||
<SessionIconButton
|
||||
iconSize={SessionIconSize.Medium}
|
||||
iconType={SessionIconType.Exit}
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
/>
|
||||
<div className="session-content-header">
|
||||
<div className="session-content-close-button">
|
||||
<SessionIconButton
|
||||
iconSize={SessionIconSize.Medium}
|
||||
iconType={SessionIconType.Exit}
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="session-content-session-button">
|
||||
<img alt="brand" src="./images/session/brand.svg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="session-content-accent">
|
||||
<AccentText />
|
||||
</div>
|
||||
<div className="session-content-registration">
|
||||
<RegistrationTabs />
|
||||
</div>
|
||||
<div className="session-content-session-button">
|
||||
<img alt="brand" src="./images/session/brand.svg" />
|
||||
<div className="session-content-body">
|
||||
<div className="session-content-accent">
|
||||
<AccentText />
|
||||
</div>
|
||||
<div className="session-content-registration">
|
||||
<RegistrationTabs />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -23,6 +23,7 @@ export enum SessionIconType {
|
|||
Microphone = 'microphone',
|
||||
Moon = 'moon',
|
||||
Pencil = 'pencil',
|
||||
Plus = 'plus',
|
||||
Reply = 'reply',
|
||||
Search = 'search',
|
||||
Send = 'send',
|
||||
|
@ -160,6 +161,11 @@ export const icons = {
|
|||
'M4,16.4142136 L4,20 L7.58578644,20 L19.5857864,8 L16,4.41421356 L4,16.4142136 Z M16.7071068,2.29289322 L21.7071068,7.29289322 C22.0976311,7.68341751 22.0976311,8.31658249 21.7071068,8.70710678 L8.70710678,21.7071068 C8.5195704,21.8946432 8.26521649,22 8,22 L3,22 C2.44771525,22 2,21.5522847 2,21 L2,16 C2,15.7347835 2.10535684,15.4804296 2.29289322,15.2928932 L15.2928932,2.29289322 C15.6834175,1.90236893 16.3165825,1.90236893 16.7071068,2.29289322 Z',
|
||||
viewBox: '1 1 21 21',
|
||||
},
|
||||
[SessionIconType.Plus]: {
|
||||
path:
|
||||
'm405.332031 192h-170.664062v-170.667969c0-11.773437-9.558594-21.332031-21.335938-21.332031-11.773437 0-21.332031 9.558594-21.332031 21.332031v170.667969h-170.667969c-11.773437 0-21.332031 9.558594-21.332031 21.332031 0 11.777344 9.558594 21.335938 21.332031 21.335938h170.667969v170.664062c0 11.777344 9.558594 21.335938 21.332031 21.335938 11.777344 0 21.335938-9.558594 21.335938-21.335938v-170.664062h170.664062c11.777344 0 21.335938-9.558594 21.335938-21.335938 0-11.773437-9.558594-21.332031-21.335938-21.332031zm0 0',
|
||||
viewBox: '0 0 427 427',
|
||||
},
|
||||
[SessionIconType.Reply]: {
|
||||
path:
|
||||
'M4,3 C4.55228475,3 5,3.44771525 5,4 L5,4 L5,11 C5,12.6568542 6.34314575,14 8,14 L8,14 L17.585,14 L14.2928932,10.7071068 C13.9324093,10.3466228 13.9046797,9.77939176 14.2097046,9.38710056 L14.2928932,9.29289322 C14.6834175,8.90236893 15.3165825,8.90236893 15.7071068,9.29289322 L15.7071068,9.29289322 L20.7071068,14.2928932 C20.7355731,14.3213595 20.7623312,14.3515341 20.787214,14.3832499 C20.788658,14.3849951 20.7902348,14.3870172 20.7918027,14.389044 C20.8140715,14.4179625 20.8348358,14.4480862 20.8539326,14.4793398 C20.8613931,14.4913869 20.8685012,14.5036056 20.8753288,14.5159379 C20.8862061,14.5357061 20.8966234,14.5561086 20.9063462,14.5769009 C20.914321,14.5939015 20.9218036,14.6112044 20.9287745,14.628664 C20.9366843,14.6484208 20.9438775,14.6682023 20.9504533,14.6882636 C20.9552713,14.7031487 20.9599023,14.7185367 20.9641549,14.734007 C20.9701664,14.7555635 20.9753602,14.7772539 20.9798348,14.7992059 C20.9832978,14.8166247 20.9863719,14.834051 20.9889822,14.8515331 C20.9962388,14.8996379 21,14.9493797 21,15 L20.9962979,14.9137692 C20.9978436,14.9317345 20.9989053,14.9497336 20.9994829,14.9677454 L21,15 C21,15.0112225 20.9998151,15.0224019 20.9994483,15.0335352 C20.9988772,15.050591 20.997855,15.0679231 20.996384,15.0852242 C20.994564,15.1070574 20.9920941,15.1281144 20.9889807,15.1489612 C20.9863719,15.165949 20.9832978,15.1833753 20.9797599,15.2007258 C20.9753602,15.2227461 20.9701664,15.2444365 20.964279,15.2658396 C20.9599023,15.2814633 20.9552713,15.2968513 20.9502619,15.3121425 C20.9438775,15.3317977 20.9366843,15.3515792 20.928896,15.3710585 C20.9218036,15.3887956 20.914321,15.4060985 20.9063266,15.4232215 C20.8974314,15.4421635 20.8879327,15.4609002 20.8778732,15.4792864 C20.8703855,15.4931447 20.862375,15.5070057 20.8540045,15.5207088 C20.8382813,15.546275 20.8215099,15.5711307 20.8036865,15.5951593 C20.774687,15.6343256 20.7425008,15.6717127 20.7071068,15.7071068 L20.787214,15.6167501 C20.7849289,15.6196628 20.7826279,15.6225624 20.7803112,15.625449 L20.7071068,15.7071068 L15.7071068,20.7071068 C15.3165825,21.0976311 14.6834175,21.0976311 14.2928932,20.7071068 C13.9023689,20.3165825 13.9023689,19.6834175 14.2928932,19.2928932 L14.2928932,19.2928932 L17.585,16 L8,16 C5.3112453,16 3.11818189,13.8776933 3.00461951,11.2168896 L3,11 L3,4 C3,3.44771525 3.44771525,3 4,3',
|
||||
|
|
7
ts/global.d.ts
vendored
7
ts/global.d.ts
vendored
|
@ -64,3 +64,10 @@ interface Window {
|
|||
interface Promise<T> {
|
||||
ignore(): void;
|
||||
}
|
||||
|
||||
// Types also correspond to messages.json keys
|
||||
enum LnsLookupErrorType {
|
||||
lnsTooFewNodes,
|
||||
lnsLookupTimeout,
|
||||
lnsMappingNotFound,
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
2
ts/session/messages/outgoing/content/data/group/index.ts
Normal file
2
ts/session/messages/outgoing/content/data/group/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './ClosedGroupMessage';
|
||||
export * from './ClosedGroupChatMessage';
|
|
@ -1,5 +1,5 @@
|
|||
export * from './ClosedGroupChatMessage';
|
||||
export * from './DataMessage';
|
||||
export * from './DeviceUnlinkMessage';
|
||||
export * from './GroupInvitationMessage';
|
||||
export * from './ChatMessage';
|
||||
export * from './group';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
*/
|
||||
}
|
||||
|
|
|
@ -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
6
ts/session/tslint.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": ["../../tslint.json"],
|
||||
"rules": {
|
||||
"no-unused-variable": false
|
||||
}
|
||||
}
|
|
@ -55,6 +55,7 @@ export type ConversationType = {
|
|||
isFriend?: boolean;
|
||||
isSecondary?: boolean;
|
||||
primaryDevice: string;
|
||||
isPendingFriendRequest?: boolean;
|
||||
hasReceivedFriendRequest?: boolean;
|
||||
hasSentFriendRequest?: boolean;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
Loading…
Reference in a new issue